@nutin/cli 2.1.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Marked } from 'marked';
|
|
5
|
+
import matter from 'gray-matter';
|
|
6
|
+
import { compileLanguages, compileSource, firstProse, folderTitle, headingId, normalizeSource, slugFromFilename, stripInlineMarkdown } from './markdown-compiler.js';
|
|
7
|
+
|
|
8
|
+
let root;
|
|
9
|
+
|
|
10
|
+
function write(relPath, content) {
|
|
11
|
+
const fullPath = path.join(root, relPath);
|
|
12
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
13
|
+
fs.writeFileSync(fullPath, content);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function compile(entry) {
|
|
17
|
+
return compileSource(normalizeSource(entry, root), { Marked, matter, root });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const I18N = { i18n: true, languages: ['en', 'fr'], defaultLanguage: 'en' };
|
|
21
|
+
|
|
22
|
+
function compileAll(entry, options = I18N) {
|
|
23
|
+
return compileLanguages(normalizeSource(entry, root), { Marked, matter, root }, options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('markdown-compiler', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'nutin-markdown-'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('derives the id, route prefix and conventional hub file from the folder', () => {
|
|
36
|
+
write('articles/ARTICLES.md', '# Articles\n');
|
|
37
|
+
const source = normalizeSource('articles', root);
|
|
38
|
+
expect(source.id).toBe('articles');
|
|
39
|
+
expect(source.hubFiles).toEqual(['ARTICLES.md']);
|
|
40
|
+
expect(source.sectionInPath).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Same cases as src/app/markdown/markdown-sources.test.js: the runtime derives these ids too.
|
|
44
|
+
it('derives manifest ids like the runtime does', () => {
|
|
45
|
+
const cases = [
|
|
46
|
+
['content', 'content', false],
|
|
47
|
+
['docs/My Guides', 'my-guides', false],
|
|
48
|
+
['docs/guides/', 'guides', false],
|
|
49
|
+
[{ folder: 'x', routePrefix: 'api' }, 'api', false],
|
|
50
|
+
[{ folder: 'docs', sectionInPath: true }, 'docs', true],
|
|
51
|
+
];
|
|
52
|
+
for (const [entry, id, sectionInPath] of cases) {
|
|
53
|
+
const source = normalizeSource(entry, root);
|
|
54
|
+
expect(source.id).toBe(id);
|
|
55
|
+
expect(source.sectionInPath).toBe(sectionInPath);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('rejects invalid entries and route prefixes', () => {
|
|
60
|
+
expect(() => normalizeSource({}, root)).toThrow('expected a folder path');
|
|
61
|
+
expect(() => normalizeSource({ folder: 'docs', routePrefix: 'Docs Pages' }, root)).toThrow('"routePrefix"');
|
|
62
|
+
expect(() => normalizeSource({ folder: 'docs', hubFiles: 'API.md' }, root)).toThrow('"hubFiles"');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('enables SEO pages by default, and validates the "seo" option', () => {
|
|
66
|
+
expect(normalizeSource('content', root).seo).toBe(true);
|
|
67
|
+
expect(normalizeSource({ folder: 'content', seo: false }, root).seo).toBe(false);
|
|
68
|
+
expect(() => normalizeSource({ folder: 'content', seo: 'no' }, root)).toThrow('"seo"');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('passes a frontmatter ogImage through to the page', () => {
|
|
72
|
+
write('content/a.md', '---\nogImage: /og/a.jpg\n---\n# A\n\nText.\n');
|
|
73
|
+
write('content/b.md', '# B\n\nText.\n');
|
|
74
|
+
|
|
75
|
+
const { manifest } = compile('content');
|
|
76
|
+
|
|
77
|
+
expect(manifest.pages.a.ogImage).toBe('/og/a.jpg');
|
|
78
|
+
expect('ogImage' in manifest.pages.b).toBeFalsy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('derives plain-text descriptions from inline Markdown', () => {
|
|
82
|
+
expect(stripInlineMarkdown('Built with [marked](https://marked.js.org/), **fast** and `small`.')).toBe('Built with marked, fast and small.');
|
|
83
|
+
expect(stripInlineMarkdown('An  _here_ and snake_case_name')).toBe('An logo here and snake_case_name');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('applies prefix replacements to file-name slugs', () => {
|
|
87
|
+
const replacements = [['HOWDOI_', ''], ['WHATIS_', 'what-is-']];
|
|
88
|
+
expect(slugFromFilename('HOWDOI_CREATE_A_VIEW.md', replacements)).toBe('create-a-view');
|
|
89
|
+
expect(slugFromFilename('WHATIS_A_VIEW.md', replacements)).toBe('what-is-a-view');
|
|
90
|
+
expect(slugFromFilename('Plain Name.md')).toBe('plain-name');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('compiles a frontmatter-only folder, sorted by order and grouped', () => {
|
|
94
|
+
write('content/b.md', '---\norder: 2\ngroup: Guides\n---\n# Second\n\nSecond page.\n');
|
|
95
|
+
write('content/a.md', '---\ntitle: First page\norder: 1\n---\n# Ignored H1\n\nFirst page.\n');
|
|
96
|
+
write('content/nested/c.md', '# Third\n\nThird page.\n');
|
|
97
|
+
|
|
98
|
+
const { manifest } = compile('content');
|
|
99
|
+
const [section] = manifest.sections;
|
|
100
|
+
|
|
101
|
+
expect(section.id).toBe('index');
|
|
102
|
+
expect(section.groups).toEqual([
|
|
103
|
+
{ id: null, title: null, pages: ['a'] },
|
|
104
|
+
{ id: 'guides', title: 'Guides', pages: ['b'] },
|
|
105
|
+
{ id: 'nested', title: 'Nested', pages: ['c'] },
|
|
106
|
+
]);
|
|
107
|
+
expect(manifest.pages.a.title).toBe('First page');
|
|
108
|
+
expect(manifest.pages.a.description).toBe('First page.');
|
|
109
|
+
expect(manifest.pages.c.source).toBe('content/nested/c.md');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('groups a frontmatter-only folder\'s pages by first-level subfolder, root pages first', () => {
|
|
113
|
+
write('content/intro.md', '# Intro\n\nWelcome.\n');
|
|
114
|
+
write('content/01-getting-started/install.md', '# Install\n\nRun it.\n');
|
|
115
|
+
write('content/01-getting-started/configure.md', '# Configure\n\nSet it up.\n');
|
|
116
|
+
write('content/02_advanced/routing.md', '# Routing\n\nRoutes.\n');
|
|
117
|
+
write('content/02_advanced/deep/guards.md', '# Guards\n\nGuards.\n');
|
|
118
|
+
write('content/02_advanced/faq.md', '---\ngroup: Help\n---\n# FAQ\n\nQuestions.\n');
|
|
119
|
+
write('content/API/reference.md', '# Reference\n\nAll of it.\n');
|
|
120
|
+
write('content/misc.md', '---\ngroup: API\n---\n# Misc\n\nOther.\n');
|
|
121
|
+
|
|
122
|
+
const [section] = compile('content').manifest.sections;
|
|
123
|
+
|
|
124
|
+
expect(section.groups).toEqual([
|
|
125
|
+
{ id: null, title: null, pages: ['intro'] },
|
|
126
|
+
{ id: 'getting-started', title: 'Getting started', pages: ['configure', 'install'] },
|
|
127
|
+
{ id: 'advanced', title: 'Advanced', pages: ['guards', 'routing'] },
|
|
128
|
+
{ id: 'help', title: 'Help', pages: ['faq'] },
|
|
129
|
+
{ id: 'api', title: 'API', pages: ['reference', 'misc'] },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('makes a folder name a readable group title', () => {
|
|
134
|
+
expect(folderTitle('getting-started')).toBe('Getting started');
|
|
135
|
+
expect(folderTitle('02_advanced_topics')).toBe('Advanced topics');
|
|
136
|
+
expect(folderTitle('3. reference')).toBe('Reference');
|
|
137
|
+
expect(folderTitle('API')).toBe('API');
|
|
138
|
+
expect(folderTitle('HowTo')).toBe('HowTo');
|
|
139
|
+
expect(folderTitle('2024')).toBe('2024');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('compiles hub files into sections, honoring their order and link titles', () => {
|
|
143
|
+
write('docs/API.md', '# API\n\nThe API.\n\n## Table of Contents\n\n### Views\n\n- [Create a view](./API/HOWDOI_CREATE_A_VIEW.md)\n');
|
|
144
|
+
write('docs/TOOLS.md', '---\ntitle: Tooling\n---\n# Nutin - Tools\n\n## Table of Contents\n\n- [Builder](./TOOLS/BUILDER.md)\n');
|
|
145
|
+
write('docs/API/HOWDOI_CREATE_A_VIEW.md', '# How do I create a view?\n\nLike this.\n\nSee the [builder](../TOOLS/BUILDER.md#usage).\n');
|
|
146
|
+
write('docs/TOOLS/BUILDER.md', '# Builder\n\n## Usage\n\n## Options\n');
|
|
147
|
+
|
|
148
|
+
const { manifest, warnings } = compile({
|
|
149
|
+
folder: 'docs',
|
|
150
|
+
hubFiles: ['API.md', 'TOOLS.md'],
|
|
151
|
+
sectionInPath: true,
|
|
152
|
+
prefixReplacements: [['HOWDOI_', '']],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(manifest.sections.map((s) => [s.id, s.title])).toEqual([['api', 'API'], ['tools', 'Tooling']]);
|
|
156
|
+
expect(manifest.sections[0].groups).toEqual([{ id: 'views', title: 'Views', pages: ['create-a-view'] }]);
|
|
157
|
+
expect(manifest.sections[1].pages).toEqual(['builder']);
|
|
158
|
+
expect(manifest.pages['create-a-view'].title).toBe('Create a view');
|
|
159
|
+
expect(manifest.pages['create-a-view'].html).toContain('href="/docs/tools/builder#usage" data-event="click:_navigateTo:@attr:href"');
|
|
160
|
+
expect(manifest.pages.builder.headings).toEqual([
|
|
161
|
+
{ depth: 2, text: 'Usage', id: 'usage' },
|
|
162
|
+
{ depth: 2, text: 'Options', id: 'options' },
|
|
163
|
+
]);
|
|
164
|
+
expect(warnings.some((w) => w.includes('Title mismatch'))).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('warns about pages no hub lists', () => {
|
|
168
|
+
write('guides/GUIDES.md', '# Guides\n\n## Table of Contents\n\n- [One](./ONE.md)\n');
|
|
169
|
+
write('guides/ONE.md', '# One\n');
|
|
170
|
+
write('guides/ORPHAN.md', '# Orphan\n');
|
|
171
|
+
|
|
172
|
+
const { manifest, warnings } = compile('guides');
|
|
173
|
+
expect(Object.keys(manifest.pages)).toEqual(['one']);
|
|
174
|
+
expect(warnings.some((w) => w.includes('guides/ORPHAN.md'))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('fails on duplicate slugs', () => {
|
|
178
|
+
write('content/a.md', '---\nslug: same\n---\n# A\n');
|
|
179
|
+
write('content/b.md', '---\nslug: same\n---\n# B\n');
|
|
180
|
+
expect(() => compile('content')).toThrow('Duplicate slug "same"');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('fails on links to pages that are not compiled', () => {
|
|
184
|
+
write('content/a.md', '# A\n\n[Missing](./missing.md)\n');
|
|
185
|
+
expect(() => compile('content')).toThrow('Unresolvable internal link "./missing.md"');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('reports every broken link of a page in one error, without marked\'s bug-report suffix', () => {
|
|
189
|
+
write('content/a.md', '# A\n\n[One](./one.md) and [Two](./two.md#part)\n');
|
|
190
|
+
let message = '';
|
|
191
|
+
try {
|
|
192
|
+
compile('content');
|
|
193
|
+
} catch (err) {
|
|
194
|
+
message = err.message;
|
|
195
|
+
}
|
|
196
|
+
expect(message).toContain('"./one.md"');
|
|
197
|
+
expect(message).toContain('"./two.md#part"');
|
|
198
|
+
expect(message).not.toContain('Please report');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('gives repeated headings unique ids and keeps accented letters as their base letter', () => {
|
|
202
|
+
const seen = new Map();
|
|
203
|
+
expect(['Usage', 'Usage', 'Usage'].map((text) => headingId(text, seen))).toEqual(['usage', 'usage-1', 'usage-2']);
|
|
204
|
+
expect(headingId('Résumé à côté', new Map())).toBe('resume-a-cote');
|
|
205
|
+
|
|
206
|
+
write('notes/a.md', '# A\n\n## Usage\n\n## Options\n\n## Usage\n\n## Été `chaud`\n');
|
|
207
|
+
const { manifest } = compile({ folder: 'notes', hubFiles: [] });
|
|
208
|
+
expect(manifest.pages.a.headings).toEqual([
|
|
209
|
+
{ depth: 2, text: 'Usage', id: 'usage' },
|
|
210
|
+
{ depth: 2, text: 'Options', id: 'options' },
|
|
211
|
+
{ depth: 2, text: 'Usage', id: 'usage-1' },
|
|
212
|
+
{ depth: 2, text: 'Été chaud', id: 'ete-chaud' },
|
|
213
|
+
]);
|
|
214
|
+
expect(manifest.pages.a.html).toContain('<h2 id="usage-1">Usage</h2>');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('keeps page slugs as they were: accents only change heading ids', () => {
|
|
218
|
+
write('notes/café.md', '# Café\n');
|
|
219
|
+
const { manifest } = compile({ folder: 'notes', hubFiles: [] });
|
|
220
|
+
expect(Object.keys(manifest.pages)).toEqual(['caf']);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('derives descriptions from the first paragraph or list item, as plain text', () => {
|
|
224
|
+
expect(firstProse('<details>\n<summary>Q</summary>\n</details>\n\nA **real** `paragraph`\nover two lines.\n', Marked)).toBe('A real paragraph over two lines.');
|
|
225
|
+
expect(firstProse('## Fixes\n\n- Fixed `npm run dev` for **nested** templates.\n- Other fix.\n', Marked)).toBe('Fixed npm run dev for nested templates.');
|
|
226
|
+
expect(firstProse('```ts\nconst a = 1;\n```\n', Marked)).toBe('');
|
|
227
|
+
// Whichever comes first: a list before a later paragraph wins.
|
|
228
|
+
expect(firstProse('- Added `x`.\n\nNotes: about x.\n', Marked)).toBe('Added x.');
|
|
229
|
+
// HTML tags are dropped, but not inside code spans, where they are text.
|
|
230
|
+
expect(firstProse('A <em>tag</em> and `<b>code</b>`.\n', Marked)).toBe('A tag and <b>code</b>.');
|
|
231
|
+
|
|
232
|
+
write('log/1-0-0.md', '# 1.0.0\n\n## Fixes\n\n- Fixed `npm run dev`.\n');
|
|
233
|
+
const { manifest } = compile({ folder: 'log', hubFiles: [] });
|
|
234
|
+
expect(manifest.pages['1-0-0'].description).toBe('Fixed npm run dev.');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('strips Markdown from hub descriptions', () => {
|
|
238
|
+
write('docs/TOOLS.md', '# Tools\n\n***Important:*** switching **package manager**\nneeds `care`.\n\n- a list after it\n\n## Table of Contents\n\n- [Builder](./BUILDER.md)\n');
|
|
239
|
+
write('docs/BUILDER.md', '# Builder\n');
|
|
240
|
+
const { manifest } = compile({ folder: 'docs', hubFiles: ['TOOLS.md'] });
|
|
241
|
+
expect(manifest.sections[0].description).toBe('Important: switching package manager needs care.');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('fails on a page listed in two hub files, or twice in one', () => {
|
|
245
|
+
write('docs/API.md', '# API\n\n## Table of Contents\n\n- [Intro](./INTRO.md)\n');
|
|
246
|
+
write('docs/TOOLS.md', '# Tools\n\n## Table of Contents\n\n- [Intro again](./INTRO.md)\n');
|
|
247
|
+
write('docs/INTRO.md', '# Intro\n');
|
|
248
|
+
expect(() => compile({ folder: 'docs', hubFiles: ['API.md', 'TOOLS.md'] }))
|
|
249
|
+
.toThrow('"docs/INTRO.md" is listed in both "API.md" and "TOOLS.md"');
|
|
250
|
+
|
|
251
|
+
write('docs/API.md', '# API\n\n## Table of Contents\n\n- [Intro](./INTRO.md)\n- [Intro](./INTRO.md)\n');
|
|
252
|
+
expect(() => compile({ folder: 'docs', hubFiles: ['API.md'] })).toThrow('is listed in "API.md" twice');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('accepts "*" and "+" page bullets in hub files, and warns about list items that are not pages', () => {
|
|
256
|
+
write('docs/DOCS.md', '---\ntitle: Docs\n---\n# Docs\n\n## Table of Contents\n\n* [A](./A.md)\n+ [B](./B.md)\n- [C](./C.md) — the third\n - [D](./D.md)\n1. [E](./E.md)\n');
|
|
257
|
+
['A', 'B', 'C', 'D', 'E'].forEach((name) => write(`docs/${name}.md`, `# ${name}\n`));
|
|
258
|
+
const { manifest, warnings } = compile('docs');
|
|
259
|
+
|
|
260
|
+
expect(manifest.sections[0].pages).toEqual(['a', 'b']);
|
|
261
|
+
const ignored = warnings.filter((w) => w.includes('is not a page entry'));
|
|
262
|
+
expect(ignored.length).toBe(3);
|
|
263
|
+
expect(ignored[0]).toContain('"DOCS.md" line 10');
|
|
264
|
+
expect(ignored[0]).toContain('- [C](./C.md) — the third');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('validates the "ogImage" and "landing" options', () => {
|
|
268
|
+
expect(normalizeSource({ folder: 'docs', ogImage: '/og/docs.jpg' }, root).ogImage).toBe('/og/docs.jpg');
|
|
269
|
+
expect(() => normalizeSource({ folder: 'docs', ogImage: 3 }, root)).toThrow('"ogImage"');
|
|
270
|
+
expect(normalizeSource('docs', root).landing).toBe(null);
|
|
271
|
+
expect(normalizeSource({ folder: 'docs', landing: true }, root).landing).toEqual({});
|
|
272
|
+
expect(normalizeSource({ folder: 'docs', landing: { title: 'Docs' } }, root).landing).toEqual({ title: 'Docs', description: undefined });
|
|
273
|
+
expect(() => normalizeSource({ folder: 'docs', landing: 'yes' }, root)).toThrow('"landing"');
|
|
274
|
+
expect(() => normalizeSource({ folder: 'docs', landing: { title: 1 } }, root)).toThrow('"landing"');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('adds a landing to the manifest: the single hub\'s title and description, or the folder\'s', () => {
|
|
278
|
+
write('guides/GUIDES.md', '# Guides\n\nHow-tos for **common** tasks.\n\n## Table of Contents\n\n- [A](./A.md)\n');
|
|
279
|
+
write('guides/A.md', '# A\n');
|
|
280
|
+
expect(compile('guides').manifest.landing).toBe(undefined);
|
|
281
|
+
expect(compile({ folder: 'guides', landing: true }).manifest.landing).toEqual({ title: 'Guides', description: 'How-tos for common tasks.' });
|
|
282
|
+
|
|
283
|
+
write('docs/API.md', '# API\n\nThe API.\n\n## Table of Contents\n\n- [A](./A.md)\n');
|
|
284
|
+
write('docs/TOOLS.md', '# Tools\n\n## Table of Contents\n\n- [B](./B.md)\n');
|
|
285
|
+
write('docs/A.md', '# A\n');
|
|
286
|
+
write('docs/B.md', '# B\n');
|
|
287
|
+
const hubs = { folder: 'docs', hubFiles: ['API.md', 'TOOLS.md'], sectionInPath: true };
|
|
288
|
+
expect(compile({ ...hubs, landing: true }).manifest.landing).toEqual({ title: 'docs', description: '' });
|
|
289
|
+
expect(compile({ ...hubs, landing: { title: 'Documentation', description: 'Everything.' } }).manifest.landing)
|
|
290
|
+
.toEqual({ title: 'Documentation', description: 'Everything.' });
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('translates a single-hub landing with its hub', () => {
|
|
294
|
+
write('guides/en/GUIDES.md', '# Guides\n\nHow-tos.\n\n## Table of Contents\n\n- [A](./A.md)\n');
|
|
295
|
+
write('guides/en/A.md', '# A\n');
|
|
296
|
+
write('guides/fr/GUIDES.md', '# Guides FR\n\nTutoriels.\n\n## Table of Contents\n\n- [A](./A.md)\n');
|
|
297
|
+
write('guides/fr/A.md', '# A FR\n');
|
|
298
|
+
const { manifests } = compileAll({ folder: 'guides', landing: true });
|
|
299
|
+
expect(manifests.en.landing).toEqual({ title: 'Guides', description: 'How-tos.' });
|
|
300
|
+
expect(manifests.fr.landing).toEqual({ title: 'Guides FR', description: 'Tutoriels.' });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('fails on missing folders, hub files and listed pages', () => {
|
|
304
|
+
expect(() => compile('nope')).toThrow('Markdown folder "nope" not found');
|
|
305
|
+
|
|
306
|
+
write('docs/intro.md', '# Intro\n');
|
|
307
|
+
expect(() => compile({ folder: 'docs', hubFiles: ['API.md'] })).toThrow('Hub file "API.md" not found');
|
|
308
|
+
|
|
309
|
+
write('docs/DOCS.md', '# Docs\n\n## Table of Contents\n\n- [Gone](./GONE.md)\n');
|
|
310
|
+
expect(() => compile('docs')).toThrow('does not exist');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('compiles one manifest per language with "/<lang>" links when i18n is on', () => {
|
|
314
|
+
write('content/a.md', '# A\n\nSee [B](./b.md).\n');
|
|
315
|
+
write('content/b.md', '# B\n\nText.\n');
|
|
316
|
+
|
|
317
|
+
const { manifests } = compileAll('content');
|
|
318
|
+
|
|
319
|
+
expect(Object.keys(manifests)).toEqual(['en', 'fr']);
|
|
320
|
+
expect(manifests.en.pages.a.html).toContain('href="/en/content/b"');
|
|
321
|
+
expect(manifests.fr.pages.a.html).toContain('href="/fr/content/b"');
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('translates a localized folder over its default language, falling back for missing pages', () => {
|
|
325
|
+
write('content/en/a.md', '---\norder: 1\n---\n# A\n\nSee [B](./b.md).\n');
|
|
326
|
+
write('content/en/b.md', '---\norder: 2\n---\n# B\n\nText.\n');
|
|
327
|
+
write('content/fr/a.md', '# A fr\n\nVoir [B](./b.md).\n');
|
|
328
|
+
|
|
329
|
+
const { manifests, warnings } = compileAll('content');
|
|
330
|
+
|
|
331
|
+
expect(manifests.fr.pages.a.title).toBe('A fr');
|
|
332
|
+
expect(manifests.fr.pages.a.html).toContain('href="/fr/content/b"');
|
|
333
|
+
expect(manifests.fr.pages.b.title).toBe('B');
|
|
334
|
+
expect(manifests.fr.pages.b.fallback).toBe(true);
|
|
335
|
+
expect(manifests.fr.pages.b.html).toBe(manifests.en.pages.b.html);
|
|
336
|
+
expect(manifests.fr.sections[0].title).toBe('content');
|
|
337
|
+
expect(warnings.some((w) => w.includes('content/fr') && w.includes('b'))).toBeTruthy();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('uses the default language for a language with no folder at all', () => {
|
|
341
|
+
write('content/en/a.md', '# A\n\nText.\n');
|
|
342
|
+
|
|
343
|
+
const { manifests, warnings } = compileAll('content');
|
|
344
|
+
|
|
345
|
+
expect(manifests.fr.pages.a.fallback).toBe(true);
|
|
346
|
+
expect(warnings.some((w) => w.includes('no "fr" folder'))).toBeTruthy();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it('fails on a page that only exists in a translation', () => {
|
|
350
|
+
write('content/en/a.md', '# A\n\nText.\n');
|
|
351
|
+
write('content/fr/extra.md', '# Extra\n\nTexte.\n');
|
|
352
|
+
|
|
353
|
+
expect(() => compileAll('content')).toThrow('has no "en" counterpart');
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('compiles only the default language of a localized folder when i18n is off', () => {
|
|
357
|
+
write('content/en/a.md', '# A\n\nText.\n');
|
|
358
|
+
write('content/fr/a.md', '# A fr\n\nTexte.\n');
|
|
359
|
+
|
|
360
|
+
const { manifests } = compileAll('content', { i18n: false, languages: ['en', 'fr'], defaultLanguage: 'en' });
|
|
361
|
+
|
|
362
|
+
expect(Object.keys(manifests)).toEqual(['']);
|
|
363
|
+
expect(manifests[''].pages.a.title).toBe('A');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('groups a localized frontmatter-only folder by subfolder within each language, ids shared across languages', () => {
|
|
367
|
+
write('content/en/start/a.md', '# A\n\nText.\n');
|
|
368
|
+
write('content/fr/start/a.md', '---\ngroup: Débuter\n---\n# A fr\n\nTexte.\n');
|
|
369
|
+
|
|
370
|
+
const { manifests } = compileAll('content');
|
|
371
|
+
|
|
372
|
+
expect(manifests.en.sections[0].groups).toEqual([{ id: 'start', title: 'Start', pages: ['a'] }]);
|
|
373
|
+
expect(manifests.fr.sections[0].groups).toEqual([{ id: 'start', title: 'Débuter', pages: ['a'] }]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('translates hub section and group titles', () => {
|
|
377
|
+
write('docs/en/GUIDES.md', '# Guides\n\nLearn.\n\n## Table of Contents\n\n### Start\n\n- [Install](./install.md)\n');
|
|
378
|
+
write('docs/en/install.md', '# Install\n\nRun it.\n');
|
|
379
|
+
write('docs/fr/GUIDES.md', '# Guides fr\n\nApprendre.\n\n## Table of Contents\n\n### Débuter\n\n- [Installer](./install.md)\n');
|
|
380
|
+
write('docs/fr/install.md', '# Installer\n\nLancez-le.\n');
|
|
381
|
+
|
|
382
|
+
const { manifests } = compileAll({ folder: 'docs', sectionInPath: true, hubFiles: ['GUIDES.md'] });
|
|
383
|
+
|
|
384
|
+
expect(manifests.fr.sections[0].title).toBe('Guides fr');
|
|
385
|
+
expect(manifests.fr.sections[0].groups[0].title).toBe('Débuter');
|
|
386
|
+
expect(manifests.fr.sections[0].groups[0].id).toBe(manifests.en.sections[0].groups[0].id);
|
|
387
|
+
expect(manifests.fr.pages.install.title).toBe('Installer');
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { print, errorExit } from '../../../utils/index.js';
|
|
4
|
+
import { PATHS } from '../app/paths.js';
|
|
5
|
+
import nutinConfig from '../../../../nutin.config.js';
|
|
6
|
+
import { LANGUAGES, DEFAULT_LANGUAGE } from '../../../utils/languages.js';
|
|
7
|
+
import { normalizeSource } from './markdown-compiler.js';
|
|
8
|
+
|
|
9
|
+
// The app route each source is served on — keep in sync with src/app/markdown/markdown-routes.ts.
|
|
10
|
+
function routePattern(source) {
|
|
11
|
+
return source.sectionInPath ? `/${source.id}/:section?/:slug?` : `/${source.id}/:slug?`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Page slugs in nav order (sections, then their groups or pages).
|
|
15
|
+
function orderedSlugs(manifest) {
|
|
16
|
+
return manifest.sections.flatMap((section) => section.groups ? section.groups.flatMap((group) => group.pages) : (section.pages ?? []));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function describe(manifest, page) {
|
|
20
|
+
if (page.description) return { description: page.description, fallback: null };
|
|
21
|
+
const section = manifest.sections.find((s) => s.id === page.section);
|
|
22
|
+
return section?.description
|
|
23
|
+
? { description: section.description, fallback: 'its section\'s' }
|
|
24
|
+
: { description: page.title, fallback: 'its title' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The landing's SEO routes: the bare /<id>, plus /<id>/<section> for each section with
|
|
28
|
+
// "sectionInPath". Titles and descriptions come from each language's manifest.
|
|
29
|
+
function landingRoutes(source, byLang, languages, warnings) {
|
|
30
|
+
const perLanguage = (pick, label) => {
|
|
31
|
+
const title = {};
|
|
32
|
+
const description = {};
|
|
33
|
+
for (const [lang, manifest] of Object.entries(byLang)) {
|
|
34
|
+
const { title: t, description: d } = pick(manifest);
|
|
35
|
+
title[lang] = t;
|
|
36
|
+
description[lang] = d || t;
|
|
37
|
+
if (!d) warnings.push(`${label}${lang ? ` (${lang})` : ''} has no description for its SEO page — using its title.`);
|
|
38
|
+
}
|
|
39
|
+
return { title: languages ? title : title[''], description: languages ? description : description[''] };
|
|
40
|
+
};
|
|
41
|
+
const ogImage = source.ogImage ? { ogImage: source.ogImage } : {};
|
|
42
|
+
const reference = Object.values(byLang)[0];
|
|
43
|
+
|
|
44
|
+
const routes = [{
|
|
45
|
+
path: routePattern(source),
|
|
46
|
+
mockParams: {},
|
|
47
|
+
...perLanguage((manifest) => manifest.landing, `The "${source.id}" landing`),
|
|
48
|
+
...ogImage,
|
|
49
|
+
}];
|
|
50
|
+
|
|
51
|
+
if (source.sectionInPath) {
|
|
52
|
+
for (const { id } of reference.sections) {
|
|
53
|
+
routes.push({
|
|
54
|
+
path: routePattern(source),
|
|
55
|
+
mockParams: { section: id },
|
|
56
|
+
...perLanguage((manifest) => manifest.sections.find((section) => section.id === id) ?? {}, `The "${source.id}/${id}" landing`),
|
|
57
|
+
...ogImage,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return routes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One SEO route per compiled Markdown page, in the shape config/seo.json routes use, so
|
|
66
|
+
* generate-seo-html.js prerenders it at its real URL (`mockParams` fill the route's params)
|
|
67
|
+
* and generate-sitemap-xml.js lists it. The bare /<id> URL gets no page of its own (the SPA
|
|
68
|
+
* replaces it with its first page's URL) unless the source has a "landing": then /<id>, and
|
|
69
|
+
* each /<id>/<section> with "sectionInPath", get one too.
|
|
70
|
+
*
|
|
71
|
+
* `manifestsById[id]` is the source's manifest, or with `languages` (i18n) a
|
|
72
|
+
* { [lang]: manifest } map: titles and descriptions then become per-language objects, and
|
|
73
|
+
* the default language's manifest defines the pages.
|
|
74
|
+
*/
|
|
75
|
+
export function buildMarkdownSeoRoutes(sources, manifestsById, { languages = null, defaultLanguage = null } = {}) {
|
|
76
|
+
const routes = [];
|
|
77
|
+
const warnings = [];
|
|
78
|
+
|
|
79
|
+
for (const source of sources) {
|
|
80
|
+
if (!source.seo) continue;
|
|
81
|
+
const byLang = languages ? manifestsById[source.id] : { '': manifestsById[source.id] };
|
|
82
|
+
const reference = byLang?.[languages ? defaultLanguage : ''];
|
|
83
|
+
if (!reference) continue;
|
|
84
|
+
|
|
85
|
+
for (const slug of orderedSlugs(reference)) {
|
|
86
|
+
const page = reference.pages[slug];
|
|
87
|
+
if (!page) continue;
|
|
88
|
+
|
|
89
|
+
const title = {};
|
|
90
|
+
const description = {};
|
|
91
|
+
for (const [lang, manifest] of Object.entries(byLang)) {
|
|
92
|
+
const localized = manifest.pages[slug] ?? page;
|
|
93
|
+
const described = describe(manifest, localized);
|
|
94
|
+
title[lang] = localized.title;
|
|
95
|
+
description[lang] = described.description;
|
|
96
|
+
if (described.fallback && !localized.fallback) {
|
|
97
|
+
warnings.push(`"${source.id}/${slug}"${lang ? ` (${lang})` : ''} has no description for its SEO page — using ${described.fallback}. Add a frontmatter "description".`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ogImage = page.ogImage ?? source.ogImage;
|
|
102
|
+
routes.push({
|
|
103
|
+
path: routePattern(source),
|
|
104
|
+
mockParams: source.sectionInPath ? { section: page.section, slug } : { slug },
|
|
105
|
+
title: languages ? title : title[''],
|
|
106
|
+
description: languages ? description : description[''],
|
|
107
|
+
...(ogImage ? { ogImage } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (reference.landing) routes.push(...landingRoutes(source, byLang, languages, warnings));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { routes, warnings };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readSources() {
|
|
118
|
+
return (nutinConfig.markdownSources?.sourceFolders ?? []).map((entry) => normalizeSource(entry, process.cwd()));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Route patterns of sources with "seo: false".
|
|
122
|
+
export function markdownSeoOptOutPaths() {
|
|
123
|
+
return readSources().filter((source) => !source.seo).map(routePattern);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let cached;
|
|
127
|
+
|
|
128
|
+
// Reads nutin.config.js "markdownSources" and the manifest(s) the markdown build step wrote
|
|
129
|
+
// to dist-build earlier in the pipeline. Memoized: the SEO HTML and sitemap steps both ask.
|
|
130
|
+
export function markdownSeoRoutes() {
|
|
131
|
+
if (cached) return cached;
|
|
132
|
+
|
|
133
|
+
const sources = readSources();
|
|
134
|
+
const languages = nutinConfig.i18n ? LANGUAGES : null;
|
|
135
|
+
const readManifest = (fileName) => {
|
|
136
|
+
const manifestPath = path.join(PATHS.tempSource, 'generated', fileName);
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
139
|
+
} catch (err) {
|
|
140
|
+
errorExit(`Cannot read the Markdown manifest ${manifestPath} for SEO pages: ${err.message}`, 'markdown-seo');
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const manifestsById = {};
|
|
145
|
+
for (const source of sources.filter((s) => s.seo)) {
|
|
146
|
+
manifestsById[source.id] = languages
|
|
147
|
+
? Object.fromEntries(languages.map((lang) => [lang, readManifest(`${source.id}.${lang}.json`)]))
|
|
148
|
+
: readManifest(`${source.id}.json`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { routes, warnings } = buildMarkdownSeoRoutes(sources, manifestsById, { languages, defaultLanguage: DEFAULT_LANGUAGE });
|
|
152
|
+
warnings.forEach((warning) => print.warn(`markdown: ${warning}`));
|
|
153
|
+
cached = routes;
|
|
154
|
+
return routes;
|
|
155
|
+
}
|
package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { buildMarkdownSeoRoutes } from './markdown-seo-routes.js';
|
|
2
|
+
|
|
3
|
+
const page = (slug, section, extra = {}) => ({ slug, section, title: `T ${slug}`, description: `D ${slug}`, ...extra });
|
|
4
|
+
|
|
5
|
+
describe('buildMarkdownSeoRoutes', () => {
|
|
6
|
+
it('emits one route per page on /<id>/:slug?, in nav order', () => {
|
|
7
|
+
const manifest = {
|
|
8
|
+
sections: [{ id: 'index', title: 'x', description: '', pages: ['b', 'a'] }],
|
|
9
|
+
pages: { a: page('a', 'index'), b: page('b', 'index') },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const { routes } = buildMarkdownSeoRoutes([{ id: 'content', seo: true, sectionInPath: false }], { content: manifest });
|
|
13
|
+
|
|
14
|
+
expect(routes).toEqual([
|
|
15
|
+
{ path: '/content/:slug?', mockParams: { slug: 'b' }, title: 'T b', description: 'D b' },
|
|
16
|
+
{ path: '/content/:slug?', mockParams: { slug: 'a' }, title: 'T a', description: 'D a' },
|
|
17
|
+
]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('fills the section param for sectionInPath sources and walks groups', () => {
|
|
21
|
+
const manifest = {
|
|
22
|
+
sections: [{ id: 'api', title: 'API', description: '', groups: [{ id: 'g', title: 'G', pages: ['nav'] }] }],
|
|
23
|
+
pages: { nav: page('nav', 'api') },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const { routes } = buildMarkdownSeoRoutes([{ id: 'docs', seo: true, sectionInPath: true }], { docs: manifest });
|
|
27
|
+
|
|
28
|
+
expect(routes[0].path).toBe('/docs/:section?/:slug?');
|
|
29
|
+
expect(routes[0].mockParams).toEqual({ section: 'api', slug: 'nav' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('uses the folder ogImage when a page has none of its own', () => {
|
|
33
|
+
const manifest = {
|
|
34
|
+
sections: [{ id: 'index', title: 'x', description: '', pages: ['a', 'b'] }],
|
|
35
|
+
pages: { a: page('a', 'index'), b: page('b', 'index', { ogImage: '/og/b.jpg' }) },
|
|
36
|
+
};
|
|
37
|
+
const { routes } = buildMarkdownSeoRoutes([{ id: 'blog', seo: true, ogImage: '/og/blog.jpg' }], { blog: manifest });
|
|
38
|
+
expect(routes.map((r) => r.ogImage)).toEqual(['/og/blog.jpg', '/og/b.jpg']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('adds landing routes: the bare /<id>, and each section with sectionInPath', () => {
|
|
42
|
+
const manifest = {
|
|
43
|
+
sections: [
|
|
44
|
+
{ id: 'api', title: 'API', description: 'The API.', pages: ['nav'] },
|
|
45
|
+
{ id: 'tools', title: 'Tools', description: '', pages: ['cli'] },
|
|
46
|
+
],
|
|
47
|
+
pages: { nav: page('nav', 'api'), cli: page('cli', 'tools') },
|
|
48
|
+
landing: { title: 'Docs', description: 'Everything.' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const { routes, warnings } = buildMarkdownSeoRoutes([{ id: 'docs', seo: true, sectionInPath: true, ogImage: '/og/docs.jpg' }], { docs: manifest });
|
|
52
|
+
const landings = routes.filter((r) => !r.mockParams.slug);
|
|
53
|
+
|
|
54
|
+
expect(landings).toEqual([
|
|
55
|
+
{ path: '/docs/:section?/:slug?', mockParams: {}, title: 'Docs', description: 'Everything.', ogImage: '/og/docs.jpg' },
|
|
56
|
+
{ path: '/docs/:section?/:slug?', mockParams: { section: 'api' }, title: 'API', description: 'The API.', ogImage: '/og/docs.jpg' },
|
|
57
|
+
{ path: '/docs/:section?/:slug?', mockParams: { section: 'tools' }, title: 'Tools', description: 'Tools', ogImage: '/og/docs.jpg' },
|
|
58
|
+
]);
|
|
59
|
+
expect(warnings.some((w) => w.includes('"docs/tools" landing'))).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('gives a landing per-language titles with i18n, and none without "landing"', () => {
|
|
63
|
+
const en = { sections: [{ id: 'index', title: 'Guides', description: 'How-tos.', pages: ['a'] }], pages: { a: page('a', 'index') }, landing: { title: 'Guides', description: 'How-tos.' } };
|
|
64
|
+
const fr = { ...en, landing: { title: 'Guides FR', description: 'Tutoriels.' } };
|
|
65
|
+
const { routes } = buildMarkdownSeoRoutes([{ id: 'guides', seo: true }], { guides: { en, fr } }, { languages: ['en', 'fr'], defaultLanguage: 'en' });
|
|
66
|
+
const landing = routes.find((r) => !r.mockParams.slug);
|
|
67
|
+
expect(landing.title).toEqual({ en: 'Guides', fr: 'Guides FR' });
|
|
68
|
+
expect(landing.description).toEqual({ en: 'How-tos.', fr: 'Tutoriels.' });
|
|
69
|
+
|
|
70
|
+
const { landing: _, ...noLanding } = en;
|
|
71
|
+
expect(buildMarkdownSeoRoutes([{ id: 'guides', seo: true }], { guides: noLanding }).routes.every((r) => r.mockParams.slug)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('skips sources with seo: false', () => {
|
|
75
|
+
const manifest = { sections: [{ id: 'index', pages: ['a'] }], pages: { a: page('a', 'index') } };
|
|
76
|
+
|
|
77
|
+
expect(buildMarkdownSeoRoutes([{ id: 'content', seo: false }], { content: manifest }).routes).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('falls back to the section description, then the title, with a warning', () => {
|
|
81
|
+
const manifest = {
|
|
82
|
+
sections: [
|
|
83
|
+
{ id: 'one', title: 'One', description: 'Section one', pages: ['a'] },
|
|
84
|
+
{ id: 'two', title: 'Two', description: '', pages: ['b'] },
|
|
85
|
+
],
|
|
86
|
+
pages: { a: page('a', 'one', { description: '' }), b: page('b', 'two', { description: '' }) },
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const { routes, warnings } = buildMarkdownSeoRoutes([{ id: 'c', seo: true }], { c: manifest });
|
|
90
|
+
|
|
91
|
+
expect(routes.map((r) => r.description)).toEqual(['Section one', 'T b']);
|
|
92
|
+
expect(warnings.length).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('passes a page ogImage through', () => {
|
|
96
|
+
const manifest = { sections: [{ id: 'index', pages: ['a'] }], pages: { a: page('a', 'index', { ogImage: '/og/a.jpg' }) } };
|
|
97
|
+
|
|
98
|
+
expect(buildMarkdownSeoRoutes([{ id: 'c', seo: true }], { c: manifest }).routes[0].ogImage).toBe('/og/a.jpg');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('with languages, emits per-language titles and descriptions from each manifest', () => {
|
|
102
|
+
const en = { sections: [{ id: 'index', pages: ['a'] }], pages: { a: page('a', 'index', { title: 'Hello', description: 'Hi' }) } };
|
|
103
|
+
const fr = { sections: [{ id: 'index', pages: ['a'] }], pages: { a: page('a', 'index', { title: 'Bonjour', description: 'Salut' }) } };
|
|
104
|
+
|
|
105
|
+
const { routes } = buildMarkdownSeoRoutes([{ id: 'c', seo: true }], { c: { en, fr } }, { languages: ['en', 'fr'], defaultLanguage: 'en' });
|
|
106
|
+
|
|
107
|
+
expect(routes).toEqual([
|
|
108
|
+
{ path: '/c/:slug?', mockParams: { slug: 'a' }, title: { en: 'Hello', fr: 'Bonjour' }, description: { en: 'Hi', fr: 'Salut' } },
|
|
109
|
+
]);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
File without changes
|