@nutin/cli 2.1.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -4,18 +4,20 @@ import { errorExit } from '../../utils/index.js';
|
|
|
4
4
|
import nutinConfig from '../../../nutin.config.js';
|
|
5
5
|
import { validateDockerPorts } from './docker-ports.js';
|
|
6
6
|
import { renderFromTemplate } from './render-docker-template.js';
|
|
7
|
+
import { markdownSourcesCopy } from './markdown-sources-copy.js';
|
|
8
|
+
import { localeRedirects } from './locale-redirects.js';
|
|
7
9
|
|
|
8
10
|
const DOCKER_DIR = path.join(process.cwd(), 'tools', 'docker');
|
|
9
11
|
const PLACEHOLDER = '__PORTS_PLACEHOLDER__';
|
|
10
12
|
const ROOT_REDIRECT_PLACEHOLDER = '__ROOT_REDIRECT_PLACEHOLDER__';
|
|
13
|
+
const MARKDOWN_SOURCES_PLACEHOLDER = '__MARKDOWN_SOURCES_PLACEHOLDER__';
|
|
11
14
|
|
|
12
|
-
// Only i18n builds serve every page under a language prefix — without it, "/" is a real page.
|
|
13
15
|
function rootRedirect() {
|
|
14
16
|
if (!nutinConfig.i18n) return '';
|
|
15
17
|
|
|
16
18
|
const languagesConfigPath = path.join(process.cwd(), 'config', 'languages.json');
|
|
17
|
-
const { defaultLanguage } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
18
|
-
return
|
|
19
|
+
const { languages, defaultLanguage } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
20
|
+
return localeRedirects({ i18n: true, languages, defaultLanguage });
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
async function validateDocker() {
|
|
@@ -26,14 +28,29 @@ async function validateDocker() {
|
|
|
26
28
|
errorExit(err.message);
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
let markdownCopy;
|
|
32
|
+
try {
|
|
33
|
+
markdownCopy = markdownSourcesCopy(nutinConfig.markdownSources?.sourceFolders);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
errorExit(err.message);
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
await renderFromTemplate(DOCKER_DIR, 'Dockerfile.template', 'Dockerfile', [
|
|
30
39
|
[PLACEHOLDER, ports.join(' ')],
|
|
31
40
|
['__PRIMARY_PORT_PLACEHOLDER__', String(ports[0])],
|
|
41
|
+
[MARKDOWN_SOURCES_PLACEHOLDER, markdownCopy],
|
|
32
42
|
]).catch((err) => errorExit(err.message));
|
|
33
43
|
|
|
44
|
+
let redirects;
|
|
45
|
+
try {
|
|
46
|
+
redirects = rootRedirect();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
errorExit(err.message);
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
await renderFromTemplate(DOCKER_DIR, 'nginx.conf.template', 'nginx.conf', [
|
|
35
52
|
[PLACEHOLDER, ports.map((port) => `listen ${port};`).join('\n ')],
|
|
36
|
-
[ROOT_REDIRECT_PLACEHOLDER,
|
|
53
|
+
[ROOT_REDIRECT_PLACEHOLDER, redirects],
|
|
37
54
|
]).catch((err) => errorExit(err.message));
|
|
38
55
|
}
|
|
39
56
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Getting started
|
|
3
|
+
description: Pages in this folder are compiled to /generated/markdown-content.json at build time.
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Getting started
|
|
8
|
+
|
|
9
|
+
Pages in this folder are compiled to `/generated/markdown-content.json` at build time, then served at `/markdown-content` by `MarkdownView`.
|
|
10
|
+
|
|
11
|
+
## Adding a page
|
|
12
|
+
|
|
13
|
+
Create a `.md` file anywhere in `markdown-content/`. Its YAML frontmatter is optional:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
---
|
|
17
|
+
title: My page # defaults to the first "# Heading"
|
|
18
|
+
description: Summary # defaults to the first paragraph
|
|
19
|
+
slug: my-page # defaults to the file name
|
|
20
|
+
order: 2 # pages are sorted by order, then by path
|
|
21
|
+
group: Guides # groups pages under a heading in the navigation
|
|
22
|
+
---
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Linking pages
|
|
26
|
+
|
|
27
|
+
Link to another page with its relative path, like [writing pages](./writing-pages.md) —
|
|
28
|
+
the link becomes an in-app route. A link to a missing page fails the build.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
order: 2
|
|
3
|
+
group: Guides
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing pages
|
|
7
|
+
|
|
8
|
+
Markdown is compiled with [marked](https://marked.js.org/) (GitHub-flavored), so tables,
|
|
9
|
+
fenced code blocks and task lists all work.
|
|
10
|
+
|
|
11
|
+
## Headings
|
|
12
|
+
|
|
13
|
+
Every `##` to `######` heading gets an `id`, and pages with two or more of them show an
|
|
14
|
+
"On this page" table of contents.
|
|
15
|
+
|
|
16
|
+
## Checking your content
|
|
17
|
+
|
|
18
|
+
Run the `markdown:check` script to compile every folder without building the app: it fails
|
|
19
|
+
on duplicate slugs and broken links between pages.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// :where() keeps these rules at zero specificity: see markdown.view.scss.
|
|
2
|
+
// A landing (.markdown-landing, .markdown-landing__body) shares the page's rules, so it
|
|
3
|
+
// looks like the folder's pages and any restyling of them applies to it too.
|
|
4
|
+
:where(.markdown-content, .markdown-landing) {
|
|
5
|
+
display: flex;
|
|
6
|
+
gap: var(--markdown-gap, 2rem);
|
|
7
|
+
flex: 1 1 auto;
|
|
8
|
+
min-width: 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
:where(.markdown-content__body, .markdown-landing__body) {
|
|
12
|
+
flex: 1 1 auto;
|
|
13
|
+
min-width: 0;
|
|
14
|
+
line-height: var(--markdown-line-height, 1.7);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
:where(.markdown-content__body pre) {
|
|
18
|
+
overflow-x: auto;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
:where(.markdown-toc) {
|
|
22
|
+
flex: 0 0 var(--markdown-toc-width, 14rem);
|
|
23
|
+
font-size: 0.875rem;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
:where(.markdown-toc ul) {
|
|
27
|
+
list-style: none;
|
|
28
|
+
margin: 0;
|
|
29
|
+
padding: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
:where(.markdown-toc__title) {
|
|
33
|
+
display: block;
|
|
34
|
+
font-weight: bold;
|
|
35
|
+
margin-bottom: 0.5rem;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
:where(.markdown-toc__item--depth-3) {
|
|
39
|
+
padding-left: 1rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
:where(.markdown-toc__item--depth-4, .markdown-toc__item--depth-5, .markdown-toc__item--depth-6) {
|
|
43
|
+
padding-left: 2rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The landing's index: its sections and pages, with their descriptions.
|
|
47
|
+
:where(.markdown-landing__pages, .markdown-landing__sections) {
|
|
48
|
+
list-style: none;
|
|
49
|
+
margin: 0;
|
|
50
|
+
padding: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
:where(.markdown-landing__page, .markdown-landing__sections > .markdown-landing__section) {
|
|
54
|
+
margin-bottom: 0.75rem;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
:where(.markdown-landing__link) {
|
|
58
|
+
font-weight: bold;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
:where(.markdown-landing__page-description, .markdown-landing__section-description) {
|
|
62
|
+
margin: 0.25rem 0 0;
|
|
63
|
+
opacity: 0.75;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@media (max-width: 900px) {
|
|
67
|
+
:where(.markdown-content, .markdown-landing) {
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
:where(.markdown-toc) {
|
|
72
|
+
order: -1;
|
|
73
|
+
flex-basis: auto;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { MarkdownContentComponent } from '#root/dist/src/app/markdown/components/markdown-content/markdown-content.component.js';
|
|
2
|
+
|
|
3
|
+
function mount(config) {
|
|
4
|
+
const target = document.createElement('div');
|
|
5
|
+
document.body.appendChild(target);
|
|
6
|
+
const component = new MarkdownContentComponent(target, config);
|
|
7
|
+
component.render();
|
|
8
|
+
return component;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('MarkdownContentComponent', () => {
|
|
12
|
+
it('shows the empty state when the folder has no page to show', () => {
|
|
13
|
+
const component = mount({});
|
|
14
|
+
expect(component.element.querySelector('.markdown-content__empty')).toBeTruthy();
|
|
15
|
+
expect(component.element.querySelector('.markdown-content__error')).toBe(null);
|
|
16
|
+
component.destroy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('shows an error, not the empty state, when the manifest failed to load', () => {
|
|
20
|
+
const component = mount({ loadFailed: true });
|
|
21
|
+
const error = component.element.querySelector('.markdown-content__error');
|
|
22
|
+
expect(error.getAttribute('role')).toBe('alert');
|
|
23
|
+
expect(error.textContent.trim()).not.toBe('');
|
|
24
|
+
expect(component.element.querySelector('.markdown-content__empty')).toBe(null);
|
|
25
|
+
component.destroy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders the page with its table of contents', () => {
|
|
29
|
+
const component = mount({
|
|
30
|
+
page: {
|
|
31
|
+
slug: 'a', title: 'A', description: '', section: 'index', group: null, order: 0, source: 'a.md',
|
|
32
|
+
html: '<h1 id="a">A</h1><h2 id="one">One</h2><h2 id="two">Two</h2>',
|
|
33
|
+
headings: [{ depth: 2, text: 'One', id: 'one' }, { depth: 2, text: 'Two', id: 'two' }],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
expect(component.element.querySelector('.markdown-content__body h2#one')).toBeTruthy();
|
|
37
|
+
expect([...component.element.querySelectorAll('.markdown-toc a')].map((a) => a.getAttribute('href'))).toEqual(['#one', '#two']);
|
|
38
|
+
component.destroy();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Component, Navigation, html, trustedRaw, SafeHtml } from '../../../../core/index.js';
|
|
2
|
+
import { IMarkdownHeading, IMarkdownPage } from '../../markdown-manifest.js';
|
|
3
|
+
import { markdownText } from '../../markdown-i18n.js';
|
|
4
|
+
|
|
5
|
+
export interface IMarkdownContentConfig {
|
|
6
|
+
page?: IMarkdownPage;
|
|
7
|
+
// The folder's manifest failed to load: shown as an error, not as an empty folder.
|
|
8
|
+
loadFailed?: boolean;
|
|
9
|
+
// Called after each render that shows a page (MarkdownView's "onContentRendered" option).
|
|
10
|
+
onRendered?: (element: HTMLElement, page: IMarkdownPage) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Exported so a custom template can reuse it.
|
|
14
|
+
export function renderToc(headings: IMarkdownHeading[]): SafeHtml | string {
|
|
15
|
+
// A single heading adds nothing next to the page title — only worth a TOC at 2+.
|
|
16
|
+
if (headings.length <= 1) return '';
|
|
17
|
+
|
|
18
|
+
const items = headings
|
|
19
|
+
.map((heading) => html`
|
|
20
|
+
<li class="markdown-toc__item markdown-toc__item--depth-${heading.depth}">
|
|
21
|
+
<a href="#${heading.id}">${heading.text}</a>
|
|
22
|
+
</li>
|
|
23
|
+
`);
|
|
24
|
+
|
|
25
|
+
return html`
|
|
26
|
+
<nav class="markdown-toc" aria-label="${markdownText('onThisPage', 'On this page')}">
|
|
27
|
+
<span class="markdown-toc__title">${markdownText('onThisPage', 'On this page')}</span>
|
|
28
|
+
<ul>${items}</ul>
|
|
29
|
+
</nav>
|
|
30
|
+
`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// page.html is compiled at build time from the project's own Markdown files (not user
|
|
34
|
+
// input), hence trustLevel 'trusted'. With generateSEOFiles, every page is also prerendered
|
|
35
|
+
// (tools/builder/core/markdown/markdown-seo-routes.js).
|
|
36
|
+
export const markdownContentTemplate = (_config: IMarkdownContentConfig) => {
|
|
37
|
+
if (!_config.page) {
|
|
38
|
+
return _config.loadFailed
|
|
39
|
+
? html`<p class="markdown-content__error" role="alert">${markdownText('loadError', "This page couldn't be loaded.")}</p>`
|
|
40
|
+
: html`<p class="markdown-content__empty">${markdownText('empty', 'Nothing here yet.')}</p>`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return html`
|
|
44
|
+
<article class="markdown-content__body">${trustedRaw(_config.page.html)}</article>
|
|
45
|
+
${renderToc(_config.page.headings)}
|
|
46
|
+
`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class MarkdownContentComponent extends Component<HTMLElement, IMarkdownContentConfig> {
|
|
50
|
+
// templateFn: your own template, e.g. built from renderToc() and the page's html.
|
|
51
|
+
constructor(mountTarget: HTMLElement, config: IMarkdownContentConfig, templateFn: (config: IMarkdownContentConfig) => Nutin.Template = markdownContentTemplate) {
|
|
52
|
+
// Mounting replaces the placeholder element, so its class is re-applied here.
|
|
53
|
+
super({ templateFn, mountTarget, config, trustLevel: 'trusted', props: { className: 'markdown-content' } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
protected override onAfterRender(): void {
|
|
57
|
+
if (this.config.page) this.config.onRendered?.(this.element, this.config.page);
|
|
58
|
+
super.onAfterRender();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Internal links between pages carry data-event="click:_navigateTo:@attr:href".
|
|
62
|
+
protected _navigateTo(href: string): void {
|
|
63
|
+
Navigation.navigateTo(href);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -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
|
+
}
|