@nutin/cli 1.3.0 → 2.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 +122 -8
- package/README.md +21 -37
- package/bin/add-feature.mjs +9 -0
- package/bin/cli.mjs +8 -0
- package/bin/create-app.mjs +5 -4
- package/bin/update-app.mjs +9 -0
- package/lib/index.mjs +81 -30
- package/lib/src/add-feature/feature-adder.mjs +83 -0
- package/lib/src/add-feature/feature-context-builder.mjs +25 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +29 -0
- package/lib/src/add-feature/package-json-updater.mjs +49 -0
- package/lib/src/common/feature-registry.mjs +11 -0
- package/lib/src/common/file-generator.mjs +159 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +33 -0
- package/lib/src/common/package-data.mjs +10 -0
- package/lib/src/common/package-json-helper.mjs +103 -0
- package/lib/src/common/project-meta.mjs +54 -0
- package/lib/src/create-app/context-builder.mjs +21 -0
- package/lib/src/{git-manager.mjs → create-app/git-manager.mjs} +2 -2
- package/lib/src/create-app/json-manager.mjs +73 -0
- package/lib/src/create-app/new-app-prompt.mjs +51 -0
- package/lib/src/{project-generator.mjs → create-app/project-generator.mjs} +15 -13
- package/lib/src/create-app/success-message.mjs +14 -0
- package/lib/src/update-app/conflict-report-writer.mjs +31 -0
- package/lib/src/update-app/fetch-old-templates.mjs +61 -0
- package/lib/src/update-app/project-updater.mjs +136 -0
- package/lib/src/update-app/template-differ.mjs +124 -0
- package/lib/src/update-app/update-context-builder.mjs +25 -0
- package/lib/src/update-app/update-summary-printer.mjs +22 -0
- package/lib/src/update-app/version-compare.mjs +12 -0
- package/lib/src/utils/print.mjs +36 -0
- package/lib/src/utils/promise-exec-alias.mjs +4 -0
- package/package.json +17 -12
- package/templates/base/.gitignore.hbs +6 -1
- package/templates/base/AGENTS.md.hbs +169 -0
- package/templates/base/GETTING_STARTED.md.hbs +227 -0
- package/templates/base/README.md.hbs +8 -17
- package/templates/base/config/languages.json.hbs +6 -0
- package/templates/base/config/seo.json.hbs +12 -0
- package/templates/base/nutin.config.js.hbs +51 -0
- package/templates/base/src/app/components/index.ts.hbs +1 -2
- package/templates/base/src/app/globals.d.ts.hbs +3 -15
- package/templates/base/src/{libs/guards → app}/guards.ts.hbs +1 -5
- package/templates/base/src/app/main.ts.hbs +7 -26
- package/templates/base/src/app/services/index.ts.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.html.hbs +7 -0
- package/templates/base/src/app/views/home/home.view.ts.hbs +1 -56
- package/templates/base/src/app/views/index.ts.hbs +1 -0
- package/templates/base/src/app/views/not-found/not-found.view.html.hbs +8 -0
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +4 -37
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +158 -0
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +81 -31
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +124 -0
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +38 -23
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +74 -0
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +1 -1
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +145 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +41 -1
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +88 -0
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +13 -14
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +62 -0
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +82 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +142 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +67 -22
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +183 -0
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +85 -0
- package/templates/base/src/core/base-classes/component/component.ts.hbs +27 -40
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +58 -0
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +17 -0
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +75 -0
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +150 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +26 -20
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +74 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +17 -41
- package/templates/base/src/core/config.ts.hbs +7 -0
- package/templates/base/src/core/index.ts.hbs +0 -3
- package/templates/base/src/core/internals.d.ts.hbs +27 -0
- package/templates/base/src/core/nutin-config.d.ts.hbs +4 -0
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +67 -3
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +9 -4
- package/templates/base/src/core/services/event-bus/index.ts.hbs +3 -0
- package/templates/base/src/core/services/event-bus/lifecycle.facade.test.js.hbs +123 -0
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +61 -0
- package/templates/base/src/core/services/event-bus/navigation.facade.test.js.hbs +46 -0
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +25 -0
- package/templates/base/src/core/services/globals/globals.test.js.hbs +149 -0
- package/templates/base/src/core/services/globals/globals.ts.hbs +95 -0
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +25 -0
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +20 -0
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +43 -0
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +270 -0
- package/templates/{features/i18n → base}/src/core/services/i18n/i18n.ts.hbs +60 -9
- package/templates/base/src/core/services/index.ts.hbs +5 -6
- package/templates/base/src/core/services/pipe-registry/pipe-registry.test.js.hbs +60 -1
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +1 -1
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +128 -0
- package/templates/base/src/{libs/pipes → core/services/pipe-registry}/pipes.ts.hbs +8 -5
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +149 -6
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +42 -30
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +27 -0
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +83 -17
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +13 -70
- package/templates/base/src/core/services/router/router.test.js.hbs +226 -2
- package/templates/base/src/core/services/router/router.ts.hbs +40 -9
- package/templates/base/src/index.html.hbs +2 -6
- package/templates/base/src/styles/_styles.scss.hbs +102 -43
- package/templates/base/src/styles/base/_variables.scss.hbs +1 -85
- package/templates/base/src/styles/main.scss.hbs +1 -18
- package/templates/base/src/styles/tailwind.css.hbs +2 -0
- package/templates/base/tools/builder/builder.config.js.hbs +40 -0
- package/templates/base/tools/builder/builder.js.hbs +24 -26
- package/templates/base/tools/builder/core/app/binary-extensions.test.js.hbs +16 -0
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +16 -0
- package/templates/base/tools/builder/core/{copy-static.js.hbs → app/copy-static.js.hbs} +27 -34
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +77 -0
- package/templates/base/tools/builder/core/finalize-build.js.hbs +16 -7
- package/templates/base/tools/builder/core/{add-tags.js.hbs → html-index/add-tags.js.hbs} +2 -2
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +56 -0
- package/templates/base/tools/builder/core/{validate-html.js.hbs → html-index/validate-html.js.hbs} +5 -11
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +152 -0
- package/templates/base/tools/builder/core/html-templates/minify-html.js.hbs +15 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +8 -0
- package/templates/{features/i18n/tools/builder/core → base/tools/builder/core/i18n}/build-i18n.js.hbs +6 -17
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +37 -0
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +30 -0
- package/templates/base/tools/builder/core/{hash-files.js.hbs → prod-bundle/hash-files.js.hbs} +14 -11
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +51 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +16 -0
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +128 -0
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +43 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +99 -0
- package/templates/base/tools/builder/core/seo/i18n-resolution.js.hbs +66 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +64 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +54 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +132 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +95 -0
- package/templates/base/tools/builder/core/styles/sass.js.hbs +40 -0
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +84 -0
- package/templates/base/tools/dev/dev-serve.js.hbs +26 -18
- package/templates/base/tools/dev/serve.js.hbs +11 -1
- package/templates/base/tools/dev/watcher.js.hbs +14 -6
- package/templates/base/tools/generator/generator.js.hbs +64 -47
- package/templates/base/tools/generator/handle-file.js.hbs +17 -44
- package/templates/base/tools/generator/handle-file.test.js.hbs +72 -0
- package/templates/base/tools/generator/templates/{component.template.js.hbs → component/component.template.js.hbs} +3 -2
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +23 -0
- package/templates/base/tools/generator/templates/html/html.template.js.hbs +2 -0
- package/templates/base/tools/generator/templates/html/html.template.test.js.hbs +7 -0
- package/templates/base/tools/generator/templates/index.js.hbs +7 -12
- package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +4 -0
- package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +7 -0
- package/templates/base/tools/generator/templates/scss/scss.template.js.hbs +1 -0
- package/templates/base/tools/generator/templates/scss/scss.template.test.js.hbs +7 -0
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +17 -0
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +22 -0
- package/templates/base/tools/generator/templates/test/test.template.js.hbs +10 -0
- package/templates/base/tools/generator/templates/test/test.template.test.js.hbs +27 -0
- package/templates/base/tools/generator/templates/{view.template.js.hbs → view/view.template.js.hbs} +3 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +24 -0
- package/templates/base/tools/testin-nutin/core/coverage/collect-coverage.js.hbs +39 -0
- package/templates/base/tools/testin-nutin/core/coverage/compute-coverage.js.hbs +115 -0
- package/templates/base/tools/testin-nutin/core/coverage/compute-coverage.test.js.hbs +74 -0
- package/templates/base/tools/testin-nutin/core/coverage/write-summary-report.js.hbs +54 -0
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +44 -0
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/assertion-lib.js.hbs +62 -24
- package/templates/base/tools/testin-nutin/core/globals/clock.js.hbs +206 -0
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/jsdom-setup.js.hbs +19 -6
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/register-test-globals.js.hbs +13 -4
- package/templates/base/tools/testin-nutin/core/globals/silence-console.js.hbs +23 -0
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/spyon.js.hbs +2 -0
- package/templates/base/tools/testin-nutin/core/index.js.hbs +9 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +85 -0
- package/templates/base/tools/testin-nutin/core/queue/queue.test.js.hbs +46 -0
- package/templates/base/tools/testin-nutin/core/queue/test-discovery.test.js.hbs +46 -0
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +154 -0
- package/templates/{features/testinNutin → base/tools}/testin-nutin/core/tests/assertions.test.js.hbs +27 -0
- package/templates/base/tools/testin-nutin/core/tests/clock.test.js.hbs +118 -0
- package/templates/base/tools/testin-nutin/core/tests/globals-each-all.test.js.hbs +28 -0
- package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/create-mock-method.js.hbs +2 -1
- package/templates/base/tools/testin-nutin/mocks/create-mock-method.test.js.hbs +64 -0
- package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-event-bus.js.hbs +3 -2
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +66 -0
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +26 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +61 -0
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +29 -0
- package/templates/base/tools/testin-nutin/runner.js.hbs +100 -0
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +37 -0
- package/templates/base/tools/utils/error-exit.js.hbs +8 -0
- package/templates/base/tools/utils/format.js.hbs +10 -10
- package/templates/base/tools/utils/format.test.js.hbs +45 -0
- package/templates/base/tools/utils/get-files-recursive.js.hbs +6 -1
- package/templates/base/tools/utils/get-files-recursive.test.js.hbs +49 -0
- package/templates/base/tools/utils/get-rel-to-core.test.js.hbs +18 -0
- package/templates/base/tools/utils/index.js.hbs +1 -3
- package/templates/base/tools/utils/languages.js.hbs +15 -0
- package/templates/base/tools/utils/languages.test.js.hbs +16 -0
- package/templates/base/tools/utils/print.js.hbs +19 -13
- package/templates/base/tools/utils/print.test.js.hbs +60 -0
- package/templates/base/tools/utils/prompt-boolean.js.hbs +7 -0
- package/templates/base/tools/utils/prompt-boolean.test.js.hbs +54 -0
- package/templates/base/tools/utils/run-command.js.hbs +4 -2
- package/templates/base/tools/utils/run-command.test.js.hbs +19 -0
- package/templates/base/tools/utils/run-script.js.hbs +14 -3
- package/templates/base/tools/utils/run-script.test.js.hbs +40 -0
- package/templates/features/{deployHelper → docker}/.dockerignore.hbs +4 -4
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +51 -0
- package/templates/features/{deployHelper/tools/deployment/nginx.conf.hbs → docker/tools/docker/nginx.conf.template} +4 -2
- package/templates/features/docker/tools/docker/scripts/docker-build.js.hbs +20 -0
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +48 -0
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +52 -0
- package/templates/features/docker/tools/docker/scripts/docker-run.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +23 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +60 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +30 -0
- package/lib/src/context-builder.mjs +0 -35
- package/lib/src/file-generator.mjs +0 -109
- package/lib/src/json-generator.mjs +0 -31
- package/lib/src/package-manager.mjs +0 -139
- package/lib/src/print.mjs +0 -31
- package/lib/src/prompts.mjs +0 -123
- package/lib/src/utils.mjs +0 -18
- package/lib/src/version.mjs +0 -1
- package/templates/base/builder.config.js.hbs +0 -35
- package/templates/base/src/app/components/common/anchor/anchor.component.ts.hbs +0 -32
- package/templates/base/src/app/components/common/button/button.component.ts.hbs +0 -40
- package/templates/base/src/app/dom.test.js.hbs +0 -13
- package/templates/base/src/app/services/example/example.service.ts.hbs +0 -18
- package/templates/base/src/app/views/home/home.view.test.js.hbs +0 -26
- package/templates/base/src/core/services/store/store.test.js.hbs +0 -8
- package/templates/base/src/core/services/store/store.ts.hbs +0 -39
- package/templates/base/src/core/utils/anchor-manager/anchor-manager.utils.test.js.hbs +0 -0
- package/templates/base/src/core/utils/anchor-manager/anchor-manager.utils.ts.hbs +0 -85
- package/templates/base/src/core/utils/button-manager/button-manager.utils.test.js.hbs +0 -249
- package/templates/base/src/core/utils/button-manager/button-manager.utils.ts.hbs +0 -108
- package/templates/base/src/core/utils/helpers/attributes.helper.ts.hbs +0 -50
- package/templates/base/src/core/utils/index.ts.hbs +0 -2
- package/templates/base/src/libs/index.ts.hbs +0 -4
- package/templates/base/src/libs/popover/popover.ts.hbs +0 -145
- package/templates/base/src/libs/snackbar/snackbar.ts.hbs +0 -72
- package/templates/base/src/styles/base/_functions.scss.hbs +0 -9
- package/templates/base/src/styles/core/_libs.scss.hbs +0 -132
- package/templates/base/tools/builder/core/esbuild.js.hbs +0 -25
- package/templates/base/tools/builder/core/minify-html.js.hbs +0 -60
- package/templates/base/tools/builder/core/sass.js.hbs +0 -22
- package/templates/base/tools/builder/core/tsc.js.hbs +0 -12
- package/templates/base/tools/generator/templates/service.template.js.hbs +0 -17
- package/templates/base/tools/utils/export-flags.js.hbs +0 -6
- package/templates/features/deployHelper/tools/builder/core/compress-files.js.hbs +0 -56
- package/templates/features/deployHelper/tools/deployment/Dockerfile.hbs +0 -30
- package/templates/features/i18n/src/app/views/home/locales/en.json.hbs +0 -9
- package/templates/features/i18n/src/app/views/not-found/locales/en.json +0 -8
- package/templates/features/i18n/src/app/views/not-found/locales/fr.json +0 -8
- package/templates/features/i18n/src/core/config.ts.hbs +0 -3
- package/templates/features/i18n/src/core/services/i18n/i18n.test.js.hbs +0 -125
- package/templates/features/i18n/tools/generator/templates/json.template.js.hbs +0 -8
- package/templates/features/i18n/tools/utils/languages.js.hbs +0 -7
- package/templates/features/stylinNutin/src/styles/_custom-classes.scss.hbs +0 -9
- package/templates/features/stylinNutin/src/styles/base/_mixins.scss.hbs +0 -372
- package/templates/features/stylinNutin/src/styles/components/_index.scss.hbs +0 -3
- package/templates/features/stylinNutin/src/styles/core/_utilities.scss.hbs +0 -240
- package/templates/features/stylinNutin/src/styles/views/_index.scss.hbs +0 -0
- package/templates/features/stylinNutin/tools/generator/templates/scss.template.js.hbs +0 -4
- package/templates/features/template/src/app/views/home/home.view.html.hbs +0 -56
- package/templates/features/template/src/app/views/not-found/not-found.view.html.hbs +0 -19
- package/templates/features/template/tools/builder/core/merge-templates.js.hbs +0 -49
- package/templates/features/template/tools/generator/templates/html.template.js.hbs +0 -2
- package/templates/features/testinNutin/testin-nutin/core/index.js.hbs +0 -5
- package/templates/features/testinNutin/testin-nutin/core/src/utils/print.js.hbs +0 -64
- package/templates/features/testinNutin/testin-nutin/core/src/utils/test-queue.js.hbs +0 -64
- package/templates/features/testinNutin/testin-nutin/core/tests/globals-each-all.test.js.hbs +0 -28
- package/templates/features/testinNutin/testin-nutin/mocks/mock-store.js.hbs +0 -38
- package/templates/features/testinNutin/testin-nutin/runner.js.hbs +0 -36
- package/templates/features/testinNutin/testin-nutin/watch-tests.js.hbs +0 -21
- package/templates/features/testinNutin/testin-nutin.config.js.hbs +0 -14
- /package/lib/src/{template-compiler.mjs → common/template-compiler.mjs} +0 -0
- /package/templates/{features/i18n → base}/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +0 -0
- /package/templates/{features/i18n → base}/src/core/services/i18n/languages.ts.hbs +0 -0
- /package/templates/base/tools/builder/{variables → core/app}/binary-extensions.js.hbs +0 -0
- /package/templates/base/tools/builder/core/{paths.js.hbs → app/paths.js.hbs} +0 -0
- /package/templates/{features/testinNutin/testin-nutin/core/src/utils → base/tools/testin-nutin/core/queue}/queue.js.hbs +0 -0
- /package/templates/{features/testinNutin/testin-nutin/core/src/utils → base/tools/testin-nutin/core/queue}/test-discovery.js.hbs +0 -0
- /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-http-client.js.hbs +0 -0
- /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-i18n.js.hbs +0 -0
- /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-router.js.hbs +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { PATHS } from './paths.js';
|
|
3
|
+
import { getFilesRecursive, print, errorExit } from '../../../utils/index.js';
|
|
4
|
+
import { PATHS } from '../app/paths.js';
|
|
6
5
|
|
|
7
6
|
const destDir = path.join(PATHS.tempSource, 'locales');
|
|
8
7
|
|
|
@@ -50,7 +49,7 @@ async function removeJsonFiles(directory) {
|
|
|
50
49
|
try {
|
|
51
50
|
await fs.promises.access(directory);
|
|
52
51
|
} catch {
|
|
53
|
-
print.
|
|
52
|
+
print.grayError(`Directory ${directory} does not exist, skipping cleanup`);
|
|
54
53
|
return;
|
|
55
54
|
}
|
|
56
55
|
|
|
@@ -60,19 +59,13 @@ async function removeJsonFiles(directory) {
|
|
|
60
59
|
if (file.includes(`${path.sep}locales${path.sep}`)) {
|
|
61
60
|
try {
|
|
62
61
|
await fs.promises.unlink(file);
|
|
63
|
-
if (isVerbose) print.info(`Deleted locale JSON file: ${file}`);
|
|
64
62
|
} catch (err) {
|
|
65
63
|
print.error(`Failed to remove ${file}: ${err.message}`);
|
|
66
64
|
}
|
|
67
|
-
}
|
|
68
|
-
else if (isVerbose) {
|
|
69
|
-
print.info(`Skipping non-locale JSON file: ${file}`);
|
|
70
65
|
}
|
|
71
66
|
}
|
|
72
67
|
|
|
73
68
|
await removeEmptyDirectories(directory);
|
|
74
|
-
|
|
75
|
-
if (isVerbose) print.boldInfo(`JSON files cleanup complete.`);
|
|
76
69
|
}
|
|
77
70
|
|
|
78
71
|
async function removeEmptyDirectories(directory) {
|
|
@@ -102,10 +95,9 @@ async function removeEmptyDirectories(directory) {
|
|
|
102
95
|
const updatedItems = await fs.promises.readdir(directory);
|
|
103
96
|
if (updatedItems.length === 0) {
|
|
104
97
|
await fs.promises.rmdir(directory, { recursive: false });
|
|
105
|
-
if (isVerbose) print.info(`📁 Removed empty directory: ${directory}`);
|
|
106
98
|
}
|
|
107
99
|
} catch (err) {
|
|
108
|
-
print.
|
|
100
|
+
print.grayError(`Failed to remove empty directory ${directory}: ${err.message}`);
|
|
109
101
|
}
|
|
110
102
|
}
|
|
111
103
|
}
|
|
@@ -114,19 +106,16 @@ async function mergeJson() {
|
|
|
114
106
|
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
115
107
|
|
|
116
108
|
const localeFilesMap = findLocaleFiles();
|
|
117
|
-
|
|
118
109
|
for (const [locale, files] of Object.entries(localeFilesMap)) {
|
|
119
110
|
const combined = combineJsonFiles(files);
|
|
120
111
|
fs.writeFileSync(
|
|
121
112
|
`${destDir}/${locale}.json`,
|
|
122
113
|
JSON.stringify(combined, null, 2)
|
|
123
114
|
);
|
|
124
|
-
if (isVerbose) print.boldInfo(`Json files for locale ${locale} merged successfully`);
|
|
125
115
|
}
|
|
126
|
-
removeJsonFiles(PATHS.tempApp);
|
|
116
|
+
await removeJsonFiles(PATHS.tempApp);
|
|
127
117
|
}
|
|
128
118
|
|
|
129
119
|
mergeJson().catch((err) => {
|
|
130
|
-
|
|
131
|
-
exit(1);
|
|
120
|
+
errorExit(err)
|
|
132
121
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { gzipSync, brotliCompressSync, constants } from 'zlib';
|
|
3
|
+
import { print, getFilesRecursive, errorExit } from '../../../utils/index.js';
|
|
4
|
+
import { PATHS } from '../app/paths.js';
|
|
5
|
+
|
|
6
|
+
function compressStaticAssets() {
|
|
7
|
+
const files = getFilesRecursive(
|
|
8
|
+
PATHS.tempSource,
|
|
9
|
+
['.js', '.css', '.json', '.svg', '.ttf', '.otf', '.eot']
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
const content = readFileSync(file);
|
|
14
|
+
|
|
15
|
+
const gzipped = gzipSync(content, {
|
|
16
|
+
level: 9,
|
|
17
|
+
memLevel: 9,
|
|
18
|
+
windowBits: 15
|
|
19
|
+
});
|
|
20
|
+
writeFileSync(`${file}.gz`, gzipped);
|
|
21
|
+
|
|
22
|
+
const brotlied = brotliCompressSync(content, {
|
|
23
|
+
params: {
|
|
24
|
+
[constants.BROTLI_PARAM_QUALITY]: 11,
|
|
25
|
+
[constants.BROTLI_PARAM_MODE]: constants.BROTLI_MODE_GENERIC,
|
|
26
|
+
[constants.BROTLI_PARAM_SIZE_HINT]: content.length
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
writeFileSync(`${file}.br`, brotlied);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
compressStaticAssets()
|
|
35
|
+
} catch(err) {
|
|
36
|
+
errorExit(err, 'compress-files');
|
|
37
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import esbuild from 'esbuild';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { print, errorExit } from '../../../utils/index.js';
|
|
4
|
+
import { PATHS } from '../app/paths.js';
|
|
5
|
+
import { builderConfig } from '../../builder.config.js';
|
|
6
|
+
|
|
7
|
+
const ENTRY_FILE = path.join(PATHS.tempApp, 'main.ts');
|
|
8
|
+
const OUT_FILE = path.join(PATHS.tempSource, 'bundle.js');
|
|
9
|
+
|
|
10
|
+
async function build() {
|
|
11
|
+
await esbuild.build({
|
|
12
|
+
...builderConfig.esbuild,
|
|
13
|
+
platform: 'browser',
|
|
14
|
+
format: 'esm',
|
|
15
|
+
legalComments: 'none',
|
|
16
|
+
loader: {
|
|
17
|
+
'.json': 'json',
|
|
18
|
+
},
|
|
19
|
+
define: {
|
|
20
|
+
'process.env.NODE_ENV': '"production"',
|
|
21
|
+
},
|
|
22
|
+
entryPoints: [ENTRY_FILE],
|
|
23
|
+
outfile: OUT_FILE,
|
|
24
|
+
keepNames: false,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
build().catch((err) => {
|
|
29
|
+
errorExit(err, 'esbuild');
|
|
30
|
+
});
|
package/templates/base/tools/builder/core/{hash-files.js.hbs → prod-bundle/hash-files.js.hbs}
RENAMED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
|
-
import { PATHS } from '
|
|
5
|
-
import { print,
|
|
4
|
+
import { PATHS } from '../app/paths.js';
|
|
5
|
+
import { print, getFilesRecursive, errorExit } from '../../../utils/index.js';
|
|
6
6
|
|
|
7
7
|
function addHashToFiles(distDir) {
|
|
8
8
|
const files = getFilesRecursive(distDir, ['.js', '.css']);
|
|
@@ -24,10 +24,6 @@ function addHashToFiles(distDir) {
|
|
|
24
24
|
const newRel = path.relative(distDir, newPath).replace(/\\/g, '/');
|
|
25
25
|
|
|
26
26
|
fileMap[oldRel] = newRel;
|
|
27
|
-
|
|
28
|
-
if (isVerbose) {
|
|
29
|
-
print.info(`Renamed: ${oldRel} → ${newRel}`);
|
|
30
|
-
}
|
|
31
27
|
});
|
|
32
28
|
|
|
33
29
|
updateHtmlReferences(path.join(distDir, 'index.html'), fileMap);
|
|
@@ -38,15 +34,22 @@ function hashFile(filePath) {
|
|
|
38
34
|
return crypto.createHash('sha256').update(content).digest('hex').substring(0, 8);
|
|
39
35
|
}
|
|
40
36
|
|
|
37
|
+
function escapeRegExp(str) {
|
|
38
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
41
|
function updateHtmlReferences(htmlPath, fileMap) {
|
|
42
42
|
let html = fs.readFileSync(htmlPath, 'utf8');
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
Object.entries(fileMap).forEach(([oldName, newName]) => {
|
|
45
|
-
html = html.replace(new RegExp(oldName, 'g'), newName);
|
|
45
|
+
html = html.replace(new RegExp(escapeRegExp(oldName), 'g'), newName);
|
|
46
46
|
});
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
fs.writeFileSync(htmlPath, html);
|
|
49
|
-
if (isVerbose) print.info('Updated index.html references');
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
try {
|
|
52
|
+
addHashToFiles(PATHS.tempSource)
|
|
53
|
+
} catch(err) {
|
|
54
|
+
errorExit(err, 'hash-files');
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from '../app/paths.js';
|
|
4
|
+
import { print } from '../../../utils/index.js';
|
|
5
|
+
|
|
6
|
+
export function generateRobotsTxt() {
|
|
7
|
+
const seoConfigPath = path.join(PATHS.temp, 'config', 'seo.json');
|
|
8
|
+
const seoConfig = JSON.parse(fs.readFileSync(seoConfigPath, 'utf-8'));
|
|
9
|
+
const baseUrl = seoConfig.baseUrl.replace(/\/$/, '');
|
|
10
|
+
const routes = seoConfig.routes ?? [];
|
|
11
|
+
const disallowBots = seoConfig.disallowBots ?? [];
|
|
12
|
+
|
|
13
|
+
// Paths disallowed for all bots (disallow: true)
|
|
14
|
+
const globalDisallows = routes
|
|
15
|
+
.filter(r => r.disallow === true)
|
|
16
|
+
.map(r => `Disallow: ${r.path}`);
|
|
17
|
+
|
|
18
|
+
// Paths disallowed per named bot: { botName -> [path, ...] }
|
|
19
|
+
const perBotDisallows = {};
|
|
20
|
+
for (const r of routes) {
|
|
21
|
+
if (!Array.isArray(r.disallow)) continue;
|
|
22
|
+
for (const bot of r.disallow) {
|
|
23
|
+
(perBotDisallows[bot] ??= []).push(r.path);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Bots that are fully blocked contribute an empty-path disallow
|
|
27
|
+
for (const bot of disallowBots) {
|
|
28
|
+
perBotDisallows[bot] ??= [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const lines = [
|
|
32
|
+
'User-agent: *',
|
|
33
|
+
'Allow: /',
|
|
34
|
+
...globalDisallows,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const [bot, paths] of Object.entries(perBotDisallows)) {
|
|
38
|
+
lines.push('');
|
|
39
|
+
lines.push(`User-agent: ${bot}`);
|
|
40
|
+
if (paths.length > 0) {
|
|
41
|
+
for (const p of paths) lines.push(`Disallow: ${p}`);
|
|
42
|
+
} else {
|
|
43
|
+
lines.push('Disallow: /');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lines.push('', `Sitemap: ${baseUrl}/sitemap.xml`);
|
|
48
|
+
|
|
49
|
+
const outputPath = path.join(PATHS.tempSource, 'robots.txt');
|
|
50
|
+
fs.writeFileSync(outputPath, lines.join('\n') + '\n', 'utf-8');
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { print, errorExit } from '../../../utils/index.js';
|
|
2
|
+
import { generateSeoHtml } from './generate-seo-html.js';
|
|
3
|
+
import { generateRobotsTxt } from './generate-robots-txt.js';
|
|
4
|
+
import { generateSitemapXml } from './generate-sitemap-xml.js';
|
|
5
|
+
|
|
6
|
+
async function generateSeoFiles() {
|
|
7
|
+
await generateSeoHtml();
|
|
8
|
+
generateRobotsTxt();
|
|
9
|
+
generateSitemapXml();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await generateSeoFiles();
|
|
14
|
+
} catch (err) {
|
|
15
|
+
errorExit(err, 'generate-seo-files');
|
|
16
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from '../app/paths.js';
|
|
4
|
+
import { print, errorExit } from '../../../utils/index.js';
|
|
5
|
+
import { buildSsrBundle, cleanupSsrBundle } from './ssr/ssr-bundle.js';
|
|
6
|
+
import { renderRoute } from './ssr/ssr-render.js';
|
|
7
|
+
import { builderConfig } from '../../builder.config.js';
|
|
8
|
+
import { resolveLocaleValue, valueForLangWithFallback, collectI18nSeoIssues } from './i18n-resolution.js';
|
|
9
|
+
import { segmentsOf, validateMockParams, writeRouteHtml, warnForRoutesMissingSeoConfig } from './route-output.js';
|
|
10
|
+
|
|
11
|
+
async function processRoute(template, baseUrl, defaultLanguage, languages, route, bundleUrl) {
|
|
12
|
+
validateMockParams(route);
|
|
13
|
+
|
|
14
|
+
const routeSuffix = route.path === '/' ? '' : route.path;
|
|
15
|
+
|
|
16
|
+
if (builderConfig.i18n) {
|
|
17
|
+
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
|
+
const pageUrl = `${baseUrl}/${lang}${routeSuffix}`;
|
|
23
|
+
const body = await renderRoute({
|
|
24
|
+
bundleUrl,
|
|
25
|
+
appRoutesKey: route.path,
|
|
26
|
+
mockParams: route.mockParams,
|
|
27
|
+
mockFetch: route.mockFetch,
|
|
28
|
+
lang,
|
|
29
|
+
pageUrl,
|
|
30
|
+
i18nEnabled: builderConfig.i18n,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await writeRouteHtml({
|
|
34
|
+
template, lang, title, description, pageUrl, ogImage, body,
|
|
35
|
+
outputSegments: [lang, ...segmentsOf(routeSuffix)],
|
|
36
|
+
routePath: route.path,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
const title = resolveLocaleValue(route.title, defaultLanguage);
|
|
41
|
+
const description = resolveLocaleValue(route.description, defaultLanguage);
|
|
42
|
+
const ogImage = resolveLocaleValue(route.ogImage, defaultLanguage);
|
|
43
|
+
|
|
44
|
+
if (!title || !description) {
|
|
45
|
+
errorExit(`Missing title or description for route "${route.path}" in seo.json`, 'generate-seo-html');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pageUrl = `${baseUrl}${routeSuffix}`;
|
|
49
|
+
const body = await renderRoute({
|
|
50
|
+
bundleUrl,
|
|
51
|
+
appRoutesKey: route.path,
|
|
52
|
+
mockParams: route.mockParams,
|
|
53
|
+
mockFetch: route.mockFetch,
|
|
54
|
+
lang: defaultLanguage,
|
|
55
|
+
pageUrl,
|
|
56
|
+
i18nEnabled: builderConfig.i18n,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await writeRouteHtml({
|
|
60
|
+
template, lang: defaultLanguage, title, description, pageUrl, ogImage, body,
|
|
61
|
+
outputSegments: segmentsOf(routeSuffix),
|
|
62
|
+
routePath: route.path,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function generateSeoHtml() {
|
|
68
|
+
const templatePath = path.join(PATHS.tempSource, 'index.html');
|
|
69
|
+
const template = fs.readFileSync(templatePath, 'utf-8');
|
|
70
|
+
|
|
71
|
+
if (!template.includes('</head>')) {
|
|
72
|
+
errorExit('index.html is missing a </head> tag — cannot inject SEO tags', 'generate-seo-html');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const seoConfigPath = path.join(PATHS.temp, 'config', 'seo.json');
|
|
76
|
+
const seoConfig = JSON.parse(fs.readFileSync(seoConfigPath, 'utf-8'));
|
|
77
|
+
const baseUrl = seoConfig.baseUrl.replace(/\/$/, '');
|
|
78
|
+
|
|
79
|
+
const languagesConfigPath = path.join(PATHS.temp, 'config', 'languages.json');
|
|
80
|
+
const { languages, defaultLanguage } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
81
|
+
|
|
82
|
+
if (builderConfig.i18n) {
|
|
83
|
+
const { fallbacks, missing, noLocalization } = collectI18nSeoIssues(seoConfig.routes, languages, defaultLanguage);
|
|
84
|
+
|
|
85
|
+
if (noLocalization.length > 0) {
|
|
86
|
+
const lines = noLocalization
|
|
87
|
+
.map(({ path, field }) => ` - route "${path}": "${field}"`)
|
|
88
|
+
.join('\n');
|
|
89
|
+
print.warn(
|
|
90
|
+
`[generate-seo-html] i18n is enabled, but these routes have no language values in seo.json:\n${lines}\n\n` +
|
|
91
|
+
`Add per-language objects (e.g. { "en": ..., "fr": ... }) under the matching route's ` +
|
|
92
|
+
`"title"/"description" in config/seo.json to localize this content, or ignore this ` +
|
|
93
|
+
`warning if sharing the same content across every language is intentional.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (fallbacks.length > 0) {
|
|
98
|
+
const lines = fallbacks
|
|
99
|
+
.map(({ path, field, lang, sourceLang }) => ` - route "${path}": "${field}.${lang}" not set — using "${field}.${sourceLang}"`)
|
|
100
|
+
.join('\n');
|
|
101
|
+
print.error(`[generate-seo-html] Using defaultLanguage fallback for missing i18n content:\n${lines}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (missing.length > 0) {
|
|
105
|
+
const lines = missing
|
|
106
|
+
.map(({ path, field, lang }) => ` - route "${path}": missing "${field}.${lang}" (no language has a value for this field)`)
|
|
107
|
+
.join('\n');
|
|
108
|
+
|
|
109
|
+
errorExit(
|
|
110
|
+
`config/seo.json is missing required content:\n${lines}\n\n` +
|
|
111
|
+
`Add these keys under the matching route's "title"/"description" in config/seo.json. ` +
|
|
112
|
+
`Configured languages (config/languages.json): ${languages.join(', ')}.`
|
|
113
|
+
, 'generate-seo-html'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const bundleUrl = await buildSsrBundle();
|
|
120
|
+
await warnForRoutesMissingSeoConfig(bundleUrl, seoConfig.routes, defaultLanguage, baseUrl);
|
|
121
|
+
|
|
122
|
+
for (const route of seoConfig.routes) {
|
|
123
|
+
await processRoute(template, baseUrl, defaultLanguage, languages, route, bundleUrl);
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
cleanupSsrBundle();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from '../app/paths.js';
|
|
4
|
+
import { print } from '../../../utils/index.js';
|
|
5
|
+
import { builderConfig } from '../../builder.config.js';
|
|
6
|
+
|
|
7
|
+
function collectUrls(baseUrl, routes, languages) {
|
|
8
|
+
const urls = [];
|
|
9
|
+
|
|
10
|
+
for (const route of routes) {
|
|
11
|
+
const routeSuffix = route.path === '/' ? '' : route.path;
|
|
12
|
+
|
|
13
|
+
if (builderConfig.i18n) {
|
|
14
|
+
for (const lang of languages) {
|
|
15
|
+
urls.push(`${baseUrl}/${lang}${routeSuffix}/`);
|
|
16
|
+
}
|
|
17
|
+
} else {
|
|
18
|
+
urls.push(`${baseUrl}${routeSuffix}/`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return urls;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildSitemapXml(urls) {
|
|
26
|
+
const urlEntries = urls.map(loc => ` <url>\n <loc>${loc}</loc>\n </url>`).join('\n');
|
|
27
|
+
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
|
+
}
|
|
29
|
+
|
|
30
|
+
export function generateSitemapXml() {
|
|
31
|
+
const seoConfigPath = path.join(PATHS.temp, 'config', 'seo.json');
|
|
32
|
+
const seoConfig = JSON.parse(fs.readFileSync(seoConfigPath, 'utf-8'));
|
|
33
|
+
const baseUrl = seoConfig.baseUrl.replace(/\/$/, '');
|
|
34
|
+
|
|
35
|
+
const languagesConfigPath = path.join(PATHS.temp, 'config', 'languages.json');
|
|
36
|
+
const { languages } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
37
|
+
|
|
38
|
+
const urls = collectUrls(baseUrl, seoConfig.routes, languages);
|
|
39
|
+
const xml = buildSitemapXml(urls);
|
|
40
|
+
|
|
41
|
+
const outputPath = path.join(PATHS.tempSource, 'sitemap.xml');
|
|
42
|
+
fs.writeFileSync(outputPath, xml, 'utf-8');
|
|
43
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export function escape(str) {
|
|
2
|
+
return str
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function upsertInHead(html, pattern, replacement, fullTag) {
|
|
11
|
+
if (pattern.test(html)) {
|
|
12
|
+
return html.replace(pattern, replacement);
|
|
13
|
+
}
|
|
14
|
+
return html.replace('</head>', `\n ${fullTag}\n </head>`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function applySubstitutions(template, lang, title, description, pageUrl, ogImage) {
|
|
18
|
+
let html = template;
|
|
19
|
+
|
|
20
|
+
// lang attribute on <html>
|
|
21
|
+
if (/(<html[^>]*\slang=)["'][^"']*["']/.test(html)) {
|
|
22
|
+
html = html.replace(/(<html[^>]*\slang=)["'][^"']*["']/, `$1"${lang}"`);
|
|
23
|
+
} else {
|
|
24
|
+
html = html.replace(/<html/, `<html lang="${lang}"`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// <title>
|
|
28
|
+
html = upsertInHead(html,
|
|
29
|
+
/(<title>)[^<]*(<\/title>)/,
|
|
30
|
+
`$1${escape(title)}$2`,
|
|
31
|
+
`<title>${escape(title)}</title>`
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// <meta name="description"> — replace all occurrences, or insert one
|
|
35
|
+
const descPattern = /(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/;
|
|
36
|
+
if (descPattern.test(html)) {
|
|
37
|
+
html = html.replace(/(<meta\s+name=["']description["']\s+content=)["'][^"']*["']/g, `$1"${escape(description)}"`);
|
|
38
|
+
} else {
|
|
39
|
+
html = html.replace('</head>', `\n <meta name="description" content="${escape(description)}" />\n </head>`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// <link rel="canonical">
|
|
43
|
+
html = upsertInHead(html,
|
|
44
|
+
/(<link\s+rel=["']canonical["']\s+href=)["'][^"']*["']/,
|
|
45
|
+
`$1"${pageUrl}/"`,
|
|
46
|
+
`<link rel="canonical" href="${pageUrl}/" />`
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// og:url
|
|
50
|
+
html = upsertInHead(html,
|
|
51
|
+
/(<meta\s+property=["']og:url["']\s+content=)["'][^"']*["']/,
|
|
52
|
+
`$1"${pageUrl}/"`,
|
|
53
|
+
`<meta property="og:url" content="${pageUrl}/" />`
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// og:title
|
|
57
|
+
html = upsertInHead(html,
|
|
58
|
+
/(<meta\s+property=["']og:title["']\s+content=)["'][^"']*["']/,
|
|
59
|
+
`$1"${escape(title)}"`,
|
|
60
|
+
`<meta property="og:title" content="${escape(title)}" />`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// og:description
|
|
64
|
+
html = upsertInHead(html,
|
|
65
|
+
/(<meta\s+property=["']og:description["']\s+content=)["'][^"']*["']/,
|
|
66
|
+
`$1"${escape(description)}"`,
|
|
67
|
+
`<meta property="og:description" content="${escape(description)}" />`
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// twitter:title
|
|
71
|
+
html = upsertInHead(html,
|
|
72
|
+
/(<meta\s+name=["']twitter:title["']\s+content=)["'][^"']*["']/,
|
|
73
|
+
`$1"${escape(title)}"`,
|
|
74
|
+
`<meta name="twitter:title" content="${escape(title)}" />`
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// twitter:description
|
|
78
|
+
html = upsertInHead(html,
|
|
79
|
+
/(<meta\s+name=["']twitter:description["']\s+content=)["'][^"']*["']/,
|
|
80
|
+
`$1"${escape(description)}"`,
|
|
81
|
+
`<meta name="twitter:description" content="${escape(description)}" />`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// og:image + twitter:image — only when explicitly provided in route config
|
|
85
|
+
if (ogImage) {
|
|
86
|
+
html = upsertInHead(html,
|
|
87
|
+
/(<meta\s+property=["']og:image["']\s+content=)["'][^"']*["']/,
|
|
88
|
+
`$1"${escape(ogImage)}"`,
|
|
89
|
+
`<meta property="og:image" content="${escape(ogImage)}" />`
|
|
90
|
+
);
|
|
91
|
+
html = upsertInHead(html,
|
|
92
|
+
/(<meta\s+name=["']twitter:image["']\s+content=)["'][^"']*["']/,
|
|
93
|
+
`$1"${escape(ogImage)}"`,
|
|
94
|
+
`<meta name="twitter:image" content="${escape(ogImage)}" />`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return html;
|
|
99
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Collapses a per-lang-object-or-flat value down to one language, falling back to the
|
|
2
|
+
// first available value if the exact key is missing. Used when i18n is off, where a route
|
|
3
|
+
// may still be authored as a per-lang object (e.g. the default seo.json.hbs) or a flat value.
|
|
4
|
+
export function resolveLocaleValue(value, defaultLanguage) {
|
|
5
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
6
|
+
return value[defaultLanguage] ?? Object.values(value)[0];
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Used when i18n is on: a per-lang object must have an exact entry for `lang` (no silent
|
|
12
|
+
// fallback to another language), while a flat value is applied uniformly to every language.
|
|
13
|
+
function valueForLang(value, lang) {
|
|
14
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value[lang] : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Used to actually resolve HTML content when i18n is on: falls back to defaultLanguage (then
|
|
18
|
+
// the first available value) when `lang` has no exact entry, mirroring resolveLocaleValue's
|
|
19
|
+
// fallback. Missing translations become a warning (see collectI18nSeoIssues), not a crash.
|
|
20
|
+
export function valueForLangWithFallback(value, lang, defaultLanguage) {
|
|
21
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
22
|
+
return value[lang] ?? value[defaultLanguage] ?? Object.values(value)[0];
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Which language's value valueForLangWithFallback would actually use for `lang`, given the
|
|
28
|
+
// same value[lang] ?? value[defaultLanguage] ?? Object.values(value)[0] precedence — so
|
|
29
|
+
// warnings can name the real source language instead of assuming defaultLanguage.
|
|
30
|
+
function sourceLangFor(value, lang, defaultLanguage) {
|
|
31
|
+
if (value[lang]) return lang;
|
|
32
|
+
if (value[defaultLanguage]) return defaultLanguage;
|
|
33
|
+
return Object.keys(value).find((key) => value[key]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Upfront pass over every route x lang x field, run before the (expensive) SSR bundle build.
|
|
37
|
+
// Buckets each combination into a fallback warning (some language has content, this one
|
|
38
|
+
// doesn't) or a hard error (no language has any content for this field on this route).
|
|
39
|
+
export function collectI18nSeoIssues(routes, languages, defaultLanguage) {
|
|
40
|
+
const fallbacks = [];
|
|
41
|
+
const missing = [];
|
|
42
|
+
const noLocalization = [];
|
|
43
|
+
|
|
44
|
+
for (const route of routes) {
|
|
45
|
+
for (const field of ['title', 'description']) {
|
|
46
|
+
const value = route[field];
|
|
47
|
+
|
|
48
|
+
if (value && typeof value !== 'object') {
|
|
49
|
+
noLocalization.push({ path: route.path, field });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const lang of languages) {
|
|
53
|
+
if (valueForLang(value, lang)) continue;
|
|
54
|
+
|
|
55
|
+
const sourceLang = value && typeof value === 'object' ? sourceLangFor(value, lang, defaultLanguage) : undefined;
|
|
56
|
+
if (sourceLang) {
|
|
57
|
+
fallbacks.push({ path: route.path, field, lang, sourceLang });
|
|
58
|
+
} else {
|
|
59
|
+
missing.push({ path: route.path, field, lang });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { fallbacks, missing, noLocalization };
|
|
66
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { PATHS } from '../app/paths.js';
|
|
4
|
+
import { print, errorExit } from '../../../utils/index.js';
|
|
5
|
+
import { applySubstitutions } from './html-substitutions.js';
|
|
6
|
+
import { getAppRoutePaths } from './ssr/ssr-render.js';
|
|
7
|
+
import { builderConfig } from '../../builder.config.js';
|
|
8
|
+
|
|
9
|
+
export function segmentsOf(routeSuffix) {
|
|
10
|
+
return routeSuffix ? routeSuffix.split('/').filter(Boolean) : [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function validateMockParams(route) {
|
|
14
|
+
const requiredParams = [...route.path.matchAll(/:([^/]+)/g)]
|
|
15
|
+
.map(([, name]) => name)
|
|
16
|
+
.filter((name) => !name.endsWith('?')); // optional segments don't require a mock value
|
|
17
|
+
|
|
18
|
+
if (requiredParams.length === 0) return;
|
|
19
|
+
|
|
20
|
+
const provided = route.mockParams ?? {};
|
|
21
|
+
const missing = requiredParams.filter((name) => !(name in provided));
|
|
22
|
+
|
|
23
|
+
if (missing.length > 0) {
|
|
24
|
+
errorExit(
|
|
25
|
+
`Route "${route.path}" has dynamic segment(s) but is missing "mockParams" ` +
|
|
26
|
+
`for: ${missing.join(', ')}. Add a "mockParams" object to this route in config/seo.json.`
|
|
27
|
+
, 'generate-seo-html'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeRouteHtml({ template, lang, title, description, pageUrl, ogImage, body, outputSegments, routePath }) {
|
|
33
|
+
let html = applySubstitutions(template, lang, title, description, pageUrl, ogImage);
|
|
34
|
+
|
|
35
|
+
if (html === template) {
|
|
36
|
+
errorExit(`Failed to apply any changes for ${routePath} (lang "${lang}") — index.html may be missing a </head> tag`, 'generate-seo-html');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
html = html.replace(
|
|
40
|
+
/<main\s+id=["']app["'][^>]*>[\s\S]*?<\/main>/,
|
|
41
|
+
`<main id="app">\n${body}\n </main>`
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const outputDir = path.join(PATHS.tempSource, ...outputSegments);
|
|
45
|
+
const outputPath = path.join(outputDir, 'index.html');
|
|
46
|
+
|
|
47
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
48
|
+
fs.writeFileSync(outputPath, html, 'utf-8');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function warnForRoutesMissingSeoConfig(bundleUrl, seoRoutes, defaultLanguage, baseUrl) {
|
|
52
|
+
const appRoutePaths = await getAppRoutePaths(bundleUrl, defaultLanguage, baseUrl);
|
|
53
|
+
const seoRoutePaths = new Set(seoRoutes.map((route) => route.path));
|
|
54
|
+
|
|
55
|
+
for (const path of appRoutePaths) {
|
|
56
|
+
if (seoRoutePaths.has(path)) continue;
|
|
57
|
+
|
|
58
|
+
if (builderConfig.WELL_KNOWN_NON_SEO_ROUTES.includes(path)) {
|
|
59
|
+
continue;
|
|
60
|
+
} else {
|
|
61
|
+
print.error(`[generate-seo-html] Route "${path}" has no matching entry in config/seo.json; consider adding it so this route gets SEO HTML, sitemap, and meta tags.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|