| 1 | import { describe, expect, test } from "bun:test"; |
| 2 | import { annotate, applyEdit, strip } from "../src/source.ts"; |
| 3 | |
| 4 | const PAGE = `--- |
| 5 | const title = "<not a tag>"; |
| 6 | --- |
| 7 | <section> |
| 8 | <h1 data-copy class="hero">Reach for the better primitives.</h1> |
| 9 | <p |
| 10 | data-copy |
| 11 | class="lede" |
| 12 | > |
| 13 | Already have a Vite app? One resource in{" "} |
| 14 | <code>alchemy.run.ts</code> is the whole |
| 15 | integration &mdash; really. |
| 16 | </p> |
| 17 | <p data-copy>{title}</p> |
| 18 | <script>const x = "<h2 data-copy>not markup</h2>";</script> |
| 19 | </section> |
| 20 | `; |
| 21 | |
| 22 | // The DOM text nodes the browser produces for each element. |
| 23 | const H1 = ["Reach for the better primitives."]; |
| 24 | const LEDE = [ |
| 25 | "\n Already have a Vite app? One resource in \n ", |
| 26 | "alchemy.run.ts", |
| 27 | " is the whole\n integration — really.\n ", |
| 28 | ]; |
| 29 | |
| 30 | describe("annotate", () => { |
| 31 | test("stamps each data-copy with its file and index", () => { |
| 32 | const out = annotate(PAGE, "src/index.astro"); |
| 33 | expect(out).toContain( |
| 34 | '<h1 data-copy="src/index.astro#0" data-copy-format="inline" class="hero">', |
| 35 | ); |
| 36 | expect(out).toContain('data-copy="src/index.astro#1"'); |
| 37 | expect(out).toContain('<p data-copy="src/index.astro#2">'); |
| 38 | }); |
| 39 | |
| 40 | test("ignores frontmatter and script contents", () => { |
| 41 | const out = annotate(PAGE, "f"); |
| 42 | expect(out).toContain('"<h2 data-copy>not markup</h2>"'); |
| 43 | expect(out).not.toContain("#3"); |
| 44 | }); |
| 45 | }); |
| 46 | |
| 47 | describe("strip", () => { |
| 48 | test("removes every data-copy attribute", () => { |
| 49 | const out = strip(PAGE); |
| 50 | expect(out).toContain('<h1 class="hero">'); |
| 51 | expect(out).toContain('<p\n class="lede"\n >'); |
| 52 | expect(out).toContain("<p>{title}</p>"); |
| 53 | // Script contents are left alone. |
| 54 | expect(out).toContain('"<h2 data-copy>not markup</h2>"'); |
| 55 | }); |
| 56 | }); |
| 57 | |
| 58 | describe("applyEdit", () => { |
| 59 | test("rewrites a single text run", () => { |
| 60 | const out = applyEdit(PAGE, { |
| 61 | index: 0, |
| 62 | before: H1, |
| 63 | after: ["Reach for better primitives."], |
| 64 | }); |
| 65 | expect(out).toContain( |
| 66 | '<h1 data-copy class="hero">Reach for better primitives.</h1>', |
| 67 | ); |
| 68 | }); |
| 69 | |
| 70 | test("keeps line wrapping, spacers, entities, and tags outside the changed words", () => { |
| 71 | const after = [...LEDE]; |
| 72 | after[2] = " is the entire\n integration — really.\n "; |
| 73 | const out = applyEdit(PAGE, { index: 1, before: LEDE, after }); |
| 74 | expect(out).toContain( |
| 75 | ' Already have a Vite app? One resource in{" "}\n <code>alchemy.run.ts</code> is the entire\n integration &mdash; really.', |
| 76 | ); |
| 77 | }); |
| 78 | |
| 79 | test("edits text inside nested tags", () => { |
| 80 | const after = [...LEDE]; |
| 81 | after[1] = "alchemy.config.ts"; |
| 82 | const out = applyEdit(PAGE, { index: 1, before: LEDE, after }); |
| 83 | expect(out).toContain("<code>alchemy.config.ts</code>"); |
| 84 | }); |
| 85 | |
| 86 | test("escapes characters that are special in templates", () => { |
| 87 | const out = applyEdit(PAGE, { |
| 88 | index: 0, |
| 89 | before: H1, |
| 90 | after: ["Use <T> & {x}"], |
| 91 | }); |
| 92 | expect(out).toContain("Use &lt;T&gt; &amp; &#123;x&#125;</h1>"); |
| 93 | }); |
| 94 | |
| 95 | test("deletes and inserts words", () => { |
| 96 | const removed = applyEdit(PAGE, { |
| 97 | index: 0, |
| 98 | before: H1, |
| 99 | after: ["Reach for primitives."], |
| 100 | }); |
| 101 | expect(removed).toContain(">Reach for primitives.</h1>"); |
| 102 | const inserted = applyEdit(PAGE, { |
| 103 | index: 0, |
| 104 | before: H1, |
| 105 | after: ["Reach for the very best better primitives."], |
| 106 | }); |
| 107 | expect(inserted).toContain( |
| 108 | ">Reach for the very best better primitives.</h1>", |
| 109 | ); |
| 110 | }); |
| 111 | |
| 112 | test("returns the source unchanged for whitespace-only edits", () => { |
| 113 | const out = applyEdit(PAGE, { |
| 114 | index: 0, |
| 115 | before: H1, |
| 116 | after: [" Reach for the better primitives. "], |
| 117 | }); |
| 118 | expect(out).toBe(PAGE); |
| 119 | }); |
| 120 | |
| 121 | test("rejects stale text", () => { |
| 122 | expect(() => |
| 123 | applyEdit(PAGE, { index: 0, before: ["Something else"], after: ["x"] }), |
| 124 | ).toThrow(/doesn't match the source/); |
| 125 | }); |
| 126 | |
| 127 | test("rejects edits that change markup", () => { |
| 128 | expect(() => |
| 129 | applyEdit(PAGE, { index: 1, before: LEDE, after: ["only one node"] }), |
| 130 | ).toThrow(/removed or added markup/); |
| 131 | }); |
| 132 | |
| 133 | test("rejects elements containing expressions", () => { |
| 134 | expect(() => |
| 135 | applyEdit(PAGE, { index: 2, before: ["x"], after: ["y"] }), |
| 136 | ).toThrow(/expressions/); |
| 137 | }); |
| 138 | }); |