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.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- 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, '"')}"`;
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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><head></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
|
+
});
|