@janga/norna 0.7.16 → 0.7.17
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/README.md +6 -6
- package/astro.config.mjs +35 -3
- package/docs/README.md +10 -10
- package/docs/commands.md +2 -2
- package/docs/content.md +82 -102
- package/docs/design/navigation-and-theme-plan.md +115 -0
- package/docs/design/norna-diagram-design.md +9 -9
- package/docs/design/site-examples-structure.md +9 -9
- package/docs/engine-development.md +9 -9
- package/docs/getting-started.md +20 -15
- package/docs/images-and-metadata.md +10 -9
- package/docs/pages.md +134 -0
- package/docs/public-files.md +49 -36
- package/docs/publishing.md +19 -3
- package/docs/site-files.md +29 -28
- package/docs/sitewide-content.md +20 -36
- package/docs/theme.md +20 -20
- package/docs/typography.md +10 -10
- package/examples/README.md +2 -2
- package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/site.schema.json +17 -41
- package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/sitewide.schema.json +26 -67
- package/examples/complete-sites/dog-shelter-multi-page/.astro/collections/theme.schema.json +185 -88
- package/examples/complete-sites/dog-shelter-multi-page/.astro/data-store.json +1 -1
- package/examples/complete-sites/dog-shelter-multi-page/.astro/dev-local.json +4 -4
- package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.json +5 -5
- package/examples/complete-sites/dog-shelter-multi-page/.astro/dev.log +33 -23
- package/examples/complete-sites/dog-shelter-multi-page/README.md +1 -1
- package/examples/complete-sites/dog-shelter-multi-page/node_modules/.vite/deps/_metadata.json +8 -8
- package/examples/complete-sites/dog-shelter-multi-page/site/.norna/generated-images.json +20 -20
- package/examples/complete-sites/dog-shelter-multi-page/site/content.md +3 -4
- package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/010-dogs/content.md +3 -4
- package/examples/complete-sites/dog-shelter-multi-page/site/{routes → pages}/020-adopt/content.md +3 -4
- package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +2 -4
- package/examples/complete-sites/dog-shelter-single-page/.astro/collections/site.schema.json +17 -41
- package/examples/complete-sites/dog-shelter-single-page/.astro/collections/sitewide.schema.json +26 -67
- package/examples/complete-sites/dog-shelter-single-page/.astro/collections/theme.schema.json +185 -88
- package/examples/complete-sites/dog-shelter-single-page/.astro/data-store.json +1 -1
- package/examples/complete-sites/dog-shelter-single-page/.astro/dev-local.json +8 -0
- package/examples/complete-sites/dog-shelter-single-page/.astro/dev.json +5 -5
- package/examples/complete-sites/dog-shelter-single-page/.astro/dev.log +16 -12
- package/examples/complete-sites/dog-shelter-single-page/README.md +1 -1
- package/examples/complete-sites/dog-shelter-single-page/node_modules/.vite/deps/_metadata.json +8 -8
- package/examples/complete-sites/dog-shelter-single-page/site/.norna/generated-images.json +12 -12
- package/examples/complete-sites/dog-shelter-single-page/site/content.md +5 -2
- package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +2 -4
- package/examples/feature-demos/media-and-surfaces/.astro/collections/site.schema.json +17 -41
- package/examples/feature-demos/media-and-surfaces/.astro/collections/sitewide.schema.json +26 -67
- package/examples/feature-demos/media-and-surfaces/.astro/collections/theme.schema.json +185 -88
- package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
- package/examples/feature-demos/media-and-surfaces/.astro/dev.log +4 -14
- package/examples/feature-demos/media-and-surfaces/README.md +2 -2
- package/examples/feature-demos/media-and-surfaces/node_modules/.vite/deps/_metadata.json +9 -9
- package/examples/feature-demos/media-and-surfaces/site/.norna/generated-images.json +16 -16
- package/examples/feature-demos/media-and-surfaces/site/content.md +4 -7
- package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/010-media/content.md +6 -5
- package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/content.md +7 -6
- package/examples/feature-demos/media-and-surfaces/site/sitewide-content.yaml +0 -2
- package/examples/feature-demos/sitewide-content/README.md +3 -3
- package/examples/feature-demos/sitewide-content/site/content.md +8 -7
- package/examples/feature-demos/sitewide-content/site/pages/010-logo-and-navigation/content.md +17 -0
- package/examples/feature-demos/sitewide-content/site/{routes → pages}/020-notices/content.md +7 -6
- package/examples/feature-demos/sitewide-content/site/{routes → pages}/030-footer/content.md +6 -5
- package/examples/feature-demos/sitewide-content/site/sitewide-content.yaml +4 -13
- package/examples/feature-demos/theme-presets/README.md +1 -1
- package/examples/feature-demos/theme-presets/site/.norna/generated-images.json +60 -60
- package/examples/feature-demos/theme-presets/site/content.md +4 -7
- package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/content.md +4 -7
- package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/content.md +4 -7
- package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/content.md +4 -7
- package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/content.md +4 -7
- package/examples/feature-demos/theme-presets/site/sitewide-content.yaml +1 -10
- package/fixtures/basic/site/content.md +5 -6
- package/fixtures/basic/site/sitewide-content.yaml +1 -8
- package/fixtures/content-model-v2/site/content.md +11 -13
- package/fixtures/content-model-v2/site/pages/010-guide/content.md +18 -0
- package/fixtures/nested-pages/site/.norna/generated-images.json +10 -0
- package/fixtures/nested-pages/site/config.yaml +1 -0
- package/fixtures/nested-pages/site/content.md +15 -0
- package/fixtures/nested-pages/site/pages/010-guides/content.md +15 -0
- package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/content.md +21 -0
- package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/images/diagram.svg +21 -0
- package/fixtures/nested-pages/site/pages/010-guides/pages/010-installation/pages/010-macos/content.md +14 -0
- package/fixtures/nested-pages/site/pages/010-guides/pages/020-workflows/content.md +11 -0
- package/fixtures/nested-pages/site/pages/010-guides/theme.yaml +3 -0
- package/fixtures/nested-pages/site/pages/020-reference/content.md +10 -0
- package/fixtures/nested-pages/site/pages/020-reference/pages/010-installation/content.md +11 -0
- package/fixtures/nested-pages/site/theme.yaml +3 -0
- package/package.json +7 -4
- package/schemas/config.schema.json +5 -5
- package/schemas/content-frontmatter.schema.json +28 -63
- package/schemas/manifest.json +1 -0
- package/schemas/sitewide-content.schema.json +58 -132
- package/schemas/theme.schema.json +396 -86
- package/scripts/check-config.mjs +6 -5
- package/scripts/deploy-site.mjs +6 -8
- package/scripts/doctor.mjs +2 -2
- package/scripts/generate-images.mjs +11 -18
- package/scripts/generate-schemas.mjs +3 -217
- package/scripts/lib/base-path-redirect.mjs +9 -0
- package/scripts/lib/editor-language-service.mjs +126 -66
- package/scripts/lib/heading-ids.mjs +161 -0
- package/scripts/lib/navigation-model.mjs +51 -0
- package/scripts/lib/norna-markdown-blocks.mjs +3 -4
- package/scripts/lib/page-model.mjs +83 -0
- package/scripts/lib/project-config.mjs +28 -0
- package/scripts/lib/schema-definitions.mjs +41 -46
- package/scripts/lib/schema-editor-metadata.mjs +475 -0
- package/scripts/lib/schema-value-definitions.mjs +6 -6
- package/scripts/lib/site-content.mjs +124 -87
- package/scripts/lib/site-paths.mjs +2 -2
- package/scripts/lib/theme-config.mjs +9 -16
- package/scripts/lib/theme-presets.mjs +9 -1
- package/scripts/release.mjs +1 -1
- package/scripts/show-typography.mjs +26 -20
- package/scripts/sync-content-sections.mjs +106 -65
- package/scripts/{test-temporary-visibility.mjs → test-banner-visibility.mjs} +7 -24
- package/scripts/test-client-javascript.mjs +39 -42
- package/scripts/test-content-check.mjs +138 -54
- package/scripts/test-content-model-v2.mjs +445 -228
- package/scripts/test-documentation.mjs +6 -1
- package/scripts/test-editor-language-service.mjs +168 -24
- package/scripts/test-engine-commands.mjs +2 -1
- package/scripts/test-heading-ids.mjs +68 -0
- package/scripts/test-navigation-model.mjs +63 -0
- package/scripts/test-nested-pages.mjs +110 -0
- package/scripts/test-package-check.mjs +31 -40
- package/scripts/test-page-model.mjs +80 -0
- package/scripts/test-project-config.mjs +25 -1
- package/scripts/test-schemas.mjs +66 -14
- package/scripts/test-theme-presets.mjs +33 -24
- package/src/components/NavigationPageTree.astro +68 -0
- package/src/components/SectionNavigationScript.astro +110 -5
- package/src/components/SiteBreadcrumbs.astro +29 -0
- package/src/components/SiteNavigation.astro +65 -69
- package/src/components/SitePage.astro +70 -46
- package/src/components/SiteSection.astro +27 -20
- package/src/content.config.ts +9 -5
- package/src/layouts/BaseLayout.astro +11 -20
- package/src/lib/pageThemes.ts +42 -0
- package/src/lib/sectionContent.ts +109 -106
- package/src/lib/siteNavigation.ts +48 -0
- package/src/lib/sitePages.ts +85 -38
- package/src/pages/{[slug].astro → [...slug].astro} +2 -2
- package/src/styles/global.css +373 -55
- package/starters/basic/README.md +5 -7
- package/starters/basic/site/content.md +7 -4
- package/starters/basic/site/sitewide-content.yaml +1 -10
- package/starters/project/README.md +8 -8
- package/starters/project/site/content.md +5 -4
- package/starters/project/site/{routes → pages}/010-guide/content.md +5 -4
- package/starters/project/site/sitewide-content.yaml +1 -10
- package/docs/routes.md +0 -121
- package/examples/feature-demos/sitewide-content/site/routes/010-identity/content.md +0 -20
- package/fixtures/content-model-v2/site/routes/010-guide/content.md +0 -17
- package/scripts/lib/route-model.mjs +0 -17
- package/scripts/test-route-model.mjs +0 -32
- package/src/lib/routeThemes.ts +0 -41
- /package/examples/complete-sites/dog-shelter-multi-page/site/images/{intro/shelter-dog.jpg → shelter-dog.jpg} +0 -0
- /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rex.jpg +0 -0
- /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/010-dogs/images/dogs → pages/010-dogs/images}/rover.jpg +0 -0
- /package/examples/complete-sites/dog-shelter-multi-page/site/{routes/020-adopt/images/adopt → pages/020-adopt/images}/adoption-dog.jpg +0 -0
- /package/examples/complete-sites/dog-shelter-single-page/site/images/{you-can-help/foster-dog.jpg → foster-dog.jpg} +0 -0
- /package/examples/complete-sites/dog-shelter-single-page/site/images/{what-we-do/rescue-dog.jpg → rescue-dog.jpg} +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-carousel.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-stack.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/cards → pages/010-media/images}/card-surfaces.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-one.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-three.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/carousel → pages/010-media/images}/carousel-two.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-one.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes/010-media/images/stack → pages/010-media/images}/stack-two.svg +0 -0
- /package/examples/feature-demos/media-and-surfaces/site/{routes → pages}/020-surfaces/theme.yaml +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/puppy-portrait.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/shepherd-portrait.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/010-portfolio/images/portfolio → pages/010-portfolio/images}/white-puppy.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes → pages}/010-portfolio/theme.yaml +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/black-schnauzer.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/retriever-portrait.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/020-documentation/images/documentation → pages/020-documentation/images}/schnauzer-portrait.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes → pages}/020-documentation/theme.yaml +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/boxer-portrait.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grass-puppy.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/030-project/images/project → pages/030-project/images}/grey-street-dog.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes → pages}/030-project/theme.yaml +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/black-pedigree.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-grass.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes/040-statement/images/statement → pages/040-statement/images}/sheepdog-wide.jpg +0 -0
- /package/examples/feature-demos/theme-presets/site/{routes → pages}/040-statement/theme.yaml +0 -0
- /package/fixtures/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
- /package/fixtures/content-model-v2/site/images/{card-section/adopt.svg → adopt.svg} +0 -0
- /package/fixtures/content-model-v2/site/images/{intro/detail.jpg → detail.jpg} +0 -0
- /package/fixtures/content-model-v2/site/images/{intro/duplicate.jpg → duplicate.jpg} +0 -0
- /package/fixtures/content-model-v2/site/images/{card-section/foster.svg → foster.svg} +0 -0
- /package/fixtures/content-model-v2/site/images/{intro/hero.jpg → hero.jpg} +0 -0
- /package/fixtures/content-model-v2/site/images/{plain/image.jpg → image.jpg} +0 -0
- /package/fixtures/content-model-v2/site/images/{timed/slide-one.jpg → slide-one.jpg} +0 -0
- /package/fixtures/content-model-v2/site/images/{timed/slide-three.jpg → slide-three.jpg} +0 -0
- /package/fixtures/content-model-v2/site/images/{timed/slide-two.jpg → slide-two.jpg} +0 -0
- /package/fixtures/content-model-v2/site/{routes/010-guide/images/intro → pages/010-guide/images}/duplicate.jpg +0 -0
- /package/starters/basic/site/images/{work/.gitkeep → .gitkeep} +0 -0
|
@@ -9,12 +9,11 @@ import {
|
|
|
9
9
|
nornaMarkdownBlockDefinitions,
|
|
10
10
|
} from './norna-markdown-blocks.mjs';
|
|
11
11
|
import { inspectPublicAssetFilenames, logoAssetFilenames } from './public-asset-conventions.mjs';
|
|
12
|
+
import { getHeadingIdentifierIssues, getMarkdownHeadings } from './heading-ids.mjs';
|
|
12
13
|
import { siteSchema } from './schema-definitions.mjs';
|
|
13
14
|
|
|
14
15
|
const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png', '.svg']);
|
|
15
16
|
const siteConfigNames = ['config.yaml'];
|
|
16
|
-
const headingIdPattern = /^##\s+.+?\s*\{#([a-z0-9-]+)\}\s*$/;
|
|
17
|
-
|
|
18
17
|
const fileExists = (filePath) => access(filePath).then(() => true, () => false);
|
|
19
18
|
const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
|
|
20
19
|
|
|
@@ -37,7 +36,7 @@ export const findNornaSiteRoot = async (documentPath) => {
|
|
|
37
36
|
}
|
|
38
37
|
};
|
|
39
38
|
|
|
40
|
-
const
|
|
39
|
+
const readSitewideLogoForEditor = async (siteRoot) => {
|
|
41
40
|
const filename = await findFile(siteRoot, ['sitewide-content.yaml']);
|
|
42
41
|
if (!filename) return null;
|
|
43
42
|
const absolutePath = path.join(siteRoot, filename);
|
|
@@ -45,15 +44,16 @@ const readSitewideNavigationForEditor = async (siteRoot) => {
|
|
|
45
44
|
|
|
46
45
|
try {
|
|
47
46
|
const data = load(source) ?? {};
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
const logoConfigured = Boolean(
|
|
48
|
+
data
|
|
49
|
+
&& typeof data === 'object'
|
|
50
|
+
&& Object.hasOwn(data, 'logo'),
|
|
51
|
+
);
|
|
51
52
|
const logoLine = source.replace(/\r\n?/g, '\n').split('\n')
|
|
52
|
-
.findIndex((line) =>
|
|
53
|
+
.findIndex((line) => /^logo:\s*(?:#.*)?$/.test(line)) + 1;
|
|
53
54
|
return {
|
|
54
55
|
absolutePath,
|
|
55
|
-
|
|
56
|
-
logoConfigured: Object.hasOwn(navigation, 'logo'),
|
|
56
|
+
logoConfigured,
|
|
57
57
|
logoLine: logoLine > 0 ? logoLine : 1,
|
|
58
58
|
};
|
|
59
59
|
} catch {
|
|
@@ -73,7 +73,7 @@ export const getSitePublicAssetStatus = async (documentPath) => {
|
|
|
73
73
|
const inspection = inspectPublicAssetFilenames(
|
|
74
74
|
entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
|
|
75
75
|
);
|
|
76
|
-
const
|
|
76
|
+
const sitewideLogo = await readSitewideLogoForEditor(siteRoot);
|
|
77
77
|
const issues = inspection.suspicious.map((issue) => ({
|
|
78
78
|
...issue,
|
|
79
79
|
absolutePath: path.join(publicDirectory, issue.filename),
|
|
@@ -92,14 +92,14 @@ export const getSitePublicAssetStatus = async (documentPath) => {
|
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
|
-
if (inspection.logos.length === 0 &&
|
|
95
|
+
if (inspection.logos.length === 0 && sitewideLogo?.logoConfigured) {
|
|
96
96
|
const publicLabel = toPosixPath(path.relative(path.dirname(siteRoot), publicDirectory));
|
|
97
97
|
issues.push({
|
|
98
|
-
absolutePath:
|
|
98
|
+
absolutePath: sitewideLogo.absolutePath,
|
|
99
99
|
code: 'missing-logo-file',
|
|
100
|
-
filename: path.basename(
|
|
101
|
-
line:
|
|
102
|
-
message: `
|
|
100
|
+
filename: path.basename(sitewideLogo.absolutePath),
|
|
101
|
+
line: sitewideLogo.logoLine,
|
|
102
|
+
message: `Logo display settings are configured, but no logo file was found. Add exactly one of ${logoAssetFilenames.map((filename) => `${publicLabel}/${filename}`).join(', ')}, or remove logo.`,
|
|
103
103
|
severity: 'error',
|
|
104
104
|
});
|
|
105
105
|
}
|
|
@@ -107,7 +107,7 @@ export const getSitePublicAssetStatus = async (documentPath) => {
|
|
|
107
107
|
return {
|
|
108
108
|
...inspection,
|
|
109
109
|
issues,
|
|
110
|
-
|
|
110
|
+
sitewideLogo,
|
|
111
111
|
publicDirectory,
|
|
112
112
|
siteRoot,
|
|
113
113
|
};
|
|
@@ -121,34 +121,22 @@ const getPageContext = (siteRoot, documentPath) => {
|
|
|
121
121
|
contentPath: absoluteDocumentPath,
|
|
122
122
|
imagesRoot: path.join(siteRoot, 'images'),
|
|
123
123
|
pageLabel: 'Home',
|
|
124
|
-
|
|
124
|
+
pageDirectory: null,
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
const match = relativePath.match(/^
|
|
128
|
+
const match = relativePath.match(/^pages\/(.+)\/content\.md$/);
|
|
129
129
|
if (!match) return null;
|
|
130
130
|
|
|
131
|
-
const
|
|
131
|
+
const pageDirectory = match[1];
|
|
132
132
|
return {
|
|
133
133
|
contentPath: absoluteDocumentPath,
|
|
134
|
-
imagesRoot: path.join(siteRoot, '
|
|
135
|
-
pageLabel:
|
|
136
|
-
|
|
134
|
+
imagesRoot: path.join(siteRoot, 'pages', ...pageDirectory.split('/'), 'images'),
|
|
135
|
+
pageLabel: pageDirectory,
|
|
136
|
+
pageDirectory,
|
|
137
137
|
};
|
|
138
138
|
};
|
|
139
139
|
|
|
140
|
-
const getSectionAtLine = (source, lineIndex) => {
|
|
141
|
-
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
142
|
-
let section = null;
|
|
143
|
-
|
|
144
|
-
for (let index = 0; index <= Math.min(lineIndex, lines.length - 1); index += 1) {
|
|
145
|
-
const match = lines[index].match(headingIdPattern);
|
|
146
|
-
if (match) section = { id: match[1], line: index };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return section;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
140
|
const getOpenFenceAtLine = (source, lineIndex) => {
|
|
153
141
|
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
154
142
|
let open = null;
|
|
@@ -304,12 +292,11 @@ const collectImageFiles = async (directory, imageRoot, pageLabel, files) => {
|
|
|
304
292
|
filename: entry.name,
|
|
305
293
|
pageLabel,
|
|
306
294
|
relativeToImages,
|
|
307
|
-
sectionId: relativeToImages.split('/')[0] ?? '',
|
|
308
295
|
});
|
|
309
296
|
}
|
|
310
297
|
};
|
|
311
298
|
|
|
312
|
-
const
|
|
299
|
+
const collectPageImageRoots = async (directory, siteRoot, roots) => {
|
|
313
300
|
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
314
301
|
if (error?.code === 'ENOENT') return [];
|
|
315
302
|
throw error;
|
|
@@ -319,11 +306,11 @@ const collectRouteImageRoots = async (directory, siteRoot, roots) => {
|
|
|
319
306
|
if (!entry.isDirectory()) continue;
|
|
320
307
|
const absolutePath = path.join(directory, entry.name);
|
|
321
308
|
if (entry.name === 'images') {
|
|
322
|
-
const
|
|
323
|
-
roots.push({ imageRoot: absolutePath, pageLabel:
|
|
309
|
+
const pageDirectory = toPosixPath(path.relative(path.join(siteRoot, 'pages'), path.dirname(absolutePath)));
|
|
310
|
+
roots.push({ imageRoot: absolutePath, pageLabel: pageDirectory });
|
|
324
311
|
continue;
|
|
325
312
|
}
|
|
326
|
-
await
|
|
313
|
+
await collectPageImageRoots(absolutePath, siteRoot, roots);
|
|
327
314
|
}
|
|
328
315
|
};
|
|
329
316
|
|
|
@@ -344,7 +331,7 @@ const getContentFiles = async (siteRoot) => {
|
|
|
344
331
|
}
|
|
345
332
|
};
|
|
346
333
|
|
|
347
|
-
await visit(path.join(siteRoot, '
|
|
334
|
+
await visit(path.join(siteRoot, 'pages'));
|
|
348
335
|
return files;
|
|
349
336
|
};
|
|
350
337
|
|
|
@@ -366,9 +353,9 @@ export const createSiteImageIndex = async (siteRoot) => {
|
|
|
366
353
|
const homeImageRoot = path.join(siteRoot, 'images');
|
|
367
354
|
await collectImageFiles(homeImageRoot, homeImageRoot, 'Home', files);
|
|
368
355
|
|
|
369
|
-
const
|
|
370
|
-
await
|
|
371
|
-
for (const root of
|
|
356
|
+
const pageRoots = [];
|
|
357
|
+
await collectPageImageRoots(path.join(siteRoot, 'pages'), siteRoot, pageRoots);
|
|
358
|
+
for (const root of pageRoots) {
|
|
372
359
|
await collectImageFiles(root.imageRoot, root.imageRoot, root.pageLabel, files);
|
|
373
360
|
}
|
|
374
361
|
|
|
@@ -397,9 +384,7 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
|
|
|
397
384
|
const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
|
|
398
385
|
if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
|
|
399
386
|
|
|
400
|
-
const
|
|
401
|
-
if (!section) return null;
|
|
402
|
-
const expectedDirectory = path.join(page.imagesRoot, section.id);
|
|
387
|
+
const expectedDirectory = page.imagesRoot;
|
|
403
388
|
const index = await createSiteImageIndex(siteRoot);
|
|
404
389
|
const candidates = index.files.map((file) => {
|
|
405
390
|
const isExpected = path.dirname(file.absolutePath) === expectedDirectory;
|
|
@@ -421,7 +406,6 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
|
|
|
421
406
|
candidates,
|
|
422
407
|
expectedDirectory,
|
|
423
408
|
page,
|
|
424
|
-
section,
|
|
425
409
|
siteRoot,
|
|
426
410
|
};
|
|
427
411
|
};
|
|
@@ -434,10 +418,9 @@ export const getImageDefinitionContext = async ({ documentPath, source, line })
|
|
|
434
418
|
const filename = match[1].replace(/^['"]|['"]$/g, '');
|
|
435
419
|
const siteRoot = await findNornaSiteRoot(documentPath);
|
|
436
420
|
const page = siteRoot ? getPageContext(siteRoot, documentPath) : null;
|
|
437
|
-
|
|
438
|
-
if (!siteRoot || !page || !section) return null;
|
|
421
|
+
if (!siteRoot || !page) return null;
|
|
439
422
|
|
|
440
|
-
const expectedPath = path.join(page.imagesRoot,
|
|
423
|
+
const expectedPath = path.join(page.imagesRoot, filename);
|
|
441
424
|
if (await fileExists(expectedPath)) return { files: [expectedPath], filename, siteRoot };
|
|
442
425
|
|
|
443
426
|
const index = await createSiteImageIndex(siteRoot);
|
|
@@ -480,7 +463,9 @@ const getImageReferenceLines = (source) => {
|
|
|
480
463
|
const getContentFrontmatterDiagnostics = (source) => {
|
|
481
464
|
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
482
465
|
if (!match) {
|
|
483
|
-
return
|
|
466
|
+
return /^---(?:\r?\n|$)/.test(source)
|
|
467
|
+
? [{ severity: 'error', line: 1, message: 'YAML frontmatter starts with ---, but no closing --- delimiter was found.' }]
|
|
468
|
+
: [];
|
|
484
469
|
}
|
|
485
470
|
|
|
486
471
|
let data;
|
|
@@ -504,21 +489,98 @@ const getContentFrontmatterDiagnostics = (source) => {
|
|
|
504
489
|
}));
|
|
505
490
|
};
|
|
506
491
|
|
|
507
|
-
const
|
|
508
|
-
.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
492
|
+
const getMarkdownStructure = (source) => {
|
|
493
|
+
const lines = source.replace(/\r\n?/g, '\n').split('\n');
|
|
494
|
+
let bodyStart = 0;
|
|
495
|
+
if (lines[0]?.trim() === '---') {
|
|
496
|
+
const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
|
|
497
|
+
if (closingIndex < 0) return { bodyStart: lines.length, headings: [], lines };
|
|
498
|
+
bodyStart = closingIndex + 1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const headings = [];
|
|
502
|
+
let fence = null;
|
|
503
|
+
for (let index = bodyStart; index < lines.length; index += 1) {
|
|
504
|
+
const line = lines[index];
|
|
505
|
+
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)$/);
|
|
506
|
+
if (fenceMatch) {
|
|
507
|
+
if (!fence) {
|
|
508
|
+
fence = { character: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
509
|
+
} else if (
|
|
510
|
+
fenceMatch[1][0] === fence.character
|
|
511
|
+
&& fenceMatch[1].length >= fence.length
|
|
512
|
+
&& !fenceMatch[2].trim()
|
|
513
|
+
) {
|
|
514
|
+
fence = null;
|
|
515
|
+
}
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (fence) continue;
|
|
519
|
+
|
|
520
|
+
const match = line.match(/^(#{1,2})\s+(.+?)\s*$/);
|
|
521
|
+
if (match) headings.push({ level: match[1].length, line: index + 1, source: line });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return { bodyStart, headings, lines };
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const getPageTitleDiagnostics = (structure) => {
|
|
528
|
+
const { bodyStart, headings, lines } = structure;
|
|
529
|
+
const pageHeadings = headings.filter((heading) => heading.level === 1);
|
|
530
|
+
const diagnostics = [];
|
|
531
|
+
|
|
532
|
+
if (pageHeadings.length === 0) {
|
|
533
|
+
diagnostics.push({
|
|
534
|
+
code: 'missing-page-title',
|
|
512
535
|
severity: 'error',
|
|
513
|
-
line:
|
|
514
|
-
message: '
|
|
515
|
-
}
|
|
516
|
-
})
|
|
536
|
+
line: 1,
|
|
537
|
+
message: 'Norna pages require exactly one Markdown H1 page title, for example "# About".',
|
|
538
|
+
});
|
|
539
|
+
} else if (pageHeadings.length > 1) {
|
|
540
|
+
for (const heading of pageHeadings.slice(1)) {
|
|
541
|
+
diagnostics.push({
|
|
542
|
+
code: 'duplicate-page-title',
|
|
543
|
+
severity: 'error',
|
|
544
|
+
line: heading.line,
|
|
545
|
+
message: 'A Norna page can contain only one Markdown H1. Use a level 2 heading with an explicit id for a section.',
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (headings[0] && headings[0].level !== 1) {
|
|
551
|
+
diagnostics.push({
|
|
552
|
+
code: 'page-title-order',
|
|
553
|
+
severity: 'error',
|
|
554
|
+
line: headings[0].line,
|
|
555
|
+
message: 'The Markdown H1 page title must come before every level 2 section.',
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const firstHeadingLineIndex = (headings[0]?.line ?? lines.length + 1) - 1;
|
|
560
|
+
if (lines.slice(bodyStart, firstHeadingLineIndex).some((line) => line.trim())) {
|
|
561
|
+
diagnostics.push({
|
|
562
|
+
code: 'page-title-order',
|
|
563
|
+
severity: 'error',
|
|
564
|
+
line: bodyStart + 1,
|
|
565
|
+
message: 'The Markdown H1 page title must be the first content after optional frontmatter.',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return diagnostics;
|
|
570
|
+
};
|
|
517
571
|
|
|
518
572
|
export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
|
|
573
|
+
const markdownStructure = getMarkdownStructure(source);
|
|
574
|
+
const { headings } = await getMarkdownHeadings(source);
|
|
519
575
|
const diagnostics = [
|
|
520
576
|
...getContentFrontmatterDiagnostics(source),
|
|
521
|
-
...
|
|
577
|
+
...getPageTitleDiagnostics(markdownStructure),
|
|
578
|
+
...getHeadingIdentifierIssues(headings).map((issue) => ({
|
|
579
|
+
code: issue.code,
|
|
580
|
+
severity: 'error',
|
|
581
|
+
line: issue.heading.line,
|
|
582
|
+
message: `${issue.message} ${issue.fix}`,
|
|
583
|
+
})),
|
|
522
584
|
];
|
|
523
585
|
const blockResult = extractNornaMarkdownBlockDiagnostics(source, { label: path.basename(documentPath) });
|
|
524
586
|
for (const error of blockResult.errors) {
|
|
@@ -547,9 +609,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
|
|
|
547
609
|
|
|
548
610
|
const index = await createSiteImageIndex(siteRoot);
|
|
549
611
|
for (const reference of getImageReferenceLines(source)) {
|
|
550
|
-
const
|
|
551
|
-
if (!section) continue;
|
|
552
|
-
const expectedPath = path.join(page.imagesRoot, section.id, reference.filename);
|
|
612
|
+
const expectedPath = path.join(page.imagesRoot, reference.filename);
|
|
553
613
|
if (await fileExists(expectedPath)) continue;
|
|
554
614
|
|
|
555
615
|
const candidates = index.filesByName.get(reference.filename) ?? [];
|
|
@@ -558,7 +618,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
|
|
|
558
618
|
code: 'missing-image',
|
|
559
619
|
severity: 'error',
|
|
560
620
|
line: reference.line,
|
|
561
|
-
message: `Image "${reference.filename}" was not found in
|
|
621
|
+
message: `Image "${reference.filename}" was not found in this page's images folder or elsewhere in this Norna site.`,
|
|
562
622
|
});
|
|
563
623
|
continue;
|
|
564
624
|
}
|
|
@@ -586,7 +646,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
|
|
|
586
646
|
line: reference.line,
|
|
587
647
|
message: otherReferences.length > 0
|
|
588
648
|
? `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))} and is still referenced by ${otherReferences.join(', ')}. Norna cannot relocate it safely.`
|
|
589
|
-
: `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))}. Run "norna content:sync" to relocate it to this
|
|
649
|
+
: `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))}. Run "norna content:sync" to relocate it to this page's images folder.`,
|
|
590
650
|
});
|
|
591
651
|
}
|
|
592
652
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { markdownToMdast } from 'satteri';
|
|
2
|
+
|
|
3
|
+
export const explicitHeadingIdPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
4
|
+
export const reservedHeadingIds = new Set(['page-title']);
|
|
5
|
+
|
|
6
|
+
const explicitHeadingIdSuffixPattern = /\s*\{#([^{}]*)\}\s*$/;
|
|
7
|
+
const transliterations = new Map([
|
|
8
|
+
['æ', 'ae'],
|
|
9
|
+
['đ', 'd'],
|
|
10
|
+
['ð', 'd'],
|
|
11
|
+
['ħ', 'h'],
|
|
12
|
+
['ı', 'i'],
|
|
13
|
+
['ł', 'l'],
|
|
14
|
+
['ø', 'o'],
|
|
15
|
+
['œ', 'oe'],
|
|
16
|
+
['ß', 'ss'],
|
|
17
|
+
['þ', 'th'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const transliterateToAscii = (value) => Array.from(value)
|
|
21
|
+
.map((character) => transliterations.get(character) ?? character)
|
|
22
|
+
.join('');
|
|
23
|
+
|
|
24
|
+
export const slugifyHeadingText = (value) => transliterateToAscii(value
|
|
25
|
+
.normalize('NFKD')
|
|
26
|
+
.toLowerCase())
|
|
27
|
+
.replace(/\p{Mark}+/gu, '')
|
|
28
|
+
.replace(/['’]/g, '')
|
|
29
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
30
|
+
.replace(/^-+|-+$/g, '');
|
|
31
|
+
|
|
32
|
+
const getNodeText = (node) => {
|
|
33
|
+
if (!node || typeof node !== 'object') return '';
|
|
34
|
+
if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? '';
|
|
35
|
+
if (node.type === 'image') return node.alt ?? '';
|
|
36
|
+
if (!Array.isArray(node.children)) return '';
|
|
37
|
+
return node.children.map(getNodeText).join('');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const resolveHeadingIdentifier = (headingText, depth) => {
|
|
41
|
+
const explicitMatch = headingText.match(explicitHeadingIdSuffixPattern);
|
|
42
|
+
const explicitSource = explicitMatch?.[1];
|
|
43
|
+
const title = explicitMatch
|
|
44
|
+
? headingText.slice(0, explicitMatch.index).trim()
|
|
45
|
+
: headingText.trim();
|
|
46
|
+
const explicitId = explicitSource && explicitHeadingIdPattern.test(explicitSource)
|
|
47
|
+
? explicitSource
|
|
48
|
+
: null;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
depth,
|
|
52
|
+
explicitId,
|
|
53
|
+
explicitSource: explicitSource ?? null,
|
|
54
|
+
id: depth === 1 ? null : explicitId ?? slugifyHeadingText(title),
|
|
55
|
+
title,
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const getMarkdownHeadings = async (source) => {
|
|
60
|
+
const normalizedSource = source.replace(/\r\n?/g, '\n');
|
|
61
|
+
const tree = await markdownToMdast(normalizedSource, {
|
|
62
|
+
features: {
|
|
63
|
+
frontmatter: true,
|
|
64
|
+
gfm: true,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const headings = tree.children
|
|
69
|
+
.filter((node) => node.type === 'heading' && node.depth >= 1 && node.depth <= 3)
|
|
70
|
+
.map((node) => ({
|
|
71
|
+
...resolveHeadingIdentifier(getNodeText(node), node.depth),
|
|
72
|
+
index: node.position?.start.offset ?? 0,
|
|
73
|
+
line: node.position?.start.line ?? 1,
|
|
74
|
+
source: normalizedSource.slice(
|
|
75
|
+
node.position?.start.offset ?? 0,
|
|
76
|
+
node.position?.end.offset ?? node.position?.start.offset ?? 0,
|
|
77
|
+
),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
return { headings, source: normalizedSource };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const getHeadingIdentifierIssues = (headings) => {
|
|
84
|
+
const issues = [];
|
|
85
|
+
const headingsById = new Map();
|
|
86
|
+
|
|
87
|
+
for (const heading of headings) {
|
|
88
|
+
if (heading.depth === 1) {
|
|
89
|
+
if (heading.explicitSource !== null) {
|
|
90
|
+
issues.push({
|
|
91
|
+
code: 'page-title-id',
|
|
92
|
+
heading,
|
|
93
|
+
message: 'The Markdown H1 is the page title and must not have an id.',
|
|
94
|
+
fix: 'Remove the {#...} suffix. Heading ids apply to H2 and H3.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (heading.explicitSource !== null && !heading.explicitId) {
|
|
101
|
+
issues.push({
|
|
102
|
+
code: 'invalid-heading-id',
|
|
103
|
+
heading,
|
|
104
|
+
message: `Heading "${heading.title}" has an invalid explicit id "${heading.explicitSource}".`,
|
|
105
|
+
fix: 'Use lowercase ASCII letters, numbers, and single hyphens, for example {#getting-started}.',
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!heading.id) {
|
|
111
|
+
issues.push({
|
|
112
|
+
code: 'empty-heading-id',
|
|
113
|
+
heading,
|
|
114
|
+
message: `Heading "${heading.title}" cannot produce an automatic ASCII id.`,
|
|
115
|
+
fix: `Add an explicit id, for example ${'#'.repeat(heading.depth)} ${heading.title} {#section-id}.`,
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (reservedHeadingIds.has(heading.id)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
code: 'reserved-heading-id',
|
|
123
|
+
heading,
|
|
124
|
+
message: `Heading id "${heading.id}" is reserved by Norna.`,
|
|
125
|
+
fix: 'Choose another explicit id.',
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const existing = headingsById.get(heading.id);
|
|
131
|
+
if (existing) {
|
|
132
|
+
issues.push({
|
|
133
|
+
code: 'duplicate-heading-id',
|
|
134
|
+
heading,
|
|
135
|
+
otherHeading: existing,
|
|
136
|
+
message: `Two headings resolve to id "${heading.id}".`,
|
|
137
|
+
fix: `Add a unique explicit id to at least one heading, for example {#${heading.id}-details}.`,
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
headingsById.set(heading.id, heading);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return issues;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const formatHeadingIdentifierIssue = (issue, sourceLabel = 'Markdown') => {
|
|
149
|
+
const lines = [`${sourceLabel}: ${issue.message}`];
|
|
150
|
+
if (issue.otherHeading) {
|
|
151
|
+
lines.push(
|
|
152
|
+
`- line ${issue.otherHeading.line}: "${issue.otherHeading.title}"`,
|
|
153
|
+
`- line ${issue.heading.line}: "${issue.heading.title}"`,
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
lines.push(`- line ${issue.heading.line}: "${issue.heading.title}"`);
|
|
157
|
+
}
|
|
158
|
+
if (issue.fix) lines.push(issue.fix);
|
|
159
|
+
|
|
160
|
+
return lines.join('\n');
|
|
161
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const navigationModeNames = Object.freeze([
|
|
2
|
+
'automatic',
|
|
3
|
+
'sections',
|
|
4
|
+
'top',
|
|
5
|
+
'tree',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const assertNavigationMode = (mode) => {
|
|
9
|
+
if (!navigationModeNames.includes(mode)) {
|
|
10
|
+
throw new Error(`Unknown navigation mode "${mode}". Use one of: ${navigationModeNames.join(', ')}.`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return mode;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const getListedPages = (pages) => pages.filter((page) => page.isHome || page.listed !== false);
|
|
17
|
+
|
|
18
|
+
const getPageNavigationDepth = (page) => {
|
|
19
|
+
const pageDepth = page.depth ?? 1;
|
|
20
|
+
return (page.headings ?? []).reduce((maximum, heading) => (
|
|
21
|
+
Math.max(maximum, pageDepth + heading.depth - 1)
|
|
22
|
+
), pageDepth);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getAutomaticNavigationMode = (pages) => {
|
|
26
|
+
const listedPages = getListedPages(pages);
|
|
27
|
+
if (listedPages.length <= 1) return 'sections';
|
|
28
|
+
|
|
29
|
+
const maximumDepth = listedPages.reduce((maximum, page) => (
|
|
30
|
+
Math.max(maximum, getPageNavigationDepth(page))
|
|
31
|
+
), 1);
|
|
32
|
+
|
|
33
|
+
return maximumDepth <= 2 ? 'top' : 'tree';
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const resolveNavigationModel = ({ mode = 'automatic', pages }) => {
|
|
37
|
+
const requestedMode = assertNavigationMode(mode);
|
|
38
|
+
const listedPages = getListedPages(pages);
|
|
39
|
+
const maximumDepth = listedPages.reduce((maximum, page) => (
|
|
40
|
+
Math.max(maximum, getPageNavigationDepth(page))
|
|
41
|
+
), 1);
|
|
42
|
+
|
|
43
|
+
return Object.freeze({
|
|
44
|
+
mode: requestedMode === 'automatic'
|
|
45
|
+
? getAutomaticNavigationMode(listedPages)
|
|
46
|
+
: requestedMode,
|
|
47
|
+
requestedMode,
|
|
48
|
+
listedPageCount: listedPages.length,
|
|
49
|
+
maximumDepth,
|
|
50
|
+
});
|
|
51
|
+
};
|
|
@@ -3,12 +3,11 @@ import { documentationLink } from './documentation-links.mjs';
|
|
|
3
3
|
|
|
4
4
|
const field = (description, options = {}) => Object.freeze({ description, ...options });
|
|
5
5
|
const value = (title, description) => Object.freeze({ title, description });
|
|
6
|
-
const nornaBlocksDocumentation = documentationLink('Norna blocks reference', 'content.md', 'norna-blocks');
|
|
7
6
|
|
|
8
7
|
export const nornaMarkdownBlockDefinitions = Object.freeze({
|
|
9
8
|
'norna-image-stack': Object.freeze({
|
|
10
9
|
description: 'Display one or more managed images in a vertical stack.',
|
|
11
|
-
documentation:
|
|
10
|
+
documentation: documentationLink('Image stack reference', 'content.md', 'image-stack'),
|
|
12
11
|
item: Object.freeze({
|
|
13
12
|
start: field('Start another managed image.', { key: 'image', prefix: '- ' }),
|
|
14
13
|
fields: Object.freeze({
|
|
@@ -19,7 +18,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
|
|
|
19
18
|
}),
|
|
20
19
|
'norna-image-carousel': Object.freeze({
|
|
21
20
|
description: 'Display two or more managed images in an interactive carousel.',
|
|
22
|
-
documentation:
|
|
21
|
+
documentation: documentationLink('Image carousel reference', 'content.md', 'image-carousel'),
|
|
23
22
|
item: Object.freeze({
|
|
24
23
|
start: field('Start another managed carousel image.', { key: 'image', prefix: '- ' }),
|
|
25
24
|
fields: Object.freeze({
|
|
@@ -30,7 +29,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
|
|
|
30
29
|
}),
|
|
31
30
|
'norna-card-list': Object.freeze({
|
|
32
31
|
description: 'Display a structured list of cards.',
|
|
33
|
-
documentation:
|
|
32
|
+
documentation: documentationLink('Card list reference', 'content.md', 'card-list'),
|
|
34
33
|
options: Object.freeze({
|
|
35
34
|
layout: field('Place each card image above, left, or right of its text. Defaults to image-top.', {
|
|
36
35
|
default: 'image-top',
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const pageDirectoryPattern = /^(?!000)(\d{3})-([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
2
|
+
|
|
3
|
+
const pageEntryPathSeparator = '--';
|
|
4
|
+
|
|
5
|
+
export const parsePageDirectory = (pageDirectory, label = 'page directory') => {
|
|
6
|
+
const match = pageDirectory.match(pageDirectoryPattern);
|
|
7
|
+
|
|
8
|
+
if (!match) {
|
|
9
|
+
throw new Error(
|
|
10
|
+
`Invalid ${label} "${pageDirectory}". Page directories must use the form NNN-page-id, for example 010-getting-started. The prefix must be 001-999 and page-id may contain only lowercase letters, numbers, and single hyphens.`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
pageDirectory,
|
|
16
|
+
pageOrder: Number.parseInt(match[1], 10),
|
|
17
|
+
pageId: match[2],
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const normalizePageDirectoryPath = (pageDirectoryPath) => pageDirectoryPath.replaceAll('\\', '/');
|
|
22
|
+
|
|
23
|
+
export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page directory path') => {
|
|
24
|
+
const normalizedPath = normalizePageDirectoryPath(pageDirectoryPath);
|
|
25
|
+
const segments = normalizedPath.split('/').filter(Boolean);
|
|
26
|
+
|
|
27
|
+
if (!normalizedPath || normalizedPath.startsWith('/') || normalizedPath.endsWith('/') || segments.length % 2 === 0) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Invalid ${label} "${pageDirectoryPath}". Nested pages must use the form NNN-page-id/pages/NNN-child-id.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const pageDirectories = [];
|
|
34
|
+
for (const [index, segment] of segments.entries()) {
|
|
35
|
+
if (index % 2 === 1) {
|
|
36
|
+
if (segment !== 'pages') {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Invalid ${label} "${pageDirectoryPath}". Use a pages directory between nested page directories.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
pageDirectories.push(parsePageDirectory(segment, `${label} segment ${segment}`));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pageIds = pageDirectories.map(({ pageId }) => pageId);
|
|
48
|
+
const pageOrders = pageDirectories.map(({ pageOrder }) => pageOrder);
|
|
49
|
+
const currentPage = pageDirectories.at(-1);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
pageDirectory: normalizedPath,
|
|
53
|
+
pageDirectories: pageDirectories.map(({ pageDirectory }) => pageDirectory),
|
|
54
|
+
pageId: currentPage.pageId,
|
|
55
|
+
pageIds,
|
|
56
|
+
pageOrder: currentPage.pageOrder,
|
|
57
|
+
pageOrders,
|
|
58
|
+
pagePath: pageIds.join('/'),
|
|
59
|
+
parentPagePath: pageIds.length > 1 ? pageIds.slice(0, -1).join('/') : null,
|
|
60
|
+
depth: pageIds.length,
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const encodePageDirectoryPath = (pageDirectoryPath) => {
|
|
65
|
+
const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
|
|
66
|
+
return pageDirectories.join(pageEntryPathSeparator);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path') => {
|
|
70
|
+
const pageDirectories = pageEntryPath.split(pageEntryPathSeparator);
|
|
71
|
+
if (pageDirectories.some((pageDirectory) => !pageDirectory)) {
|
|
72
|
+
throw new Error(`Invalid ${label} "${pageEntryPath}".`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return pageDirectories.join('/pages/');
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const getPageDirectoryAncestors = (pageDirectoryPath) => {
|
|
79
|
+
const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
|
|
80
|
+
return pageDirectories.map((_, index) => (
|
|
81
|
+
pageDirectories.slice(0, index + 1).join('/pages/')
|
|
82
|
+
));
|
|
83
|
+
};
|