@madojs/mado 0.11.0 → 0.12.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 +102 -25
- package/CHANGELOG.md +185 -1
- package/README.md +137 -172
- package/dist/src/component.d.ts +3 -2
- package/dist/src/component.js +98 -6
- package/dist/src/component.js.map +1 -1
- package/dist/src/css.js +34 -5
- package/dist/src/css.js.map +1 -1
- package/dist/src/head.d.ts +6 -6
- package/dist/src/head.js +6 -6
- package/dist/src/html/bindings.js +3 -3
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html/parser.d.ts +1 -1
- package/dist/src/html/parser.js +1 -1
- package/dist/src/html/template.js +24 -10
- package/dist/src/html/template.js.map +1 -1
- package/dist/src/index.d.ts +8 -4
- package/dist/src/index.js +13 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +62 -28
- package/dist/src/page.js +5 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/resource.js +2 -1
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/base.d.ts +65 -0
- package/dist/src/router/base.js +168 -0
- package/dist/src/router/base.js.map +1 -0
- package/dist/src/router/manifest.js +75 -31
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +16 -2
- package/dist/src/router/match.js +41 -1
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.js +58 -9
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/static-runtime.d.ts +81 -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 +31 -3
- package/dist/src/vite/index.js +49 -18
- 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/en/00-the-mado-way.md +1 -1
- package/docs/en/01-quickstart.md +183 -0
- package/docs/en/10-pages-and-components.md +271 -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 +225 -0
- package/docs/en/14-forms.md +244 -0
- package/docs/en/15-static-snapshots.md +181 -0
- package/docs/en/{10-app-architecture.md → 16-app-architecture.md} +53 -2
- package/docs/en/{13-deployment.md → 20-deployment.md} +59 -10
- package/docs/en/{14-testing.md → 22-testing.md} +6 -5
- package/docs/en/23-cookbook.md +292 -0
- package/docs/en/{18-api-freeze-map.md → 30-api-freeze-map.md} +12 -3
- package/docs/en/{20-v1-stability.md → 32-v1-stability.md} +3 -3
- package/docs/en/40-llm-guide.md +776 -0
- package/docs/en/{06-for-backenders.md → 41-for-backenders.md} +15 -16
- package/docs/en/{05-why-mado.md → 42-why-mado.md} +1 -1
- package/docs/en/README.md +97 -27
- package/docs/recipes/nginx/Containerfile +26 -0
- package/docs/recipes/nginx/nginx.conf +42 -0
- package/llms.txt +246 -211
- package/package.json +17 -10
- package/scripts/bake.mjs +4 -568
- package/scripts/cli/generate.mjs +55 -5
- package/scripts/cli/help.mjs +11 -5
- package/scripts/cli/index.mjs +20 -4
- package/scripts/cli/init.mjs +7 -2
- package/scripts/cli/release.mjs +20 -68
- package/scripts/docs-lint.mjs +170 -0
- package/scripts/package-smoke.mjs +67 -9
- package/scripts/preview.mjs +95 -6
- package/scripts/size-budget.mjs +5 -1
- package/scripts/static/browser.mjs +654 -0
- package/scripts/static/discover.mjs +281 -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 +191 -0
- package/starters/default/README.md +57 -55
- package/starters/default/package.json +3 -9
- 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/vite.config.ts +14 -1
- 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 +8 -5
- 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/billing/pages/invoices-list.page.ts +3 -3
- 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/modular/src/styles.d.ts +1 -0
- package/starters/modular/src/vite-env.d.ts +1 -0
- package/starters/modular/tsconfig.json +24 -0
- package/starters/modular/vite.config.ts +131 -0
- package/TODO.md +0 -79
- 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/starters/default/src/modules/home/not-found.page.ts +0 -11
- /package/docs/en/{15-error-handling.md → 21-error-handling.md} +0 -0
- /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/auth/login.page.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/modules/billing/pages/invoice-detail.page.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/reset.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-button.component.ts +0 -0
- /package/starters/{default → modular}/src/shared/ui/x-spinner.component.ts +0 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { detectContext, getPackageRoot, resolveProjectPath } from "../_config.mjs";
|
|
5
|
+
import { assertJsonSerializable } from "./serialize.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build-time route discovery for static snapshots.
|
|
9
|
+
*
|
|
10
|
+
* Uses Vite's SSR module loader as a *control plane only* — no templates
|
|
11
|
+
* or components are rendered in Node. Routing, flattening, param
|
|
12
|
+
* extraction and pathname materialisation reuse the canonical helpers
|
|
13
|
+
* exported by src/router/match.ts so the discovery pipeline never drifts
|
|
14
|
+
* out of sync with the runtime router.
|
|
15
|
+
*/
|
|
16
|
+
export async function discoverStaticRoutes(options) {
|
|
17
|
+
const projectRoot = resolve(options.projectRoot);
|
|
18
|
+
const context = detectContext(projectRoot);
|
|
19
|
+
const entry = resolveProjectPath(projectRoot, options.entry ?? pickDefaultEntry(projectRoot));
|
|
20
|
+
|
|
21
|
+
if (!existsSync(entry)) {
|
|
22
|
+
// Throwing — rather than `process.exit()` — lets the outer
|
|
23
|
+
// `scripts/static.mjs` finalize its temp-directory cleanup before
|
|
24
|
+
// the process terminates.
|
|
25
|
+
throw new Error(
|
|
26
|
+
`[mado:static] entry not found: ${entry}\n` +
|
|
27
|
+
`[mado:static] expected src/app.routes.ts or src/routes.ts; ` +
|
|
28
|
+
`pass --entry <file> to override`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
installNodeDomStubs();
|
|
33
|
+
|
|
34
|
+
let createViteServer;
|
|
35
|
+
try {
|
|
36
|
+
({ createServer: createViteServer } = await import("vite"));
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(
|
|
39
|
+
"[mado:static] package 'vite' is required.\n" +
|
|
40
|
+
"[mado:static] Install it as a dev dependency in this project:\n" +
|
|
41
|
+
"[mado:static] npm i -D vite playwright-core",
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Two facts to keep in sync:
|
|
46
|
+
// 1. In-repo runs MUST resolve `@madojs/mado` to the live source so the
|
|
47
|
+
// framework can test itself without a build artefact.
|
|
48
|
+
// 2. App runs need our routing helpers loaded through SSR alongside the
|
|
49
|
+
// user manifest; we expose them via `__mado_match__` so we can ask
|
|
50
|
+
// for them via ssrLoadModule without colliding with user imports.
|
|
51
|
+
const aliases = {
|
|
52
|
+
__mado_match__: resolve(getPackageRoot(), "src/router/match.ts"),
|
|
53
|
+
...(context === "repo"
|
|
54
|
+
? { "@madojs/mado": resolve(projectRoot, "src/index.ts") }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const viteServer = await createViteServer({
|
|
59
|
+
root: projectRoot,
|
|
60
|
+
logLevel: "error",
|
|
61
|
+
server: { middlewareMode: true },
|
|
62
|
+
appType: "custom",
|
|
63
|
+
resolve: { alias: aliases },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Load the canonical routing utilities through the SAME Vite SSR loader
|
|
68
|
+
// that loads the user manifest, so both halves see one consistent copy
|
|
69
|
+
// of @madojs/mado. Try the source first (works in-repo) and fall back
|
|
70
|
+
// to a direct import for app contexts where the package may not be
|
|
71
|
+
// resolvable through Vite's SSR alias chain.
|
|
72
|
+
const { flatten, paramKeys, applyParams, isPage } = await loadCoreHelpers(viteServer);
|
|
73
|
+
|
|
74
|
+
const routesModule = await viteServer.ssrLoadModule(toViteId(entry));
|
|
75
|
+
const manifest = routesModule.manifest ?? routesModule.default?.manifest;
|
|
76
|
+
if (!manifest) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`${entry} must export const manifest = {...} (the same object passed to routes()).`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const records = [];
|
|
83
|
+
const seen = new Map();
|
|
84
|
+
for (const [pattern, flat] of flatten(manifest)) {
|
|
85
|
+
const page = await resolvePage(flat.loader, pattern, isPage);
|
|
86
|
+
if (!page?.static) continue;
|
|
87
|
+
|
|
88
|
+
validateStaticRoute(pattern, flat, page, paramKeys);
|
|
89
|
+
for (const layoutLoader of flat.layouts) {
|
|
90
|
+
const layout = await resolvePage(layoutLoader, `${pattern} layout`, isPage);
|
|
91
|
+
if (layout?.guard) {
|
|
92
|
+
throw new Error(`[mado:static] ${pattern}: static routes cannot use guarded layouts.`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = page.static === true ? {} : page.static;
|
|
97
|
+
const paramsList = await resolveParams(pattern, config, paramKeys);
|
|
98
|
+
for (const params of paramsList) {
|
|
99
|
+
const pathname = applyParams(pattern, params);
|
|
100
|
+
if (seen.has(pathname)) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`[mado:static] duplicate generated URL ${pathname} from ${pattern}; ` +
|
|
103
|
+
`already produced by ${seen.get(pathname)}.`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
seen.set(pathname, pattern);
|
|
107
|
+
|
|
108
|
+
const record = {
|
|
109
|
+
pattern,
|
|
110
|
+
pathname,
|
|
111
|
+
params,
|
|
112
|
+
};
|
|
113
|
+
if (config.initialData) {
|
|
114
|
+
const initialData = await config.initialData(params);
|
|
115
|
+
assertJsonSerializable(initialData, `${pattern} -> ${pathname}`);
|
|
116
|
+
record.initialData = initialData;
|
|
117
|
+
}
|
|
118
|
+
records.push(record);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
records.sort((a, b) => a.pathname.localeCompare(b.pathname));
|
|
123
|
+
return { entry, records };
|
|
124
|
+
} finally {
|
|
125
|
+
await viteServer.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateStaticRoute(pattern, flat, page, paramKeys) {
|
|
130
|
+
if (pattern === "*") {
|
|
131
|
+
throw new Error("[mado:static] wildcard routes cannot be static.");
|
|
132
|
+
}
|
|
133
|
+
if (flat.guards.length > 0) {
|
|
134
|
+
throw new Error(`[mado:static] ${pattern}: static routes cannot inherit layout guards.`);
|
|
135
|
+
}
|
|
136
|
+
if (page.guard) {
|
|
137
|
+
throw new Error(`[mado:static] ${pattern}: guarded pages cannot be static.`);
|
|
138
|
+
}
|
|
139
|
+
if (pattern.includes("*")) {
|
|
140
|
+
throw new Error(`[mado:static] ${pattern}: wildcard routes cannot be static.`);
|
|
141
|
+
}
|
|
142
|
+
const keys = paramKeys(pattern);
|
|
143
|
+
if (keys.length > 0 && page.static === true) {
|
|
144
|
+
throw new Error(`[mado:static] ${pattern}: dynamic static routes must provide static.paths().`);
|
|
145
|
+
}
|
|
146
|
+
if (keys.length > 0 && !page.static.paths) {
|
|
147
|
+
throw new Error(`[mado:static] ${pattern}: dynamic static routes must provide static.paths().`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resolveParams(pattern, config, paramKeys) {
|
|
152
|
+
const keys = paramKeys(pattern);
|
|
153
|
+
const paramsList = config.paths ? await config.paths() : [{}];
|
|
154
|
+
if (!Array.isArray(paramsList)) {
|
|
155
|
+
throw new Error(`[mado:static] ${pattern}: static.paths() must return an array.`);
|
|
156
|
+
}
|
|
157
|
+
if (keys.length === 0 && !config.paths) return [{}];
|
|
158
|
+
for (const params of paramsList) {
|
|
159
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
160
|
+
throw new Error(`[mado:static] ${pattern}: static.paths() must return params objects.`);
|
|
161
|
+
}
|
|
162
|
+
for (const key of keys) {
|
|
163
|
+
if (params[key] == null) {
|
|
164
|
+
throw new Error(`[mado:static] ${pattern}: missing route param "${key}".`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return paramsList;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function loadCoreHelpers(viteServer) {
|
|
172
|
+
// The Vite resolve.alias bridge maps `__mado_match__` to the live source
|
|
173
|
+
// file of this package, so the user manifest and discovery share the
|
|
174
|
+
// exact same routing helper functions (same function identity).
|
|
175
|
+
try {
|
|
176
|
+
const mod = await viteServer.ssrLoadModule("__mado_match__");
|
|
177
|
+
if (mod?.flatten && mod?.paramKeys && mod?.applyParams && mod?.isPage) {
|
|
178
|
+
return mod;
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Try the build artefact for published-only installs.
|
|
182
|
+
const distPath = resolve(getPackageRoot(), "dist/src/router/match.js");
|
|
183
|
+
if (existsSync(distPath)) {
|
|
184
|
+
const mod = await import(distPath);
|
|
185
|
+
if (mod?.flatten && mod?.paramKeys && mod?.applyParams && mod?.isPage) {
|
|
186
|
+
return mod;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
throw new Error(
|
|
190
|
+
`[mado:static] failed to load core routing helpers: ${err.message}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
throw new Error(
|
|
194
|
+
"[mado:static] core routing helpers loaded but missing expected exports.",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function resolvePage(loader, label, isPage) {
|
|
199
|
+
try {
|
|
200
|
+
const value = await loader();
|
|
201
|
+
if (isPage(value)) return value;
|
|
202
|
+
// Tolerate fixtures that hand-roll a plain object with _page: true
|
|
203
|
+
// (the dynamic-static-route validation test does this).
|
|
204
|
+
if (value && value._page === true) return value;
|
|
205
|
+
throw new Error("loader did not resolve to page({...}).");
|
|
206
|
+
} catch (err) {
|
|
207
|
+
throw new Error(`[mado:static] failed to load ${label}: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function installNodeDomStubs() {
|
|
212
|
+
if (globalThis.document && globalThis.customElements) return;
|
|
213
|
+
|
|
214
|
+
globalThis.location = new URL("http://localhost/");
|
|
215
|
+
globalThis.history = { pushState() {}, replaceState() {} };
|
|
216
|
+
globalThis.window = {
|
|
217
|
+
addEventListener() {},
|
|
218
|
+
removeEventListener() {},
|
|
219
|
+
dispatchEvent() {},
|
|
220
|
+
location: globalThis.location,
|
|
221
|
+
history: globalThis.history,
|
|
222
|
+
};
|
|
223
|
+
globalThis.document = {
|
|
224
|
+
adoptedStyleSheets: [],
|
|
225
|
+
createElement(tag) {
|
|
226
|
+
return {
|
|
227
|
+
tagName: String(tag).toUpperCase(),
|
|
228
|
+
setAttribute() {},
|
|
229
|
+
appendChild() {},
|
|
230
|
+
append() {},
|
|
231
|
+
remove() {},
|
|
232
|
+
style: {},
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
head: { appendChild() {}, querySelectorAll: () => [] },
|
|
236
|
+
body: { appendChild() {} },
|
|
237
|
+
addEventListener() {},
|
|
238
|
+
removeEventListener() {},
|
|
239
|
+
dispatchEvent() {},
|
|
240
|
+
querySelector: () => null,
|
|
241
|
+
querySelectorAll: () => [],
|
|
242
|
+
getElementById: () => null,
|
|
243
|
+
};
|
|
244
|
+
globalThis.customElements = {
|
|
245
|
+
define() {},
|
|
246
|
+
get() {
|
|
247
|
+
return undefined;
|
|
248
|
+
},
|
|
249
|
+
whenDefined() {
|
|
250
|
+
return Promise.resolve();
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
globalThis.HTMLElement = class {};
|
|
254
|
+
globalThis.Element = class {};
|
|
255
|
+
globalThis.Node = class {};
|
|
256
|
+
globalThis.Comment = class {};
|
|
257
|
+
globalThis.DocumentFragment = class {};
|
|
258
|
+
globalThis.CSSStyleSheet = class {
|
|
259
|
+
cssRules = [];
|
|
260
|
+
replaceSync(text) {
|
|
261
|
+
this.cssRules = text ? [{ cssText: String(text) }] : [];
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
globalThis.CSS = { supports: () => false };
|
|
265
|
+
globalThis.CSSRule = {};
|
|
266
|
+
globalThis.matchMedia = () => ({
|
|
267
|
+
matches: false,
|
|
268
|
+
addEventListener() {},
|
|
269
|
+
removeEventListener() {},
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function pickDefaultEntry(projectRoot) {
|
|
274
|
+
const appRoutes = "src/app.routes.ts";
|
|
275
|
+
if (existsSync(resolve(projectRoot, appRoutes))) return appRoutes;
|
|
276
|
+
return "src/routes.ts";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toViteId(path) {
|
|
280
|
+
return path.split("\\").join("/");
|
|
281
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { addNoIndex, escapeXml } from "./serialize.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Stage a deterministic temp output for the snapshot pipeline.
|
|
9
|
+
*
|
|
10
|
+
* out/index.html ← Vite-built SPA shell, consumed as capture seed
|
|
11
|
+
* <os-temp>/spa.html ← noindexed copy, staged before promotion
|
|
12
|
+
* <os-temp>/routes/... ← per-route captured HTML, also staged
|
|
13
|
+
*
|
|
14
|
+
* Nothing inside `out/` is mutated until all routes capture cleanly. A
|
|
15
|
+
* mid-pipeline crash leaves the existing deployment untouched; only a
|
|
16
|
+
* verified, complete capture is promoted by `promoteCapturedRoutes()`
|
|
17
|
+
* and `promoteSpaShell()`.
|
|
18
|
+
*/
|
|
19
|
+
export async function prepareStaticOutput(outDir) {
|
|
20
|
+
const shellPath = join(outDir, "index.html");
|
|
21
|
+
if (!existsSync(shellPath)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`[mado:static] missing ${shellPath}. Run \`mado build\` or Vite build before \`mado static\`.`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const shellHtml = await readFile(shellPath, "utf8");
|
|
28
|
+
|
|
29
|
+
// External temp directory — never leaks into `out/` on failure.
|
|
30
|
+
const { mkdtemp } = await import("node:fs/promises");
|
|
31
|
+
const { tmpdir } = await import("node:os");
|
|
32
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "mado-static-"));
|
|
33
|
+
const routesDir = join(tempRoot, "routes");
|
|
34
|
+
await mkdir(routesDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
// Stage the SPA fallback in temp; promote later, only after all
|
|
37
|
+
// captures verified. Until then `out/_mado/spa.html` either does not
|
|
38
|
+
// exist (cold release) or holds the previous release's copy (re-run).
|
|
39
|
+
const stagedSpaPath = join(tempRoot, "spa.html");
|
|
40
|
+
await writeFile(stagedSpaPath, addNoIndex(shellHtml));
|
|
41
|
+
|
|
42
|
+
return { shellHtml, tempRoot, routesDir, stagedSpaPath };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function writeCapturedRoutes(routesDir, captured) {
|
|
46
|
+
for (const record of captured) {
|
|
47
|
+
const file = safeRouteFile(routesDir, record.pathname);
|
|
48
|
+
await mkdir(dirname(file), { recursive: true });
|
|
49
|
+
await writeFile(file, record.html);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function promoteCapturedRoutes({ outDir, routesDir, captured }) {
|
|
54
|
+
for (const record of captured) {
|
|
55
|
+
const source = safeRouteFile(routesDir, record.pathname);
|
|
56
|
+
const target = safeRouteFile(outDir, record.pathname);
|
|
57
|
+
await mkdir(dirname(target), { recursive: true });
|
|
58
|
+
await copyFile(source, target);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Promote the staged SPA fallback shell into `out/_mado/spa.html` after
|
|
64
|
+
* all routes captured successfully. Called once at the end of the
|
|
65
|
+
* pipeline; any crash before this point leaves the previous shell (or
|
|
66
|
+
* no shell at all) in place.
|
|
67
|
+
*/
|
|
68
|
+
export async function promoteSpaShell({ outDir, stagedSpaPath }) {
|
|
69
|
+
const target = join(outDir, "_mado", "spa.html");
|
|
70
|
+
await mkdir(dirname(target), { recursive: true });
|
|
71
|
+
await copyFile(stagedSpaPath, target);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Drop the build-time bridge `out/_mado/build.json` from the final
|
|
76
|
+
* deployment artifact. It is internal CLI plumbing (the
|
|
77
|
+
* `@madojs/mado/vite` plugin emits it so `mado static` can read the
|
|
78
|
+
* resolved Vite base/site without parsing `vite.config.ts`); leaving it
|
|
79
|
+
* in `out/` would leak the build pipeline's view of the project to
|
|
80
|
+
* production traffic.
|
|
81
|
+
*/
|
|
82
|
+
export async function dropBuildBridge(outDir) {
|
|
83
|
+
await rm(join(outDir, "_mado", "build.json"), { force: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Files owned by `mado static`: the sitemap (derived from discovered
|
|
88
|
+
* static routes) and the SPA fallback shell.
|
|
89
|
+
*
|
|
90
|
+
* Files owned by `mado release` (so it can decide writeIfMissing
|
|
91
|
+
* semantics for user-customised deployments): 404.html, _headers,
|
|
92
|
+
* _redirects, and asset precompression.
|
|
93
|
+
*/
|
|
94
|
+
export async function writeStaticDeploymentFiles({ outDir, records, site, base }) {
|
|
95
|
+
await writeFile(join(outDir, "sitemap.xml"), sitemap(records, site, base));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function cleanupTemp(tempRoot) {
|
|
99
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Map a route pathname to an `index.html` file inside `root`, refusing
|
|
104
|
+
* any path that would traverse outside the deployment tree.
|
|
105
|
+
*/
|
|
106
|
+
function safeRouteFile(root, pathname) {
|
|
107
|
+
const decoded = decodeURIComponent(pathname);
|
|
108
|
+
if (decoded.includes("\0") || decoded.includes("\\")) {
|
|
109
|
+
throw new Error(`[mado:static] illegal pathname: ${pathname}`);
|
|
110
|
+
}
|
|
111
|
+
const cleaned = decoded === "/" ? "" : decoded.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
112
|
+
if (cleaned.split("/").some((seg) => seg === "..")) {
|
|
113
|
+
throw new Error(`[mado:static] pathname traversal not allowed: ${pathname}`);
|
|
114
|
+
}
|
|
115
|
+
const target = resolve(root, cleaned, "index.html");
|
|
116
|
+
const rootAbs = resolve(root) + sep;
|
|
117
|
+
if (!target.startsWith(rootAbs)) {
|
|
118
|
+
throw new Error(`[mado:static] pathname escapes deployment root: ${pathname}`);
|
|
119
|
+
}
|
|
120
|
+
// Use forward slashes inside join so cross-platform output is stable.
|
|
121
|
+
return target;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sitemap(records, site, base) {
|
|
125
|
+
const origin = (site ?? "").replace(/\/+$/, "");
|
|
126
|
+
const prefix = ((base ?? "/") || "/").replace(/\/+$/, "");
|
|
127
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
128
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
129
|
+
${records
|
|
130
|
+
.map((record) => {
|
|
131
|
+
const pathname = record.pathname === "/" ? "" : record.pathname;
|
|
132
|
+
const loc = `${origin}${prefix}${pathname}` || "/";
|
|
133
|
+
return ` <url><loc>${escapeXml(loc)}</loc></url>`;
|
|
134
|
+
})
|
|
135
|
+
.join("\n")}
|
|
136
|
+
</urlset>
|
|
137
|
+
`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Silence the unused-import linter for the helpers we still re-export.
|
|
141
|
+
void relative;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict JsonValue validator.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the public `JsonValue` type from src/page.ts: only null,
|
|
5
|
+
* booleans, finite numbers, strings, plain arrays and plain object trees
|
|
6
|
+
* are allowed. Forbidden shapes (Date, Map, Set, class instances, NaN,
|
|
7
|
+
* Infinity, undefined values, functions, symbols, bigints, cycles,
|
|
8
|
+
* non-plain prototypes) are reported with a path-aware error so a static
|
|
9
|
+
* route author can find the bad field immediately.
|
|
10
|
+
*
|
|
11
|
+
* [mado:static] /products/keyboard:
|
|
12
|
+
* seed.product.createdAt is Date; expected JsonValue.
|
|
13
|
+
*
|
|
14
|
+
* `JSON.stringify()` alone is too forgiving — Date coerces to a string,
|
|
15
|
+
* Map serialises as `{}`, undefined fields silently disappear — so the
|
|
16
|
+
* snapshot would look "fine" at build time but mismatch the runtime
|
|
17
|
+
* shape on the first SPA navigation. The custom walker stops the bad
|
|
18
|
+
* value before it ever reaches the snapshot HTML.
|
|
19
|
+
*/
|
|
20
|
+
function describeNonJson(value) {
|
|
21
|
+
if (value === undefined) return "undefined";
|
|
22
|
+
if (value === null) return "null";
|
|
23
|
+
if (typeof value === "number" && !Number.isFinite(value)) {
|
|
24
|
+
return Number.isNaN(value) ? "NaN" : "Infinity";
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === "bigint") return "bigint";
|
|
27
|
+
if (typeof value === "symbol") return "symbol";
|
|
28
|
+
if (typeof value === "function") return "function";
|
|
29
|
+
if (Array.isArray(value)) return "Array";
|
|
30
|
+
if (value instanceof Date) return "Date";
|
|
31
|
+
if (typeof Map !== "undefined" && value instanceof Map) return "Map";
|
|
32
|
+
if (typeof Set !== "undefined" && value instanceof Set) return "Set";
|
|
33
|
+
if (typeof RegExp !== "undefined" && value instanceof RegExp) return "RegExp";
|
|
34
|
+
if (typeof URL !== "undefined" && value instanceof URL) return "URL";
|
|
35
|
+
if (ArrayBuffer.isView?.(value)) return value.constructor?.name ?? "TypedArray";
|
|
36
|
+
if (value instanceof ArrayBuffer) return "ArrayBuffer";
|
|
37
|
+
if (value && typeof value === "object") {
|
|
38
|
+
const proto = Object.getPrototypeOf(value);
|
|
39
|
+
if (proto !== null && proto !== Object.prototype) {
|
|
40
|
+
return value.constructor?.name ?? "non-plain object";
|
|
41
|
+
}
|
|
42
|
+
return "object";
|
|
43
|
+
}
|
|
44
|
+
return typeof value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isPlainObject(value) {
|
|
48
|
+
if (value === null || typeof value !== "object") return false;
|
|
49
|
+
if (Array.isArray(value)) return false;
|
|
50
|
+
const proto = Object.getPrototypeOf(value);
|
|
51
|
+
return proto === null || proto === Object.prototype;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Walk a value and throw on the first non-JsonValue shape. `seen`
|
|
56
|
+
* tracks containers to catch cycles deterministically (Set keyed by
|
|
57
|
+
* identity).
|
|
58
|
+
*/
|
|
59
|
+
function walkJsonValue(value, path, contextLabel, seen) {
|
|
60
|
+
if (value === null) return;
|
|
61
|
+
const t = typeof value;
|
|
62
|
+
if (t === "boolean" || t === "string") return;
|
|
63
|
+
if (t === "number") {
|
|
64
|
+
if (!Number.isFinite(value)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[mado:static] ${contextLabel}: ${path} is ${
|
|
67
|
+
Number.isNaN(value) ? "NaN" : "Infinity"
|
|
68
|
+
}; expected JsonValue.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
if (seen.has(value)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`[mado:static] ${contextLabel}: ${path} contains a circular reference; expected JsonValue.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
seen.add(value);
|
|
80
|
+
for (let i = 0; i < value.length; i++) {
|
|
81
|
+
walkJsonValue(value[i], `${path}[${i}]`, contextLabel, seen);
|
|
82
|
+
}
|
|
83
|
+
seen.delete(value);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (isPlainObject(value)) {
|
|
87
|
+
if (seen.has(value)) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[mado:static] ${contextLabel}: ${path} contains a circular reference; expected JsonValue.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
seen.add(value);
|
|
93
|
+
for (const key of Object.keys(value)) {
|
|
94
|
+
const child = value[key];
|
|
95
|
+
// `undefined` would silently disappear through JSON.stringify; we
|
|
96
|
+
// reject it explicitly so the schema stays honest. To omit a field
|
|
97
|
+
// simply do not include the key.
|
|
98
|
+
if (child === undefined) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`[mado:static] ${contextLabel}: ${path}.${key} is undefined; ` +
|
|
101
|
+
`expected JsonValue. Omit the key instead of assigning undefined.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
walkJsonValue(child, `${path}.${key}`, contextLabel, seen);
|
|
105
|
+
}
|
|
106
|
+
seen.delete(value);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[mado:static] ${contextLabel}: ${path} is ${describeNonJson(value)}; ` +
|
|
111
|
+
`expected JsonValue.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Throw if `value` is not a strict JsonValue (see walkJsonValue).
|
|
117
|
+
* `contextLabel` is included in every error so the discovery output can
|
|
118
|
+
* point a user at the failing route or pathname.
|
|
119
|
+
*/
|
|
120
|
+
export function assertJsonSerializable(value, contextLabel) {
|
|
121
|
+
walkJsonValue(value, "seed", contextLabel, new Set());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function serializeJsonForScript(value, contextLabel) {
|
|
125
|
+
assertJsonSerializable(value, contextLabel);
|
|
126
|
+
// The validator guarantees JSON.stringify cannot drop, coerce or
|
|
127
|
+
// silently expand anything, so the resulting string is exactly the
|
|
128
|
+
// shape page.head/load will receive on first client boot.
|
|
129
|
+
const json = JSON.stringify(value);
|
|
130
|
+
if (json === undefined) {
|
|
131
|
+
// Should be unreachable after assertJsonSerializable; defensive.
|
|
132
|
+
throw new Error(
|
|
133
|
+
`[mado:static] ${contextLabel}: top-level value cannot be serialized as JSON.`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return json
|
|
137
|
+
.replace(/</g, "\\u003C")
|
|
138
|
+
.replace(/>/g, "\\u003E")
|
|
139
|
+
.replace(/&/g, "\\u0026")
|
|
140
|
+
.replace(/\u2028/g, "\\u2028")
|
|
141
|
+
.replace(/\u2029/g, "\\u2029");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Prepare the shell HTML for browser capture of a single route.
|
|
146
|
+
*
|
|
147
|
+
* Two CSP-safe markers are injected:
|
|
148
|
+
* 1. `data-mado-static-capture` on the <html> tag — the runtime checks
|
|
149
|
+
* that attribute (and removes it before serialization) instead of
|
|
150
|
+
* relying on an inline script that strict CSP would block.
|
|
151
|
+
* 2. `<script type="application/json" data-mado-static-data="${pathname}">`
|
|
152
|
+
* carrying the build-time seed. The runtime consumes it once, the
|
|
153
|
+
* serializer preserves it in the final snapshot, and the real client
|
|
154
|
+
* boot consumes it again on first load.
|
|
155
|
+
*
|
|
156
|
+
* The seed is URL-bound through the `data-mado-static-data` attribute so
|
|
157
|
+
* SPA navigations to other routes (or accidentally cached copies) cannot
|
|
158
|
+
* reuse a stale value.
|
|
159
|
+
*/
|
|
160
|
+
export function injectSnapshotMode(html, record) {
|
|
161
|
+
let out = setHtmlAttribute(html, "data-mado-static-capture", "");
|
|
162
|
+
if ("initialData" in record) {
|
|
163
|
+
const seedScript =
|
|
164
|
+
`<script type="application/json" data-mado-static-data="${escapeAttr(record.pathname)}">` +
|
|
165
|
+
serializeJsonForScript(record.initialData, record.pathname) +
|
|
166
|
+
`</script>`;
|
|
167
|
+
out = injectAfterHeadOpen(out, seedScript);
|
|
168
|
+
}
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function setHtmlAttribute(html, name, value) {
|
|
173
|
+
const match = /<html\b([^>]*)>/i.exec(html);
|
|
174
|
+
if (!match) return html;
|
|
175
|
+
const attrs = match[1] ?? "";
|
|
176
|
+
if (new RegExp(`\\b${name}\\b`, "i").test(attrs)) return html;
|
|
177
|
+
const replacement = `<html${attrs} ${name}="${escapeAttr(value)}">`;
|
|
178
|
+
return html.slice(0, match.index) + replacement + html.slice(match.index + match[0].length);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function escapeAttr(value) {
|
|
182
|
+
return String(value)
|
|
183
|
+
.replace(/&/g, "&")
|
|
184
|
+
.replace(/"/g, """)
|
|
185
|
+
.replace(/</g, "<")
|
|
186
|
+
.replace(/>/g, ">");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function addNoIndex(html) {
|
|
190
|
+
if (/<meta\s+[^>]*(?:name=["']robots["']|content=["'][^"']*noindex)/i.test(html)) {
|
|
191
|
+
return html;
|
|
192
|
+
}
|
|
193
|
+
return injectAfterHeadOpen(
|
|
194
|
+
html,
|
|
195
|
+
`<meta name="robots" content="noindex">`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function escapeXml(value) {
|
|
200
|
+
return String(value)
|
|
201
|
+
.replace(/&/g, "&")
|
|
202
|
+
.replace(/</g, "<")
|
|
203
|
+
.replace(/>/g, ">")
|
|
204
|
+
.replace(/"/g, """);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function injectAfterHeadOpen(html, content) {
|
|
208
|
+
const match = /<head\b[^>]*>/i.exec(html);
|
|
209
|
+
if (!match) return content + html;
|
|
210
|
+
const at = match.index + match[0].length;
|
|
211
|
+
return html.slice(0, at) + content + html.slice(at);
|
|
212
|
+
}
|