@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,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
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { View } from '../../core/index.js';
|
|
2
|
+
import { MarkdownGuards } from './markdown-guards.js';
|
|
3
|
+
import { MarkdownManifestsService } from './markdown-manifests.service.js';
|
|
4
|
+
|
|
5
|
+
export interface IMarkdownRoutesOptions {
|
|
6
|
+
// Builds the view of a folder's routes, e.g. a MarkdownView with your own components or a
|
|
7
|
+
// subclass of it. Called for every folder, with its id: return new MarkdownView({ id }) for
|
|
8
|
+
// the folders you don't customize. May return a Promise, to load the view lazily (see
|
|
9
|
+
// ViewFactory): e.g. import('./views/docs/docs.view.js').then((m) => new m.DocsView(id)).
|
|
10
|
+
view?: (id: string) => View | Promise<View>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// One route per nutin.config.js "markdownSources" folder: '/<id>/:slug?', or
|
|
14
|
+
// '/<id>/:section?/:slug?' with "sectionInPath: true". Spread into appRoutes; a route
|
|
15
|
+
// written after the spread with the same path replaces the generated one.
|
|
16
|
+
// By default, the routes are lazy: MarkdownView and its components are loaded on the first
|
|
17
|
+
// visit to one of them, not with the rest of the app.
|
|
18
|
+
export function markdownRoutes(options: IMarkdownRoutesOptions = {}): Nutin.Routes {
|
|
19
|
+
const view = options.view ?? ((id: string) => import('./view/markdown.view.js').then((m) => new m.MarkdownView({ id })));
|
|
20
|
+
return Object.fromEntries(
|
|
21
|
+
MarkdownManifestsService.all.map(({ id, sectionInPath }) => sectionInPath
|
|
22
|
+
? [`/${id}/:section?/:slug?`, { view: () => view(id), guards: [MarkdownGuards.sectionPageExists(id)] }]
|
|
23
|
+
: [`/${id}/:slug?`, { view: () => view(id), guards: [MarkdownGuards.pageExists(id)] }]
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { resolveMarkdownSources } from '#root/dist/src/app/markdown/markdown-sources.js';
|
|
2
|
+
|
|
3
|
+
describe('resolveMarkdownSources', () => {
|
|
4
|
+
// Same cases as tools/builder/core/markdown/markdown-compiler.test.js: the compiler names
|
|
5
|
+
// each manifest with this id, so both rules must agree.
|
|
6
|
+
it('derives manifest ids like the compiler does', () => {
|
|
7
|
+
const cases = [
|
|
8
|
+
['content', 'content', false],
|
|
9
|
+
['docs/My Guides', 'my-guides', false],
|
|
10
|
+
['docs/guides/', 'guides', false],
|
|
11
|
+
[{ folder: 'x', routePrefix: 'api' }, 'api', false],
|
|
12
|
+
[{ folder: 'docs', sectionInPath: true }, 'docs', true],
|
|
13
|
+
];
|
|
14
|
+
const sources = resolveMarkdownSources({ sourceFolders: cases.map(([entry]) => entry) });
|
|
15
|
+
expect(sources).toEqual(cases.map(([, id, sectionInPath]) => ({ id, sectionInPath })));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('returns no sources when markdownSources is missing', () => {
|
|
19
|
+
expect(resolveMarkdownSources(undefined)).toEqual([]);
|
|
20
|
+
expect(resolveMarkdownSources({})).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { IMarkdownSource } from './markdown-manifest.js';
|
|
2
|
+
|
|
3
|
+
export type MarkdownSourceEntry = string | {
|
|
4
|
+
folder: string;
|
|
5
|
+
routePrefix?: string;
|
|
6
|
+
hubFiles?: string[];
|
|
7
|
+
sectionInPath?: boolean;
|
|
8
|
+
prefixReplacements?: [string, string][];
|
|
9
|
+
seo?: boolean;
|
|
10
|
+
ogImage?: string;
|
|
11
|
+
landing?: boolean | { title?: string; description?: string };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
declare global {
|
|
15
|
+
// Set by the builder's esbuild define (tools/builder/core/prod-bundle/bundle-options.js):
|
|
16
|
+
// nutin.config.js's markdownSources.sourceFolders, as JSON. Undefined in tsc output (tests).
|
|
17
|
+
var __NUTIN_MARKDOWN_SOURCES__: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface IMarkdownSourcesConfig {
|
|
21
|
+
sourceFolders?: MarkdownSourceEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function slugify(text: string): string {
|
|
25
|
+
return text
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
+
.replace(/^-+|-+$/g, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mirrors normalizeSource() in tools/builder/core/markdown/markdown-compiler.js, which names
|
|
32
|
+
// each manifest: keep both id rules identical (the build validates entries, not this).
|
|
33
|
+
export function resolveMarkdownSources(config: IMarkdownSourcesConfig | undefined): IMarkdownSource[] {
|
|
34
|
+
return (config?.sourceFolders ?? []).map((entry) => {
|
|
35
|
+
const options = typeof entry === 'string' ? { folder: entry } : entry;
|
|
36
|
+
const folderName = options.folder.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '';
|
|
37
|
+
return {
|
|
38
|
+
id: options.routePrefix ?? slugify(folderName),
|
|
39
|
+
sectionInPath: options.sectionInPath === true,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Every selector is wrapped in :where(), so it has no specificity: any rule of your own wins.
|
|
2
|
+
// Adjust sizes through the custom properties below, e.g. in your own stylesheet:
|
|
3
|
+
// .markdown-view { --markdown-nav-width: 20rem; }
|
|
4
|
+
:where(.markdown-view) {
|
|
5
|
+
--markdown-gap: 2rem;
|
|
6
|
+
--markdown-nav-width: 16rem;
|
|
7
|
+
--markdown-toc-width: 14rem;
|
|
8
|
+
--markdown-line-height: 1.7;
|
|
9
|
+
|
|
10
|
+
display: flex;
|
|
11
|
+
gap: var(--markdown-gap);
|
|
12
|
+
padding: 2rem 1rem;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@media (max-width: 700px) {
|
|
16
|
+
:where(.markdown-view) {
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { MarkdownView } from '#root/dist/src/app/markdown/view/markdown.view.js';
|
|
2
|
+
import { MarkdownManifest } from '#root/dist/src/app/markdown/markdown-manifest.js';
|
|
3
|
+
import { MarkdownManifestsService } from '#root/dist/src/app/markdown/markdown-manifests.service.js';
|
|
4
|
+
import { MarkdownNavComponent } from '#root/dist/src/app/markdown/components/markdown-nav/markdown-nav.component.js';
|
|
5
|
+
import { MarkdownContentComponent } from '#root/dist/src/app/markdown/components/markdown-content/markdown-content.component.js';
|
|
6
|
+
|
|
7
|
+
const page = (slug, section, title) => ({
|
|
8
|
+
slug, section, title, description: `About ${title}`, group: null, order: 0, source: `${slug}.md`, headings: [], html: `<h1>${title}</h1>`,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const DATA = {
|
|
12
|
+
sections: [
|
|
13
|
+
{ id: 'api', title: 'API', description: 'The API.', pages: ['a'] },
|
|
14
|
+
{ id: 'tools', title: 'Tools', description: '', pages: ['b'] },
|
|
15
|
+
],
|
|
16
|
+
pages: { a: page('a', 'api', 'Page A'), b: page('b', 'tools', 'Page B') },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// A fresh manifest per test: the service's own manifests are shared singletons.
|
|
20
|
+
async function manifestOf(data, sectionInPath = false) {
|
|
21
|
+
const originalFetch = global.fetch;
|
|
22
|
+
global.fetch = () => Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) });
|
|
23
|
+
try {
|
|
24
|
+
const manifest = new MarkdownManifest({ id: 'docs', sectionInPath }, () => null);
|
|
25
|
+
await manifest.load();
|
|
26
|
+
return manifest;
|
|
27
|
+
} finally {
|
|
28
|
+
global.fetch = originalFetch;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function viewOf(manifest, params = {}, options = {}) {
|
|
33
|
+
const view = new MarkdownView({ id: MarkdownManifestsService.ids[0], ...options });
|
|
34
|
+
view.manifest = manifest;
|
|
35
|
+
view.setRouteParams(params);
|
|
36
|
+
return view;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('MarkdownView', () => {
|
|
40
|
+
let replaceSpy;
|
|
41
|
+
let view;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
// NavigationManager.replaceState() ends in history.replaceState(state, title, url).
|
|
45
|
+
replaceSpy = spyOn(window.history, 'replaceState').andCallFake(() => {});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
view?.onExit();
|
|
50
|
+
view?.destroy();
|
|
51
|
+
view = null;
|
|
52
|
+
replaceSpy.restore();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders the page of the route, and uses its title as the document title', async () => {
|
|
56
|
+
view = viewOf(await manifestOf(DATA), { slug: 'b' });
|
|
57
|
+
view.render();
|
|
58
|
+
expect(view.getElement().querySelector('.markdown-content__body h1').textContent).toBe('Page B');
|
|
59
|
+
expect(view.documentTitle()).toBe('Page B');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('without a landing, renders the first page on the bare route and points the URL at it', async () => {
|
|
63
|
+
view = viewOf(await manifestOf(DATA));
|
|
64
|
+
view.render();
|
|
65
|
+
view.onEnter();
|
|
66
|
+
expect(view.getElement().querySelector('.markdown-content__body h1').textContent).toBe('Page A');
|
|
67
|
+
expect(replaceSpy.lastCall[2]).toBe('/docs/a');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('with a landing, renders it on the bare route and keeps the URL', async () => {
|
|
71
|
+
view = viewOf(await manifestOf({ ...DATA, landing: { title: 'Docs', description: 'Everything.' } }));
|
|
72
|
+
view.render();
|
|
73
|
+
view.onEnter();
|
|
74
|
+
const element = view.getElement();
|
|
75
|
+
expect(element.querySelector('.markdown-landing__title').textContent).toBe('Docs');
|
|
76
|
+
expect(element.querySelector('.markdown-landing__description').textContent).toBe('Everything.');
|
|
77
|
+
expect([...element.querySelectorAll('.markdown-landing__link')].map((a) => a.getAttribute('href'))).toEqual(['/docs/a', '/docs/b']);
|
|
78
|
+
expect(element.querySelector('.markdown-content__body')).toBe(null);
|
|
79
|
+
expect(replaceSpy.callCount).toBe(0);
|
|
80
|
+
expect(view.documentTitle()).toBe('Docs');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('with sections in the URL, the folder landing links each section and the nav lists them all', async () => {
|
|
84
|
+
view = viewOf(await manifestOf({ ...DATA, landing: { title: 'Docs', description: '' } }, true));
|
|
85
|
+
view.render();
|
|
86
|
+
const element = view.getElement();
|
|
87
|
+
expect([...element.querySelectorAll('.markdown-landing__link')].map((a) => a.getAttribute('href'))).toEqual(['/docs/api', '/docs/tools']);
|
|
88
|
+
expect([...element.querySelectorAll('.markdown-nav__section-title')].map((s) => s.textContent)).toEqual(['API', 'Tools']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('with sections in the URL, a section landing lists its pages under its own title', async () => {
|
|
92
|
+
view = viewOf(await manifestOf({ ...DATA, landing: { title: 'Docs', description: '' } }, true), { section: 'api' });
|
|
93
|
+
view.render();
|
|
94
|
+
const element = view.getElement();
|
|
95
|
+
expect(element.querySelector('.markdown-landing__title').textContent).toBe('API');
|
|
96
|
+
expect([...element.querySelectorAll('.markdown-landing__link')].map((a) => a.getAttribute('href'))).toEqual(['/docs/api/a']);
|
|
97
|
+
expect(view.documentTitle()).toBe('API');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uses the nav and content components passed as options', async () => {
|
|
101
|
+
class MyNav extends MarkdownNavComponent {
|
|
102
|
+
constructor(el, config) { super(el, config, () => '<nav class="my-nav"></nav>'); }
|
|
103
|
+
}
|
|
104
|
+
class MyContent extends MarkdownContentComponent {}
|
|
105
|
+
view = viewOf(await manifestOf(DATA), { slug: 'a' }, { navComponent: MyNav, contentComponent: MyContent });
|
|
106
|
+
view.render();
|
|
107
|
+
expect(view.getElement().querySelector('.my-nav')).toBeTruthy();
|
|
108
|
+
expect(view.getElement().querySelector('.markdown-nav')).toBe(null);
|
|
109
|
+
expect(view.getElement().querySelector('.markdown-content__body')).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('calls onContentRendered with the content element and the page', async () => {
|
|
113
|
+
const calls = [];
|
|
114
|
+
view = viewOf(await manifestOf(DATA), { slug: 'a' }, { onContentRendered: (element, shown) => calls.push([element.className, shown.slug]) });
|
|
115
|
+
view.render();
|
|
116
|
+
expect(calls).toEqual([['markdown-content', 'a']]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('renders without a nav when the template has no nav placeholder', async () => {
|
|
120
|
+
view = viewOf(await manifestOf(DATA), { slug: 'a' }, { template: '<div><div data-component="markdown-content"></div></div>' });
|
|
121
|
+
view.render();
|
|
122
|
+
expect(view.getElement().querySelector('.markdown-nav')).toBe(null);
|
|
123
|
+
expect(view.getElement().querySelector('.markdown-content__body')).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
});
|