@madojs/mado 0.11.1 → 0.13.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/AGENTS.md +116 -32
- package/CHANGELOG.md +216 -1
- package/README.md +158 -173
- package/dist/src/component.d.ts +6 -3
- package/dist/src/component.d.ts.map +1 -0
- package/dist/src/component.js +163 -36
- package/dist/src/component.js.map +1 -1
- package/dist/src/context.d.ts +13 -32
- package/dist/src/context.d.ts.map +1 -0
- package/dist/src/context.js +73 -48
- package/dist/src/context.js.map +1 -1
- package/dist/src/css.d.ts +1 -0
- package/dist/src/css.d.ts.map +1 -0
- package/dist/src/css.js +34 -5
- package/dist/src/css.js.map +1 -1
- package/dist/src/devtools-hook.d.ts +14 -0
- package/dist/src/devtools-hook.d.ts.map +1 -0
- package/dist/src/devtools-hook.js +27 -0
- package/dist/src/devtools-hook.js.map +1 -0
- package/dist/src/devtools.d.ts +24 -22
- package/dist/src/devtools.d.ts.map +1 -0
- package/dist/src/devtools.js +230 -58
- package/dist/src/devtools.js.map +1 -1
- package/dist/src/diagnostics.d.ts +12 -0
- package/dist/src/diagnostics.d.ts.map +1 -0
- package/dist/src/diagnostics.js +68 -8
- package/dist/src/diagnostics.js.map +1 -1
- package/dist/src/each.d.ts +1 -5
- package/dist/src/each.d.ts.map +1 -0
- package/dist/src/each.js +0 -8
- package/dist/src/each.js.map +1 -1
- package/dist/src/forms.d.ts +27 -96
- package/dist/src/forms.d.ts.map +1 -0
- package/dist/src/forms.js +184 -394
- package/dist/src/forms.js.map +1 -1
- package/dist/src/head.d.ts +7 -6
- package/dist/src/head.d.ts.map +1 -0
- package/dist/src/head.js +8 -7
- package/dist/src/head.js.map +1 -1
- package/dist/src/html/bindings.d.ts +1 -0
- package/dist/src/html/bindings.d.ts.map +1 -0
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html/parser.d.ts +4 -5
- package/dist/src/html/parser.d.ts.map +1 -0
- package/dist/src/html/parser.js +64 -15
- package/dist/src/html/parser.js.map +1 -1
- package/dist/src/html/template-types.d.ts +1 -0
- package/dist/src/html/template-types.d.ts.map +1 -0
- package/dist/src/html/template-types.js.map +1 -1
- package/dist/src/html/template.d.ts +5 -1
- package/dist/src/html/template.d.ts.map +1 -0
- package/dist/src/html/template.js +102 -13
- package/dist/src/html/template.js.map +1 -1
- package/dist/src/index.d.ts +15 -12
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +18 -10
- package/dist/src/index.js.map +1 -1
- package/dist/src/json.d.ts +3 -0
- package/dist/src/json.d.ts.map +1 -0
- package/dist/src/json.js +14 -0
- package/dist/src/json.js.map +1 -0
- package/dist/src/lifecycle.d.ts +1 -0
- package/dist/src/lifecycle.d.ts.map +1 -0
- package/dist/src/lifecycle.js +3 -2
- package/dist/src/lifecycle.js.map +1 -1
- package/dist/src/page.d.ts +63 -28
- package/dist/src/page.d.ts.map +1 -0
- package/dist/src/page.js +5 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/persisted.d.ts +6 -1
- package/dist/src/persisted.d.ts.map +1 -0
- package/dist/src/persisted.js +17 -11
- package/dist/src/persisted.js.map +1 -1
- package/dist/src/resource.d.ts +2 -1
- package/dist/src/resource.d.ts.map +1 -0
- package/dist/src/resource.js +128 -50
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/base.d.ts +66 -0
- package/dist/src/router/base.d.ts.map +1 -0
- package/dist/src/router/base.js +168 -0
- package/dist/src/router/base.js.map +1 -0
- package/dist/src/router/manifest.d.ts +1 -0
- package/dist/src/router/manifest.d.ts.map +1 -0
- package/dist/src/router/manifest.js +156 -171
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +17 -2
- package/dist/src/router/match.d.ts.map +1 -0
- package/dist/src/router/match.js +60 -6
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.d.ts +1 -0
- package/dist/src/router/navigation.d.ts.map +1 -0
- package/dist/src/router/navigation.js +58 -9
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.d.ts +1 -0
- package/dist/src/signal.d.ts.map +1 -0
- package/dist/src/signal.js +46 -17
- package/dist/src/signal.js.map +1 -1
- package/dist/src/static-runtime.d.ts +82 -0
- package/dist/src/static-runtime.d.ts.map +1 -0
- package/dist/src/static-runtime.js +209 -0
- package/dist/src/static-runtime.js.map +1 -0
- package/dist/src/vite/index.d.ts +32 -3
- package/dist/src/vite/index.d.ts.map +1 -0
- package/dist/src/vite/index.js +49 -17
- package/dist/src/vite/index.js.map +1 -1
- package/docs/README.md +5 -8
- package/docs/architecture/adr/0001-browser-static-snapshots.md +132 -0
- package/docs/architecture/v1-roadmap.md +19 -0
- package/docs/en/00-the-mado-way.md +1 -1
- package/docs/en/01-quickstart.md +183 -0
- package/docs/en/10-pages-and-components.md +279 -0
- package/docs/en/11-templates-and-signals.md +211 -0
- package/docs/en/12-routing.md +229 -0
- package/docs/en/13-data.md +227 -0
- package/docs/en/14-forms.md +91 -0
- package/docs/en/15-static-snapshots.md +181 -0
- package/docs/en/{10-app-architecture.md → 16-app-architecture.md} +56 -4
- package/docs/en/{13-deployment.md → 20-deployment.md} +79 -17
- package/docs/en/{15-error-handling.md → 21-error-handling.md} +9 -6
- package/docs/en/{14-testing.md → 22-testing.md} +6 -5
- package/docs/en/23-cookbook.md +292 -0
- package/docs/en/24-devtools-and-diagnostics.md +45 -0
- package/docs/en/{18-api-freeze-map.md → 30-api-freeze-map.md} +17 -9
- package/docs/en/{20-v1-stability.md → 32-v1-stability.md} +5 -5
- package/docs/en/33-migration-0.12-0.13.md +56 -0
- package/docs/en/40-llm-guide.md +774 -0
- package/docs/en/{06-for-backenders.md → 41-for-backenders.md} +28 -25
- package/docs/en/{05-why-mado.md → 42-why-mado.md} +3 -3
- package/docs/en/README.md +99 -27
- package/docs/recipes/nginx/Containerfile +26 -0
- package/docs/recipes/nginx/nginx.conf +42 -0
- package/llms.txt +263 -210
- package/package.json +51 -23
- package/scripts/clean.mjs +8 -0
- package/scripts/cli/generate.mjs +57 -10
- package/scripts/cli/help.mjs +13 -5
- package/scripts/cli/index.mjs +31 -7
- package/scripts/cli/init.mjs +19 -19
- package/scripts/cli/release.mjs +38 -83
- package/scripts/cli/run.mjs +5 -3
- package/scripts/logger.mjs +117 -0
- package/scripts/output-guard.mjs +128 -0
- package/scripts/preview.mjs +105 -24
- package/scripts/static/browser.mjs +695 -0
- package/scripts/static/discover.mjs +284 -0
- package/scripts/static/output.mjs +141 -0
- package/scripts/static/serialize.mjs +212 -0
- package/scripts/static/server.mjs +167 -0
- package/scripts/static.mjs +205 -0
- package/starters/default/README.md +57 -55
- package/starters/default/package.json +6 -12
- package/starters/default/src/app.routes.ts +20 -33
- package/starters/default/src/components/feature-card.component.ts +40 -0
- package/starters/default/src/components/live-counter.component.ts +57 -0
- package/starters/default/src/content/guides.ts +55 -0
- package/starters/default/src/main.ts +9 -13
- package/starters/default/src/pages/app.page.ts +41 -0
- package/starters/default/src/pages/guide.page.ts +40 -0
- package/starters/default/src/pages/home.page.ts +49 -0
- package/starters/default/src/pages/not-found.page.ts +18 -0
- package/starters/default/src/styles/document.css +39 -0
- package/starters/default/src/styles/reset.css +37 -0
- package/starters/default/src/styles/tokens.css +30 -0
- package/starters/default/src/styles.d.ts +3 -1
- package/starters/default/src/vite-env.d.ts +1 -1
- package/starters/default/tsconfig.node.json +13 -0
- package/starters/default/vite.config.ts +14 -4
- package/starters/modular/README.md +142 -0
- package/starters/modular/index.html +13 -0
- package/starters/modular/package.json +30 -0
- package/starters/modular/src/app.routes.ts +39 -0
- package/starters/{default → modular}/src/layouts/app-shell.layout.ts +9 -7
- package/starters/{default → modular}/src/layouts/auth-shell.layout.ts +3 -3
- package/starters/modular/src/main.ts +16 -0
- package/starters/{default → modular}/src/modules/auth/login.page.ts +5 -12
- package/starters/{default → modular}/src/modules/billing/pages/invoice-detail.page.ts +3 -4
- package/starters/{default → modular}/src/modules/billing/pages/invoices-list.page.ts +4 -4
- package/starters/{default → modular}/src/modules/home/home.page.ts +5 -7
- package/starters/modular/src/modules/home/not-found.page.ts +14 -0
- package/starters/{default → modular}/src/shared/styles/reset.css +21 -1
- package/starters/modular/src/styles.d.ts +1 -0
- package/starters/modular/src/vite-env.d.ts +1 -0
- package/starters/modular/tsconfig.json +33 -0
- package/starters/modular/tsconfig.node.json +13 -0
- package/starters/modular/vite.config.ts +132 -0
- package/TODO.md +0 -79
- package/dist/src/lazy.d.ts +0 -38
- package/dist/src/lazy.js +0 -73
- package/dist/src/lazy.js.map +0 -1
- package/docs/en/01-routing.md +0 -152
- package/docs/en/02-project-layout.md +0 -124
- package/docs/en/03-static-bake.md +0 -249
- package/docs/en/04-ide-setup.md +0 -162
- package/docs/en/07-llm-pitfalls.md +0 -724
- package/docs/en/08-llm-zero-history-test.md +0 -56
- package/docs/en/09-shadow-vs-light-dom.md +0 -174
- package/docs/en/11-layouts.md +0 -113
- package/docs/en/12-auth-and-api.md +0 -124
- package/docs/en/16-bake-cookbook.md +0 -100
- package/docs/en/17-shadow-dom-forms.md +0 -192
- package/docs/fr/00-the-mado-way.md +0 -85
- package/docs/fr/01-routing.md +0 -120
- package/docs/fr/02-project-layout.md +0 -84
- package/docs/fr/03-static-bake.md +0 -289
- package/docs/fr/04-ide-setup.md +0 -162
- package/docs/fr/05-why-mado.md +0 -193
- package/docs/fr/06-for-backenders.md +0 -438
- package/docs/fr/07-llm-pitfalls.md +0 -625
- package/docs/fr/08-llm-zero-history-test.md +0 -38
- package/docs/fr/09-shadow-vs-light-dom.md +0 -65
- package/docs/fr/10-app-architecture.md +0 -138
- package/docs/fr/11-layouts.md +0 -47
- package/docs/fr/12-auth-and-api.md +0 -76
- package/docs/fr/13-deployment.md +0 -57
- package/docs/fr/14-testing.md +0 -41
- package/docs/fr/15-error-handling.md +0 -50
- package/docs/fr/16-bake-cookbook.md +0 -88
- package/docs/fr/17-shadow-dom-forms.md +0 -196
- package/docs/fr/18-api-freeze-map.md +0 -63
- package/docs/fr/19-reactivity-ordering.md +0 -97
- package/docs/fr/20-v1-stability.md +0 -88
- package/docs/fr/README.md +0 -27
- package/docs/ru/00-the-mado-way.md +0 -84
- package/docs/ru/01-routing.md +0 -119
- package/docs/ru/02-project-layout.md +0 -84
- package/docs/ru/03-static-bake.md +0 -250
- package/docs/ru/04-ide-setup.md +0 -144
- package/docs/ru/05-why-mado.md +0 -193
- package/docs/ru/06-for-backenders.md +0 -428
- package/docs/ru/07-llm-pitfalls.md +0 -624
- package/docs/ru/08-llm-zero-history-test.md +0 -57
- package/docs/ru/09-shadow-vs-light-dom.md +0 -63
- package/docs/ru/10-app-architecture.md +0 -152
- package/docs/ru/11-layouts.md +0 -47
- package/docs/ru/12-auth-and-api.md +0 -75
- package/docs/ru/13-deployment.md +0 -66
- package/docs/ru/14-testing.md +0 -50
- package/docs/ru/15-error-handling.md +0 -56
- package/docs/ru/16-bake-cookbook.md +0 -95
- package/docs/ru/17-shadow-dom-forms.md +0 -193
- package/docs/ru/18-api-freeze-map.md +0 -64
- package/docs/ru/19-reactivity-ordering.md +0 -95
- package/docs/ru/20-v1-stability.md +0 -82
- package/docs/ru/README.md +0 -25
- package/docs/uk/00-the-mado-way.md +0 -82
- package/docs/uk/01-routing.md +0 -76
- package/docs/uk/02-project-layout.md +0 -73
- package/docs/uk/03-static-bake.md +0 -48
- package/docs/uk/04-ide-setup.md +0 -26
- package/docs/uk/05-why-mado.md +0 -34
- package/docs/uk/06-for-backenders.md +0 -55
- package/docs/uk/07-llm-pitfalls.md +0 -145
- package/docs/uk/08-llm-zero-history-test.md +0 -34
- package/docs/uk/09-shadow-vs-light-dom.md +0 -58
- package/docs/uk/10-app-architecture.md +0 -97
- package/docs/uk/11-layouts.md +0 -47
- package/docs/uk/12-auth-and-api.md +0 -70
- package/docs/uk/13-deployment.md +0 -40
- package/docs/uk/14-testing.md +0 -34
- package/docs/uk/15-error-handling.md +0 -32
- package/docs/uk/16-bake-cookbook.md +0 -36
- package/docs/uk/17-shadow-dom-forms.md +0 -193
- package/docs/uk/18-api-freeze-map.md +0 -61
- package/docs/uk/19-reactivity-ordering.md +0 -95
- package/docs/uk/20-v1-stability.md +0 -83
- package/docs/uk/README.md +0 -27
- package/scripts/bake.mjs +0 -570
- package/scripts/package-smoke.mjs +0 -77
- package/scripts/release-notes.mjs +0 -66
- package/scripts/size-budget.mjs +0 -91
- package/starters/default/src/modules/home/not-found.page.ts +0 -11
- package/starters/default/src/shared/ui/x-button.component.ts +0 -49
- /package/docs/en/{19-reactivity-ordering.md → 31-reactivity-ordering.md} +0 -0
- /package/starters/{default → modular}/.editorconfig +0 -0
- /package/starters/{default → modular}/eslint.config.mjs +0 -0
- /package/starters/{default → modular}/public/favicon.svg +0 -0
- /package/starters/{default → modular}/src/modules/auth/_contracts/auth-api.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.connector.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.guard.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.public.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.routes.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.service.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/_contracts/stripe.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/api/stripe.connector.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/billing.public.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/billing.routes.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/billing.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/components/invoice-status-badge.component.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/data/invoices.resource.ts +0 -0
- /package/starters/{default → modular}/src/shared/http/http-client.ts +0 -0
- /package/starters/{default → modular}/src/shared/http/http-error.ts +0 -0
- /package/starters/{default → modular}/src/shared/http/interceptors.ts +0 -0
- /package/starters/{default → modular}/src/shared/lib/format-date.ts +0 -0
- /package/starters/{default → modular}/src/shared/styles/content.css +0 -0
- /package/starters/{default → modular}/src/shared/styles/shell.css +0 -0
- /package/starters/{default → modular}/src/shared/styles/tokens.css +0 -0
- /package/starters/{default → modular}/src/shared/ui/x-spinner.component.ts +0 -0
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot capture: HTTP policy.
|
|
3
|
+
*
|
|
4
|
+
* Mado's static snapshot is a "strict" capture by default — any failed
|
|
5
|
+
* fetch surfaces as a snapshot failure unless it falls into a small,
|
|
6
|
+
* explicit allow-list. The intent is to catch broken deployments at
|
|
7
|
+
* build time, not at first-paint in production.
|
|
8
|
+
*
|
|
9
|
+
* FATAL (snapshot fails):
|
|
10
|
+
* - the main document
|
|
11
|
+
* - any script / module
|
|
12
|
+
* - any stylesheet
|
|
13
|
+
* - any fetch tracked by the runtime (resource(), mutation())
|
|
14
|
+
* - any custom-element definition referenced from the rendered DOM
|
|
15
|
+
* - any route module dynamic import
|
|
16
|
+
*
|
|
17
|
+
* IGNORED (warning at most):
|
|
18
|
+
* - /favicon.ico, /favicon.svg, /robots.txt (often missing in
|
|
19
|
+
* development; user owns the production copy)
|
|
20
|
+
* - data: URLs (inlined, not network traffic)
|
|
21
|
+
*
|
|
22
|
+
* QUALITY HINTS (timeout → diagnostic, snapshot proceeds):
|
|
23
|
+
* - document.fonts.ready (cap: 5s)
|
|
24
|
+
* - requestAnimationFrame paint frames (cap: 1s)
|
|
25
|
+
*
|
|
26
|
+
* To intentionally allow an optional resource to fail without breaking
|
|
27
|
+
* the snapshot, host it through one of the ignored paths or remove it
|
|
28
|
+
* from the initial render. There is no per-element "this can 404" escape
|
|
29
|
+
* hatch yet; if you need one, the place to add it is
|
|
30
|
+
* `isIgnorableResourceUrl()` below.
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync } from "node:fs";
|
|
33
|
+
|
|
34
|
+
import { logger } from "../logger.mjs";
|
|
35
|
+
|
|
36
|
+
const KNOWN_CHROMIUM_PATHS = [
|
|
37
|
+
"/usr/bin/google-chrome",
|
|
38
|
+
"/usr/bin/google-chrome-stable",
|
|
39
|
+
"/usr/bin/chromium",
|
|
40
|
+
"/usr/bin/chromium-browser",
|
|
41
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
42
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
43
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
44
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
45
|
+
"C:\\Program Files\\Chromium\\Application\\chrome.exe",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export async function captureStaticRoutes(options) {
|
|
49
|
+
const { chromium } = await importPlaywright();
|
|
50
|
+
const browser = await launchBrowser(chromium, options);
|
|
51
|
+
let captured = [];
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const browserVersion =
|
|
55
|
+
typeof browser.version === "function" ? browser.version() : null;
|
|
56
|
+
if (browserVersion) {
|
|
57
|
+
logger.info("static", "browser", `browser: chromium ${browserVersion}`);
|
|
58
|
+
}
|
|
59
|
+
captured = await mapConcurrent(
|
|
60
|
+
options.records,
|
|
61
|
+
options.concurrency ?? 4,
|
|
62
|
+
async (record) => ({ ...record, html: await captureRoute(browser, record, options) }),
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
await browser.close();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return captured;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function mapConcurrent(items, concurrency, task) {
|
|
72
|
+
const results = new Array(items.length);
|
|
73
|
+
let cursor = 0;
|
|
74
|
+
const worker = async () => {
|
|
75
|
+
while (cursor < items.length) {
|
|
76
|
+
const index = cursor++;
|
|
77
|
+
results[index] = await task(items[index], index);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const count = Math.max(1, Math.min(items.length, Number(concurrency) || 1));
|
|
81
|
+
await Promise.all(Array.from({ length: count }, worker));
|
|
82
|
+
return results;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Join a Mado-canonical base (`"/"` or `"/prefix/"`) with a route
|
|
87
|
+
* pathname so the capture browser navigates to the same URL shape the
|
|
88
|
+
* deployed app will serve.
|
|
89
|
+
*
|
|
90
|
+
* withBase("/", "/docs") === "/docs"
|
|
91
|
+
* withBase("/mado/", "/docs") === "/mado/docs"
|
|
92
|
+
* withBase("/mado/", "/") === "/mado/"
|
|
93
|
+
*/
|
|
94
|
+
function withBaseLocal(base, pathname) {
|
|
95
|
+
const b = base && base !== "/" ? base : "/";
|
|
96
|
+
const p = pathname || "/";
|
|
97
|
+
const abs = p.startsWith("/") ? p : "/" + p;
|
|
98
|
+
if (b === "/") return abs;
|
|
99
|
+
if (abs === "/") return b.slice(0, -1) || "/";
|
|
100
|
+
return b + abs.slice(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function captureRoute(browser, record, options) {
|
|
104
|
+
const context = await browser.newContext({ serviceWorkers: "block" });
|
|
105
|
+
const pageErrors = [];
|
|
106
|
+
const failedRequests = [];
|
|
107
|
+
const consoleErrors = [];
|
|
108
|
+
await context.route("**/*", async (route) => {
|
|
109
|
+
const request = route.request();
|
|
110
|
+
if (isCaptureAllowedUrl(request.url(), options.serverOrigin)) {
|
|
111
|
+
await route.continue();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
failedRequests.push(
|
|
115
|
+
`${request.method()} ${request.url()} blocked external request`,
|
|
116
|
+
);
|
|
117
|
+
await route.abort("blockedbyclient");
|
|
118
|
+
});
|
|
119
|
+
const page = await context.newPage();
|
|
120
|
+
|
|
121
|
+
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
122
|
+
page.on("requestfailed", (request) => {
|
|
123
|
+
if (isIgnorableResourceUrl(request.url())) return;
|
|
124
|
+
failedRequests.push(
|
|
125
|
+
`${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`.trim(),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
page.on("response", (response) => {
|
|
129
|
+
if (response.status() < 400) return;
|
|
130
|
+
if (isIgnorableResourceUrl(response.url())) return;
|
|
131
|
+
failedRequests.push(`${response.request().method()} ${response.url()} ${response.status()}`);
|
|
132
|
+
});
|
|
133
|
+
page.on("console", (msg) => {
|
|
134
|
+
if (msg.type() !== "error") return;
|
|
135
|
+
const text = msg.text();
|
|
136
|
+
if (isIgnorableConsoleError(text)) return;
|
|
137
|
+
consoleErrors.push(text);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// Navigate through the capture server using the real deployment URL
|
|
142
|
+
// shape: base-prefixed pathname. The runtime sees the same
|
|
143
|
+
// `location.pathname` it would see on the production CDN, so the
|
|
144
|
+
// router's stripBase()/withBase() codepaths are exercised exactly
|
|
145
|
+
// once per snapshot.
|
|
146
|
+
const browserPath = withBaseLocal(options.base ?? "/", record.pathname);
|
|
147
|
+
await page.goto(`${options.serverOrigin}${browserPath}`, {
|
|
148
|
+
waitUntil: "domcontentloaded",
|
|
149
|
+
timeout: options.timeout,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
await waitForMadoStability(page, record, options.timeout);
|
|
153
|
+
|
|
154
|
+
// Web fonts that never resolve must not block the snapshot. Fonts
|
|
155
|
+
// are best-effort: if `document.fonts.ready` does not settle within
|
|
156
|
+
// the cap, we proceed and let the missing-font warning surface
|
|
157
|
+
// through the existing console-message listener.
|
|
158
|
+
await runWithTimeout(
|
|
159
|
+
page.evaluate(async () => {
|
|
160
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
161
|
+
}),
|
|
162
|
+
options.fontsTimeout ?? 5_000,
|
|
163
|
+
`[mado:static] ${record.pathname}: document.fonts.ready did not settle in time; capturing without font metrics.`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Two paint frames so any rAF-driven layout/style effect can flush
|
|
167
|
+
// before we serialize the DOM. Bounded to prevent a stalled tab from
|
|
168
|
+
// hanging the pipeline.
|
|
169
|
+
await runWithTimeout(
|
|
170
|
+
page.evaluate(
|
|
171
|
+
() =>
|
|
172
|
+
new Promise((resolve) =>
|
|
173
|
+
requestAnimationFrame(() => requestAnimationFrame(resolve)),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
options.paintTimeout ?? 1_000,
|
|
177
|
+
`[mado:static] ${record.pathname}: paint frames did not flush in time; capturing anyway.`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const undefinedCustomElements = await collectUndefinedCustomElements(page);
|
|
181
|
+
if (undefinedCustomElements.length > 0) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`[mado:static] ${record.pathname}: unresolved custom elements: ` +
|
|
184
|
+
undefinedCustomElements.join(", "),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (pageErrors.length || consoleErrors.length || failedRequests.length) {
|
|
189
|
+
const diagnostics = await runtimeDiagnostics(page);
|
|
190
|
+
throw new Error(
|
|
191
|
+
formatRouteFailure(record, {
|
|
192
|
+
diagnostics,
|
|
193
|
+
pageErrors,
|
|
194
|
+
consoleErrors,
|
|
195
|
+
failedRequests,
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return await serializeDocument(page, {
|
|
201
|
+
appId: options.appId ?? "app",
|
|
202
|
+
serverOrigin: options.serverOrigin,
|
|
203
|
+
baseUrl: options.baseUrl,
|
|
204
|
+
site: options.site ?? "",
|
|
205
|
+
base: options.base ?? "/",
|
|
206
|
+
pathname: record.pathname,
|
|
207
|
+
});
|
|
208
|
+
} finally {
|
|
209
|
+
await context.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function waitForMadoStability(page, record, timeout) {
|
|
214
|
+
try {
|
|
215
|
+
await withTimeout(
|
|
216
|
+
page.evaluate(async () => {
|
|
217
|
+
const runtime = window.__MADO_STATIC__;
|
|
218
|
+
if (!runtime) {
|
|
219
|
+
throw new Error("window.__MADO_STATIC__ was not installed.");
|
|
220
|
+
}
|
|
221
|
+
// Runtime exposes phase-bounded timeouts internally
|
|
222
|
+
// (routeReady, resources). The outer `timeout` here is a
|
|
223
|
+
// safety net for the whole sequence.
|
|
224
|
+
await runtime.whenStable();
|
|
225
|
+
}),
|
|
226
|
+
timeout,
|
|
227
|
+
);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const diagnostics = await runtimeDiagnostics(page);
|
|
230
|
+
throw new Error(
|
|
231
|
+
formatRouteFailure(record, {
|
|
232
|
+
diagnostics,
|
|
233
|
+
pageErrors: [err.message],
|
|
234
|
+
consoleErrors: [],
|
|
235
|
+
failedRequests: [],
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Best-effort timeout wrapper that swallows timeouts (logging a
|
|
243
|
+
* diagnostic) instead of failing the snapshot. Used for fonts and paint
|
|
244
|
+
* frames — both are render-quality hints, not correctness checks.
|
|
245
|
+
*/
|
|
246
|
+
async function runWithTimeout(promise, timeout, swallowMessage) {
|
|
247
|
+
let timer;
|
|
248
|
+
try {
|
|
249
|
+
await Promise.race([
|
|
250
|
+
promise,
|
|
251
|
+
new Promise((_, reject) => {
|
|
252
|
+
timer = setTimeout(
|
|
253
|
+
() => reject(new Error("timeout")),
|
|
254
|
+
timeout,
|
|
255
|
+
);
|
|
256
|
+
}),
|
|
257
|
+
]);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
if (err && err.message === "timeout") {
|
|
260
|
+
logger.warn("static", "browser-resource", swallowMessage);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
throw err;
|
|
264
|
+
} finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function runtimeDiagnostics(page) {
|
|
270
|
+
try {
|
|
271
|
+
return await page.evaluate(() => window.__MADO_STATIC__?.diagnostics?.() ?? null);
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function collectUndefinedCustomElements(page) {
|
|
278
|
+
return page.evaluate(() => {
|
|
279
|
+
const names = new Set();
|
|
280
|
+
const visit = (root) => {
|
|
281
|
+
for (const el of root.querySelectorAll("*")) {
|
|
282
|
+
const name = el.localName;
|
|
283
|
+
if (name.includes("-") && !customElements.get(name)) names.add(name);
|
|
284
|
+
if (el.shadowRoot) visit(el.shadowRoot);
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
visit(document);
|
|
288
|
+
return [...names].sort();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function serializeDocument(page, options) {
|
|
293
|
+
return page.evaluate((opts) => {
|
|
294
|
+
const { appId, serverOrigin, baseUrl, site, base, pathname } = opts;
|
|
295
|
+
const added = [];
|
|
296
|
+
// Strip both the modern attribute marker and the legacy inline script
|
|
297
|
+
// (older snapshots may still contain it). The seed <script> is
|
|
298
|
+
// intentionally kept so the production client can consume it on boot.
|
|
299
|
+
document.documentElement.removeAttribute("data-mado-static-capture");
|
|
300
|
+
for (const script of document.querySelectorAll("script[data-mado-static-mode]")) {
|
|
301
|
+
script.remove();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const app = document.getElementById(appId);
|
|
305
|
+
if (app) app.setAttribute("data-mado-static", "");
|
|
306
|
+
|
|
307
|
+
// ---- canonical / og:url fallback ----
|
|
308
|
+
//
|
|
309
|
+
// If page.head() did not produce these, derive them from `site + base
|
|
310
|
+
// + pathname` so static documents always carry the production URL.
|
|
311
|
+
// We only fill in absent values; explicit user-provided canonical /
|
|
312
|
+
// og:url wins.
|
|
313
|
+
if (site) {
|
|
314
|
+
const absoluteUrl = buildAbsoluteUrl(site, base, pathname);
|
|
315
|
+
ensureCanonical(absoluteUrl);
|
|
316
|
+
ensureOgUrl(absoluteUrl);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Walk every open shadow root reachable from the document and run the
|
|
320
|
+
// same normalization in each. Mado canonical components live inside
|
|
321
|
+
// shadow trees, so a Light-DOM-only walker would lose form state and
|
|
322
|
+
// CSS for the very components DSD is meant to serialize.
|
|
323
|
+
const openShadowRoots = collectShadowRoots(document);
|
|
324
|
+
for (const root of [document, ...openShadowRoots]) {
|
|
325
|
+
normalizeDomStateIn(root);
|
|
326
|
+
}
|
|
327
|
+
materializeShadowStyles(openShadowRoots, added);
|
|
328
|
+
|
|
329
|
+
// Verification: count live open shadow roots before serialization and
|
|
330
|
+
// assert the same count of <template shadowrootmode> in the output.
|
|
331
|
+
// A drop indicates a host that was not opened with `serializable: true`.
|
|
332
|
+
const expectedShadowRoots = openShadowRoots.length;
|
|
333
|
+
|
|
334
|
+
let html;
|
|
335
|
+
try {
|
|
336
|
+
if (typeof document.documentElement.getHTML !== "function") {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"document.documentElement.getHTML() is unavailable in this browser.",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const attrs = [...document.documentElement.attributes]
|
|
342
|
+
.map((attr) => `${attr.name}="${escapeAttr(attr.value)}"`)
|
|
343
|
+
.join(" ");
|
|
344
|
+
html =
|
|
345
|
+
"<!doctype html>\n" +
|
|
346
|
+
`<html${attrs ? ` ${attrs}` : ""}>` +
|
|
347
|
+
document.documentElement.getHTML({
|
|
348
|
+
serializableShadowRoots: true,
|
|
349
|
+
}) +
|
|
350
|
+
"</html>";
|
|
351
|
+
} finally {
|
|
352
|
+
for (const node of added) node.remove();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const serializedShadowRoots = (
|
|
356
|
+
html.match(/<template[^>]+shadowrootmode\b/g) ?? []
|
|
357
|
+
).length;
|
|
358
|
+
if (serializedShadowRoots < expectedShadowRoots) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`[mado:static] DSD count mismatch: ${expectedShadowRoots} open ` +
|
|
361
|
+
`shadow roots present but ${serializedShadowRoots} serialized. ` +
|
|
362
|
+
"Make sure all custom elements attach their shadow root with " +
|
|
363
|
+
"{ mode: 'open', serializable: true }.",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return html.split(serverOrigin).join(baseUrl.replace(/\/$/, ""));
|
|
368
|
+
|
|
369
|
+
function buildAbsoluteUrl(origin, baseValue, route) {
|
|
370
|
+
const left = origin.replace(/\/+$/, "");
|
|
371
|
+
const b = baseValue && baseValue !== "/" ? baseValue : "/";
|
|
372
|
+
const p = route || "/";
|
|
373
|
+
const abs = p.startsWith("/") ? p : "/" + p;
|
|
374
|
+
let combined;
|
|
375
|
+
if (b === "/") {
|
|
376
|
+
combined = left + abs;
|
|
377
|
+
} else if (abs === "/") {
|
|
378
|
+
// "/mado/" + "/" → "/mado" (canonical pathnames have no trailing slash
|
|
379
|
+
// except for the bare root).
|
|
380
|
+
combined = left + b.slice(0, -1);
|
|
381
|
+
} else {
|
|
382
|
+
combined = left + b + abs.slice(1);
|
|
383
|
+
}
|
|
384
|
+
// Strip a single trailing slash unless the whole URL is the bare
|
|
385
|
+
// origin or origin + base root.
|
|
386
|
+
if (
|
|
387
|
+
combined.length > left.length + 1 &&
|
|
388
|
+
combined.endsWith("/")
|
|
389
|
+
) {
|
|
390
|
+
combined = combined.slice(0, -1);
|
|
391
|
+
}
|
|
392
|
+
return combined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function ensureCanonical(absoluteUrl) {
|
|
396
|
+
const existing = document.head.querySelectorAll('link[rel="canonical"]');
|
|
397
|
+
// De-duplicate: keep the first, drop the rest. A second canonical
|
|
398
|
+
// is always wrong and confuses search engines.
|
|
399
|
+
for (let i = 1; i < existing.length; i++) existing[i].remove();
|
|
400
|
+
const first = existing[0];
|
|
401
|
+
if (first) {
|
|
402
|
+
const href = first.getAttribute("href") || "";
|
|
403
|
+
if (!isUsableAbsoluteUrl(href)) {
|
|
404
|
+
first.setAttribute("href", absoluteUrl);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const link = document.createElement("link");
|
|
409
|
+
link.setAttribute("rel", "canonical");
|
|
410
|
+
link.setAttribute("href", absoluteUrl);
|
|
411
|
+
// Mark the static fallback as Mado-managed so the runtime
|
|
412
|
+
// `applyHead()` (which clears every `[data-mado-head]` before
|
|
413
|
+
// writing the new metadata) can remove stale canonicals after an
|
|
414
|
+
// SPA navigation into a page that does not declare its own.
|
|
415
|
+
link.setAttribute("data-mado-head", "static");
|
|
416
|
+
document.head.appendChild(link);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function ensureOgUrl(absoluteUrl) {
|
|
420
|
+
const existing = document.head.querySelectorAll('meta[property="og:url"]');
|
|
421
|
+
for (let i = 1; i < existing.length; i++) existing[i].remove();
|
|
422
|
+
const first = existing[0];
|
|
423
|
+
if (first) {
|
|
424
|
+
const content = first.getAttribute("content") || "";
|
|
425
|
+
if (!isUsableAbsoluteUrl(content)) {
|
|
426
|
+
first.setAttribute("content", absoluteUrl);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const meta = document.createElement("meta");
|
|
431
|
+
meta.setAttribute("property", "og:url");
|
|
432
|
+
meta.setAttribute("content", absoluteUrl);
|
|
433
|
+
// Same rationale as ensureCanonical: handing the tag to
|
|
434
|
+
// applyHead()'s clean-slate selector prevents stale OG URLs from
|
|
435
|
+
// surviving a navigation into a page without explicit head().
|
|
436
|
+
meta.setAttribute("data-mado-head", "static");
|
|
437
|
+
document.head.appendChild(meta);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isUsableAbsoluteUrl(value) {
|
|
441
|
+
if (!value) return false;
|
|
442
|
+
let parsed;
|
|
443
|
+
try {
|
|
444
|
+
parsed = new URL(value);
|
|
445
|
+
} catch {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
449
|
+
if (parsed.hostname === "localhost") return false;
|
|
450
|
+
if (parsed.hostname === "127.0.0.1") return false;
|
|
451
|
+
if (parsed.hostname === "::1") return false;
|
|
452
|
+
// Reject the capture server's own origin: that proves head() emitted
|
|
453
|
+
// a value derived from `location.origin` instead of the public site.
|
|
454
|
+
if (parsed.origin === serverOrigin) return false;
|
|
455
|
+
return true;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function collectShadowRoots(start) {
|
|
459
|
+
const out = [];
|
|
460
|
+
const stack = [start];
|
|
461
|
+
while (stack.length) {
|
|
462
|
+
const root = stack.pop();
|
|
463
|
+
for (const el of root.querySelectorAll("*")) {
|
|
464
|
+
if (el.shadowRoot) {
|
|
465
|
+
out.push(el.shadowRoot);
|
|
466
|
+
stack.push(el.shadowRoot);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function materializeShadowStyles(roots, out) {
|
|
474
|
+
for (const shadow of roots) {
|
|
475
|
+
for (const sheet of shadow.adoptedStyleSheets ?? []) {
|
|
476
|
+
const text = stylesheetText(sheet);
|
|
477
|
+
if (!text) continue;
|
|
478
|
+
const style = document.createElement("style");
|
|
479
|
+
style.setAttribute("data-mado-static-style", stableHash(text));
|
|
480
|
+
style.textContent = text;
|
|
481
|
+
shadow.insertBefore(style, shadow.firstChild);
|
|
482
|
+
out.push(style);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function stylesheetText(sheet) {
|
|
488
|
+
try {
|
|
489
|
+
return [...sheet.cssRules].map((rule) => rule.cssText).join("\n");
|
|
490
|
+
} catch {
|
|
491
|
+
return "";
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function normalizeDomStateIn(root) {
|
|
496
|
+
for (const input of root.querySelectorAll("input")) {
|
|
497
|
+
if (input.type === "password") continue;
|
|
498
|
+
if (input.type === "checkbox" || input.type === "radio") {
|
|
499
|
+
if (input.checked) input.setAttribute("checked", "");
|
|
500
|
+
else input.removeAttribute("checked");
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (input.value !== input.getAttribute("value")) {
|
|
504
|
+
input.setAttribute("value", input.value);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
for (const textarea of root.querySelectorAll("textarea")) {
|
|
509
|
+
textarea.textContent = textarea.value;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
for (const select of root.querySelectorAll("select")) {
|
|
513
|
+
for (const option of select.options) {
|
|
514
|
+
if (option.selected) option.setAttribute("selected", "");
|
|
515
|
+
else option.removeAttribute("selected");
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const details of root.querySelectorAll("details")) {
|
|
520
|
+
if (details.open) details.setAttribute("open", "");
|
|
521
|
+
else details.removeAttribute("open");
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function stableHash(text) {
|
|
526
|
+
let hash = 0x811c9dc5;
|
|
527
|
+
for (let i = 0; i < text.length; i++) {
|
|
528
|
+
hash ^= text.charCodeAt(i);
|
|
529
|
+
hash = Math.imul(hash, 0x01000193);
|
|
530
|
+
}
|
|
531
|
+
return (hash >>> 0).toString(36);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function escapeAttr(value) {
|
|
535
|
+
return String(value)
|
|
536
|
+
.replace(/&/g, "&")
|
|
537
|
+
.replace(/"/g, """)
|
|
538
|
+
.replace(/</g, "<")
|
|
539
|
+
.replace(/>/g, ">");
|
|
540
|
+
}
|
|
541
|
+
}, options);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function importPlaywright() {
|
|
545
|
+
try {
|
|
546
|
+
return await import("playwright-core");
|
|
547
|
+
} catch {
|
|
548
|
+
throw new Error(
|
|
549
|
+
"[mado:static] package 'playwright-core' is required.\n" +
|
|
550
|
+
"Install it as a dev dependency in this project:\n" +
|
|
551
|
+
" npm i -D playwright-core",
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The browser pulls `/favicon.ico` automatically and many user shells do
|
|
558
|
+
* not provide one; a 404 there must not fail the snapshot. Likewise any
|
|
559
|
+
* resource the user explicitly marked as optional (data:, devtools).
|
|
560
|
+
*/
|
|
561
|
+
function isIgnorableResourceUrl(url) {
|
|
562
|
+
if (!url) return false;
|
|
563
|
+
if (url.startsWith("data:")) return true;
|
|
564
|
+
try {
|
|
565
|
+
const u = new URL(url);
|
|
566
|
+
if (u.pathname === "/favicon.ico") return true;
|
|
567
|
+
if (u.pathname === "/favicon.svg") return true;
|
|
568
|
+
if (u.pathname === "/robots.txt") return true;
|
|
569
|
+
return false;
|
|
570
|
+
} catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function isCaptureAllowedUrl(url, serverOrigin) {
|
|
576
|
+
if (!url) return false;
|
|
577
|
+
if (url.startsWith("data:") || url.startsWith("blob:")) return true;
|
|
578
|
+
try {
|
|
579
|
+
return new URL(url).origin === new URL(serverOrigin).origin;
|
|
580
|
+
} catch {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** @internal */
|
|
586
|
+
export const _testHooks = { isCaptureAllowedUrl };
|
|
587
|
+
|
|
588
|
+
function isIgnorableConsoleError(text) {
|
|
589
|
+
if (!text) return false;
|
|
590
|
+
// Browser-level "Failed to load resource" lines without context. The
|
|
591
|
+
// matching response/requestfailed listeners already classify the URL.
|
|
592
|
+
if (/Failed to load resource/i.test(text) && !/\bmado\b/i.test(text)) {
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Browser launch order (explicit-first, Playwright-managed second):
|
|
600
|
+
*
|
|
601
|
+
* 1. --browser-path / MADO_BROWSER_PATH (operator override)
|
|
602
|
+
* 2. --browser-channel / MADO_BROWSER_CHANNEL (operator override)
|
|
603
|
+
* 3. chromium.launch() (Playwright-managed Chromium)
|
|
604
|
+
* 4. channel: "chrome" (system Chrome stable)
|
|
605
|
+
* 5. known system executable paths (Linux/macOS/Windows)
|
|
606
|
+
*
|
|
607
|
+
* Playwright recommends installing browsers via its own CLI in CI
|
|
608
|
+
* (`npx playwright install --with-deps chromium`) so the browser
|
|
609
|
+
* revision is version-pinned against the running Playwright. We honour
|
|
610
|
+
* that recommendation by trying the managed Chromium *before* any
|
|
611
|
+
* branded Chrome so that pinned-environment CI is deterministic.
|
|
612
|
+
*/
|
|
613
|
+
async function launchBrowser(chromium, options) {
|
|
614
|
+
const explicitPath = options.browserPath || process.env.MADO_BROWSER_PATH;
|
|
615
|
+
if (explicitPath) {
|
|
616
|
+
return chromium.launch({
|
|
617
|
+
executablePath: explicitPath,
|
|
618
|
+
headless: true,
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const explicitChannel = options.browserChannel || process.env.MADO_BROWSER_CHANNEL;
|
|
623
|
+
if (explicitChannel) {
|
|
624
|
+
return chromium.launch({ channel: explicitChannel, headless: true });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const errors = [];
|
|
628
|
+
|
|
629
|
+
// Playwright-managed Chromium first: matches Playwright's protocol and
|
|
630
|
+
// gives deterministic DSD serialization independent of any branded
|
|
631
|
+
// Chrome that happens to be installed.
|
|
632
|
+
try {
|
|
633
|
+
return await chromium.launch({ headless: true });
|
|
634
|
+
} catch (err) {
|
|
635
|
+
errors.push(err.message);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// System Chrome stable second: useful for "does this still work with
|
|
639
|
+
// the latest branded Chrome?" smoke checks.
|
|
640
|
+
try {
|
|
641
|
+
return await chromium.launch({ channel: "chrome", headless: true });
|
|
642
|
+
} catch (err) {
|
|
643
|
+
errors.push(err.message);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
for (const path of KNOWN_CHROMIUM_PATHS) {
|
|
647
|
+
if (!existsSync(path)) continue;
|
|
648
|
+
try {
|
|
649
|
+
return await chromium.launch({ executablePath: path, headless: true });
|
|
650
|
+
} catch (err) {
|
|
651
|
+
errors.push(err.message);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
throw new Error(
|
|
656
|
+
"[mado:static] No compatible Chromium browser was found.\n\n" +
|
|
657
|
+
"Provide one of:\n" +
|
|
658
|
+
" npx playwright install --with-deps chromium (preferred in CI)\n" +
|
|
659
|
+
" mado static --browser-channel chrome\n" +
|
|
660
|
+
" mado static --browser-path /path/to/chrome\n" +
|
|
661
|
+
" MADO_BROWSER_CHANNEL=chrome\n" +
|
|
662
|
+
" MADO_BROWSER_PATH=/path/to/chrome\n\n" +
|
|
663
|
+
(errors.length ? `Last launch error: ${errors.at(-1)}` : ""),
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function withTimeout(promise, timeout) {
|
|
668
|
+
let timer;
|
|
669
|
+
return Promise.race([
|
|
670
|
+
promise,
|
|
671
|
+
new Promise((_, reject) => {
|
|
672
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${timeout}ms`)), timeout);
|
|
673
|
+
}),
|
|
674
|
+
]).finally(() => clearTimeout(timer));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function formatRouteFailure(record, details) {
|
|
678
|
+
const lines = [
|
|
679
|
+
`[mado:static] failed to capture ${record.pathname}`,
|
|
680
|
+
`route: ${record.pattern}`,
|
|
681
|
+
];
|
|
682
|
+
if (details.diagnostics) {
|
|
683
|
+
lines.push(`router state: ${details.diagnostics.lastRouterState ?? "unknown"}`);
|
|
684
|
+
lines.push(
|
|
685
|
+
`pending: ${details.diagnostics.pending?.join(", ") || "none"}`,
|
|
686
|
+
);
|
|
687
|
+
if (details.diagnostics.errors?.length) {
|
|
688
|
+
lines.push(`runtime errors: ${details.diagnostics.errors.join(" | ")}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (details.pageErrors.length) lines.push(`page errors: ${details.pageErrors.join(" | ")}`);
|
|
692
|
+
if (details.consoleErrors.length) lines.push(`console errors: ${details.consoleErrors.join(" | ")}`);
|
|
693
|
+
if (details.failedRequests.length) lines.push(`failed requests: ${details.failedRequests.join(" | ")}`);
|
|
694
|
+
return lines.join("\n");
|
|
695
|
+
}
|