@nutin/cli 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +322 -0
- package/README.md +14 -6
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +12 -11
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +45 -43
- package/templates/base/GETTING_STARTED.md.hbs +32 -23
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/nutin.config.js.hbs +1 -1
- package/templates/base/src/app/components/.gitkeep +0 -0
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +9 -3
- package/templates/base/src/app/services/.gitkeep +0 -0
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +307 -21
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +105 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +101 -51
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +217 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +321 -0
- package/templates/base/src/core/services/router/router.ts.hbs +122 -54
- package/templates/base/src/index.html.hbs +1 -0
- package/templates/base/src/styles/_styles.scss.hbs +1 -113
- package/templates/base/src/styles/main.scss.hbs +1 -1
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +26 -8
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +25 -8
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +61 -15
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +48 -18
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +26 -8
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +26 -9
- package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +75 -28
- package/templates/base/tools/generator/generator.js.hbs +23 -18
- package/templates/base/tools/generator/handle-file.js.hbs +4 -24
- package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +97 -0
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +3 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +5 -2
- package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +29 -0
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- package/templates/base/src/app/components/index.ts.hbs +0 -1
- package/templates/base/src/app/services/index.ts.hbs +0 -1
- package/templates/base/src/app/views/index.ts.hbs +0 -3
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Navigation } from '#root/dist/src/core/index.js';
|
|
2
|
+
import { MarkdownLandingComponent } from '#root/dist/src/app/markdown/components/markdown-landing/markdown-landing.component.js';
|
|
3
|
+
|
|
4
|
+
const PAGES = {
|
|
5
|
+
a: { slug: 'a', title: 'Page A', description: 'About A' },
|
|
6
|
+
b: { slug: 'b', title: 'Page B', description: '' },
|
|
7
|
+
};
|
|
8
|
+
const manifest = { getPage: (slug) => PAGES[slug] };
|
|
9
|
+
const pageHref = (slug) => `/docs/${slug}`;
|
|
10
|
+
|
|
11
|
+
function mount(config) {
|
|
12
|
+
const target = document.createElement('div');
|
|
13
|
+
document.body.appendChild(target);
|
|
14
|
+
const component = new MarkdownLandingComponent(target, { title: 'Docs', description: 'Everything.', manifest, pageHref, ...config });
|
|
15
|
+
component.render();
|
|
16
|
+
return component;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const texts = (element, selector) => [...element.querySelectorAll(selector)].map((node) => node.textContent.trim());
|
|
20
|
+
|
|
21
|
+
describe('MarkdownLandingComponent', () => {
|
|
22
|
+
it('lists a single hub\'s groups and pages, without repeating its title', () => {
|
|
23
|
+
const component = mount({ sections: [{ id: 'index', title: 'Docs', description: '', groups: [{ id: 'g', title: 'Basics', pages: ['a', 'b'] }] }] });
|
|
24
|
+
const element = component.getElement();
|
|
25
|
+
expect(element.querySelector('.markdown-landing__title').textContent).toBe('Docs');
|
|
26
|
+
expect(element.querySelector('.markdown-landing__section-title')).toBe(null);
|
|
27
|
+
expect(texts(element, '.markdown-landing__group-title')).toEqual(['Basics']);
|
|
28
|
+
expect(texts(element, '.markdown-landing__link')).toEqual(['Page A', 'Page B']);
|
|
29
|
+
expect(texts(element, '.markdown-landing__page-description')).toEqual(['About A']);
|
|
30
|
+
component.destroy();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('heads each section when there are several', () => {
|
|
34
|
+
const component = mount({
|
|
35
|
+
sections: [
|
|
36
|
+
{ id: 'api', title: 'API', description: 'The API.', pages: ['a'] },
|
|
37
|
+
{ id: 'tools', title: 'Tools', description: '', pages: ['b'] },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
expect(texts(component.getElement(), '.markdown-landing__section-title')).toEqual(['API', 'Tools']);
|
|
41
|
+
expect(texts(component.getElement(), '.markdown-landing__section-description')).toEqual(['The API.']);
|
|
42
|
+
component.destroy();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('links each section to its own landing when sectionHref is set', () => {
|
|
46
|
+
const component = mount({
|
|
47
|
+
sections: [{ id: 'api', title: 'API', description: 'The API.', pages: ['a'] }],
|
|
48
|
+
sectionHref: (id) => `/docs/${id}`,
|
|
49
|
+
});
|
|
50
|
+
const links = [...component.getElement().querySelectorAll('.markdown-landing__link')];
|
|
51
|
+
expect(links.map((a) => a.getAttribute('href'))).toEqual(['/docs/api']);
|
|
52
|
+
expect(component.getElement().querySelector('.markdown-landing__pages')).toBe(null);
|
|
53
|
+
component.destroy();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('routes link clicks through the SPA router', () => {
|
|
57
|
+
const navigateSpy = spyOn(Navigation, 'navigateTo').andCallFake(() => {});
|
|
58
|
+
const component = mount({ sections: [{ id: 'index', title: 'Docs', description: '', pages: ['a'] }] });
|
|
59
|
+
try {
|
|
60
|
+
component.getElement().querySelector('.markdown-landing__link').click();
|
|
61
|
+
} finally {
|
|
62
|
+
navigateSpy.restore();
|
|
63
|
+
}
|
|
64
|
+
expect(navigateSpy.lastCall[0]).toBe('/docs/a');
|
|
65
|
+
component.destroy();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Component, Navigation, html, SafeHtml } from '../../../../core/index.js';
|
|
2
|
+
import { IMarkdownGroup, IMarkdownSection, MarkdownManifest } from '../../markdown-manifest.js';
|
|
3
|
+
|
|
4
|
+
export interface IMarkdownLandingConfig {
|
|
5
|
+
title: string;
|
|
6
|
+
description: string;
|
|
7
|
+
// Listed with their pages, or as links to their own landing when sectionHref is set.
|
|
8
|
+
sections: IMarkdownSection[];
|
|
9
|
+
manifest: MarkdownManifest;
|
|
10
|
+
pageHref: (slug: string) => string;
|
|
11
|
+
sectionHref?: (sectionId: string) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The landing's building blocks, exported so a custom template can reuse them.
|
|
15
|
+
export function renderLandingPages(slugs: string[], config: IMarkdownLandingConfig): SafeHtml {
|
|
16
|
+
const items = slugs.map((slug) => {
|
|
17
|
+
const page = config.manifest.getPage(slug);
|
|
18
|
+
if (!page) return '';
|
|
19
|
+
return html`
|
|
20
|
+
<li class="markdown-landing__page">
|
|
21
|
+
<a href="${config.pageHref(slug)}" class="markdown-landing__link" data-event="click:_navigateTo:@attr:href">${page.title}</a>
|
|
22
|
+
${page.description ? html`<p class="markdown-landing__page-description">${page.description}</p>` : ''}
|
|
23
|
+
</li>
|
|
24
|
+
`;
|
|
25
|
+
});
|
|
26
|
+
return html`<ul class="markdown-landing__pages">${items}</ul>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function renderLandingGroup(group: IMarkdownGroup, config: IMarkdownLandingConfig): SafeHtml {
|
|
30
|
+
return html`
|
|
31
|
+
<div class="markdown-landing__group">
|
|
32
|
+
${group.title ? html`<h3 class="markdown-landing__group-title">${group.title}</h3>` : ''}
|
|
33
|
+
${renderLandingPages(group.pages, config)}
|
|
34
|
+
</div>
|
|
35
|
+
`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// withHeading: false for the only section of a landing, whose title is already the page's.
|
|
39
|
+
export function renderLandingSection(section: IMarkdownSection, config: IMarkdownLandingConfig, withHeading: boolean): SafeHtml {
|
|
40
|
+
const body = section.groups
|
|
41
|
+
? section.groups.map((group) => renderLandingGroup(group, config))
|
|
42
|
+
: renderLandingPages(section.pages ?? [], config);
|
|
43
|
+
return html`
|
|
44
|
+
<section class="markdown-landing__section">
|
|
45
|
+
${withHeading ? html`<h2 class="markdown-landing__section-title">${section.title}</h2>` : ''}
|
|
46
|
+
${withHeading && section.description ? html`<p class="markdown-landing__section-description">${section.description}</p>` : ''}
|
|
47
|
+
${body}
|
|
48
|
+
</section>
|
|
49
|
+
`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderLandingSectionLinks(config: IMarkdownLandingConfig): SafeHtml {
|
|
53
|
+
const sectionHref = config.sectionHref ?? ((id: string) => id);
|
|
54
|
+
return html`
|
|
55
|
+
<ul class="markdown-landing__sections">
|
|
56
|
+
${config.sections.map((section) => html`
|
|
57
|
+
<li class="markdown-landing__section">
|
|
58
|
+
<a href="${sectionHref(section.id)}" class="markdown-landing__link" data-event="click:_navigateTo:@attr:href">${section.title}</a>
|
|
59
|
+
${section.description ? html`<p class="markdown-landing__section-description">${section.description}</p>` : ''}
|
|
60
|
+
</li>
|
|
61
|
+
`)}
|
|
62
|
+
</ul>
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const markdownLandingTemplate = (_config: IMarkdownLandingConfig) => html`
|
|
67
|
+
<div class="markdown-landing__body">
|
|
68
|
+
<h1 class="markdown-landing__title">${_config.title}</h1>
|
|
69
|
+
${_config.description ? html`<p class="markdown-landing__description">${_config.description}</p>` : ''}
|
|
70
|
+
${_config.sectionHref
|
|
71
|
+
? renderLandingSectionLinks(_config)
|
|
72
|
+
: _config.sections.map((section) => renderLandingSection(section, _config, _config.sections.length > 1))}
|
|
73
|
+
</div>
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// The index shown on the bare route of a folder with "landing": its hub(s) and pages.
|
|
77
|
+
export class MarkdownLandingComponent extends Component<HTMLElement, IMarkdownLandingConfig> {
|
|
78
|
+
// templateFn: your own template, e.g. built from the render helpers above.
|
|
79
|
+
constructor(mountTarget: HTMLElement, config: IMarkdownLandingConfig, templateFn: (config: IMarkdownLandingConfig) => Nutin.Template = markdownLandingTemplate) {
|
|
80
|
+
// Mounting replaces the placeholder element, so its class is set here.
|
|
81
|
+
super({ templateFn, mountTarget, config, props: { className: 'markdown-landing' } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
protected _navigateTo(href: string): void {
|
|
85
|
+
Navigation.navigateTo(href);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// :where() keeps these rules at zero specificity: see markdown.view.scss.
|
|
2
|
+
:where(.markdown-nav__container) {
|
|
3
|
+
flex: 0 0 var(--markdown-nav-width, 16rem);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
:where(.markdown-nav ul) {
|
|
7
|
+
list-style: none;
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
:where(.markdown-nav__section + .markdown-nav__section, .markdown-nav__group + .markdown-nav__group) {
|
|
13
|
+
margin-top: 1rem;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
:where(.markdown-nav__section-title, .markdown-nav__group-title) {
|
|
17
|
+
display: block;
|
|
18
|
+
font-weight: bold;
|
|
19
|
+
margin-bottom: 0.25rem;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
:where(.markdown-nav__group-title) {
|
|
23
|
+
font-size: 0.875rem;
|
|
24
|
+
opacity: 0.75;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
:where(.markdown-nav__link) {
|
|
28
|
+
display: block;
|
|
29
|
+
padding: 0.25rem 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
:where(.markdown-nav__link--active) {
|
|
33
|
+
font-weight: bold;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Stacked above the content on narrow screens (see markdown.view.scss): the width
|
|
37
|
+
// basis would otherwise become a fixed height.
|
|
38
|
+
@media (max-width: 700px) {
|
|
39
|
+
:where(.markdown-nav__container) {
|
|
40
|
+
flex-basis: auto;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Navigation, html } from '#root/dist/src/core/index.js';
|
|
2
|
+
import { MarkdownNavComponent, renderNavSection } from '#root/dist/src/app/markdown/components/markdown-nav/markdown-nav.component.js';
|
|
3
|
+
|
|
4
|
+
const PAGES = { a: { slug: 'a', title: 'Page A' }, b: { slug: 'b', title: 'Page B' } };
|
|
5
|
+
const manifest = { getPage: (slug) => PAGES[slug] };
|
|
6
|
+
|
|
7
|
+
function mount(config, templateFn) {
|
|
8
|
+
const target = document.createElement('div');
|
|
9
|
+
document.body.appendChild(target);
|
|
10
|
+
const component = new MarkdownNavComponent(target, {
|
|
11
|
+
sections: [{ id: 'api', title: 'API', groups: [{ id: 'g', title: 'Group', pages: ['a', 'b', 'missing'] }] }],
|
|
12
|
+
manifest,
|
|
13
|
+
currentSlug: 'b',
|
|
14
|
+
pageHref: (slug) => `/docs/api/${slug}`,
|
|
15
|
+
...config,
|
|
16
|
+
}, templateFn);
|
|
17
|
+
component.render();
|
|
18
|
+
return component;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('MarkdownNavComponent', () => {
|
|
22
|
+
it('lists sections, group titles and pages, skipping slugs without a page', () => {
|
|
23
|
+
const component = mount();
|
|
24
|
+
const element = component.getElement();
|
|
25
|
+
expect(element.querySelector('.markdown-nav__section-title').textContent).toBe('API');
|
|
26
|
+
expect(element.querySelector('.markdown-nav__group-title').textContent).toBe('Group');
|
|
27
|
+
expect([...element.querySelectorAll('.markdown-nav__link')].map((a) => a.getAttribute('href'))).toEqual(['/docs/api/a', '/docs/api/b']);
|
|
28
|
+
component.destroy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('marks the current page as active, with aria-current', () => {
|
|
32
|
+
const component = mount();
|
|
33
|
+
const active = component.getElement().querySelector('.markdown-nav__link--active');
|
|
34
|
+
expect(active.textContent).toBe('Page B');
|
|
35
|
+
expect(active.getAttribute('aria-current')).toBe('page');
|
|
36
|
+
expect(component.getElement().querySelectorAll('[aria-current]').length).toBe(1);
|
|
37
|
+
component.destroy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('routes a link click through the SPA router', () => {
|
|
41
|
+
const navigateSpy = spyOn(Navigation, 'navigateTo').andCallFake(() => {});
|
|
42
|
+
const component = mount();
|
|
43
|
+
try {
|
|
44
|
+
component.getElement().querySelector('[href="/docs/api/a"]').click();
|
|
45
|
+
} finally {
|
|
46
|
+
navigateSpy.restore();
|
|
47
|
+
}
|
|
48
|
+
expect(navigateSpy.lastCall[0]).toBe('/docs/api/a');
|
|
49
|
+
component.destroy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('accepts its own template, built from the exported render helpers', () => {
|
|
53
|
+
const component = mount({}, (config) => html`<ul class="my-nav">${config.sections.map((section) => renderNavSection(section, config))}</ul>`);
|
|
54
|
+
const element = component.getElement();
|
|
55
|
+
expect(element.querySelector('.my-nav .markdown-nav__section-title').textContent).toBe('API');
|
|
56
|
+
expect(element.querySelector('nav.markdown-nav')).toBe(null);
|
|
57
|
+
component.destroy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Component, Navigation, html, raw, SafeHtml } from '../../../../core/index.js';
|
|
2
|
+
import { IMarkdownGroup, IMarkdownSection, MarkdownManifest } from '../../markdown-manifest.js';
|
|
3
|
+
import { markdownText } from '../../markdown-i18n.js';
|
|
4
|
+
|
|
5
|
+
export interface IMarkdownNavConfig {
|
|
6
|
+
sections: IMarkdownSection[];
|
|
7
|
+
manifest: MarkdownManifest;
|
|
8
|
+
currentSlug: string;
|
|
9
|
+
pageHref: (slug: string) => string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The nav's building blocks, exported so a custom template can reuse them.
|
|
13
|
+
// Links route through the component's _navigateTo (data-event).
|
|
14
|
+
export function renderNavLink(slug: string, config: IMarkdownNavConfig): SafeHtml | string {
|
|
15
|
+
const page = config.manifest.getPage(slug);
|
|
16
|
+
if (!page) return '';
|
|
17
|
+
|
|
18
|
+
const current = slug === config.currentSlug;
|
|
19
|
+
return html`
|
|
20
|
+
<li>
|
|
21
|
+
<a
|
|
22
|
+
href="${config.pageHref(slug)}"
|
|
23
|
+
class="markdown-nav__link${current ? ' markdown-nav__link--active' : ''}"
|
|
24
|
+
${current ? raw('aria-current="page"') : ''}
|
|
25
|
+
data-event="click:_navigateTo:@attr:href"
|
|
26
|
+
>${page.title}</a>
|
|
27
|
+
</li>
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function renderNavGroup(group: IMarkdownGroup, config: IMarkdownNavConfig): SafeHtml {
|
|
32
|
+
const title = group.title ? html`<span class="markdown-nav__group-title">${group.title}</span>` : '';
|
|
33
|
+
return html`
|
|
34
|
+
<li class="markdown-nav__group">
|
|
35
|
+
${title}
|
|
36
|
+
<ul class="markdown-nav__pages">${group.pages.map((slug) => renderNavLink(slug, config))}</ul>
|
|
37
|
+
</li>
|
|
38
|
+
`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderNavSection(section: IMarkdownSection, config: IMarkdownNavConfig): SafeHtml {
|
|
42
|
+
const items = section.groups
|
|
43
|
+
? section.groups.map((group) => renderNavGroup(group, config))
|
|
44
|
+
: (section.pages ?? []).map((slug) => renderNavLink(slug, config));
|
|
45
|
+
|
|
46
|
+
return html`
|
|
47
|
+
<li class="markdown-nav__section">
|
|
48
|
+
<span class="markdown-nav__section-title">${section.title}</span>
|
|
49
|
+
<ul class="markdown-nav__groups">${items}</ul>
|
|
50
|
+
</li>
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const markdownNavTemplate = (_config: IMarkdownNavConfig) => html`
|
|
55
|
+
<nav class="markdown-nav" aria-label="${markdownText('pages', 'Pages')}">
|
|
56
|
+
<ul class="markdown-nav__sections">
|
|
57
|
+
${_config.sections.map((section) => renderNavSection(section, _config))}
|
|
58
|
+
</ul>
|
|
59
|
+
</nav>
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
export class MarkdownNavComponent extends Component<HTMLElement, IMarkdownNavConfig> {
|
|
63
|
+
// templateFn: your own template, e.g. built from the render helpers above.
|
|
64
|
+
constructor(mountTarget: HTMLElement, config: IMarkdownNavConfig, templateFn: (config: IMarkdownNavConfig) => Nutin.Template = markdownNavTemplate) {
|
|
65
|
+
// Mounting replaces the placeholder element, so its class is re-applied here.
|
|
66
|
+
super({ templateFn, mountTarget, config, props: { className: 'markdown-nav__container' } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
protected _navigateTo(href: string): void {
|
|
70
|
+
Navigation.navigateTo(href);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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
|
+
});
|