@nutin/cli 2.1.0 → 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 +322 -0
- package/README.md +14 -6
- 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 +12 -11
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +45 -43
- package/templates/base/GETTING_STARTED.md.hbs +32 -23
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/nutin.config.js.hbs +1 -1
- package/templates/base/src/app/components/.gitkeep +0 -0
- 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 +9 -3
- package/templates/base/src/app/services/.gitkeep +0 -0
- 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 +307 -21
- 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 +105 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
- 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 +101 -51
- 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 +217 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
- 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 +321 -0
- package/templates/base/src/core/services/router/router.ts.hbs +122 -54
- package/templates/base/src/index.html.hbs +1 -0
- package/templates/base/src/styles/_styles.scss.hbs +1 -113
- package/templates/base/src/styles/main.scss.hbs +1 -1
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +26 -8
- 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/add-tags.js.hbs +1 -4
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
- 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 +25 -8
- 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 +61 -15
- 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 +48 -18
- 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 +26 -8
- 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 +26 -9
- package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +75 -28
- package/templates/base/tools/generator/generator.js.hbs +23 -18
- package/templates/base/tools/generator/handle-file.js.hbs +4 -24
- package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
- 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 +97 -0
- 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 +3 -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 +5 -2
- package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
- 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 +29 -0
- 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/src/app/components/index.ts.hbs +0 -1
- package/templates/base/src/app/services/index.ts.hbs +0 -1
- package/templates/base/src/app/views/index.ts.hbs +0 -3
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { pathToFileURL } from 'url';
|
|
4
|
+
|
|
5
|
+
// nutin.config.js keys only the tooling reads — never worth shipping to the browser.
|
|
6
|
+
// markdownSources reaches the markdown feature's runtime through a define instead (see
|
|
7
|
+
// sharedDefine), so it's only bundled where that code is.
|
|
8
|
+
const TOOLING_ONLY_KEYS = ['builder', 'testinNutin', 'dockerPorts', 'markdownSources'];
|
|
9
|
+
|
|
10
|
+
const NUTIN_CONFIG_FILTER = /[\\/]nutin\.config\.js$/;
|
|
11
|
+
const SEO_JSON_FILTER = /[\\/]seo\.json$/;
|
|
12
|
+
const CORE_CONFIG_IMPORTER = /[\\/]core[\\/]config\.(ts|js)$/;
|
|
13
|
+
const SLIM_SEO_NAMESPACE = 'nutin-slim-seo';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* esbuild `define`s shared by the client and SSR bundles. `globalThis.__NUTIN_I18N__`
|
|
17
|
+
* lets esbuild fold core's i18n guards to a constant and drop the i18n code when it's off.
|
|
18
|
+
* `globalThis.__NUTIN_MARKDOWN_SOURCES__` (the markdown feature's source folders) is only
|
|
19
|
+
* emitted where code reads it, so an app without that code ships none of it.
|
|
20
|
+
*/
|
|
21
|
+
export function sharedDefine({ isProd, i18n, markdownSources }) {
|
|
22
|
+
return {
|
|
23
|
+
'process.env.NODE_ENV': isProd ? '"production"' : '"development"',
|
|
24
|
+
'globalThis.__NUTIN_I18N__': JSON.stringify(Boolean(i18n)),
|
|
25
|
+
// A JSON *string* literal: esbuild inlines and folds it (`define ?? fallback`), while
|
|
26
|
+
// an array define is hoisted into a variable and keeps the fallback code around.
|
|
27
|
+
'globalThis.__NUTIN_MARKDOWN_SOURCES__': JSON.stringify(JSON.stringify(markdownSources?.sourceFolders ?? [])),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Keeps tooling-only config out of the client bundle:
|
|
33
|
+
* - nutin.config.js loses its TOOLING_ONLY_KEYS (app code may read any other key);
|
|
34
|
+
* - config/seo.json, as imported by core/config.ts, keeps only each route's path and title.
|
|
35
|
+
*/
|
|
36
|
+
export function slimConfigPlugin({ generateSEO }) {
|
|
37
|
+
return {
|
|
38
|
+
name: 'nutin-slim-config',
|
|
39
|
+
setup(build) {
|
|
40
|
+
build.onLoad({ filter: NUTIN_CONFIG_FILTER }, async (args) => {
|
|
41
|
+
const { default: config } = await import(pathToFileURL(args.path).href);
|
|
42
|
+
const slim = slimNutinConfig(config);
|
|
43
|
+
// Not plain data (a function, a RegExp, ...): JSON can't carry it, so ship the file as-is.
|
|
44
|
+
if (!slim) return undefined;
|
|
45
|
+
return { contents: `export default ${JSON.stringify(slim)};`, loader: 'js' };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
build.onResolve({ filter: SEO_JSON_FILTER }, (args) => {
|
|
49
|
+
if (!CORE_CONFIG_IMPORTER.test(args.importer)) return undefined;
|
|
50
|
+
return { path: path.resolve(args.resolveDir, args.path), namespace: SLIM_SEO_NAMESPACE };
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
build.onLoad({ filter: /.*/, namespace: SLIM_SEO_NAMESPACE }, (args) => {
|
|
54
|
+
const seo = JSON.parse(fs.readFileSync(args.path, 'utf-8'));
|
|
55
|
+
return { contents: JSON.stringify(slimSeoConfig(seo, generateSEO)), loader: 'json' };
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function slimNutinConfig(config) {
|
|
62
|
+
const slim = Object.fromEntries(
|
|
63
|
+
Object.entries(config ?? {}).filter(([key]) => !TOOLING_ONLY_KEYS.includes(key))
|
|
64
|
+
);
|
|
65
|
+
return isPlainData(slim) ? slim : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The client only reads seo.json to resolve document titles (see NavigationManager.updateDocumentTitle).
|
|
69
|
+
export function slimSeoConfig(seo, generateSEO) {
|
|
70
|
+
if (!generateSEO) return {};
|
|
71
|
+
const routes = Array.isArray(seo?.routes) ? seo.routes : [];
|
|
72
|
+
return { routes: routes.map(({ path, title }) => ({ path, title })) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isPlainData(value) {
|
|
76
|
+
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) return true;
|
|
77
|
+
if (Array.isArray(value)) return value.every(isPlainData);
|
|
78
|
+
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
79
|
+
return Object.values(value).every(isPlainData);
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { sharedDefine, slimNutinConfig, slimSeoConfig, isPlainData } from './bundle-options.js';
|
|
2
|
+
|
|
3
|
+
describe('bundle-options', () => {
|
|
4
|
+
it('sharedDefine sets NODE_ENV, the i18n flag and the markdown sources from the build config', () => {
|
|
5
|
+
expect(sharedDefine({ isProd: true, i18n: false })).toEqual({
|
|
6
|
+
'process.env.NODE_ENV': '"production"',
|
|
7
|
+
'globalThis.__NUTIN_I18N__': 'false',
|
|
8
|
+
'globalThis.__NUTIN_MARKDOWN_SOURCES__': JSON.stringify('[]'),
|
|
9
|
+
});
|
|
10
|
+
expect(sharedDefine({ isProd: false, i18n: true, markdownSources: { sourceFolders: ['docs', { folder: 'x', seo: false }] } })).toEqual({
|
|
11
|
+
'process.env.NODE_ENV': '"development"',
|
|
12
|
+
'globalThis.__NUTIN_I18N__': 'true',
|
|
13
|
+
'globalThis.__NUTIN_MARKDOWN_SOURCES__': JSON.stringify('["docs",{"folder":"x","seo":false}]'),
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('slimNutinConfig drops tooling-only keys and keeps everything app code may read', () => {
|
|
18
|
+
const slim = slimNutinConfig({
|
|
19
|
+
i18n: true,
|
|
20
|
+
generateSEOFiles: false,
|
|
21
|
+
markdownSources: { sourceFolders: ['docs'] },
|
|
22
|
+
myAppKey: 'kept',
|
|
23
|
+
builder: { esbuild: { minify: true } },
|
|
24
|
+
testinNutin: { includeApp: false },
|
|
25
|
+
dockerPorts: [9090],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(slim).toEqual({
|
|
29
|
+
i18n: true,
|
|
30
|
+
generateSEOFiles: false,
|
|
31
|
+
myAppKey: 'kept',
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('slimNutinConfig returns null when the remaining config is not plain data', () => {
|
|
36
|
+
expect(slimNutinConfig({ i18n: false, onBuild: () => {} })).toBe(null);
|
|
37
|
+
expect(slimNutinConfig({ i18n: false, pattern: /x/ })).toBe(null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('slimNutinConfig ignores non-plain values inside tooling-only keys', () => {
|
|
41
|
+
expect(slimNutinConfig({ i18n: false, builder: { plugin: () => {} } })).toEqual({ i18n: false });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('slimSeoConfig keeps only each route\'s path and title when SEO generation is enabled', () => {
|
|
45
|
+
const seo = {
|
|
46
|
+
baseUrl: 'https://example.com',
|
|
47
|
+
disallowBots: ['GPTBot'],
|
|
48
|
+
routes: [{ path: '/', title: { en: 'Home' }, description: 'd', ogImage: '/og.jpg' }],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
expect(slimSeoConfig(seo, true)).toEqual({ routes: [{ path: '/', title: { en: 'Home' } }] });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('slimSeoConfig returns an empty object when SEO generation is disabled or routes are missing', () => {
|
|
55
|
+
expect(slimSeoConfig({ routes: [{ path: '/', title: 'Home' }] }, false)).toEqual({});
|
|
56
|
+
expect(slimSeoConfig({}, true)).toEqual({ routes: [] });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('isPlainData accepts JSON-like values only', () => {
|
|
60
|
+
expect(isPlainData({ a: [1, 'b', true, null, { c: 2 }] })).toBeTruthy();
|
|
61
|
+
expect(isPlainData(undefined)).toBeFalsy();
|
|
62
|
+
expect(isPlainData(new Date())).toBeFalsy();
|
|
63
|
+
expect(isPlainData([() => {}])).toBeFalsy();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'fs';
|
|
2
2
|
import { gzipSync, brotliCompressSync, constants } from 'zlib';
|
|
3
|
-
import {
|
|
4
|
-
import { PATHS } from '../app/paths.js';
|
|
3
|
+
import { getFilesRecursive, errorExit } from '../../../utils/index.js';
|
|
4
|
+
import { PATHS, isBundledSource } from '../app/paths.js';
|
|
5
5
|
|
|
6
6
|
function compressStaticAssets() {
|
|
7
7
|
const files = getFilesRecursive(
|
|
8
8
|
PATHS.tempSource,
|
|
9
|
-
['.js', '.css', '.json', '.svg', '.ttf', '.otf', '.eot']
|
|
10
|
-
);
|
|
9
|
+
['.html', '.js', '.css', '.json', '.svg', '.xml', '.txt', '.ttf', '.otf', '.eot']
|
|
10
|
+
).filter((file) => !isBundledSource(file));
|
|
11
11
|
|
|
12
12
|
for (const file of files) {
|
|
13
13
|
const content = readFileSync(file);
|
|
@@ -27,11 +27,11 @@ function compressStaticAssets() {
|
|
|
27
27
|
}
|
|
28
28
|
});
|
|
29
29
|
writeFileSync(`${file}.br`, brotlied);
|
|
30
|
-
}
|
|
30
|
+
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
try {
|
|
34
|
-
compressStaticAssets()
|
|
34
|
+
compressStaticAssets();
|
|
35
35
|
} catch(err) {
|
|
36
36
|
errorExit(err, 'compress-files');
|
|
37
37
|
}
|
|
@@ -1,26 +1,43 @@
|
|
|
1
1
|
import esbuild from 'esbuild';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { errorExit } from '../../../utils/index.js';
|
|
4
4
|
import { PATHS } from '../app/paths.js';
|
|
5
5
|
import { builderConfig } from '../../builder.config.js';
|
|
6
|
+
import { sharedDefine, slimConfigPlugin } from './bundle-options.js';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
{{#if (eq lang "js")}}
|
|
9
|
+
// Bundles the copied .js sources; in dev they're also kept on disk for testin-nutin.
|
|
10
|
+
const ENTRY_FILE = path.join(PATHS.tempApp, 'main.js');
|
|
11
|
+
{{else}}
|
|
12
|
+
// Prod bundles straight from the copied .ts sources (tsc runs --noEmit there);
|
|
13
|
+
// dev bundles tsc's emitted .js, which is kept on disk for testin-nutin.
|
|
14
|
+
const ENTRY_FILE = path.join(PATHS.tempApp, builderConfig.isProd ? 'main.ts' : 'main.js');
|
|
15
|
+
{{/if}}
|
|
16
|
+
|
|
17
|
+
const DEV_OVERRIDES = {
|
|
18
|
+
minify: false,
|
|
19
|
+
sourcemap: true,
|
|
20
|
+
drop: [],
|
|
21
|
+
};
|
|
9
22
|
|
|
10
23
|
async function build() {
|
|
11
24
|
await esbuild.build({
|
|
12
25
|
...builderConfig.esbuild,
|
|
26
|
+
...(builderConfig.isProd ? {} : DEV_OVERRIDES),
|
|
13
27
|
platform: 'browser',
|
|
14
28
|
format: 'esm',
|
|
15
29
|
legalComments: 'none',
|
|
16
30
|
loader: {
|
|
17
31
|
'.json': 'json',
|
|
18
32
|
},
|
|
19
|
-
define:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
define: sharedDefine(builderConfig),
|
|
34
|
+
plugins: [slimConfigPlugin({ generateSEO: builderConfig.generateSEO })],
|
|
35
|
+
// Code splitting: each dynamic import() becomes a chunk in chunks/, only fetched
|
|
36
|
+
// the first time it runs. The entry still comes out as bundle.js (see add-tags.js).
|
|
37
|
+
splitting: true,
|
|
38
|
+
entryPoints: [{ in: ENTRY_FILE, out: 'bundle' }],
|
|
39
|
+
outdir: PATHS.tempSource,
|
|
40
|
+
chunkNames: `${path.basename(PATHS.tempChunks)}/[name]-[hash]`,
|
|
24
41
|
keepNames: false,
|
|
25
42
|
});
|
|
26
43
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
|
-
import { PATHS } from '../app/paths.js';
|
|
5
|
-
import {
|
|
4
|
+
import { PATHS, isBundledSource, isChunk } from '../app/paths.js';
|
|
5
|
+
import { getFilesRecursive, errorExit } from '../../../utils/index.js';
|
|
6
6
|
|
|
7
7
|
function addHashToFiles(distDir) {
|
|
8
|
-
const files = getFilesRecursive(distDir, ['.js', '.css']);
|
|
8
|
+
const files = getFilesRecursive(distDir, ['.js', '.css']).filter((file) => !isBundledSource(file) && !isChunk(file));
|
|
9
9
|
|
|
10
10
|
const fileMap = {}; // Track old → new filenames
|
|
11
11
|
|
|
@@ -49,7 +49,7 @@ function updateHtmlReferences(htmlPath, fileMap) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
try {
|
|
52
|
-
addHashToFiles(PATHS.tempSource)
|
|
52
|
+
addHashToFiles(PATHS.tempSource);
|
|
53
53
|
} catch(err) {
|
|
54
54
|
errorExit(err, 'hash-files');
|
|
55
55
|
}
|
|
@@ -1,29 +1,36 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { PATHS } from '../app/paths.js';
|
|
4
|
-
import {
|
|
4
|
+
import { builderConfig } from '../../builder.config.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// The URL path(s) a route's Disallow lines must cover: `:param` segments become `*` (every
|
|
7
|
+
// value of that segment), and with i18n on the pages live under each /<lang>/ prefix.
|
|
8
|
+
function disallowPathsFor(route, { i18n, languages }) {
|
|
9
|
+
const pattern = route.path.replace(/:[^/]+/g, '*');
|
|
10
|
+
if (!i18n) return [pattern];
|
|
11
|
+
const suffix = pattern === '/' ? '/' : pattern;
|
|
12
|
+
return languages.map((lang) => `/${lang}${suffix}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildRobotsTxt(seoConfig, { i18n = false, languages = [] } = {}) {
|
|
16
|
+
const i18nOptions = { i18n, languages };
|
|
9
17
|
const baseUrl = seoConfig.baseUrl.replace(/\/$/, '');
|
|
10
18
|
const routes = seoConfig.routes ?? [];
|
|
11
|
-
const disallowBots = seoConfig.disallowBots ?? [];
|
|
19
|
+
const disallowBots = new Set(seoConfig.disallowBots ?? []);
|
|
12
20
|
|
|
13
21
|
// Paths disallowed for all bots (disallow: true)
|
|
14
22
|
const globalDisallows = routes
|
|
15
23
|
.filter(r => r.disallow === true)
|
|
16
|
-
.
|
|
24
|
+
.flatMap(r => disallowPathsFor(r, i18nOptions));
|
|
17
25
|
|
|
18
26
|
// Paths disallowed per named bot: { botName -> [path, ...] }
|
|
19
27
|
const perBotDisallows = {};
|
|
20
28
|
for (const r of routes) {
|
|
21
29
|
if (!Array.isArray(r.disallow)) continue;
|
|
22
30
|
for (const bot of r.disallow) {
|
|
23
|
-
(perBotDisallows[bot] ??= []).push(r
|
|
31
|
+
(perBotDisallows[bot] ??= []).push(...disallowPathsFor(r, i18nOptions));
|
|
24
32
|
}
|
|
25
33
|
}
|
|
26
|
-
// Bots that are fully blocked contribute an empty-path disallow
|
|
27
34
|
for (const bot of disallowBots) {
|
|
28
35
|
perBotDisallows[bot] ??= [];
|
|
29
36
|
}
|
|
@@ -31,21 +38,32 @@ export function generateRobotsTxt() {
|
|
|
31
38
|
const lines = [
|
|
32
39
|
'User-agent: *',
|
|
33
40
|
'Allow: /',
|
|
34
|
-
...globalDisallows,
|
|
41
|
+
...globalDisallows.map(p => `Disallow: ${p}`),
|
|
35
42
|
];
|
|
36
43
|
|
|
37
44
|
for (const [bot, paths] of Object.entries(perBotDisallows)) {
|
|
38
45
|
lines.push('');
|
|
39
46
|
lines.push(`User-agent: ${bot}`);
|
|
40
|
-
if (
|
|
41
|
-
for
|
|
42
|
-
} else {
|
|
47
|
+
if (disallowBots.has(bot)) {
|
|
48
|
+
// Fully blocked — per-route entries for this bot are redundant.
|
|
43
49
|
lines.push('Disallow: /');
|
|
50
|
+
continue;
|
|
44
51
|
}
|
|
52
|
+
// A bot with its own group ignores the `*` group entirely, so it has to
|
|
53
|
+
// repeat the global disallows to stay blocked from them.
|
|
54
|
+
for (const p of new Set([...globalDisallows, ...paths])) lines.push(`Disallow: ${p}`);
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
lines.push('', `Sitemap: ${baseUrl}/sitemap.xml`);
|
|
58
|
+
return lines.join('\n') + '\n';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function generateRobotsTxt() {
|
|
62
|
+
const seoConfigPath = path.join(PATHS.temp, 'config', 'seo.json');
|
|
63
|
+
const seoConfig = JSON.parse(fs.readFileSync(seoConfigPath, 'utf-8'));
|
|
64
|
+
const languagesConfigPath = path.join(PATHS.temp, 'config', 'languages.json');
|
|
65
|
+
const { languages } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
48
66
|
|
|
49
67
|
const outputPath = path.join(PATHS.tempSource, 'robots.txt');
|
|
50
|
-
fs.writeFileSync(outputPath,
|
|
68
|
+
fs.writeFileSync(outputPath, buildRobotsTxt(seoConfig, { i18n: builderConfig.i18n, languages }), 'utf-8');
|
|
51
69
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { buildRobotsTxt } from './generate-robots-txt.js';
|
|
2
|
+
|
|
3
|
+
const groupOf = (txt, bot) => {
|
|
4
|
+
const lines = txt.split('\n');
|
|
5
|
+
const start = lines.indexOf(`User-agent: ${bot}`);
|
|
6
|
+
const end = lines.indexOf('', start);
|
|
7
|
+
return lines.slice(start + 1, end === -1 ? undefined : end);
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
describe('buildRobotsTxt', () => {
|
|
11
|
+
it('a bot in disallowBots is fully blocked even when a route also names it', () => {
|
|
12
|
+
const txt = buildRobotsTxt({
|
|
13
|
+
baseUrl: 'https://site.test/',
|
|
14
|
+
disallowBots: ['GPTBot'],
|
|
15
|
+
routes: [{ path: '/private', disallow: ['GPTBot'] }],
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
expect(groupOf(txt, 'GPTBot')).toEqual(['Disallow: /']);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('a bot with its own group repeats the global disallows', () => {
|
|
22
|
+
const txt = buildRobotsTxt({
|
|
23
|
+
baseUrl: 'https://site.test',
|
|
24
|
+
routes: [
|
|
25
|
+
{ path: '/admin', disallow: true },
|
|
26
|
+
{ path: '/drafts', disallow: ['Bingbot'] },
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(groupOf(txt, '*')).toEqual(['Allow: /', 'Disallow: /admin']);
|
|
31
|
+
expect(groupOf(txt, 'Bingbot')).toEqual(['Disallow: /admin', 'Disallow: /drafts']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('turns :param segments into wildcards and prefixes every language with i18n on', () => {
|
|
35
|
+
const txt = buildRobotsTxt(
|
|
36
|
+
{ baseUrl: 'https://site.test', routes: [{ path: '/users/:id', disallow: true }] },
|
|
37
|
+
{ i18n: true, languages: ['en', 'fr'] }
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(groupOf(txt, '*')).toEqual(['Allow: /', 'Disallow: /en/users/*', 'Disallow: /fr/users/*']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('ends with the sitemap URL', () => {
|
|
44
|
+
const txt = buildRobotsTxt({ baseUrl: 'https://site.test/', routes: [] });
|
|
45
|
+
|
|
46
|
+
expect(txt.endsWith('Sitemap: https://site.test/sitemap.xml\n')).toBeTruthy();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { errorExit } from '../../../utils/index.js';
|
|
2
2
|
import { generateSeoHtml } from './generate-seo-html.js';
|
|
3
3
|
import { generateRobotsTxt } from './generate-robots-txt.js';
|
|
4
4
|
import { generateSitemapXml } from './generate-sitemap-xml.js';
|
|
5
5
|
|
|
6
6
|
async function generateSeoFiles() {
|
|
7
|
-
await generateSeoHtml();
|
|
7
|
+
const skippedSuffixes = await generateSeoHtml();
|
|
8
8
|
generateRobotsTxt();
|
|
9
|
-
generateSitemapXml();
|
|
9
|
+
generateSitemapXml(skippedSuffixes);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
try {
|
|
@@ -6,21 +6,38 @@ import { buildSsrBundle, cleanupSsrBundle } from './ssr/ssr-bundle.js';
|
|
|
6
6
|
import { renderRoute } from './ssr/ssr-render.js';
|
|
7
7
|
import { builderConfig } from '../../builder.config.js';
|
|
8
8
|
import { resolveLocaleValue, valueForLangWithFallback, collectI18nSeoIssues } from './i18n-resolution.js';
|
|
9
|
-
import { segmentsOf, validateMockParams, writeRouteHtml, warnForRoutesMissingSeoConfig } from './route-output.js';
|
|
9
|
+
import { segmentsOf, routeSuffixOf, validateMockParams, writeRouteHtml, warnForRoutesMissingSeoConfig } from './route-output.js';
|
|
10
|
+
import { resolveSeoRoutes, resolveSeoOptOutPaths } from './seo-routes.js';
|
|
11
|
+
|
|
12
|
+
// og:image/twitter:image require an absolute URL for social platforms to resolve them;
|
|
13
|
+
// every other absolute field (og:url, canonical) is already baseUrl-prefixed, so ogImage
|
|
14
|
+
// (a repo-relative path in config/seo.json) needs the same treatment.
|
|
15
|
+
function toAbsoluteUrl(urlOrPath, baseUrl) {
|
|
16
|
+
if (!urlOrPath) return urlOrPath;
|
|
17
|
+
return /^https?:\/\//.test(urlOrPath) ? urlOrPath : `${baseUrl}${urlOrPath}`;
|
|
18
|
+
}
|
|
10
19
|
|
|
20
|
+
// Returns the guard result when a route guard refused the route (nothing written), else null.
|
|
11
21
|
async function processRoute(template, baseUrl, defaultLanguage, languages, route, bundleUrl) {
|
|
12
22
|
validateMockParams(route);
|
|
13
23
|
|
|
14
|
-
const routeSuffix = route
|
|
24
|
+
const routeSuffix = routeSuffixOf(route);
|
|
15
25
|
|
|
16
26
|
if (builderConfig.i18n) {
|
|
27
|
+
// Same for every language variant of this route — lets crawlers know these URLs
|
|
28
|
+
// are alternates of one page rather than duplicate/competing content. x-default
|
|
29
|
+
// points at the default language.
|
|
30
|
+
const hreflangLinks = [
|
|
31
|
+
...languages.map((l) => ({ hreflang: l, href: `${baseUrl}/${l}${routeSuffix}/` })),
|
|
32
|
+
{ hreflang: 'x-default', href: `${baseUrl}/${defaultLanguage}${routeSuffix}/` },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// Every language is rendered before anything is written: a guard refusing any of
|
|
36
|
+
// them skips the whole route, rather than shipping some language variants.
|
|
37
|
+
const pages = [];
|
|
17
38
|
for (const lang of languages) {
|
|
18
|
-
const title = valueForLangWithFallback(route.title, lang, defaultLanguage);
|
|
19
|
-
const description = valueForLangWithFallback(route.description, lang, defaultLanguage);
|
|
20
|
-
const ogImage = valueForLangWithFallback(route.ogImage, lang, defaultLanguage);
|
|
21
|
-
|
|
22
39
|
const pageUrl = `${baseUrl}/${lang}${routeSuffix}`;
|
|
23
|
-
const
|
|
40
|
+
const result = await renderRoute({
|
|
24
41
|
bundleUrl,
|
|
25
42
|
appRoutesKey: route.path,
|
|
26
43
|
mockParams: route.mockParams,
|
|
@@ -29,9 +46,17 @@ async function processRoute(template, baseUrl, defaultLanguage, languages, route
|
|
|
29
46
|
pageUrl,
|
|
30
47
|
i18nEnabled: builderConfig.i18n,
|
|
31
48
|
});
|
|
49
|
+
if (result.blocked) return result;
|
|
50
|
+
pages.push({ lang, pageUrl, body: result.html });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const { lang, pageUrl, body } of pages) {
|
|
54
|
+
const title = valueForLangWithFallback(route.title, lang, defaultLanguage);
|
|
55
|
+
const description = valueForLangWithFallback(route.description, lang, defaultLanguage);
|
|
56
|
+
const ogImage = toAbsoluteUrl(valueForLangWithFallback(route.ogImage, lang, defaultLanguage), baseUrl);
|
|
32
57
|
|
|
33
58
|
await writeRouteHtml({
|
|
34
|
-
template, lang, title, description, pageUrl, ogImage, body,
|
|
59
|
+
template, lang, title, description, pageUrl, ogImage, body, hreflangLinks,
|
|
35
60
|
outputSegments: [lang, ...segmentsOf(routeSuffix)],
|
|
36
61
|
routePath: route.path,
|
|
37
62
|
});
|
|
@@ -39,14 +64,14 @@ async function processRoute(template, baseUrl, defaultLanguage, languages, route
|
|
|
39
64
|
} else {
|
|
40
65
|
const title = resolveLocaleValue(route.title, defaultLanguage);
|
|
41
66
|
const description = resolveLocaleValue(route.description, defaultLanguage);
|
|
42
|
-
const ogImage = resolveLocaleValue(route.ogImage, defaultLanguage);
|
|
67
|
+
const ogImage = toAbsoluteUrl(resolveLocaleValue(route.ogImage, defaultLanguage), baseUrl);
|
|
43
68
|
|
|
44
69
|
if (!title || !description) {
|
|
45
70
|
errorExit(`Missing title or description for route "${route.path}" in seo.json`, 'generate-seo-html');
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
const pageUrl = `${baseUrl}${routeSuffix}`;
|
|
49
|
-
const
|
|
74
|
+
const result = await renderRoute({
|
|
50
75
|
bundleUrl,
|
|
51
76
|
appRoutesKey: route.path,
|
|
52
77
|
mockParams: route.mockParams,
|
|
@@ -55,13 +80,24 @@ async function processRoute(template, baseUrl, defaultLanguage, languages, route
|
|
|
55
80
|
pageUrl,
|
|
56
81
|
i18nEnabled: builderConfig.i18n,
|
|
57
82
|
});
|
|
83
|
+
if (result.blocked) return result;
|
|
58
84
|
|
|
59
85
|
await writeRouteHtml({
|
|
60
|
-
template, lang: defaultLanguage, title, description, pageUrl, ogImage, body,
|
|
86
|
+
template, lang: defaultLanguage, title, description, pageUrl, ogImage, body: result.html,
|
|
61
87
|
outputSegments: segmentsOf(routeSuffix),
|
|
62
88
|
routePath: route.path,
|
|
63
89
|
});
|
|
64
90
|
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function describeBlocked(route, { redirectTo }) {
|
|
96
|
+
const outcome = redirectTo ? `redirected an anonymous visitor to "${redirectTo}"` : 'blocked an anonymous visitor';
|
|
97
|
+
const url = routeSuffixOf(route) || '/';
|
|
98
|
+
const where = url === route.path ? `"${route.path}"` : `"${route.path}" (${url})`;
|
|
99
|
+
return `[generate-seo-html] Route ${where} was not prerendered: its guard ${outcome}. ` +
|
|
100
|
+
`It is left out of sitemap.xml too.`;
|
|
65
101
|
}
|
|
66
102
|
|
|
67
103
|
export async function generateSeoHtml() {
|
|
@@ -115,14 +151,24 @@ export async function generateSeoHtml() {
|
|
|
115
151
|
}
|
|
116
152
|
}
|
|
117
153
|
|
|
154
|
+
// routeSuffixOf() of every route a guard refused, so the sitemap can leave them out too.
|
|
155
|
+
const skippedSuffixes = new Set();
|
|
156
|
+
|
|
118
157
|
try {
|
|
119
158
|
const bundleUrl = await buildSsrBundle();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
159
|
+
// seo.json routes plus feature-generated ones (e.g. one per Markdown page).
|
|
160
|
+
const routes = resolveSeoRoutes(seoConfig);
|
|
161
|
+
await warnForRoutesMissingSeoConfig(bundleUrl, routes, defaultLanguage, baseUrl, resolveSeoOptOutPaths());
|
|
162
|
+
|
|
163
|
+
for (const route of routes) {
|
|
164
|
+
const blocked = await processRoute(template, baseUrl, defaultLanguage, languages, route, bundleUrl);
|
|
165
|
+
if (!blocked) continue;
|
|
166
|
+
print.warn(describeBlocked(route, blocked));
|
|
167
|
+
skippedSuffixes.add(routeSuffixOf(route));
|
|
124
168
|
}
|
|
125
169
|
} finally {
|
|
126
170
|
cleanupSsrBundle();
|
|
127
171
|
}
|
|
172
|
+
|
|
173
|
+
return skippedSuffixes;
|
|
128
174
|
}
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { PATHS } from '../app/paths.js';
|
|
4
|
-
import { print } from '../../../utils/index.js';
|
|
5
4
|
import { builderConfig } from '../../builder.config.js';
|
|
5
|
+
import { routeSuffixOf } from './route-output.js';
|
|
6
|
+
import { resolveSeoRoutes } from './seo-routes.js';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
// `skippedSuffixes`: routeSuffixOf() of routes generate-seo-html.js didn't prerender
|
|
9
|
+
// because a guard refused them.
|
|
10
|
+
export function collectUrls(baseUrl, routes, languages, { i18n = builderConfig.i18n, skippedSuffixes = new Set() } = {}) {
|
|
8
11
|
const urls = [];
|
|
9
12
|
|
|
10
13
|
for (const route of routes) {
|
|
11
|
-
|
|
14
|
+
// Disallowed for every bot in robots.txt — listing it here would contradict that.
|
|
15
|
+
if (route.disallow === true) continue;
|
|
16
|
+
const routeSuffix = routeSuffixOf(route);
|
|
17
|
+
if (skippedSuffixes.has(routeSuffix)) continue;
|
|
12
18
|
|
|
13
|
-
if (
|
|
19
|
+
if (i18n) {
|
|
14
20
|
for (const lang of languages) {
|
|
15
21
|
urls.push(`${baseUrl}/${lang}${routeSuffix}/`);
|
|
16
22
|
}
|
|
@@ -22,12 +28,21 @@ function collectUrls(baseUrl, routes, languages) {
|
|
|
22
28
|
return urls;
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
function escapeXml(str) {
|
|
32
|
+
return str
|
|
33
|
+
.replace(/&/g, '&')
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/g, ''');
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
function buildSitemapXml(urls) {
|
|
26
|
-
const urlEntries = urls.map(loc => ` <url>\n <loc>${loc}</loc>\n </url>`).join('\n');
|
|
41
|
+
const urlEntries = urls.map(loc => ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`).join('\n');
|
|
27
42
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urlEntries}\n</urlset>\n`;
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
export function generateSitemapXml() {
|
|
45
|
+
export function generateSitemapXml(skippedSuffixes = new Set()) {
|
|
31
46
|
const seoConfigPath = path.join(PATHS.temp, 'config', 'seo.json');
|
|
32
47
|
const seoConfig = JSON.parse(fs.readFileSync(seoConfigPath, 'utf-8'));
|
|
33
48
|
const baseUrl = seoConfig.baseUrl.replace(/\/$/, '');
|
|
@@ -35,7 +50,7 @@ export function generateSitemapXml() {
|
|
|
35
50
|
const languagesConfigPath = path.join(PATHS.temp, 'config', 'languages.json');
|
|
36
51
|
const { languages } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
37
52
|
|
|
38
|
-
const urls = collectUrls(baseUrl, seoConfig
|
|
53
|
+
const urls = collectUrls(baseUrl, resolveSeoRoutes(seoConfig), languages, { skippedSuffixes });
|
|
39
54
|
const xml = buildSitemapXml(urls);
|
|
40
55
|
|
|
41
56
|
const outputPath = path.join(PATHS.tempSource, 'sitemap.xml');
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { collectUrls } from './generate-sitemap-xml.js';
|
|
2
|
+
|
|
3
|
+
const ROUTES = [
|
|
4
|
+
{ path: '/' },
|
|
5
|
+
{ path: '/admin' },
|
|
6
|
+
{ path: '/users/:id', mockParams: { id: '42' } },
|
|
7
|
+
{ path: '/drafts', disallow: true },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe('collectUrls', () => {
|
|
11
|
+
it('lists every route at its prerendered URL, minus routes disallowed for every bot', () => {
|
|
12
|
+
expect(collectUrls('https://site.test', ROUTES, ['en'], { i18n: false })).toEqual([
|
|
13
|
+
'https://site.test/',
|
|
14
|
+
'https://site.test/admin/',
|
|
15
|
+
'https://site.test/users/42/',
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('leaves out routes a guard kept from being prerendered', () => {
|
|
20
|
+
const urls = collectUrls('https://site.test', ROUTES, ['en'], { i18n: false, skippedSuffixes: new Set(['/admin']) });
|
|
21
|
+
|
|
22
|
+
expect(urls).toEqual(['https://site.test/', 'https://site.test/users/42/']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('skips a guarded route in every language with i18n on', () => {
|
|
26
|
+
const urls = collectUrls('https://site.test', ROUTES.slice(0, 2), ['en', 'fr'], { i18n: true, skippedSuffixes: new Set(['/admin']) });
|
|
27
|
+
|
|
28
|
+
expect(urls).toEqual(['https://site.test/en/', 'https://site.test/fr/']);
|
|
29
|
+
});
|
|
30
|
+
});
|