@nutin/cli 2.1.1 → 3.0.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/CHANGELOG.md +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -7,11 +7,18 @@ export function escape(str) {
|
|
|
7
7
|
.replace(/'/g, ''');
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
// Replacements go through functions: a replacement *string* would expand `$&`, `$'`
|
|
11
|
+
// etc. found in a title/description, copying raw page HTML into the tag.
|
|
12
|
+
// `replacement` receives the match's capture groups.
|
|
10
13
|
export function upsertInHead(html, pattern, replacement, fullTag) {
|
|
11
14
|
if (pattern.test(html)) {
|
|
12
|
-
return html.replace(pattern, replacement);
|
|
15
|
+
return html.replace(pattern, (_match, ...groups) => replacement(...groups));
|
|
13
16
|
}
|
|
14
|
-
return html
|
|
17
|
+
return insertBeforeHeadEnd(html, fullTag);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function insertBeforeHeadEnd(html, tag) {
|
|
21
|
+
return html.replace('</head>', () => `\n ${tag}\n </head>`);
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
export function applySubstitutions(template, lang, title, description, pageUrl, ogImage, hreflangLinks = []) {
|
|
@@ -19,88 +26,88 @@ export function applySubstitutions(template, lang, title, description, pageUrl,
|
|
|
19
26
|
|
|
20
27
|
// lang attribute on <html>
|
|
21
28
|
if (/(<html[^>]*\slang=)["'][^"']*["']/.test(html)) {
|
|
22
|
-
html = html.replace(/(<html[^>]*\slang=)["'][^"']*["']/, `$
|
|
29
|
+
html = html.replace(/(<html[^>]*\slang=)["'][^"']*["']/, (_m, start) => `${start}"${escape(lang)}"`);
|
|
23
30
|
} else {
|
|
24
|
-
html = html.replace(/<html/, `<html lang="${lang}"`);
|
|
31
|
+
html = html.replace(/<html/, () => `<html lang="${escape(lang)}"`);
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
// <title>
|
|
28
35
|
html = upsertInHead(html,
|
|
29
36
|
/(<title>)[^<]*(<\/title>)/,
|
|
30
|
-
`$
|
|
37
|
+
(start, end) => `${start}${escape(title)}${end}`,
|
|
31
38
|
`<title>${escape(title)}</title>`
|
|
32
39
|
);
|
|
33
40
|
|
|
34
41
|
// <meta name="description"> — replace all occurrences, or insert one
|
|
35
42
|
const descPattern = /(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/;
|
|
36
43
|
if (descPattern.test(html)) {
|
|
37
|
-
html = html.replace(/(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/g, `$
|
|
44
|
+
html = html.replace(/(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/g, (_m, start) => `${start}"${escape(description)}"`);
|
|
38
45
|
} else {
|
|
39
|
-
html = html
|
|
46
|
+
html = insertBeforeHeadEnd(html, `<meta name="description" content="${escape(description)}" />`);
|
|
40
47
|
}
|
|
41
48
|
|
|
42
49
|
// <link rel="canonical">
|
|
43
50
|
html = upsertInHead(html,
|
|
44
51
|
/(<link\s+rel=["']canonical["']\s+href=)["'][^"']*["']/,
|
|
45
|
-
`$
|
|
46
|
-
`<link rel="canonical" href="${pageUrl}/" />`
|
|
52
|
+
(start) => `${start}"${escape(pageUrl)}/"`,
|
|
53
|
+
`<link rel="canonical" href="${escape(pageUrl)}/" />`
|
|
47
54
|
);
|
|
48
55
|
|
|
49
56
|
// og:url
|
|
50
57
|
html = upsertInHead(html,
|
|
51
58
|
/(<meta\s+property=["']og:url["']\s+content=)["'][^"']*["']/,
|
|
52
|
-
`$
|
|
53
|
-
`<meta property="og:url" content="${pageUrl}/" />`
|
|
59
|
+
(start) => `${start}"${escape(pageUrl)}/"`,
|
|
60
|
+
`<meta property="og:url" content="${escape(pageUrl)}/" />`
|
|
54
61
|
);
|
|
55
62
|
|
|
56
63
|
// hreflang alternates — one per active language plus x-default, so crawlers see
|
|
57
64
|
// these URLs as language variants of the same page rather than duplicate content
|
|
58
65
|
if (hreflangLinks.length > 0) {
|
|
59
66
|
const altTags = hreflangLinks
|
|
60
|
-
.map(({ hreflang, href }) => `<link rel="alternate" hreflang="${hreflang}" href="${href}" />`)
|
|
67
|
+
.map(({ hreflang, href }) => `<link rel="alternate" hreflang="${escape(hreflang)}" href="${escape(href)}" />`)
|
|
61
68
|
.join('\n ');
|
|
62
|
-
html = html
|
|
69
|
+
html = insertBeforeHeadEnd(html, altTags);
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
// og:title
|
|
66
73
|
html = upsertInHead(html,
|
|
67
74
|
/(<meta\s+property=["']og:title["']\s+content=)["'][^"']*["']/,
|
|
68
|
-
`$
|
|
75
|
+
(start) => `${start}"${escape(title)}"`,
|
|
69
76
|
`<meta property="og:title" content="${escape(title)}" />`
|
|
70
77
|
);
|
|
71
78
|
|
|
72
79
|
// og:description
|
|
73
80
|
html = upsertInHead(html,
|
|
74
81
|
/(<meta\s+property=["']og:description["']\s+content=)["'][^"']*["']/,
|
|
75
|
-
`$
|
|
82
|
+
(start) => `${start}"${escape(description)}"`,
|
|
76
83
|
`<meta property="og:description" content="${escape(description)}" />`
|
|
77
84
|
);
|
|
78
85
|
|
|
79
86
|
// twitter:title
|
|
80
87
|
html = upsertInHead(html,
|
|
81
88
|
/(<meta\s+name=["']twitter:title["']\s+content=)["'][^"']*["']/,
|
|
82
|
-
`$
|
|
89
|
+
(start) => `${start}"${escape(title)}"`,
|
|
83
90
|
`<meta name="twitter:title" content="${escape(title)}" />`
|
|
84
91
|
);
|
|
85
92
|
|
|
86
93
|
// twitter:description
|
|
87
94
|
html = upsertInHead(html,
|
|
88
95
|
/(<meta\s+name=["']twitter:description["']\s+content=)["'][^"']*["']/,
|
|
89
|
-
`$
|
|
96
|
+
(start) => `${start}"${escape(description)}"`,
|
|
90
97
|
`<meta name="twitter:description" content="${escape(description)}" />`
|
|
91
98
|
);
|
|
92
99
|
|
|
93
100
|
// og:type
|
|
94
101
|
html = upsertInHead(html,
|
|
95
102
|
/(<meta\s+property=["']og:type["']\s+content=)["'][^"']*["']/,
|
|
96
|
-
`$
|
|
103
|
+
(start) => `${start}"website"`,
|
|
97
104
|
`<meta property="og:type" content="website" />`
|
|
98
105
|
);
|
|
99
106
|
|
|
100
107
|
// twitter:card — required for Twitter/X to render an image card at all
|
|
101
108
|
html = upsertInHead(html,
|
|
102
109
|
/(<meta\s+name=["']twitter:card["']\s+content=)["'][^"']*["']/,
|
|
103
|
-
`$
|
|
110
|
+
(start) => `${start}"summary_large_image"`,
|
|
104
111
|
`<meta name="twitter:card" content="summary_large_image" />`
|
|
105
112
|
);
|
|
106
113
|
|
|
@@ -108,12 +115,12 @@ export function applySubstitutions(template, lang, title, description, pageUrl,
|
|
|
108
115
|
if (ogImage) {
|
|
109
116
|
html = upsertInHead(html,
|
|
110
117
|
/(<meta\s+property=["']og:image["']\s+content=)["'][^"']*["']/,
|
|
111
|
-
`$
|
|
118
|
+
(start) => `${start}"${escape(ogImage)}"`,
|
|
112
119
|
`<meta property="og:image" content="${escape(ogImage)}" />`
|
|
113
120
|
);
|
|
114
121
|
html = upsertInHead(html,
|
|
115
122
|
/(<meta\s+name=["']twitter:image["']\s+content=)["'][^"']*["']/,
|
|
116
|
-
`$
|
|
123
|
+
(start) => `${start}"${escape(ogImage)}"`,
|
|
117
124
|
`<meta name="twitter:image" content="${escape(ogImage)}" />`
|
|
118
125
|
);
|
|
119
126
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { applySubstitutions, upsertInHead } from './html-substitutions.js';
|
|
2
|
+
|
|
3
|
+
const TEMPLATE = '<html lang="en"><head><title>x</title><meta name="description" content="d"></head><body></body></html>';
|
|
4
|
+
|
|
5
|
+
describe('applySubstitutions', () => {
|
|
6
|
+
it('applySubstitutions sets title, description, canonical and og tags', () => {
|
|
7
|
+
const html = applySubstitutions(TEMPLATE, 'fr', 'Home', 'Welcome', 'https://site.test/fr', '/og.png');
|
|
8
|
+
expect(html).toContain('<html lang="fr">');
|
|
9
|
+
expect(html).toContain('<title>Home</title>');
|
|
10
|
+
expect(html).toContain('<meta name="description" content="Welcome">');
|
|
11
|
+
expect(html).toContain('<link rel="canonical" href="https://site.test/fr/" />');
|
|
12
|
+
expect(html).toContain('<meta property="og:image" content="/og.png" />');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('applySubstitutions keeps $ replacement patterns in values literal', () => {
|
|
16
|
+
const title = "Only $' left, $` and $& and $1";
|
|
17
|
+
const html = applySubstitutions(TEMPLATE, 'en', title, 'Save $$ now', 'https://site.test', '');
|
|
18
|
+
expect(html).toContain("<title>Only $' left, $` and $& and $1</title>");
|
|
19
|
+
expect(html).toContain('<meta name="description" content="Save $$ now">');
|
|
20
|
+
expect(html).toContain('<meta property="og:title" content="Only $' left, $` and $& and $1" />');
|
|
21
|
+
expect(html.match(/<html/g).length).toBe(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('applySubstitutions escapes markup in title, description and URLs', () => {
|
|
25
|
+
const html = applySubstitutions(TEMPLATE, 'en', '<b>"T"</b>', 'a & b', 'https://site.test/?a=1&b=2', '', [
|
|
26
|
+
{ hreflang: 'en', href: 'https://site.test/en?x="y"' },
|
|
27
|
+
]);
|
|
28
|
+
expect(html).toContain('<title><b>"T"</b></title>');
|
|
29
|
+
expect(html).toContain('content="a & b"');
|
|
30
|
+
expect(html).toContain('href="https://site.test/?a=1&b=2/"');
|
|
31
|
+
expect(html).toContain('href="https://site.test/en?x="y""');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('applySubstitutions inserts missing tags before </head>', () => {
|
|
35
|
+
const html = applySubstitutions('<html><head></head><body></body></html>', 'en', 'T $&', 'D', 'https://site.test', '');
|
|
36
|
+
expect(html).toContain('<html lang="en">');
|
|
37
|
+
expect(html).toContain('<title>T $&</title>');
|
|
38
|
+
expect(html).toContain('<meta name="description" content="D" />');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('upsertInHead passes capture groups to the replacement function', () => {
|
|
42
|
+
const html = upsertInHead('<head><title>a</title></head>', /(<title>)[^<]*(<\/title>)/, (start, end) => `${start}$'${end}`, '');
|
|
43
|
+
expect(html).toBe("<head><title>$'</title></head>");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -6,6 +6,19 @@ import { applySubstitutions } from './html-substitutions.js';
|
|
|
6
6
|
import { getAppRoutePaths } from './ssr/ssr-render.js';
|
|
7
7
|
import { builderConfig } from '../../builder.config.js';
|
|
8
8
|
|
|
9
|
+
const APP_CONTAINER = /(<([a-zA-Z][\w-]*)\b[^>]*\sid=["']app["'][^>]*>)[\s\S]*?<\/\2>/;
|
|
10
|
+
|
|
11
|
+
// The concrete URL path a seo.json route is prerendered at: `outputPath` when given,
|
|
12
|
+
// otherwise `path` with each `:param` filled from `mockParams` (an unset optional
|
|
13
|
+
// `:param?` segment is dropped). '/' maps to '' so it can be appended to baseUrl.
|
|
14
|
+
export function routeSuffixOf(route) {
|
|
15
|
+
const resolved = route.outputPath ?? route.path.replace(/\/:([^/?]+)\??/g, (_match, name) => {
|
|
16
|
+
const value = route.mockParams?.[name];
|
|
17
|
+
return value === undefined || value === '' ? '' : `/${encodeURIComponent(value)}`;
|
|
18
|
+
});
|
|
19
|
+
return resolved === '/' || resolved === '' ? '' : resolved.replace(/\/$/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
export function segmentsOf(routeSuffix) {
|
|
10
23
|
return routeSuffix ? routeSuffix.split('/').filter(Boolean) : [];
|
|
11
24
|
}
|
|
@@ -36,10 +49,13 @@ export async function writeRouteHtml({ template, lang, title, description, pageU
|
|
|
36
49
|
errorExit(`Failed to apply any changes for ${routePath} (lang "${lang}") — index.html may be missing a </head> tag`, 'generate-seo-html');
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
html =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
52
|
+
// Any element can be the #app mount (validate-html.js only checks for id="app"), and
|
|
53
|
+
// its opening tag is kept as authored. The replacement goes through a function so `$&`,
|
|
54
|
+
// `$'`… in the rendered markup stay literal.
|
|
55
|
+
if (!APP_CONTAINER.test(html)) {
|
|
56
|
+
errorExit(`index.html has no closed id="app" container to render ${routePath} into`, 'generate-seo-html');
|
|
57
|
+
}
|
|
58
|
+
html = html.replace(APP_CONTAINER, (_match, openTag, tagName) => `${openTag}\n${body}\n </${tagName}>`);
|
|
43
59
|
|
|
44
60
|
const outputDir = path.join(PATHS.tempSource, ...outputSegments);
|
|
45
61
|
const outputPath = path.join(outputDir, 'index.html');
|
|
@@ -48,9 +64,11 @@ export async function writeRouteHtml({ template, lang, title, description, pageU
|
|
|
48
64
|
fs.writeFileSync(outputPath, html, 'utf-8');
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
|
|
67
|
+
// `optOutPaths`: app route patterns deliberately without SEO pages (e.g. a Markdown source
|
|
68
|
+
// with "seo: false").
|
|
69
|
+
export async function warnForRoutesMissingSeoConfig(bundleUrl, seoRoutes, defaultLanguage, baseUrl, optOutPaths = []) {
|
|
52
70
|
const appRoutePaths = await getAppRoutePaths(bundleUrl, defaultLanguage, baseUrl);
|
|
53
|
-
const seoRoutePaths = new Set(seoRoutes.map((route) => route.path));
|
|
71
|
+
const seoRoutePaths = new Set([...seoRoutes.map((route) => route.path), ...optOutPaths]);
|
|
54
72
|
|
|
55
73
|
for (const path of appRoutePaths) {
|
|
56
74
|
if (seoRoutePaths.has(path)) continue;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { routeSuffixOf, segmentsOf } from './route-output.js';
|
|
2
|
+
|
|
3
|
+
describe('routeSuffixOf', () => {
|
|
4
|
+
it('maps "/" to an empty suffix and keeps static paths as-is', () => {
|
|
5
|
+
expect(routeSuffixOf({ path: '/' })).toBe('');
|
|
6
|
+
expect(routeSuffixOf({ path: '/about' })).toBe('/about');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('fills dynamic segments from mockParams', () => {
|
|
10
|
+
expect(routeSuffixOf({ path: '/blog/:slug', mockParams: { slug: 'hello-world' } })).toBe('/blog/hello-world');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('drops an unset optional segment', () => {
|
|
14
|
+
expect(routeSuffixOf({ path: '/docs/:page?' })).toBe('/docs');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('encodes param values', () => {
|
|
18
|
+
expect(routeSuffixOf({ path: '/tags/:tag', mockParams: { tag: 'a b' } })).toBe('/tags/a%20b');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('prefers an explicit outputPath', () => {
|
|
22
|
+
expect(routeSuffixOf({ path: '/demo/:page?', outputPath: '/demo/overlays/', mockParams: { page: 'x' } })).toBe('/demo/overlays');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('segmentsOf', () => {
|
|
27
|
+
it('splits a suffix into directory segments', () => {
|
|
28
|
+
expect(segmentsOf('/blog/hello-world')).toEqual(['blog', 'hello-world']);
|
|
29
|
+
expect(segmentsOf('')).toEqual([]);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{{#if markdown}}
|
|
2
|
+
import { markdownSeoRoutes, markdownSeoOptOutPaths } from '../markdown/markdown-seo-routes.js';
|
|
3
|
+
{{/if}}
|
|
4
|
+
import { routeSuffixOf } from './route-output.js';
|
|
5
|
+
|
|
6
|
+
// Appends generated routes to config/seo.json's, skipping any whose output URL a
|
|
7
|
+
// seo.json entry already covers: a hand-written entry always wins.
|
|
8
|
+
export function mergeSeoRoutes(configRoutes, generatedRoutes) {
|
|
9
|
+
const covered = new Set(configRoutes.map(routeSuffixOf));
|
|
10
|
+
return [...configRoutes, ...generatedRoutes.filter((route) => !covered.has(routeSuffixOf(route)))];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Every route to prerender: config/seo.json's, plus those generated by features.
|
|
14
|
+
export function resolveSeoRoutes(seoConfig) {
|
|
15
|
+
let routes = seoConfig.routes ?? [];
|
|
16
|
+
{{#if markdown}}
|
|
17
|
+
routes = mergeSeoRoutes(routes, markdownSeoRoutes());
|
|
18
|
+
{{/if}}
|
|
19
|
+
return routes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// App route patterns deliberately left without SEO pages: no "missing seo.json entry" warning.
|
|
23
|
+
export function resolveSeoOptOutPaths() {
|
|
24
|
+
let paths = [];
|
|
25
|
+
{{#if markdown}}
|
|
26
|
+
paths = [...paths, ...markdownSeoOptOutPaths()];
|
|
27
|
+
{{/if}}
|
|
28
|
+
return paths;
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mergeSeoRoutes } from './seo-routes.js';
|
|
2
|
+
|
|
3
|
+
describe('mergeSeoRoutes', () => {
|
|
4
|
+
it('appends generated routes after the seo.json ones', () => {
|
|
5
|
+
const merged = mergeSeoRoutes([{ path: '/' }], [{ path: '/docs/:slug?', mockParams: { slug: 'intro' } }]);
|
|
6
|
+
|
|
7
|
+
expect(merged.map((route) => route.path)).toEqual(['/', '/docs/:slug?']);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('keeps the seo.json entry when both produce the same URL', () => {
|
|
11
|
+
const fromConfig = { path: '/docs/:slug?', mockParams: { slug: 'intro' }, title: 'Hand-written' };
|
|
12
|
+
const generated = [
|
|
13
|
+
{ path: '/docs/:slug?', mockParams: { slug: 'intro' }, title: 'Generated' },
|
|
14
|
+
{ path: '/docs/:slug?', mockParams: { slug: 'next' }, title: 'Next' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const merged = mergeSeoRoutes([fromConfig], generated);
|
|
18
|
+
|
|
19
|
+
expect(merged.map((route) => route.title)).toEqual(['Hand-written', 'Next']);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -4,14 +4,15 @@ import path from 'path';
|
|
|
4
4
|
import { pathToFileURL } from 'url';
|
|
5
5
|
import { PATHS } from '../../app/paths.js';
|
|
6
6
|
import { builderConfig } from '../../../builder.config.js';
|
|
7
|
+
import { sharedDefine } from '../../prod-bundle/bundle-options.js';
|
|
7
8
|
|
|
8
9
|
const SSR_DIR = path.join(PATHS.temp, '.ssr');
|
|
9
|
-
const ENTRY_FILE = path.join(PATHS.tempApp, '__ssr-entry.ts');
|
|
10
|
+
const ENTRY_FILE = path.join(PATHS.tempApp, '__ssr-entry.{{#if (eq lang "js")}}js{{else}}ts{{/if}}');
|
|
10
11
|
const OUT_FILE = path.join(SSR_DIR, 'ssr-bundle.mjs');
|
|
11
12
|
|
|
12
13
|
const ENTRY_CONTENT = `export { appRoutes } from './routes.js';
|
|
13
|
-
export { I18nService, Service, registerPipes } from '../core/index.js';
|
|
14
|
-
export
|
|
14
|
+
export { I18nService, Service, SecurityHelper, registerPipes } from '../core/index.js';
|
|
15
|
+
export * as RouteGuardsManager from '../core/services/router/helpers/route-guard-manager.helper.js';
|
|
15
16
|
`;
|
|
16
17
|
|
|
17
18
|
/**
|
|
@@ -28,16 +29,14 @@ export async function buildSsrBundle() {
|
|
|
28
29
|
...builderConfig.esbuild,
|
|
29
30
|
platform: 'node',
|
|
30
31
|
format: 'esm',
|
|
31
|
-
target: ['
|
|
32
|
+
target: ['node24'],
|
|
32
33
|
minify: false,
|
|
33
34
|
drop: [],
|
|
34
35
|
legalComments: 'none',
|
|
35
36
|
loader: {
|
|
36
37
|
'.json': 'json',
|
|
37
38
|
},
|
|
38
|
-
define: {
|
|
39
|
-
'process.env.NODE_ENV': '"production"',
|
|
40
|
-
},
|
|
39
|
+
define: sharedDefine({ ...builderConfig, isProd: true }),
|
|
41
40
|
entryPoints: [ENTRY_FILE],
|
|
42
41
|
outfile: OUT_FILE,
|
|
43
42
|
keepNames: true,
|
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Installs the minimum browser globals the framework's own base classes touch
|
|
6
|
-
* unconditionally (
|
|
6
|
+
* unconditionally (the Router's `popstate` listener, I18n's `navigator`/`window.location`
|
|
7
7
|
* reads), plus a couple of cheap defensive stubs (matchMedia) for common patterns
|
|
8
8
|
* downstream projects add (e.g. dark-mode toggles).
|
|
9
9
|
*
|
|
@@ -52,7 +52,7 @@ function createMemoryStorage() {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function installGlobals(window, { lang, mockFetch = {}, localesDir } = {}) {
|
|
55
|
+
export function installGlobals(window, { lang, mockFetch = {}, localesDir, staticDir } = {}) {
|
|
56
56
|
for (const key of Object.getOwnPropertyNames(window)) {
|
|
57
57
|
setGlobal(globalThis, key, window[key]);
|
|
58
58
|
}
|
|
@@ -90,7 +90,7 @@ export function installGlobals(window, { lang, mockFetch = {}, localesDir } = {}
|
|
|
90
90
|
|
|
91
91
|
const fetchShim = (input) => {
|
|
92
92
|
const url = typeof input === 'string' ? input : input?.url;
|
|
93
|
-
const promise = resolveFetch(url, { mockFetch, localesDir });
|
|
93
|
+
const promise = resolveFetch(url, { mockFetch, localesDir, staticDir });
|
|
94
94
|
trackedFetches.push(promise);
|
|
95
95
|
return promise;
|
|
96
96
|
};
|
|
@@ -100,7 +100,7 @@ export function installGlobals(window, { lang, mockFetch = {}, localesDir } = {}
|
|
|
100
100
|
return { trackedFetches };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
async function resolveFetch(url, { mockFetch, localesDir }) {
|
|
103
|
+
export async function resolveFetch(url, { mockFetch = {}, localesDir, staticDir } = {}) {
|
|
104
104
|
if (Object.prototype.hasOwnProperty.call(mockFetch, url)) {
|
|
105
105
|
return jsonResponse(mockFetch[url]);
|
|
106
106
|
}
|
|
@@ -116,12 +116,41 @@ async function resolveFetch(url, { mockFetch, localesDir }) {
|
|
|
116
116
|
return jsonResponse({});
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
const staticFile = resolveStaticFile(url, staticDir);
|
|
120
|
+
if (staticFile) {
|
|
121
|
+
const text = fs.readFileSync(staticFile, 'utf-8');
|
|
122
|
+
return staticFile.endsWith('.json') ? jsonResponse(JSON.parse(text)) : textResponse(text);
|
|
123
|
+
}
|
|
124
|
+
|
|
119
125
|
throw new Error(
|
|
120
126
|
`Unexpected fetch("${url}") during SSR — no "mockFetch" entry for this endpoint and it isn't a ` +
|
|
121
|
-
`/locales/*.json request. Add a "mockFetch" entry for this route in config/seo.json if this is intentional.`
|
|
127
|
+
`/locales/*.json request or a file built into dist. Add a "mockFetch" entry for this route in config/seo.json if this is intentional.`
|
|
122
128
|
);
|
|
123
129
|
}
|
|
124
130
|
|
|
131
|
+
// A root-relative URL naming a file the build already wrote (e.g. /generated/<id>.json,
|
|
132
|
+
// fetched by the markdown feature's route guards). Paths escaping staticDir don't count.
|
|
133
|
+
function resolveStaticFile(url, staticDir) {
|
|
134
|
+
if (!staticDir || typeof url !== 'string' || !url.startsWith('/')) return null;
|
|
135
|
+
const root = path.resolve(staticDir);
|
|
136
|
+
try {
|
|
137
|
+
const filePath = path.resolve(root, '.' + decodeURIComponent(url.split(/[?#]/)[0]));
|
|
138
|
+
if (!filePath.startsWith(root + path.sep)) return null;
|
|
139
|
+
return fs.statSync(filePath).isFile() ? filePath : null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function textResponse(body) {
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
status: 200,
|
|
149
|
+
json: async () => JSON.parse(body),
|
|
150
|
+
text: async () => body,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
function jsonResponse(body) {
|
|
126
155
|
return {
|
|
127
156
|
ok: true,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { resolveFetch } from './ssr-polyfills.js';
|
|
5
|
+
|
|
6
|
+
describe('resolveFetch (SSR fetch shim)', () => {
|
|
7
|
+
let staticDir;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
staticDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nutin-ssr-fetch-'));
|
|
11
|
+
fs.mkdirSync(path.join(staticDir, 'generated'));
|
|
12
|
+
fs.writeFileSync(path.join(staticDir, 'generated', 'docs.json'), '{"pages":{"a":1}}');
|
|
13
|
+
fs.writeFileSync(path.join(staticDir, 'notes.txt'), 'plain text');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(staticDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('serves a mockFetch entry first', async () => {
|
|
21
|
+
const response = await resolveFetch('/generated/docs.json', { mockFetch: { '/generated/docs.json': { mocked: true } }, staticDir });
|
|
22
|
+
|
|
23
|
+
expect(await response.json()).toEqual({ mocked: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('serves a file the build wrote, parsed as JSON for .json', async () => {
|
|
27
|
+
const response = await resolveFetch('/generated/docs.json?v=1', { staticDir });
|
|
28
|
+
|
|
29
|
+
expect(response.ok).toBeTruthy();
|
|
30
|
+
expect(await response.json()).toEqual({ pages: { a: 1 } });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('serves other built files as text', async () => {
|
|
34
|
+
const response = await resolveFetch('/notes.txt', { staticDir });
|
|
35
|
+
|
|
36
|
+
expect(await response.text()).toBe('plain text');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('refuses paths escaping the build folder', async () => {
|
|
40
|
+
await expect(() => resolveFetch('/../../etc/hosts', { staticDir })).toThrow('Unexpected fetch');
|
|
41
|
+
await expect(() => resolveFetch('/%2e%2e/%2e%2e/etc/hosts', { staticDir })).toThrow('Unexpected fetch');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('still throws for an unknown endpoint', async () => {
|
|
45
|
+
await expect(() => resolveFetch('/api/users', { staticDir })).toThrow('Unexpected fetch');
|
|
46
|
+
await expect(() => resolveFetch('https://api.test/users', { staticDir })).toThrow('Unexpected fetch');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -15,28 +15,28 @@ export async function getAppRoutePaths(bundleUrl, lang, pageUrl) {
|
|
|
15
15
|
// window.location.pathname, so a location-less window crashes as soon as the
|
|
16
16
|
// bundle's module-load side effects instantiate I18n.getInstance().
|
|
17
17
|
const { window } = parseHTML('<!doctype html><html><body></body></html>', { location: new URL(pageUrl) });
|
|
18
|
-
installGlobals(window, { lang });
|
|
18
|
+
installGlobals(window, { lang, localesDir: path.join(PATHS.tempSource, 'locales') });
|
|
19
19
|
const bundle = await import(`${bundleUrl}?ssr=routes-check`);
|
|
20
20
|
return Object.keys(bundle.appRoutes);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Renders one (route, lang) pair by
|
|
25
|
-
* .render()
|
|
24
|
+
* Renders one (route, lang) pair by running the route's guards, then instantiating the real
|
|
25
|
+
* View via appRoutes and calling .render(). Returns `{ html }`, or `{ blocked, redirectTo }`
|
|
26
|
+
* when a guard refuses the route. Fresh linkedom window + cache-busted
|
|
26
27
|
* dynamic import per call, so no singleton state (I18nService, etc.) leaks between renders.
|
|
27
28
|
*/
|
|
28
29
|
export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFetch, lang, pageUrl, i18nEnabled }) {
|
|
29
30
|
// Passing a real URL instance as `location` gives `.pathname`/`.href`
|
|
30
31
|
// which is what I18n's getLocaleFromUrl() actually reads.
|
|
31
32
|
// <main id="app"> mirrors index.html's real mount target — View defaults to mounting
|
|
32
|
-
// there (view.ts),
|
|
33
|
-
// DomHelper.cleanupOptionalContent()'s document-wide `[data-optional]` query silently
|
|
34
|
-
// misses it, leaking raw "undefined" text for any unset optional field.
|
|
33
|
+
// there (view.ts), so the rendered tree is attached to `document` as in the browser.
|
|
35
34
|
const { window } = parseHTML('<!doctype html><html><body><main id="app"></main></body></html>', { location: new URL(pageUrl) });
|
|
36
35
|
const { trackedFetches } = installGlobals(window, {
|
|
37
36
|
lang,
|
|
38
37
|
mockFetch: mockFetch ?? {},
|
|
39
38
|
localesDir: path.join(PATHS.tempSource, 'locales'),
|
|
39
|
+
staticDir: PATHS.tempSource,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
let Service;
|
|
@@ -45,7 +45,7 @@ export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFet
|
|
|
45
45
|
try {
|
|
46
46
|
const bundle = await import(`${bundleUrl}?ssr=${renderIndex++}`);
|
|
47
47
|
({ Service } = bundle);
|
|
48
|
-
const { appRoutes, I18nService, RouteGuardsManager, registerPipes } = bundle;
|
|
48
|
+
const { appRoutes, I18nService, RouteGuardsManager, SecurityHelper, registerPipes } = bundle;
|
|
49
49
|
|
|
50
50
|
const routeConfig = appRoutes[appRoutesKey];
|
|
51
51
|
if (!routeConfig) {
|
|
@@ -59,8 +59,17 @@ export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFet
|
|
|
59
59
|
|
|
60
60
|
if (i18nEnabled) await I18nService.setCurrentLanguage(lang);
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
// The route's guards run as they would for an anonymous first visit (empty storage,
|
|
63
|
+
// no session): a blocked or redirected route must not ship as public static HTML.
|
|
64
|
+
// Running them in this bundle instance also keeps what loader guards fetched (e.g.
|
|
65
|
+
// a Markdown manifest) available to the view rendered below.
|
|
66
|
+
const guardResult = await RouteGuardsManager.processRouteGuards(routeConfig, pageUrl, mockParams ?? {});
|
|
67
|
+
if (!guardResult.allowed) {
|
|
68
|
+
return { blocked: true, redirectTo: guardResult.redirectTo };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A lazy route's factory returns a Promise (its import() is inlined in this bundle).
|
|
72
|
+
const view = await guardResult.viewConstructor();
|
|
64
73
|
constructorName = view.constructor.name;
|
|
65
74
|
|
|
66
75
|
if (mockParams) view.setRouteParams(mockParams);
|
|
@@ -70,7 +79,11 @@ export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFet
|
|
|
70
79
|
await Promise.all(trackedFetches);
|
|
71
80
|
await Promise.resolve();
|
|
72
81
|
|
|
73
|
-
|
|
82
|
+
// Nothing binds a prerendered page (the app re-renders it on load): Nutin's binding
|
|
83
|
+
// attributes (data-event, data-i18n, ...) are dropped, on the DOM so text is untouched.
|
|
84
|
+
SecurityHelper.stripBindingsFrom(element);
|
|
85
|
+
|
|
86
|
+
return { html: element.outerHTML };
|
|
74
87
|
} catch (err) {
|
|
75
88
|
throw new Error(
|
|
76
89
|
`[ssr] Failed to render route "${appRoutesKey}" (lang "${lang}", view "${constructorName}"): ${err.message}${hintForError(err)}`,
|
|
@@ -92,7 +105,7 @@ function hintForError(err) {
|
|
|
92
105
|
return (
|
|
93
106
|
`\n Hint: this usually means the component tree touches an unguarded browser global ` +
|
|
94
107
|
`(e.g. "window", "navigator", "matchMedia") that isn't polyfilled for SSR — see ` +
|
|
95
|
-
`tools/builder/core/ssr/ssr-polyfills.js.`
|
|
108
|
+
`tools/builder/core/seo/ssr/ssr-polyfills.js.`
|
|
96
109
|
);
|
|
97
110
|
}
|
|
98
111
|
return '';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as sass from 'sass';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { getFilesRecursive,
|
|
4
|
+
import { getFilesRecursive, errorExit } from '../../../utils/index.js';
|
|
5
5
|
import { PATHS } from '../app/paths.js';
|
|
6
6
|
import { builderConfig } from '../../builder.config.js';
|
|
7
7
|
|
|
@@ -26,7 +26,8 @@ try {
|
|
|
26
26
|
|
|
27
27
|
// features styles
|
|
28
28
|
const appInput = path.join(PATHS.source, 'app');
|
|
29
|
-
|
|
29
|
+
// Partials (_name.scss) only exist to be @use'd — compiling them standalone would duplicate their CSS.
|
|
30
|
+
const appStyles = getFilesRecursive(appInput, 'scss').filter((file) => !path.basename(file).startsWith('_'));
|
|
30
31
|
|
|
31
32
|
for (const style of appStyles) {
|
|
32
33
|
const result = await sass.compileAsync(style, {
|