caracms 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,957 @@
1
+ #!/usr/bin/env node
2
+ // look-same: does the site still look the same after adopting caracms?
3
+ //
4
+ // It screenshots each page of the old site and the new one, full page, at 1440 and 390
5
+ // wide, and writes one HTML page with old and new side by side and a difference score per
6
+ // pair (plus scores.json for an agent to read). It also diffs each page's HTML (below).
7
+ //
8
+ // Usage. playwright-core lives in a temporary folder OUTSIDE the site, so the site gains no
9
+ // dependency and its package.json and lockfile stay untouched. The machine's own Chrome is
10
+ // used, so there's no browser download:
11
+ //
12
+ // npm i --prefix /tmp/look-same playwright-core
13
+ // node look-same.mjs --playwright /tmp/look-same \
14
+ // --old http://localhost:4321 --new http://localhost:4336 \
15
+ // --pages pages.txt --out /tmp/look-same/report
16
+ //
17
+ // --playwright <dir> the folder playwright-core was installed into (with --prefix). Or set
18
+ // LOOK_SAME_PLAYWRIGHT=<dir> instead.
19
+ // --old, --new <url> the two base addresses. Each page path is appended to both.
20
+ // --pages <list> a file with one path per line (blank lines and # comments ignored),
21
+ // or a comma list: --pages /,/about,/team
22
+ // --out <dir> where the report goes: index.html, scores.json, shots/ and html/. Nothing
23
+ // else is written anywhere.
24
+ // --widths <list> optional, default 1440,390
25
+ // --image-wait <s> optional, default 5: the most seconds a shot waits for its images to
26
+ // finish (below).
27
+ // --ignore-attr <list> optional: attributes the HTML diff drops, a comma list of names. A name
28
+ // ending in * matches by prefix. A Tina port, for example:
29
+ // --ignore-attr data-tina-field,data-tina-island or --ignore-attr 'data-tina-*'
30
+ // --css <rules> optional: CSS added to both pages just before each shot, for an element
31
+ // that varies from run to run on the old site itself (a wordmark fitted to its
32
+ // box while the display font loads). Hide it or pin its size on both sides, e.g.
33
+ // --css '[data-wordmark]{visibility:hidden!important}'. It changes the shots
34
+ // only, not the HTML diff.
35
+ // --mask-ids <regex|auto> optional: ids drawn at random per render (MEC's st-a8f3k2, React's
36
+ // :r1:) differ on every build, and so does every attribute that points at
37
+ // them. Ids whose value matches the regex (JavaScript syntax, unanchored:
38
+ // anchor it yourself, e.g. '^(st|sp)-') are written [id1], [id2], ... by the
39
+ // HTML diff, numbered in the order their id="" attributes come on the page,
40
+ // and so is what references one: for, aria-controls, aria-labelledby,
41
+ // aria-describedby and the other id-list attributes (each id in the list),
42
+ // href="#…" and url(#…). One id is one mask within a page, so a reference
43
+ // that no longer points at its element still shows: [id2] against [id3], or
44
+ // [missing1] when it points at an id the page doesn't have.
45
+ // Ids in a script's or a style's text aren't masked. auto is a built-in
46
+ // pattern: React's :r1: and «r1», and ids ending in -, _ or : then a run of
47
+ // 5 or more letters and digits with at least one of each (st-a8f3k2, not
48
+ // section-12345 or step-intro).
49
+ //
50
+ // Then open <out>/index.html, or read <out>/scores.json.
51
+ //
52
+ // How a page is taken: motion is reduced (prefers-reduced-motion: reduce, and CSS
53
+ // animations and transitions are finished before the shot), the page is scrolled through
54
+ // to the bottom so lazy and scroll-triggered content loads, scrolled back to the top, and
55
+ // shot once fonts are ready and the network is idle.
56
+ //
57
+ // Images: before the shot it waits for each image still loading, but only one that has started:
58
+ // an eager image, or a lazy one that renders (not under hidden, display: none, a closed
59
+ // <details> or content-visibility: hidden) within a viewport's width of the page's sides. A lazy
60
+ // image that never started (in a hidden tab panel, say) never fires load or error, and waiting
61
+ // for it would hang. The whole wait is capped
62
+ // by --image-wait (5 s). A shot that hit the cap is still taken, and it's recorded: imageCap in
63
+ // scores.json (per side, how many images were still loading and the first few addresses), and a
64
+ // line in the report and the console.
65
+ //
66
+ // Status: a page is compared whatever status it answers, so a site's 404 page can be compared
67
+ // (put a path that doesn't exist in the list). The status is in the report and scores.json
68
+ // (status: { old, new }). When the two sides answer different statuses, that's a difference
69
+ // (statusDiffers: true), not an error: both pages are still shot and diffed as they came.
70
+ //
71
+ // The score: of the pixels both screenshots cover (the overlap of the two sizes), the share
72
+ // that differ in any colour channel by more than 16 out of 255. 0% means identical within
73
+ // that tolerance. A difference in page height is reported separately, as heightDelta (new
74
+ // minus old, in CSS pixels), because one extra line near the top shifts everything below
75
+ // it. Images may legitimately differ a little (encoding, sub-pixel scaling): a person
76
+ // judges those from the side-by-side. Text, layout and spacing should not.
77
+ //
78
+ // The HTML diff: screenshots miss what doesn't show (an attribute, an element's order under
79
+ // a cover, a dropped space the font hides), so each page's HTML is also fetched from both
80
+ // sites, as the server sends it (no JavaScript run), normalised, and diffed line by line.
81
+ // Compare two BUILT sites (astro build, then preview or the production server): a dev server
82
+ // injects its own scripts and inlines its CSS, and that would all show as differences.
83
+ // Normalising, the same on both sides, drops only what changes from build to build or
84
+ // renders the same:
85
+ // - asset hashes in file names: /_astro/index.Ab12Cd34.css becomes /_astro/index.[hash].css
86
+ // - Astro's scoped-style attributes, data-astro-cid-*
87
+ // - caracms's editor markers, data-cms-* (they're only in editor previews, never in a
88
+ // public page, so this matters only if you point the script at a preview)
89
+ // - the attributes named by --ignore-attr, and the ids matched by --mask-ids (above)
90
+ // - where a space sits, when it renders the same. The rule: a run of whitespace is one
91
+ // space. Between inline content (text, and inline tags like <span>, <em>, <a>), a space
92
+ // shows once wherever it is, so it's moved to the start of the next text and shown as ␣:
93
+ // <span>a </span><em>b</em> and <span>a</span> <em>b</em> are both "a", then "␣b". A space
94
+ // before an image or a control (img, svg, button, input, select, video, ...) is a "␣"
95
+ // line of its own. Beside a block-level tag (<p>, <div>, <li>, <h1>, <br>, ... opening or
96
+ // closing) a space doesn't render, so it's dropped, as is whitespace between block tags.
97
+ // So a space that was really lost still shows: "␣b" against "b". CSS that makes an
98
+ // element a block (display: block on a <span>) isn't known, and <pre>'s whitespace is
99
+ // collapsed like any other.
100
+ // - the order of <head>'s children: each child (a tag with what's inside it) is sorted,
101
+ // because caracms writes <title>, the description and its own tags in its own place. The
102
+ // tags themselves are still compared. Order in <body> still matters.
103
+ // - attribute order (sorted), quoting, and attr="" (written as a bare attr, which is the
104
+ // same thing). class has its spaces collapsed. attr="true" is NOT the same as attr and
105
+ // stays a difference.
106
+ // Then each tag and each text is one line. The report shows the diff per page, and
107
+ // scores.json has differingLines (added plus removed) per page, with the full diff in
108
+ // html/<page>.diff. caracms adds its own og: and twitter: meta tags; expect those.
109
+ //
110
+ // Exit codes: 0 when the report is written (whatever the scores, statuses or image caps), 1 for
111
+ // a usage error, 2 when the report is written but some page couldn't be fetched at all.
112
+
113
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
114
+ import { createRequire } from 'node:module';
115
+ import { join, resolve } from 'node:path';
116
+ import { pathToFileURL } from 'node:url';
117
+ import { deflateSync, inflateSync } from 'node:zlib';
118
+
119
+ const TOLERANCE = 16;
120
+
121
+ // ---------------------------------------------------------------- arguments
122
+
123
+ function usage(message) {
124
+ if (message) console.error(`look-same: ${message}`);
125
+ console.error(
126
+ 'usage: node look-same.mjs --playwright <dir> --old <url> --new <url> --pages <file|a,b,c> --out <dir> [--widths 1440,390] [--image-wait 5] [--ignore-attr a,data-b-*] [--mask-ids <regex>|auto] [--css <rules>]',
127
+ );
128
+ process.exit(1);
129
+ }
130
+
131
+ function parseArgs(argv) {
132
+ const args = {};
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const key = argv[i];
135
+ if (!key.startsWith('--')) usage(`unexpected argument ${key}`);
136
+ const value = argv[i + 1];
137
+ if (value === undefined || value.startsWith('--')) usage(`${key} needs a value`);
138
+ args[key.slice(2)] = value;
139
+ i++;
140
+ }
141
+ return args;
142
+ }
143
+
144
+ function readPages(spec) {
145
+ const lines = existsSync(spec) ? readFileSync(spec, 'utf8').split('\n') : spec.split(',');
146
+ return lines
147
+ .map((line) => line.replace(/#.*/, '').trim())
148
+ .filter(Boolean)
149
+ .map((path) => (path.startsWith('/') ? path : `/${path}`));
150
+ }
151
+
152
+ const joinUrl = (base, path) => base.replace(/\/+$/, '') + path;
153
+
154
+ // ---------------------------------------------------------------- playwright-core
155
+
156
+ async function loadPlaywright(dir) {
157
+ if (!dir) usage('say where playwright-core is installed: --playwright <dir> or LOOK_SAME_PLAYWRIGHT');
158
+ const require = createRequire(join(resolve(dir), 'look-same.cjs'));
159
+ let entry;
160
+ try {
161
+ entry = require.resolve('playwright-core');
162
+ } catch {
163
+ usage(`playwright-core isn't installed in ${dir}: run npm i --prefix ${dir} playwright-core`);
164
+ }
165
+ const module = await import(pathToFileURL(entry).href);
166
+ return module.chromium ?? module.default.chromium;
167
+ }
168
+
169
+ // ---------------------------------------------------------------- PNG
170
+
171
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
172
+ let c = n;
173
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
174
+ return c >>> 0;
175
+ });
176
+ function crc32(bytes) {
177
+ let c = 0xffffffff;
178
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
179
+ return (c ^ 0xffffffff) >>> 0;
180
+ }
181
+
182
+ /** Decodes an 8-bit, non-interlaced PNG (what Chrome's screenshots are) to RGBA. */
183
+ function decodePng(buffer) {
184
+ if (buffer.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG');
185
+ let offset = 8;
186
+ let width = 0;
187
+ let height = 0;
188
+ let colorType = 0;
189
+ const idat = [];
190
+ while (offset < buffer.length) {
191
+ const length = buffer.readUInt32BE(offset);
192
+ const type = buffer.toString('ascii', offset + 4, offset + 8);
193
+ const data = buffer.subarray(offset + 8, offset + 8 + length);
194
+ if (type === 'IHDR') {
195
+ width = data.readUInt32BE(0);
196
+ height = data.readUInt32BE(4);
197
+ const depth = data[8];
198
+ colorType = data[9];
199
+ if (depth !== 8 || data[12] !== 0 || ![2, 6].includes(colorType)) {
200
+ throw new Error(`unsupported PNG (depth ${depth}, colour type ${colorType}, interlace ${data[12]})`);
201
+ }
202
+ } else if (type === 'IDAT') idat.push(data);
203
+ else if (type === 'IEND') break;
204
+ offset += 12 + length;
205
+ }
206
+ const channels = colorType === 6 ? 4 : 3;
207
+ const stride = width * channels;
208
+ const raw = inflateSync(Buffer.concat(idat));
209
+ const pixels = Buffer.alloc(width * height * 4);
210
+ let prev = Buffer.alloc(stride);
211
+ for (let y = 0; y < height; y++) {
212
+ const filter = raw[y * (stride + 1)];
213
+ const line = Buffer.from(raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
214
+ for (let x = 0; x < stride; x++) {
215
+ const a = x >= channels ? line[x - channels] : 0;
216
+ const b = prev[x];
217
+ const c = x >= channels ? prev[x - channels] : 0;
218
+ let add = 0;
219
+ if (filter === 1) add = a;
220
+ else if (filter === 2) add = b;
221
+ else if (filter === 3) add = (a + b) >> 1;
222
+ else if (filter === 4) {
223
+ const p = a + b - c;
224
+ const pa = Math.abs(p - a);
225
+ const pb = Math.abs(p - b);
226
+ const pc = Math.abs(p - c);
227
+ add = pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
228
+ }
229
+ line[x] = (line[x] + add) & 0xff;
230
+ }
231
+ for (let x = 0; x < width; x++) {
232
+ const o = (y * width + x) * 4;
233
+ pixels[o] = line[x * channels];
234
+ pixels[o + 1] = line[x * channels + 1];
235
+ pixels[o + 2] = line[x * channels + 2];
236
+ pixels[o + 3] = channels === 4 ? line[x * channels + 3] : 255;
237
+ }
238
+ prev = line;
239
+ }
240
+ return { width, height, pixels };
241
+ }
242
+
243
+ function encodePng({ width, height, pixels }) {
244
+ const chunk = (type, data) => {
245
+ const head = Buffer.alloc(8);
246
+ head.writeUInt32BE(data.length, 0);
247
+ head.write(type, 4, 'ascii');
248
+ const crc = Buffer.alloc(4);
249
+ crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
250
+ return Buffer.concat([head, data, crc]);
251
+ };
252
+ const ihdr = Buffer.alloc(13);
253
+ ihdr.writeUInt32BE(width, 0);
254
+ ihdr.writeUInt32BE(height, 4);
255
+ ihdr[8] = 8;
256
+ ihdr[9] = 6;
257
+ const raw = Buffer.alloc(height * (width * 4 + 1));
258
+ for (let y = 0; y < height; y++) {
259
+ pixels.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
260
+ }
261
+ return Buffer.concat([
262
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
263
+ chunk('IHDR', ihdr),
264
+ chunk('IDAT', deflateSync(raw)),
265
+ chunk('IEND', Buffer.alloc(0)),
266
+ ]);
267
+ }
268
+
269
+ /**
270
+ * Compares two screenshots over the area both cover. Returns the score and a picture of the
271
+ * difference: differing pixels in red over a faded copy of the new page, and the part only
272
+ * one of them covers in grey.
273
+ */
274
+ function compare(oldPng, newPng) {
275
+ const a = decodePng(oldPng);
276
+ const b = decodePng(newPng);
277
+ const width = Math.max(a.width, b.width);
278
+ const height = Math.max(a.height, b.height);
279
+ const overlapW = Math.min(a.width, b.width);
280
+ const overlapH = Math.min(a.height, b.height);
281
+ const out = Buffer.alloc(width * height * 4);
282
+ let differing = 0;
283
+ for (let y = 0; y < height; y++) {
284
+ for (let x = 0; x < width; x++) {
285
+ const o = (y * width + x) * 4;
286
+ if (x >= overlapW || y >= overlapH) {
287
+ out.set([200, 200, 200, 255], o);
288
+ continue;
289
+ }
290
+ const oa = (y * a.width + x) * 4;
291
+ const ob = (y * b.width + x) * 4;
292
+ let max = 0;
293
+ for (let c = 0; c < 3; c++) max = Math.max(max, Math.abs(a.pixels[oa + c] - b.pixels[ob + c]));
294
+ if (max > TOLERANCE) {
295
+ differing++;
296
+ out.set([230, 0, 60, 255], o);
297
+ } else {
298
+ const grey = (b.pixels[ob] + b.pixels[ob + 1] + b.pixels[ob + 2]) / 3;
299
+ const faded = Math.round(255 - (255 - grey) * 0.25);
300
+ out.set([faded, faded, faded, 255], o);
301
+ }
302
+ }
303
+ }
304
+ const compared = overlapW * overlapH;
305
+ return {
306
+ old: { width: a.width, height: a.height },
307
+ new: { width: b.width, height: b.height },
308
+ comparedPixels: compared,
309
+ differingPixels: differing,
310
+ differingShare: compared ? differing / compared : 1,
311
+ heightDelta: b.height - a.height,
312
+ diff: encodePng({ width, height, pixels: out }),
313
+ };
314
+ }
315
+
316
+ // ---------------------------------------------------------------- taking a page
317
+
318
+ async function shoot(browser, url, width, imageWaitMs, css) {
319
+ const context = await browser.newContext({
320
+ viewport: { width, height: width >= 768 ? 900 : 844 },
321
+ deviceScaleFactor: 1,
322
+ isMobile: width < 768,
323
+ hasTouch: width < 768,
324
+ reducedMotion: 'reduce',
325
+ });
326
+ try {
327
+ const page = await context.newPage();
328
+ const response = await page.goto(url, { waitUntil: 'networkidle', timeout: 60_000 });
329
+ const status = response ? response.status() : null;
330
+ await page.evaluate(() => document.fonts.ready);
331
+ // Scroll through in steps so lazy images and scroll-triggered sections load. The page
332
+ // can grow as it goes, so keep going until the bottom stops moving.
333
+ await page.evaluate(async () => {
334
+ const pause = (ms) => new Promise((done) => setTimeout(done, ms));
335
+ let y = 0;
336
+ for (let i = 0; i < 200; i++) {
337
+ const bottom = document.documentElement.scrollHeight - window.innerHeight;
338
+ if (y >= bottom) break;
339
+ y = Math.min(y + Math.round(window.innerHeight * 0.75), bottom);
340
+ window.scrollTo(0, y);
341
+ await pause(120);
342
+ }
343
+ await pause(300);
344
+ window.scrollTo(0, 0);
345
+ });
346
+ await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
347
+ await page.evaluate(() => document.fonts.ready);
348
+ // Only images that have started (the header's Images): an eager one, or a lazy one that
349
+ // renders near the page. The whole wait is capped.
350
+ const imageCap = await page.evaluate(async (capMs) => {
351
+ const started = (img) => {
352
+ if (img.loading !== 'lazy') return true;
353
+ // checkVisibility is false under display: none (hidden) and content-visibility: hidden,
354
+ // which is also what a closed <details> uses.
355
+ if (img.checkVisibility && !img.checkVisibility()) return false;
356
+ const rect = img.getClientRects()[0];
357
+ return Boolean(rect) && rect.right > -window.innerWidth && rect.left < 2 * window.innerWidth;
358
+ };
359
+ const pending = [...document.images].filter((img) => !img.complete && started(img));
360
+ let timer;
361
+ const capped = await Promise.race([
362
+ Promise.all(
363
+ pending.map((img) => new Promise((done) => {
364
+ img.addEventListener('load', done, { once: true });
365
+ img.addEventListener('error', done, { once: true });
366
+ })),
367
+ ).then(() => false),
368
+ new Promise((done) => {
369
+ timer = setTimeout(() => done(true), capMs);
370
+ }),
371
+ ]);
372
+ clearTimeout(timer);
373
+ if (!capped) return null;
374
+ const still = pending.filter((img) => !img.complete);
375
+ if (!still.length) return null;
376
+ return { stillLoading: still.length, images: still.slice(0, 3).map((img) => img.currentSrc || img.src) };
377
+ }, imageWaitMs);
378
+ await page.waitForTimeout(300);
379
+ if (css) await page.addStyleTag({ content: css });
380
+ const png = await page.screenshot({ fullPage: true, animations: 'disabled', type: 'png' });
381
+ return { png, status, imageCap };
382
+ } finally {
383
+ await context.close();
384
+ }
385
+ }
386
+
387
+ // ---------------------------------------------------------------- the HTML diff
388
+
389
+ const DIFF_CONTEXT = 3;
390
+ const MAX_EDITS = 4000; // beyond this many differing lines, a page is shown as all removed and all added
391
+ const SHOWN_LINES = 600; // the report shows at most this many diff lines per page; html/<page>.diff has them all
392
+
393
+ const HASHED = /([\w-])\.[A-Za-z0-9_-]{8,}\.(m?js|css|woff2?|ttf|otf|eot|svg|png|jpe?g|webp|avif|gif|ico|json|wasm)\b/g;
394
+ const DROPPED_ATTRIBUTE = /^(data-astro-cid-[\w-]+|data-cms-[\w-]+)$/i;
395
+ const RAW_TEXT = new Set(['script', 'style', 'textarea', 'title']);
396
+ const TAG = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/y;
397
+ const ATTRIBUTE = /([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
398
+
399
+ const unhash = (text) => text.replace(HASHED, '$1.[hash].$2');
400
+
401
+ // --mask-ids auto: React's useId (:r1:, «r1»), or a separator then a run of 5+ letters and digits
402
+ // with at least one of each at the end (st-a8f3k2, sp_9QwE1).
403
+ const AUTO_IDS = /^[:«][rR][0-9a-zA-Z]*[:»]$|[-_:](?=[A-Za-z0-9]*\d)(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{5,}$/;
404
+ // Attributes whose value is a space-separated list of ids.
405
+ const ID_LISTS = new Set(
406
+ ('for aria-controls aria-labelledby aria-describedby aria-owns aria-activedescendant aria-details ' +
407
+ 'aria-errormessage aria-flowto headers list form popovertarget commandfor anchor itemref').split(' '),
408
+ );
409
+
410
+ /** --ignore-attr's list as a test of a (lower-case) attribute name. */
411
+ function ignoredAttributes(list) {
412
+ const names = String(list ?? '')
413
+ .split(',')
414
+ .map((name) => name.trim().toLowerCase())
415
+ .filter(Boolean);
416
+ const exact = new Set(names.filter((name) => !name.endsWith('*')));
417
+ const prefixes = names.filter((name) => name.endsWith('*')).map((name) => name.slice(0, -1));
418
+ return (key) => exact.has(key) || prefixes.some((prefix) => key.startsWith(prefix));
419
+ }
420
+
421
+ /**
422
+ * How one page is normalised: { ignore(key), maskIds: RegExp | null, masks: Map } — masks is
423
+ * per page, so it's made fresh for each side of each page.
424
+ */
425
+ function pageOptions(options = {}) {
426
+ return { ignore: options.ignore ?? (() => false), maskIds: options.maskIds ?? null, masks: new Map(), ids: 0 };
427
+ }
428
+
429
+ /** Numbers the page's masked ids in the order their id="" attributes come, before anything is normalised. */
430
+ function numberIds(html, page) {
431
+ if (!page.maskIds) return;
432
+ const tags = new RegExp(TAG.source, 'g');
433
+ for (const tag of html.matchAll(tags)) {
434
+ if (tag[1]) continue;
435
+ for (const m of tag[3].matchAll(ATTRIBUTE)) {
436
+ const id = m[2] ?? m[3] ?? m[4];
437
+ if (m[1].toLowerCase() === 'id' && id && page.maskIds.test(id) && !page.masks.has(id)) {
438
+ page.masks.set(id, `[id${page.masks.size + 1}]`);
439
+ }
440
+ }
441
+ }
442
+ page.ids = page.masks.size;
443
+ }
444
+
445
+ function mask(id, page) {
446
+ if (!page.maskIds || !page.maskIds.test(id)) return id;
447
+ // A reference to an id the page doesn't have.
448
+ if (!page.masks.has(id)) page.masks.set(id, `[missing${page.masks.size - page.ids + 1}]`);
449
+ return page.masks.get(id);
450
+ }
451
+
452
+ function maskValue(key, value, page) {
453
+ if (!page.maskIds) return value;
454
+ if (key === 'id') return mask(value, page);
455
+ if (ID_LISTS.has(key)) return value.split(/(\s+)/).map((part) => (/\S/.test(part) ? mask(part, page) : part)).join('');
456
+ if ((key === 'href' || key === 'xlink:href') && value.startsWith('#')) return `#${mask(value.slice(1), page)}`;
457
+ return value.replace(/url\(\s*(['"]?)#([^'")\s]+)\1\s*\)/g, (_, quote, id) => `url(${quote}#${mask(id, page)}${quote})`);
458
+ }
459
+
460
+ function normaliseTag(name, attributes, page) {
461
+ const pairs = [];
462
+ for (const m of attributes.matchAll(ATTRIBUTE)) {
463
+ const key = m[1].toLowerCase();
464
+ if (DROPPED_ATTRIBUTE.test(key) || page.ignore(key)) continue;
465
+ pairs.push([key, m[2] ?? m[3] ?? m[4]]);
466
+ }
467
+ // Sorted before masking, so the masks are numbered the same whatever order the attributes came in.
468
+ pairs.sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : (x ?? '') < (y ?? '') ? -1 : (x ?? '') > (y ?? '') ? 1 : 0));
469
+ const kept = pairs.map(([key, raw]) => {
470
+ if (raw === undefined || raw === '') return key;
471
+ let value = maskValue(key, unhash(raw), page);
472
+ if (key === 'class') value = value.replace(/\s+/g, ' ').trim();
473
+ return `${key}="${value.replace(/"/g, '&quot;')}"`;
474
+ });
475
+ return `<${name}${kept.length ? ` ${kept.join(' ')}` : ''}>`;
476
+ }
477
+
478
+ // Where a space renders (the header's whitespace rule). A BLOCK tag, opening or closing, ends a
479
+ // line of text, so a space beside it never shows; BR does too. An ATOMIC element is inline but
480
+ // not text (an image, a control): a space before it shows, so it stays as a line of its own.
481
+ // Every other tag is inline, or renders nothing (script, template, meta): a space beside it shows
482
+ // once, wherever it sits, and the run of text goes on through it.
483
+ const BLOCK = new Set(
484
+ ('address article aside blockquote body br caption center col colgroup dd details dialog dir div dl dt fieldset ' +
485
+ 'figcaption figure footer form frameset h1 h2 h3 h4 h5 h6 head header hgroup hr html legend li main menu nav ol ' +
486
+ 'optgroup option p pre search section summary table tbody td tfoot th thead tr ul').split(' '),
487
+ );
488
+ const ATOMIC = new Set('audio button canvas embed iframe img input meter object progress select svg textarea video'.split(' '));
489
+ const VOID = new Set('area base br col embed hr img input link meta source track wbr'.split(' '));
490
+
491
+ /** The page as tokens: tags (open, close or void), texts, and comments and doctypes as they are. */
492
+ function htmlTokens(html, page) {
493
+ const tokens = [];
494
+ const text = (raw) => tokens.push({ kind: 'text', raw });
495
+ let i = 0;
496
+ while (i < html.length) {
497
+ const lt = html.indexOf('<', i);
498
+ if (lt === -1) {
499
+ text(html.slice(i));
500
+ break;
501
+ }
502
+ if (lt > i) text(html.slice(i, lt));
503
+ if (html.startsWith('<!--', lt)) {
504
+ const close = html.indexOf('-->', lt + 4);
505
+ const stop = close === -1 ? html.length : close + 3;
506
+ tokens.push({ kind: 'other', line: html.slice(lt, stop).replace(/\s+/g, ' ') });
507
+ i = stop;
508
+ continue;
509
+ }
510
+ if (html[lt + 1] === '!' || html[lt + 1] === '?') {
511
+ const close = html.indexOf('>', lt);
512
+ const stop = close === -1 ? html.length : close + 1;
513
+ tokens.push({ kind: 'other', line: html.slice(lt, stop).replace(/\s+/g, ' ').toLowerCase() });
514
+ i = stop;
515
+ continue;
516
+ }
517
+ TAG.lastIndex = lt;
518
+ const m = TAG.exec(html);
519
+ if (!m) {
520
+ text('<');
521
+ i = lt + 1;
522
+ continue;
523
+ }
524
+ const name = m[2].toLowerCase();
525
+ i = TAG.lastIndex;
526
+ if (m[1]) {
527
+ tokens.push({ kind: 'close', name, line: `</${name}>` });
528
+ continue;
529
+ }
530
+ const empty = VOID.has(name) || /\/\s*$/.test(m[3]);
531
+ tokens.push({ kind: empty ? 'void' : 'open', name, line: normaliseTag(name, m[3], page) });
532
+ if (RAW_TEXT.has(name)) {
533
+ const close = html.toLowerCase().indexOf(`</${name}`, i);
534
+ const stop = close === -1 ? html.length : close;
535
+ const inner = html.slice(i, stop).replace(/\s+/g, ' ').trim();
536
+ if (inner) tokens.push({ kind: 'raw', line: unhash(inner) });
537
+ i = stop;
538
+ }
539
+ }
540
+ return tokens;
541
+ }
542
+
543
+ /**
544
+ * Texts as lines, with each space put where it renders: a run of whitespace is one space, and
545
+ * a space beside inline tags goes to the start of the next text as ␣ (or before an atomic
546
+ * element, as a line of its own), so `<span>a </span><em>b</em>` and `<span>a</span> <em>b</em>`
547
+ * both come out `a`, then `␣b`. A space at the start or end of a line of text (next to a block
548
+ * tag, or the page's ends) doesn't render, so it's dropped.
549
+ */
550
+ function placeSpaces(tokens) {
551
+ const out = [];
552
+ let space = false; // a space waiting for what comes next
553
+ let lineStart = true; // nothing has rendered since the last block boundary
554
+ for (const t of tokens) {
555
+ if (t.kind === 'text') {
556
+ const collapsed = t.raw.replace(/\s+/g, ' ');
557
+ const body = collapsed.trim();
558
+ if (collapsed.startsWith(' ') || !body) space = !lineStart;
559
+ if (!body) continue;
560
+ out.push({ kind: 'text', line: (space ? '␣' : '') + unhash(body) });
561
+ space = collapsed.endsWith(' ');
562
+ lineStart = false;
563
+ continue;
564
+ }
565
+ if (t.kind === 'open' || t.kind === 'close' || t.kind === 'void') {
566
+ if (BLOCK.has(t.name)) {
567
+ space = false;
568
+ lineStart = true;
569
+ } else if (ATOMIC.has(t.name) && t.kind !== 'close') {
570
+ if (space) out.push({ kind: 'text', line: '␣' });
571
+ space = false;
572
+ lineStart = false;
573
+ }
574
+ }
575
+ out.push(t);
576
+ }
577
+ return out;
578
+ }
579
+
580
+ /**
581
+ * <head>'s children in a fixed order (sorted by their lines), so a tag that only moved within
582
+ * <head> isn't a difference: the order of <title>, <meta> and <link> there doesn't change the page.
583
+ */
584
+ function sortHead(tokens) {
585
+ const start = tokens.findIndex((t) => t.kind === 'open' && t.name === 'head');
586
+ if (start === -1) return tokens;
587
+ const children = [];
588
+ let depth = 0;
589
+ let i = start + 1;
590
+ for (; i < tokens.length; i++) {
591
+ const t = tokens[i];
592
+ if (depth === 0 && ((t.kind === 'close' && t.name === 'head') || (t.kind === 'open' && t.name === 'body'))) break;
593
+ if (depth === 0) children.push([]);
594
+ children[children.length - 1].push(t);
595
+ if (t.kind === 'open') depth++;
596
+ else if (t.kind === 'close') depth = Math.max(0, depth - 1);
597
+ }
598
+ const key = (child) => child.map((t) => t.line).join('\n');
599
+ children.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
600
+ return [...tokens.slice(0, start + 1), ...children.flat(), ...tokens.slice(i)];
601
+ }
602
+
603
+ /** The page as lines: one per tag, text, comment or doctype, normalised as the header says. */
604
+ function htmlLines(html, options) {
605
+ const page = pageOptions(options);
606
+ numberIds(html, page);
607
+ return sortHead(placeSpaces(htmlTokens(html, page))).map((t) => t.line);
608
+ }
609
+
610
+ /** Myers's diff of two line lists: [op, line] with op ' ', '-' or '+'. */
611
+ function diffLines(a, b) {
612
+ let start = 0;
613
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
614
+ let endA = a.length;
615
+ let endB = b.length;
616
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
617
+ endA--;
618
+ endB--;
619
+ }
620
+ const head = a.slice(0, start).map((line) => [' ', line]);
621
+ const tail = a.slice(endA).map((line) => [' ', line]);
622
+ const x0 = a.slice(start, endA);
623
+ const y0 = b.slice(start, endB);
624
+ const n = x0.length;
625
+ const m = y0.length;
626
+ const max = n + m;
627
+ const offset = max + 1;
628
+ const v = new Int32Array(2 * max + 3);
629
+ const trace = [];
630
+ let found = false;
631
+ for (let d = 0; d <= Math.min(max, MAX_EDITS) && !found; d++) {
632
+ trace.push(v.slice(offset - d - 1, offset + d + 2));
633
+ for (let k = -d; k <= d; k += 2) {
634
+ let x = k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1]) ? v[offset + k + 1] : v[offset + k - 1] + 1;
635
+ let y = x - k;
636
+ while (x < n && y < m && x0[x] === y0[y]) {
637
+ x++;
638
+ y++;
639
+ }
640
+ v[offset + k] = x;
641
+ if (x >= n && y >= m) {
642
+ found = true;
643
+ break;
644
+ }
645
+ }
646
+ }
647
+ if (!found) {
648
+ return [...head, ...x0.map((line) => ['-', line]), ...y0.map((line) => ['+', line]), ...tail];
649
+ }
650
+ const middle = [];
651
+ let x = n;
652
+ let y = m;
653
+ for (let d = trace.length - 1; d >= 0; d--) {
654
+ const at = (k) => trace[d][k + d + 1];
655
+ const k = x - y;
656
+ const prevK = k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
657
+ const prevX = at(prevK);
658
+ const prevY = prevX - prevK;
659
+ while (x > prevX && y > prevY) {
660
+ x--;
661
+ y--;
662
+ middle.push([' ', x0[x]]);
663
+ }
664
+ if (d > 0) {
665
+ if (x === prevX) middle.push(['+', y0[--y]]);
666
+ else middle.push(['-', x0[--x]]);
667
+ }
668
+ }
669
+ return [...head, ...middle.reverse(), ...tail];
670
+ }
671
+
672
+ /** The diff as unified hunks with a few lines of context, and its counts. */
673
+ function unified(ops) {
674
+ const changed = ops.map(([op]) => op !== ' ');
675
+ const keep = ops.map((_, i) => {
676
+ for (let j = Math.max(0, i - DIFF_CONTEXT); j <= Math.min(ops.length - 1, i + DIFF_CONTEXT); j++) if (changed[j]) return true;
677
+ return false;
678
+ });
679
+ const lines = [];
680
+ let oldLine = 0;
681
+ let newLine = 0;
682
+ let inHunk = false;
683
+ ops.forEach(([op, line], i) => {
684
+ if (op !== '+') oldLine++;
685
+ if (op !== '-') newLine++;
686
+ if (!keep[i]) {
687
+ inHunk = false;
688
+ return;
689
+ }
690
+ if (!inHunk) {
691
+ lines.push(`@@ old line ${op === '+' ? oldLine + 1 : oldLine}, new line ${op === '-' ? newLine + 1 : newLine} @@`);
692
+ inHunk = true;
693
+ }
694
+ lines.push(`${op} ${line}`);
695
+ });
696
+ const removed = ops.filter(([op]) => op === '-').length;
697
+ const added = ops.filter(([op]) => op === '+').length;
698
+ return { lines, removed, added, differingLines: removed + added };
699
+ }
700
+
701
+ async function fetchHtml(url) {
702
+ const response = await fetch(url, { redirect: 'follow' });
703
+ return { status: response.status, text: await response.text() };
704
+ }
705
+
706
+ async function compareHtml(oldUrl, newUrl, options) {
707
+ const [oldPage, newPage] = await Promise.all([fetchHtml(oldUrl), fetchHtml(newUrl)]);
708
+ return {
709
+ status: { old: oldPage.status, new: newPage.status },
710
+ statusDiffers: oldPage.status !== newPage.status,
711
+ ...unified(diffLines(htmlLines(oldPage.text, options), htmlLines(newPage.text, options))),
712
+ };
713
+ }
714
+
715
+ // ---------------------------------------------------------------- the report
716
+
717
+ const escape = (text) =>
718
+ String(text).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
719
+ const statusLabel = (s) => (!s ? '' : s.old === s.new ? `HTTP ${s.old}` : `HTTP ${s.old} → ${s.new}`);
720
+ const statusSpan = (r) => (r.status ? ` <span class="score ${r.statusDiffers ? 'differs' : 'status'}">${escape(statusLabel(r.status))}</span>` : '');
721
+ const percent = (share) => `${(share * 100).toFixed(share > 0 && share < 0.001 ? 3 : 2)}%`;
722
+
723
+ function renderHtmlDiff(h) {
724
+ const label = `${escape(h.path)}: the HTML`;
725
+ if (h.error) return `<section><h2>${label}</h2><p class="error">Couldn't compare: ${escape(h.error)}</p></section>`;
726
+ const tone = h.differingLines === 0 && !h.statusDiffers ? 'same' : 'differs';
727
+ const summary =
728
+ h.differingLines === 0 ? 'same HTML' : `${h.differingLines} lines differ (${h.removed} only in old, ${h.added} only in new)`;
729
+ const shown = h.lines.slice(0, SHOWN_LINES);
730
+ const body = shown
731
+ .map((line) => {
732
+ const kind = line.startsWith('@@') ? 'hunk' : line[0] === '-' ? 'del' : line[0] === '+' ? 'ins' : 'ctx';
733
+ return `<span class="${kind}">${escape(line)}</span>`;
734
+ })
735
+ .join('\n');
736
+ const more = h.lines.length > SHOWN_LINES ? `<p>${h.lines.length - SHOWN_LINES} more lines in <a href="${h.file}">${h.file}</a>.</p>` : '';
737
+ return `<section>
738
+ <h2>${label} <span class="score ${tone}">${summary}</span>${statusSpan(h)}</h2>${
739
+ h.differingLines === 0 ? '' : `\n <p><a href="${h.file}">${h.file}</a></p><pre class="diff">${body}</pre>${more}`
740
+ }
741
+ </section>`;
742
+ }
743
+
744
+ function renderHtml(report) {
745
+ const htmlFor = new Map(report.html.map((h) => [h.path, h]));
746
+ const rows = report.results
747
+ .map((r, i) => {
748
+ const first = i === 0 || report.results[i - 1].path !== r.path;
749
+ return `${first && htmlFor.has(r.path) ? `${renderHtmlDiff(htmlFor.get(r.path))}\n` : ''}${renderShots(r)}`;
750
+ })
751
+ .join('\n');
752
+ return renderPage(report, rows);
753
+ }
754
+
755
+ function renderShots(r) {
756
+ const label = `${escape(r.path)} at ${r.width}px`;
757
+ if (r.error) {
758
+ return `<section><h2>${label}</h2><p class="error">Couldn't compare: ${escape(r.error)}</p></section>`;
759
+ }
760
+ const height =
761
+ r.heightDelta === 0
762
+ ? 'same height'
763
+ : `new is ${Math.abs(r.heightDelta)}px ${r.heightDelta > 0 ? 'taller' : 'shorter'} (${r.old.height} → ${r.new.height})`;
764
+ const tone = r.differingShare === 0 && r.heightDelta === 0 && !r.statusDiffers ? 'same' : 'differs';
765
+ const capped = r.imageCap
766
+ ? Object.entries(r.imageCap)
767
+ .map(
768
+ ([side, c]) =>
769
+ `<p class="error">${side === 'old' ? 'Old' : 'New'}: gave up waiting for ${c.stillLoading} image${c.stillLoading === 1 ? '' : 's'} after ${r.imageWait} s, so the shot may show ${c.stillLoading === 1 ? 'it' : 'them'} unloaded: ${c.images.map(escape).join(', ')}</p>`,
770
+ )
771
+ .join('\n ')
772
+ : '';
773
+ return `<section>
774
+ <h2>${label} <span class="score ${tone}">${percent(r.differingShare)} differ · ${height}</span>${statusSpan(r)}</h2>${capped ? `\n ${capped}` : ''}
775
+ <div class="pair" style="--w:${r.width}px">
776
+ <figure><figcaption>Old · <a href="${escape(r.oldUrl)}">${escape(r.oldUrl)}</a></figcaption><img src="${r.files.old}" alt="old ${label}" loading="lazy"></figure>
777
+ <figure><figcaption>New · <a href="${escape(r.newUrl)}">${escape(r.newUrl)}</a></figcaption><img src="${r.files.new}" alt="new ${label}" loading="lazy"></figure>
778
+ <figure><figcaption>Difference (red)</figcaption><img src="${r.files.diff}" alt="difference ${label}" loading="lazy"></figure>
779
+ </div>
780
+ </section>`;
781
+ }
782
+
783
+ function renderPage(report, rows) {
784
+ return `<!doctype html>
785
+ <html lang="en">
786
+ <head>
787
+ <meta charset="utf-8">
788
+ <meta name="viewport" content="width=device-width, initial-scale=1">
789
+ <title>Look the same</title>
790
+ <style>
791
+ :root { color-scheme: light dark; --bg: #fff; --fg: #1a1a1a; --muted: #666; --line: #ddd; --ok: #0a7a3d; --bad: #b0003a; }
792
+ @media (prefers-color-scheme: dark) { :root { --bg: #161616; --fg: #eee; --muted: #aaa; --line: #333; --ok: #4cc983; --bad: #ff6b8f; } }
793
+ body { margin: 0; padding: 24px 16px; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
794
+ header { max-width: 72ch; margin-bottom: 32px; }
795
+ h1 { margin: 0 0 8px; font-size: 22px; }
796
+ p { margin: 0 0 8px; color: var(--muted); }
797
+ section { margin: 0 0 48px; padding-top: 16px; border-top: 1px solid var(--line); }
798
+ h2 { font-size: 16px; margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; }
799
+ .score { font-weight: 500; font-size: 14px; }
800
+ .same { color: var(--ok); } .differs { color: var(--bad); } .status { color: var(--muted); }
801
+ .error { color: var(--bad); }
802
+ .pair { display: grid; grid-template-columns: repeat(3, minmax(0, var(--w))); gap: 16px; overflow-x: auto; }
803
+ figure { margin: 0; min-width: 0; }
804
+ figcaption { font-size: 13px; color: var(--muted); margin-bottom: 6px; overflow-wrap: anywhere; }
805
+ figcaption a { color: inherit; }
806
+ .diff { margin: 0; padding: 12px; overflow-x: auto; border: 1px solid var(--line); font: 12px/1.45 ui-monospace, monospace; white-space: pre; }
807
+ .diff .del { color: var(--bad); } .diff .ins { color: var(--ok); } .diff .hunk { color: var(--muted); }
808
+ img { display: block; width: 100%; height: auto; border: 1px solid var(--line); }
809
+ </style>
810
+ </head>
811
+ <body>
812
+ <header>
813
+ <h1>Look the same</h1>
814
+ <p>Old <a href="${escape(report.old)}">${escape(report.old)}</a> beside new <a href="${escape(report.new)}">${escape(report.new)}</a>, taken ${escape(report.generated)}.</p>
815
+ <p><strong>The score</strong> is the share of pixels, over the area both screenshots cover, that differ in any colour channel by more than ${TOLERANCE} out of 255. A difference in page height is shown separately, since one extra line near the top shifts everything below it.</p>
816
+ <p>Text, layout and spacing should be the same. Images may differ a little in encoding and sub-pixel scaling; judge those by eye.</p>
817
+ <p><strong>The HTML diff</strong> compares each page's HTML as the server sends it, one tag or text per line, after dropping what changes from build to build or renders the same: asset hashes in file names, Astro's <code>data-astro-cid-*</code>, caracms's <code>data-cms-*</code> editor markers, where a space sits when it renders the same (a space between inline content shows as ␣ at the start of the next text; beside a block tag it's dropped), the order of <code>&lt;head&gt;</code>'s children (sorted), attribute order and quoting, and <code>attr=""</code> against a bare <code>attr</code>.${
818
+ report.options.ignoreAttr ? ` Also dropped (<code>--ignore-attr</code>): <code>${escape(report.options.ignoreAttr)}</code>.` : ''
819
+ }${
820
+ report.options.maskIds
821
+ ? ` Ids matching <code>${escape(report.options.maskIds)}</code> (<code>--mask-ids</code>), and the references to them, are written <code>[id1]</code>, <code>[id2]</code>, … in the order the ids come on the page (a reference to an id the page doesn't have is <code>[missing1]</code>, …), so a reference that no longer points at its element still shows.`
822
+ : ''
823
+ } It catches what a screenshot can't: an attribute, an order under a cover, a lost space.</p>
824
+ <p><strong>Status:</strong> every page is compared whatever it answers (a 404 page too), and its HTTP status is shown beside it; two different statuses are a difference. <strong>Images:</strong> each shot waits up to ${report.options.imageWait} s for the images that have started loading; a shot that gave up says so.${
825
+ report.options.css ? ` <strong>CSS</strong> (<code>--css</code>) added to both pages before each shot: <code>${escape(report.options.css)}</code>.` : ''
826
+ }</p>
827
+ </header>
828
+ ${rows}
829
+ </body>
830
+ </html>
831
+ `;
832
+ }
833
+
834
+ // ---------------------------------------------------------------- run
835
+
836
+ async function main() {
837
+ const args = parseArgs(process.argv.slice(2));
838
+ for (const key of ['old', 'new', 'pages', 'out']) if (!args[key]) usage(`--${key} is required`);
839
+ const pages = readPages(args.pages);
840
+ if (!pages.length) usage('the page list is empty');
841
+ const widths = (args.widths ?? '1440,390').split(',').map(Number);
842
+ if (widths.some((w) => !Number.isInteger(w) || w < 200)) usage('--widths takes whole numbers, e.g. 1440,390');
843
+ const imageWait = Number(args['image-wait'] ?? 5);
844
+ if (!Number.isFinite(imageWait) || imageWait < 0) usage('--image-wait takes a number of seconds, e.g. 5');
845
+ let maskIds = null;
846
+ if (args['mask-ids'] === 'auto') maskIds = AUTO_IDS;
847
+ else if (args['mask-ids'] !== undefined) {
848
+ try {
849
+ maskIds = new RegExp(args['mask-ids']);
850
+ } catch (error) {
851
+ usage(`--mask-ids isn't a regular expression: ${error instanceof Error ? error.message : error}`);
852
+ }
853
+ }
854
+ const css = args.css ?? '';
855
+ const htmlOptions = { ignore: ignoredAttributes(args['ignore-attr']), maskIds };
856
+ const known = new Set(['playwright', 'old', 'new', 'pages', 'out', 'widths', 'image-wait', 'ignore-attr', 'mask-ids', 'css']);
857
+ for (const key of Object.keys(args)) if (!known.has(key)) usage(`unknown option --${key}`);
858
+
859
+ const chromium = await loadPlaywright(args.playwright ?? process.env.LOOK_SAME_PLAYWRIGHT);
860
+ const out = resolve(args.out);
861
+ mkdirSync(join(out, 'shots'), { recursive: true });
862
+ mkdirSync(join(out, 'html'), { recursive: true });
863
+
864
+ const browser = await chromium.launch({ channel: 'chrome' });
865
+ const results = [];
866
+ const html = [];
867
+ try {
868
+ for (const path of pages) {
869
+ const page = path.replace(/^\/|\/$/g, '').replace(/[^a-z0-9]+/gi, '-') || 'home';
870
+ const htmlResult = { path, oldUrl: joinUrl(args.old, path), newUrl: joinUrl(args.new, path), file: `html/${page}.diff` };
871
+ try {
872
+ const { lines, ...counts } = await compareHtml(htmlResult.oldUrl, htmlResult.newUrl, htmlOptions);
873
+ Object.assign(htmlResult, counts);
874
+ writeFileSync(
875
+ join(out, htmlResult.file),
876
+ `--- old ${htmlResult.oldUrl}\n+++ new ${htmlResult.newUrl}\n${lines.join('\n')}${lines.length ? '\n' : ''}`,
877
+ );
878
+ html.push({ ...htmlResult, lines });
879
+ } catch (error) {
880
+ htmlResult.error = error instanceof Error ? error.message.split('\n')[0] : String(error);
881
+ html.push(htmlResult);
882
+ }
883
+ console.log(
884
+ htmlResult.error
885
+ ? `${path} HTML: error: ${htmlResult.error}`
886
+ : `${path} HTML: ${htmlResult.differingLines} lines differ (${htmlResult.removed} only in old, ${htmlResult.added} only in new)${
887
+ htmlResult.statusDiffers || htmlResult.status.old !== 200 ? `, ${statusLabel(htmlResult.status)}` : ''
888
+ }`,
889
+ );
890
+ for (const width of widths) {
891
+ const slug = `${page}-${width}`;
892
+ const files = { old: `shots/${slug}-old.png`, new: `shots/${slug}-new.png`, diff: `shots/${slug}-diff.png` };
893
+ const oldUrl = joinUrl(args.old, path);
894
+ const newUrl = joinUrl(args.new, path);
895
+ const result = { path, width, oldUrl, newUrl, files };
896
+ try {
897
+ const oldShot = await shoot(browser, oldUrl, width, imageWait * 1000, css);
898
+ const newShot = await shoot(browser, newUrl, width, imageWait * 1000, css);
899
+ writeFileSync(join(out, files.old), oldShot.png);
900
+ writeFileSync(join(out, files.new), newShot.png);
901
+ const { diff, ...score } = compare(oldShot.png, newShot.png);
902
+ writeFileSync(join(out, files.diff), diff);
903
+ result.status = { old: oldShot.status, new: newShot.status };
904
+ result.statusDiffers = oldShot.status !== newShot.status;
905
+ if (oldShot.imageCap || newShot.imageCap) {
906
+ result.imageWait = imageWait;
907
+ result.imageCap = {};
908
+ if (oldShot.imageCap) result.imageCap.old = oldShot.imageCap;
909
+ if (newShot.imageCap) result.imageCap.new = newShot.imageCap;
910
+ }
911
+ Object.assign(result, score);
912
+ } catch (error) {
913
+ result.error = error instanceof Error ? error.message.split('\n')[0] : String(error);
914
+ }
915
+ results.push(result);
916
+ console.log(
917
+ result.error
918
+ ? `${path} @${width}: error: ${result.error}`
919
+ : `${path} @${width}: ${percent(result.differingShare)} differ, height ${result.heightDelta >= 0 ? '+' : ''}${result.heightDelta}px${
920
+ result.statusDiffers || result.status.old !== 200 ? `, ${statusLabel(result.status)}` : ''
921
+ }${
922
+ result.imageCap
923
+ ? `; gave up waiting for images after ${imageWait} s (${Object.entries(result.imageCap)
924
+ .map(([side, c]) => `${side}: ${c.stillLoading}`)
925
+ .join(', ')})`
926
+ : ''
927
+ }`,
928
+ );
929
+ }
930
+ }
931
+ } finally {
932
+ await browser.close();
933
+ }
934
+
935
+ const report = {
936
+ old: args.old,
937
+ new: args.new,
938
+ generated: new Date().toISOString(),
939
+ score: `differingShare: of the pixels both screenshots cover, the share differing in any colour channel by more than ${TOLERANCE}/255. heightDelta: new height minus old, in CSS pixels.`,
940
+ tolerance: TOLERANCE,
941
+ options: { widths, imageWait, ignoreAttr: args['ignore-attr'] ?? null, maskIds: maskIds ? (args['mask-ids'] === 'auto' ? `auto (${AUTO_IDS.source})` : maskIds.source) : null, css: css || null },
942
+ status: 'status: the HTTP status each side answered, { old, new }. statusDiffers: true when they differ, which is a difference, not an error. imageCap (results only, when present): the shot gave up waiting for images after imageWait seconds; per side, how many were still loading and the first few addresses.',
943
+ results,
944
+ htmlScore:
945
+ 'differingLines: lines only in old plus lines only in new, after normalising (see the report header); the diff is in file. 0 means the same HTML.',
946
+ html: html.map(({ lines, ...h }) => h),
947
+ };
948
+ writeFileSync(join(out, 'scores.json'), `${JSON.stringify(report, null, '\t')}\n`);
949
+ writeFileSync(join(out, 'index.html'), renderHtml({ ...report, html }));
950
+ console.log(`look-same: wrote ${join(out, 'index.html')}, scores.json and html/`);
951
+ if (results.some((r) => r.error) || html.some((h) => h.error)) process.exit(2);
952
+ }
953
+
954
+ main().catch((error) => {
955
+ console.error(`look-same: ${error instanceof Error ? error.message : error}`);
956
+ process.exit(1);
957
+ });