@nutin/cli 2.1.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { MarkdownGuards } from '#root/dist/src/app/markdown/markdown-guards.js';
|
|
2
|
+
import { MarkdownManifestsService } from '#root/dist/src/app/markdown/markdown-manifests.service.js';
|
|
3
|
+
|
|
4
|
+
const PAGES = { a: { slug: 'a', section: 'api' }, b: { slug: 'b', section: 'tools' } };
|
|
5
|
+
const SECTIONS = { api: { id: 'api' }, tools: { id: 'tools' } };
|
|
6
|
+
|
|
7
|
+
function fakeManifest({ hasPages = true } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
hasPages,
|
|
10
|
+
firstSlug: hasPages ? 'a' : undefined,
|
|
11
|
+
getPage: (slug) => (hasPages ? PAGES[slug] : undefined),
|
|
12
|
+
getSection: (id) => (hasPages ? SECTIONS[id] : undefined),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('MarkdownGuards', () => {
|
|
17
|
+
let loadSpy;
|
|
18
|
+
let manifest;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
manifest = fakeManifest();
|
|
22
|
+
loadSpy = spyOn(MarkdownManifestsService, 'load').andCallFake(() => Promise.resolve(manifest));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
loadSpy.restore();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('pageExists loads the manifest, then allows known slugs and the bare route', async () => {
|
|
30
|
+
const guard = MarkdownGuards.pageExists('docs');
|
|
31
|
+
expect(await guard({ slug: 'a' })).toBe(true);
|
|
32
|
+
expect(await guard({})).toBe(true);
|
|
33
|
+
expect(loadSpy.lastCall[0]).toBe('docs');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('pageExists sends unknown slugs to /404', async () => {
|
|
37
|
+
expect(await MarkdownGuards.pageExists('docs')({ slug: 'nope' })).toBe('/404');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('both guards let an empty or failed manifest through, for the empty or error state', async () => {
|
|
41
|
+
manifest = fakeManifest({ hasPages: false });
|
|
42
|
+
expect(await MarkdownGuards.pageExists('docs')({ slug: 'a' })).toBe(true);
|
|
43
|
+
expect(await MarkdownGuards.sectionPageExists('docs')({ section: 'api', slug: 'a' })).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('sectionPageExists allows the bare route, a known section, and a page of that section', async () => {
|
|
47
|
+
const guard = MarkdownGuards.sectionPageExists('docs');
|
|
48
|
+
expect(await guard({})).toBe(true);
|
|
49
|
+
expect(await guard({ section: 'api' })).toBe(true);
|
|
50
|
+
expect(await guard({ section: 'api', slug: 'a' })).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('sectionPageExists sends a page of another section, or an unknown section, to /404', async () => {
|
|
54
|
+
const guard = MarkdownGuards.sectionPageExists('docs');
|
|
55
|
+
expect(await guard({ section: 'api', slug: 'b' })).toBe('/404');
|
|
56
|
+
expect(await guard({ section: 'nope', slug: 'a' })).toBe('/404');
|
|
57
|
+
expect(await guard({ section: 'nope' })).toBe('/404');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('sectionPageExists redirects a section-less /<id>/<slug> to the page in its section', async () => {
|
|
61
|
+
expect(await MarkdownGuards.sectionPageExists('docs')({ section: 'b' })).toBe('/docs/tools/b');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MarkdownManifestsService } from './markdown-manifests.service.js';
|
|
2
|
+
|
|
3
|
+
// Both guards load the source's manifest (once) before the page renders.
|
|
4
|
+
export const MarkdownGuards = {
|
|
5
|
+
// For '/<id>/:slug?' routes. A bare '/<id>' renders the first page.
|
|
6
|
+
pageExists: (id: string): Nutin.RouteGuard => {
|
|
7
|
+
return async (params) => {
|
|
8
|
+
const manifest = await MarkdownManifestsService.load(id);
|
|
9
|
+
if (!manifest.hasPages) return true;
|
|
10
|
+
const slug = params.slug || manifest.firstSlug;
|
|
11
|
+
return (!!slug && !!manifest.getPage(slug)) || '/404';
|
|
12
|
+
};
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
// For '/<id>/:section?/:slug?' routes ("sectionInPath: true").
|
|
16
|
+
// A bare '/<id>/<slug>' redirects to that page's section.
|
|
17
|
+
sectionPageExists: (id: string): Nutin.RouteGuard => {
|
|
18
|
+
return async (params) => {
|
|
19
|
+
const manifest = await MarkdownManifestsService.load(id);
|
|
20
|
+
if (!manifest.hasPages || !params.section) return true;
|
|
21
|
+
|
|
22
|
+
if (manifest.getSection(params.section)) {
|
|
23
|
+
if (!params.slug) return true;
|
|
24
|
+
return manifest.getPage(params.slug)?.section === params.section || '/404';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const page = !params.slug ? manifest.getPage(params.section) : undefined;
|
|
28
|
+
return page ? `/${id}/${page.section}/${page.slug}` : '/404';
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import nutinConfig from '#root/dist/nutin.config.js';
|
|
2
|
+
import { I18nService } from '#root/dist/src/core/index.js';
|
|
3
|
+
import { markdownLanguage, markdownText, onMarkdownLanguageChange } from '#root/dist/src/app/markdown/markdown-i18n.js';
|
|
4
|
+
|
|
5
|
+
describe('markdown-i18n', () => {
|
|
6
|
+
let hadDefine;
|
|
7
|
+
let savedDefine;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
hadDefine = '__NUTIN_I18N__' in globalThis;
|
|
11
|
+
savedDefine = globalThis.__NUTIN_I18N__;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (hadDefine) globalThis.__NUTIN_I18N__ = savedDefine;
|
|
16
|
+
else delete globalThis.__NUTIN_I18N__;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('markdownLanguage returns the current language with i18n, null without', () => {
|
|
20
|
+
globalThis.__NUTIN_I18N__ = true;
|
|
21
|
+
expect(markdownLanguage()).toBe(I18nService.currentLanguage);
|
|
22
|
+
globalThis.__NUTIN_I18N__ = false;
|
|
23
|
+
expect(markdownLanguage()).toBe(null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('markdownLanguage follows nutin.config.js "i18n" when the builder define is missing', () => {
|
|
27
|
+
delete globalThis.__NUTIN_I18N__;
|
|
28
|
+
expect(markdownLanguage()).toBe(nutinConfig.i18n ? I18nService.currentLanguage : null);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('markdownText translates a "markdown." key with i18n, passing the fallback', () => {
|
|
32
|
+
globalThis.__NUTIN_I18N__ = true;
|
|
33
|
+
const translateSpy = spyOn(I18nService, 'translate').andCallFake(() => 'Sommaire');
|
|
34
|
+
try {
|
|
35
|
+
expect(markdownText('toc', 'Contents')).toBe('Sommaire');
|
|
36
|
+
expect(translateSpy.lastCall).toEqual(['markdown.toc', 'Contents']);
|
|
37
|
+
} finally {
|
|
38
|
+
translateSpy.restore();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('markdownText returns the fallback without i18n, without translating', () => {
|
|
43
|
+
globalThis.__NUTIN_I18N__ = false;
|
|
44
|
+
const translateSpy = spyOn(I18nService, 'translate');
|
|
45
|
+
try {
|
|
46
|
+
expect(markdownText('toc', 'Contents')).toBe('Contents');
|
|
47
|
+
expect(translateSpy.callCount).toBe(0);
|
|
48
|
+
} finally {
|
|
49
|
+
translateSpy.restore();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('onMarkdownLanguageChange subscribes to language changes with i18n and returns their unsubscribe', () => {
|
|
54
|
+
globalThis.__NUTIN_I18N__ = true;
|
|
55
|
+
const unsubscribe = () => {};
|
|
56
|
+
const callback = () => {};
|
|
57
|
+
const subscribeSpy = spyOn(I18nService, 'onLanguageChange').andCallFake(() => unsubscribe);
|
|
58
|
+
try {
|
|
59
|
+
expect(onMarkdownLanguageChange(callback)).toBe(unsubscribe);
|
|
60
|
+
expect(subscribeSpy.lastCall).toEqual([callback]);
|
|
61
|
+
} finally {
|
|
62
|
+
subscribeSpy.restore();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('onMarkdownLanguageChange returns a no-op without i18n, without subscribing', () => {
|
|
67
|
+
globalThis.__NUTIN_I18N__ = false;
|
|
68
|
+
const subscribeSpy = spyOn(I18nService, 'onLanguageChange');
|
|
69
|
+
try {
|
|
70
|
+
const unsubscribe = onMarkdownLanguageChange(() => {});
|
|
71
|
+
expect(typeof unsubscribe).toBe('function');
|
|
72
|
+
expect(unsubscribe()).toBe(undefined);
|
|
73
|
+
expect(subscribeSpy.callCount).toBe(0);
|
|
74
|
+
} finally {
|
|
75
|
+
subscribeSpy.restore();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import nutinConfig from '../../../nutin.config.js';
|
|
2
|
+
import { I18nService } from '../../core/index.js';
|
|
3
|
+
|
|
4
|
+
// i18n hooks of the markdown feature. Each I18nService use sits behind core's guard
|
|
5
|
+
// (`globalThis.__NUTIN_I18N__` is set by the builder), so bundles without i18n drop it.
|
|
6
|
+
// A block rather than an early return: esbuild only drops a constant-false block.
|
|
7
|
+
|
|
8
|
+
// The current language, or null without i18n (one manifest per source then).
|
|
9
|
+
export function markdownLanguage(): string | null {
|
|
10
|
+
if (globalThis.__NUTIN_I18N__ ?? nutinConfig.i18n) {
|
|
11
|
+
return I18nService.currentLanguage;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A UI string from the "markdown" translations (src/app/markdown/locales/<lang>.json),
|
|
17
|
+
// or `fallback` without i18n or without a translation.
|
|
18
|
+
export function markdownText(key: string, fallback: string): string {
|
|
19
|
+
if (globalThis.__NUTIN_I18N__ ?? nutinConfig.i18n) {
|
|
20
|
+
return I18nService.translate(`markdown.${key}`, fallback);
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Calls `callback` after each language change; returns the unsubscribe function.
|
|
26
|
+
export function onMarkdownLanguageChange(callback: () => void): () => void {
|
|
27
|
+
if (globalThis.__NUTIN_I18N__ ?? nutinConfig.i18n) {
|
|
28
|
+
return I18nService.onLanguageChange(callback);
|
|
29
|
+
}
|
|
30
|
+
return () => {};
|
|
31
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { MarkdownManifest } from '#root/dist/src/app/markdown/markdown-manifest.js';
|
|
2
|
+
|
|
3
|
+
const MANIFEST = {
|
|
4
|
+
sections: [
|
|
5
|
+
{ id: 'api', title: 'API', description: '', groups: [{ id: 'views', title: 'Views', pages: ['create-a-view'] }] },
|
|
6
|
+
{ id: 'tools', title: 'Tools', description: '', pages: ['builder'] },
|
|
7
|
+
],
|
|
8
|
+
pages: {
|
|
9
|
+
'create-a-view': { slug: 'create-a-view', title: 'Create a view', section: 'api' },
|
|
10
|
+
builder: { slug: 'builder', title: 'Builder', section: 'tools' },
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Single-language behavior, whatever the project's i18n setting.
|
|
15
|
+
const noLanguage = () => null;
|
|
16
|
+
|
|
17
|
+
let originalFetch;
|
|
18
|
+
let calls;
|
|
19
|
+
|
|
20
|
+
function mockFetch(...responses) {
|
|
21
|
+
calls = [];
|
|
22
|
+
global.fetch = (url) => {
|
|
23
|
+
calls.push(url);
|
|
24
|
+
const response = responses.shift();
|
|
25
|
+
return Promise.resolve(response);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ok = (data) => ({ ok: true, status: 200, json: () => Promise.resolve(data) });
|
|
30
|
+
const notFound = () => ({ ok: false, status: 404, json: () => Promise.reject(new Error('HTTP 404')) });
|
|
31
|
+
|
|
32
|
+
describe('MarkdownManifest', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
originalFetch = global.fetch;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
global.fetch = originalFetch;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('fetches /generated/<id>.json once, even with concurrent loads', async () => {
|
|
42
|
+
mockFetch(ok(MANIFEST));
|
|
43
|
+
const manifest = new MarkdownManifest({ id: 'docs', sectionInPath: true }, noLanguage);
|
|
44
|
+
|
|
45
|
+
await Promise.all([manifest.load(), manifest.load()]);
|
|
46
|
+
await manifest.load();
|
|
47
|
+
|
|
48
|
+
expect(calls).toEqual(['/generated/docs.json']);
|
|
49
|
+
expect(manifest.loaded).toBe(true);
|
|
50
|
+
expect(manifest.sectionInPath).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('exposes pages and sections', async () => {
|
|
54
|
+
mockFetch(ok(MANIFEST));
|
|
55
|
+
const manifest = new MarkdownManifest({ id: 'docs', sectionInPath: true }, noLanguage);
|
|
56
|
+
await manifest.load();
|
|
57
|
+
|
|
58
|
+
expect(manifest.hasPages).toBe(true);
|
|
59
|
+
expect(manifest.firstSlug).toBe('create-a-view');
|
|
60
|
+
expect(manifest.firstSlugOf(manifest.getSection('tools'))).toBe('builder');
|
|
61
|
+
expect(manifest.getPage('builder').title).toBe('Builder');
|
|
62
|
+
expect(manifest.getPage('nope')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('stays empty and flags loadFailed after a failed load, and retries on the next one', async () => {
|
|
66
|
+
mockFetch(notFound(), ok(MANIFEST));
|
|
67
|
+
const manifest = new MarkdownManifest({ id: 'docs', sectionInPath: false }, noLanguage);
|
|
68
|
+
|
|
69
|
+
await silenceConsole('error', () => manifest.load());
|
|
70
|
+
expect(manifest.loaded).toBe(false);
|
|
71
|
+
expect(manifest.loadFailed).toBe(true);
|
|
72
|
+
expect(manifest.hasPages).toBe(false);
|
|
73
|
+
expect(manifest.firstSlug).toBeUndefined();
|
|
74
|
+
|
|
75
|
+
await manifest.load();
|
|
76
|
+
expect(calls.length).toBe(2);
|
|
77
|
+
expect(manifest.loaded).toBe(true);
|
|
78
|
+
expect(manifest.loadFailed).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('with a language, fetches /generated/<id>.<lang>.json and reads the current language', async () => {
|
|
82
|
+
const page = (title) => ({ sections: [{ id: 'index', title: 'x', description: '', pages: ['a'] }], pages: { a: { slug: 'a', title, section: 'index' } } });
|
|
83
|
+
mockFetch(ok(page('Hello')), ok(page('Bonjour')));
|
|
84
|
+
let lang = 'en';
|
|
85
|
+
const manifest = new MarkdownManifest({ id: 'docs', sectionInPath: false }, () => lang);
|
|
86
|
+
|
|
87
|
+
await manifest.load();
|
|
88
|
+
lang = 'fr';
|
|
89
|
+
expect(manifest.loaded).toBe(false);
|
|
90
|
+
await manifest.load();
|
|
91
|
+
|
|
92
|
+
expect(manifest.getPage('a').title).toBe('Bonjour');
|
|
93
|
+
lang = 'en';
|
|
94
|
+
expect(manifest.getPage('a').title).toBe('Hello');
|
|
95
|
+
await manifest.load();
|
|
96
|
+
expect(calls).toEqual(['/generated/docs.en.json', '/generated/docs.fr.json']);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { markdownLanguage } from './markdown-i18n.js';
|
|
2
|
+
|
|
3
|
+
export interface IMarkdownHeading {
|
|
4
|
+
depth: number;
|
|
5
|
+
text: string;
|
|
6
|
+
id: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface IMarkdownPage {
|
|
10
|
+
slug: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
section: string;
|
|
14
|
+
group: string | null;
|
|
15
|
+
order: number;
|
|
16
|
+
source: string;
|
|
17
|
+
headings: IMarkdownHeading[];
|
|
18
|
+
html: string;
|
|
19
|
+
ogImage?: string;
|
|
20
|
+
// With i18n: no translation exists, this is the default language's page.
|
|
21
|
+
fallback?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IMarkdownGroup {
|
|
25
|
+
id: string | null;
|
|
26
|
+
title: string | null;
|
|
27
|
+
pages: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface IMarkdownSection {
|
|
31
|
+
id: string;
|
|
32
|
+
title: string;
|
|
33
|
+
description: string;
|
|
34
|
+
groups?: IMarkdownGroup[];
|
|
35
|
+
pages?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Compiled from a folder's "landing" option: the index shown on its bare route.
|
|
39
|
+
export interface IMarkdownLanding {
|
|
40
|
+
title: string;
|
|
41
|
+
description: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface IMarkdownSource {
|
|
45
|
+
id: string;
|
|
46
|
+
sectionInPath: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface IMarkdownManifestData {
|
|
50
|
+
sections: IMarkdownSection[];
|
|
51
|
+
pages: Record<string, IMarkdownPage>;
|
|
52
|
+
landing?: IMarkdownLanding;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const EMPTY_MANIFEST: IMarkdownManifestData = { sections: [], pages: {} };
|
|
56
|
+
|
|
57
|
+
// One compiled "markdownSources" folder (/generated/<id>.json, written at build time by
|
|
58
|
+
// tools/builder/core/markdown — or /generated/<id>.<lang>.json per language with i18n, where
|
|
59
|
+
// every getter reads the current language's data). Instances are owned by MarkdownManifestsService.
|
|
60
|
+
export class MarkdownManifest {
|
|
61
|
+
public readonly id: string;
|
|
62
|
+
public readonly sectionInPath: boolean;
|
|
63
|
+
private readonly getLanguage: () => string | null;
|
|
64
|
+
private readonly _data = new Map<string, IMarkdownManifestData>();
|
|
65
|
+
private readonly _loading = new Map<string, Promise<void>>();
|
|
66
|
+
private readonly _failed = new Set<string>();
|
|
67
|
+
|
|
68
|
+
// getLanguage: the current language, or null without i18n (overridable for tests).
|
|
69
|
+
constructor({ id, sectionInPath }: IMarkdownSource, getLanguage: () => string | null = markdownLanguage) {
|
|
70
|
+
this.id = id;
|
|
71
|
+
this.sectionInPath = sectionInPath;
|
|
72
|
+
this.getLanguage = getLanguage;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private get lang(): string {
|
|
76
|
+
return this.getLanguage() ?? '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private urlFor(lang: string): string {
|
|
80
|
+
return lang ? `/generated/${this.id}.${lang}.json` : `/generated/${this.id}.json`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public get url(): string {
|
|
84
|
+
return this.urlFor(this.lang);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public get loaded(): boolean {
|
|
88
|
+
return this._data.has(this.lang);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// True when the last load for the current language failed: the manifest is empty because of
|
|
92
|
+
// an error, not because the folder has no pages.
|
|
93
|
+
public get loadFailed(): boolean {
|
|
94
|
+
return this._failed.has(this.lang);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fetched once per language. A failed load keeps it empty, sets loadFailed, and is retried on the next call.
|
|
98
|
+
public load(): Promise<void> {
|
|
99
|
+
const lang = this.lang;
|
|
100
|
+
let loading = this._loading.get(lang);
|
|
101
|
+
if (!loading) {
|
|
102
|
+
loading = this.fetchManifest(lang);
|
|
103
|
+
this._loading.set(lang, loading);
|
|
104
|
+
}
|
|
105
|
+
return loading;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async fetchManifest(lang: string): Promise<void> {
|
|
109
|
+
const url = this.urlFor(lang);
|
|
110
|
+
try {
|
|
111
|
+
const response = await fetch(url);
|
|
112
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
113
|
+
this._data.set(lang, await response.json());
|
|
114
|
+
this._failed.delete(lang);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this._loading.delete(lang);
|
|
117
|
+
this._failed.add(lang);
|
|
118
|
+
console.error(`Markdown manifest load error (${url}):`, error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private get data(): IMarkdownManifestData {
|
|
123
|
+
return this._data.get(this.lang) ?? EMPTY_MANIFEST;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Set when the folder has a "landing": its bare route shows an index instead of the first page.
|
|
127
|
+
public get landing(): IMarkdownLanding | undefined {
|
|
128
|
+
return this.data.landing;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public get sections(): IMarkdownSection[] {
|
|
132
|
+
return this.data.sections;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public getPage(slug: string): IMarkdownPage | undefined {
|
|
136
|
+
return this.data.pages[slug];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public getSection(id: string): IMarkdownSection | undefined {
|
|
140
|
+
return this.data.sections.find((section) => section.id === id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public get firstSlug(): string | undefined {
|
|
144
|
+
return this.firstSlugOf(this.data.sections[0]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
public firstSlugOf(section: IMarkdownSection | undefined): string | undefined {
|
|
148
|
+
const pages = section?.groups?.[0]?.pages ?? section?.pages;
|
|
149
|
+
return pages?.[0];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
public get hasPages(): boolean {
|
|
153
|
+
return this.data.sections.some((section) =>
|
|
154
|
+
section.groups ? section.groups.some((group) => group.pages.length > 0) : (section.pages?.length ?? 0) > 0
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import nutinConfig from '#root/nutin.config.js';
|
|
2
|
+
import distConfig from '#root/dist/nutin.config.js';
|
|
3
|
+
import { MarkdownManifests, MarkdownManifestsService } from '#root/dist/src/app/markdown/markdown-manifests.service.js';
|
|
4
|
+
import { resolveMarkdownSources } from '#root/dist/src/app/markdown/markdown-sources.js';
|
|
5
|
+
|
|
6
|
+
// Builds a separate instance (the exported MarkdownManifestsService is left as it is),
|
|
7
|
+
// then drops it from the registry.
|
|
8
|
+
function freshManifests() {
|
|
9
|
+
MarkdownManifests.testingReset();
|
|
10
|
+
try {
|
|
11
|
+
return MarkdownManifests.getInstance();
|
|
12
|
+
} finally {
|
|
13
|
+
MarkdownManifests.testingReset();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('MarkdownManifestsService', () => {
|
|
18
|
+
let loadSpies = [];
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
loadSpies.forEach((spy) => spy.restore());
|
|
22
|
+
loadSpies = [];
|
|
23
|
+
delete globalThis.__NUTIN_MARKDOWN_SOURCES__;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('has one manifest per nutin.config.js "markdownSources" folder', () => {
|
|
27
|
+
const expected = resolveMarkdownSources(nutinConfig.markdownSources);
|
|
28
|
+
expect(MarkdownManifestsService.ids).toEqual(expected.map((source) => source.id));
|
|
29
|
+
expect(MarkdownManifestsService.all.map(({ id, sectionInPath }) => ({ id, sectionInPath }))).toEqual(expected);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns the same manifest instance for an id', () => {
|
|
33
|
+
for (const id of MarkdownManifestsService.ids) {
|
|
34
|
+
expect(MarkdownManifestsService.get(id)).toBe(MarkdownManifestsService.get(id));
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws on an unknown id, listing the known ones', () => {
|
|
39
|
+
expect(() => MarkdownManifestsService.get('__unknown__')).toThrow('Unknown Markdown source "__unknown__"');
|
|
40
|
+
expect(() => MarkdownManifestsService.get('__unknown__')).toThrow(`known: ${MarkdownManifestsService.ids.join(', ')}`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('load() loads the manifest of an id and resolves to it', async () => {
|
|
44
|
+
const [id] = MarkdownManifestsService.ids;
|
|
45
|
+
const manifest = MarkdownManifestsService.get(id);
|
|
46
|
+
const loadSpy = spyOn(manifest, 'load').andCallFake(() => Promise.resolve());
|
|
47
|
+
loadSpies.push(loadSpy);
|
|
48
|
+
expect(await MarkdownManifestsService.load(id)).toBe(manifest);
|
|
49
|
+
expect(loadSpy.callCount).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('load() rejects on an unknown id without loading anything', async () => {
|
|
53
|
+
let error;
|
|
54
|
+
try {
|
|
55
|
+
await MarkdownManifestsService.load('__unknown__');
|
|
56
|
+
} catch (e) {
|
|
57
|
+
error = e;
|
|
58
|
+
}
|
|
59
|
+
expect(error.message).toContain('Unknown Markdown source "__unknown__"');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('loadAll() loads every manifest', async () => {
|
|
63
|
+
loadSpies = MarkdownManifestsService.all.map((manifest) => spyOn(manifest, 'load').andCallFake(() => Promise.resolve()));
|
|
64
|
+
await MarkdownManifestsService.loadAll();
|
|
65
|
+
loadSpies.forEach((spy) => expect(spy.callCount).toBe(1));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('reads the folders from the builder define when bundled', () => {
|
|
69
|
+
globalThis.__NUTIN_MARKDOWN_SOURCES__ = JSON.stringify(['guides', { folder: 'content/api-docs/', routePrefix: 'api', sectionInPath: true }]);
|
|
70
|
+
const manifests = freshManifests();
|
|
71
|
+
expect(manifests.ids).toEqual(['guides', 'api']);
|
|
72
|
+
expect(manifests.get('api').sectionInPath).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('has no manifests without "markdownSources", and says so for an unknown id', () => {
|
|
76
|
+
const saved = distConfig.markdownSources;
|
|
77
|
+
delete distConfig.markdownSources;
|
|
78
|
+
try {
|
|
79
|
+
const manifests = freshManifests();
|
|
80
|
+
expect(manifests.ids).toEqual([]);
|
|
81
|
+
expect(manifests.all).toEqual([]);
|
|
82
|
+
expect(() => manifests.get('docs')).toThrow('known: none');
|
|
83
|
+
} finally {
|
|
84
|
+
distConfig.markdownSources = saved;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import nutinConfig from '../../../nutin.config.js';
|
|
2
|
+
import { Service } from '../../core/index.js';
|
|
3
|
+
import { MarkdownManifest } from './markdown-manifest.js';
|
|
4
|
+
import { IMarkdownSourcesConfig, MarkdownSourceEntry, resolveMarkdownSources } from './markdown-sources.js';
|
|
5
|
+
|
|
6
|
+
// Every folder listed in nutin.config.js "markdownSources", by id (its routePrefix,
|
|
7
|
+
// defaulting to the folder name). Manifests load on demand: MarkdownGuards load theirs
|
|
8
|
+
// before a page renders; call loadAll() at startup to have them all available upfront.
|
|
9
|
+
export class MarkdownManifests extends Service<MarkdownManifests> {
|
|
10
|
+
private readonly manifests = new Map<string, MarkdownManifest>();
|
|
11
|
+
|
|
12
|
+
constructor() {
|
|
13
|
+
super();
|
|
14
|
+
// Bundled builds get the folders from the builder's define; test runs on tsc output read the config itself.
|
|
15
|
+
// Used directly in `??` so esbuild folds the fallback away.
|
|
16
|
+
const sourceFolders: MarkdownSourceEntry[] = JSON.parse(globalThis.__NUTIN_MARKDOWN_SOURCES__
|
|
17
|
+
?? JSON.stringify((nutinConfig.markdownSources as IMarkdownSourcesConfig | undefined)?.sourceFolders ?? []));
|
|
18
|
+
for (const source of resolveMarkdownSources({ sourceFolders })) {
|
|
19
|
+
this.manifests.set(source.id, new MarkdownManifest(source));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public get ids(): string[] {
|
|
24
|
+
return [...this.manifests.keys()];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public get all(): MarkdownManifest[] {
|
|
28
|
+
return [...this.manifests.values()];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public get(id: string): MarkdownManifest {
|
|
32
|
+
const manifest = this.manifests.get(id);
|
|
33
|
+
if (!manifest) {
|
|
34
|
+
throw new Error(`Unknown Markdown source "${id}" — known: ${this.ids.join(', ') || 'none'} (nutin.config.js "markdownSources")`);
|
|
35
|
+
}
|
|
36
|
+
return manifest;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
public async load(id: string): Promise<MarkdownManifest> {
|
|
40
|
+
const manifest = this.get(id);
|
|
41
|
+
await manifest.load();
|
|
42
|
+
return manifest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public async loadAll(): Promise<void> {
|
|
46
|
+
await Promise.all(this.all.map((manifest) => manifest.load()));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const MarkdownManifestsService = /* @__PURE__ */ MarkdownManifests.getInstance();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { View } from '#root/dist/src/core/index.js';
|
|
2
|
+
import { markdownRoutes } from '#root/dist/src/app/markdown/markdown-routes.js';
|
|
3
|
+
import { MarkdownGuards } from '#root/dist/src/app/markdown/markdown-guards.js';
|
|
4
|
+
import { MarkdownView } from '#root/dist/src/app/markdown/view/markdown.view.js';
|
|
5
|
+
import { MarkdownManifestsService } from '#root/dist/src/app/markdown/markdown-manifests.service.js';
|
|
6
|
+
|
|
7
|
+
// Swaps the configured folders for these, on the shared instance markdownRoutes() reads.
|
|
8
|
+
function useSources(sources) {
|
|
9
|
+
Object.defineProperty(MarkdownManifestsService, 'all', { value: sources, configurable: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('markdownRoutes', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
delete MarkdownManifestsService.all;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('registers one guarded route per folder, with its section in the path when configured, lazy by default', async () => {
|
|
18
|
+
const routes = markdownRoutes();
|
|
19
|
+
const expected = MarkdownManifestsService.all.map(({ id, sectionInPath }) => (sectionInPath ? `/${id}/:section?/:slug?` : `/${id}/:slug?`));
|
|
20
|
+
expect(Object.keys(routes)).toEqual(expected);
|
|
21
|
+
for (const route of Object.values(routes)) {
|
|
22
|
+
expect(route.guards.length).toBe(1);
|
|
23
|
+
const view = route.view();
|
|
24
|
+
expect(typeof view.then).toBe('function');
|
|
25
|
+
expect(await view).toBeInstanceOf(MarkdownView);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('builds each view with the "view" option, given the folder id', () => {
|
|
30
|
+
const ids = [];
|
|
31
|
+
class CustomView extends View {
|
|
32
|
+
constructor() { super({ viewName: 'custom' }); }
|
|
33
|
+
}
|
|
34
|
+
const routes = markdownRoutes({ view: (id) => { ids.push(id); return new CustomView(); } });
|
|
35
|
+
for (const route of Object.values(routes)) expect(route.view()).toBeInstanceOf(CustomView);
|
|
36
|
+
expect(ids).toEqual(MarkdownManifestsService.ids);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('guards a plain folder with pageExists and a "sectionInPath" folder with sectionPageExists', () => {
|
|
40
|
+
useSources([{ id: 'guides', sectionInPath: false }, { id: 'api', sectionInPath: true }]);
|
|
41
|
+
const pageGuard = () => true;
|
|
42
|
+
const sectionGuard = () => true;
|
|
43
|
+
const pageSpy = spyOn(MarkdownGuards, 'pageExists').andCallFake(() => pageGuard);
|
|
44
|
+
const sectionSpy = spyOn(MarkdownGuards, 'sectionPageExists').andCallFake(() => sectionGuard);
|
|
45
|
+
try {
|
|
46
|
+
const routes = markdownRoutes({ view: () => new MarkdownView({ id: 'guides' }) });
|
|
47
|
+
expect(Object.keys(routes)).toEqual(['/guides/:slug?', '/api/:section?/:slug?']);
|
|
48
|
+
expect(routes['/guides/:slug?'].guards).toEqual([pageGuard]);
|
|
49
|
+
expect(routes['/api/:section?/:slug?'].guards).toEqual([sectionGuard]);
|
|
50
|
+
expect(pageSpy.calls).toEqual([['guides']]);
|
|
51
|
+
expect(sectionSpy.calls).toEqual([['api']]);
|
|
52
|
+
} finally {
|
|
53
|
+
pageSpy.restore();
|
|
54
|
+
sectionSpy.restore();
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('passes the folder id to the default lazy view, for "sectionInPath" folders too', async () => {
|
|
59
|
+
useSources([{ id: 'api', sectionInPath: true }]);
|
|
60
|
+
const getSpy = spyOn(MarkdownManifestsService, 'get').andCallFake(() => ({ id: 'api', sectionInPath: true }));
|
|
61
|
+
try {
|
|
62
|
+
const view = await markdownRoutes()['/api/:section?/:slug?'].view();
|
|
63
|
+
expect(view).toBeInstanceOf(MarkdownView);
|
|
64
|
+
expect(getSpy.lastCall).toEqual(['api']);
|
|
65
|
+
} finally {
|
|
66
|
+
getSpy.restore();
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns no routes without folders', () => {
|
|
71
|
+
useSources([]);
|
|
72
|
+
expect(markdownRoutes()).toEqual({});
|
|
73
|
+
});
|
|
74
|
+
});
|