@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
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
getBodySections,
|
|
15
15
|
getContentFiles,
|
|
16
|
-
getFrontmatterSections,
|
|
17
16
|
getImageCandidatesByName,
|
|
18
17
|
getDeprecatedInlineStyleReferences,
|
|
19
18
|
readSiteFile,
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
validateFrontmatterIndentation,
|
|
24
23
|
validateThemeYamlStructure,
|
|
25
24
|
} from './lib/site-content.mjs';
|
|
25
|
+
import { getHeadingIdentifierIssues } from './lib/heading-ids.mjs';
|
|
26
26
|
import { readImageDimensions } from './lib/image-dimensions.mjs';
|
|
27
27
|
import {
|
|
28
28
|
siteThemeLabel,
|
|
@@ -65,7 +65,7 @@ const promptForWrite = async () => {
|
|
|
65
65
|
if (!process.stdin.isTTY) return false;
|
|
66
66
|
|
|
67
67
|
const rl = createInterface({ input, output });
|
|
68
|
-
const answer = await rl.question('This will move image files into the page
|
|
68
|
+
const answer = await rl.question('This will move image files into the page image roots where Norna references them. Continue? [y/N] ');
|
|
69
69
|
rl.close();
|
|
70
70
|
|
|
71
71
|
return answer.trim().toLowerCase() === 'y';
|
|
@@ -136,7 +136,7 @@ const getReportLines = (title, unreferencedImages) => {
|
|
|
136
136
|
lines.push(
|
|
137
137
|
'',
|
|
138
138
|
'Unreferenced Images',
|
|
139
|
-
'These files are kept under page
|
|
139
|
+
'These files are kept under page image roots but are not referenced by Norna-managed image references:',
|
|
140
140
|
);
|
|
141
141
|
|
|
142
142
|
for (const imagePath of unreferencedImages) {
|
|
@@ -171,16 +171,16 @@ const getAspectRatioLabel = ({ width, height }) => {
|
|
|
171
171
|
return `${width / divisor}:${height / divisor}`;
|
|
172
172
|
};
|
|
173
173
|
|
|
174
|
-
const getExpectedImagePath = (contentFile,
|
|
175
|
-
path.join(contentFile.imagesDir,
|
|
174
|
+
const getExpectedImagePath = (contentFile, imageName) =>
|
|
175
|
+
path.join(contentFile.imagesDir, imageName);
|
|
176
176
|
|
|
177
|
-
const getExpectedImageLabel = (contentFile,
|
|
178
|
-
`${contentFile.imagesLabel}/${
|
|
177
|
+
const getExpectedImageLabel = (contentFile, imageName) =>
|
|
178
|
+
`${contentFile.imagesLabel}/${imageName}`;
|
|
179
179
|
|
|
180
180
|
const getGlobalImageCandidates = (globalImageCandidatesByName, imageName, expectedPath) =>
|
|
181
181
|
(globalImageCandidatesByName.get(imageName) ?? []).filter(({ imagePath }) => imagePath !== expectedPath);
|
|
182
182
|
|
|
183
|
-
const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id}]`;
|
|
183
|
+
const getReferenceLabel = (contentFile, section) => `${contentFile.contentLabel} [${section.id ?? 'page title'}]`;
|
|
184
184
|
|
|
185
185
|
const getRootLabel = (contentFile) => contentFile.isHome
|
|
186
186
|
? contentFile.imagesLabel
|
|
@@ -217,8 +217,8 @@ const assertCleanGitForCrossRootSync = async (moves) => {
|
|
|
217
217
|
} catch (error) {
|
|
218
218
|
addIssue({
|
|
219
219
|
severity: 'error',
|
|
220
|
-
message: 'Cross-
|
|
221
|
-
fix: 'Run content:sync inside a Git worktree, or move cross-
|
|
220
|
+
message: 'Cross-page content sync requires a clean git working tree, but git status could not be checked.',
|
|
221
|
+
fix: 'Run content:sync inside a Git worktree, or move cross-page image files manually.',
|
|
222
222
|
});
|
|
223
223
|
return;
|
|
224
224
|
}
|
|
@@ -227,7 +227,7 @@ const assertCleanGitForCrossRootSync = async (moves) => {
|
|
|
227
227
|
|
|
228
228
|
addIssue({
|
|
229
229
|
severity: 'error',
|
|
230
|
-
message: 'Cross-
|
|
230
|
+
message: 'Cross-page content sync requires a clean git working tree before moving files between page image roots.',
|
|
231
231
|
fix: `Commit or stash your current changes, then run content:sync again. Current changes:\n${stdout.trim()}`,
|
|
232
232
|
});
|
|
233
233
|
};
|
|
@@ -256,9 +256,9 @@ const addConflictingMoveIssues = () => {
|
|
|
256
256
|
if (destinations.size <= 1) continue;
|
|
257
257
|
|
|
258
258
|
const firstMove = moves[0];
|
|
259
|
-
addSectionIssue(firstMove.contentFile,
|
|
259
|
+
addSectionIssue(firstMove.contentFile, firstMove.section, {
|
|
260
260
|
severity: 'error',
|
|
261
|
-
message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.
|
|
261
|
+
message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${moves.map((move) => getExpectedImageLabel(move.contentFile, move.imageName)).join(', ')}.`,
|
|
262
262
|
fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
|
|
263
263
|
});
|
|
264
264
|
}
|
|
@@ -268,9 +268,9 @@ const addConflictingMoveIssues = () => {
|
|
|
268
268
|
if (sources.size <= 1) continue;
|
|
269
269
|
|
|
270
270
|
const firstMove = moves[0];
|
|
271
|
-
addSectionIssue(firstMove.contentFile,
|
|
271
|
+
addSectionIssue(firstMove.contentFile, firstMove.section, {
|
|
272
272
|
severity: 'error',
|
|
273
|
-
message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.
|
|
273
|
+
message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.imageName)}: ${moves.map((move) => getCandidateLabel(move.sourceContentFile ? { contentFile: move.sourceContentFile, imagePath: move.from } : move)).join(', ')}.`,
|
|
274
274
|
fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
|
|
275
275
|
});
|
|
276
276
|
}
|
|
@@ -284,8 +284,8 @@ const validateImageReference = async (
|
|
|
284
284
|
expectedReferencesByPath,
|
|
285
285
|
) => {
|
|
286
286
|
const imageName = reference.image;
|
|
287
|
-
const expectedPath = getExpectedImagePath(contentFile,
|
|
288
|
-
const expectedLabel = getExpectedImageLabel(contentFile,
|
|
287
|
+
const expectedPath = getExpectedImagePath(contentFile, imageName);
|
|
288
|
+
const expectedLabel = getExpectedImageLabel(contentFile, imageName);
|
|
289
289
|
const expectedExists = await stat(expectedPath).then((entry) => entry.isFile()).catch(() => false);
|
|
290
290
|
|
|
291
291
|
if (expectedExists) {
|
|
@@ -297,8 +297,8 @@ const validateImageReference = async (
|
|
|
297
297
|
if (candidates.length === 0) {
|
|
298
298
|
addSectionIssue(contentFile, section, {
|
|
299
299
|
severity: 'error',
|
|
300
|
-
message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page
|
|
301
|
-
fix: `Add the source image to ${contentFile.imagesLabel}
|
|
300
|
+
message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page image root.`,
|
|
301
|
+
fix: `Add the source image directly to ${contentFile.imagesLabel}/ or remove the Norna-managed image reference.`,
|
|
302
302
|
});
|
|
303
303
|
return null;
|
|
304
304
|
}
|
|
@@ -315,7 +315,7 @@ const validateImageReference = async (
|
|
|
315
315
|
const sourceCandidate = candidates[0];
|
|
316
316
|
const sourcePath = sourceCandidate.imagePath;
|
|
317
317
|
const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
|
|
318
|
-
.filter((expectedReference) => expectedReference.contentFile !== contentFile
|
|
318
|
+
.filter((expectedReference) => expectedReference.contentFile !== contentFile);
|
|
319
319
|
|
|
320
320
|
if (referencesAtCurrentLocation.length > 0) {
|
|
321
321
|
addSectionIssue(contentFile, section, {
|
|
@@ -326,15 +326,17 @@ const validateImageReference = async (
|
|
|
326
326
|
return null;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
imageMoves.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
329
|
+
if (!imageMoves.some((move) => move.from === sourcePath && move.to === expectedPath)) {
|
|
330
|
+
imageMoves.push({
|
|
331
|
+
imageName,
|
|
332
|
+
from: sourcePath,
|
|
333
|
+
to: expectedPath,
|
|
334
|
+
contentFile,
|
|
335
|
+
sourceContentFile: sourceCandidate.contentFile,
|
|
336
|
+
section,
|
|
337
|
+
crossRoot: sourceCandidate.contentFile !== contentFile,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
338
340
|
referencedImagePaths.add(sourcePath);
|
|
339
341
|
|
|
340
342
|
if (!shouldWrite) {
|
|
@@ -342,8 +344,8 @@ const validateImageReference = async (
|
|
|
342
344
|
severity: 'error',
|
|
343
345
|
message: `Image "${imageName}" is used here but is located in ${getCandidateLabel(sourceCandidate)}.`,
|
|
344
346
|
fix: sourceCandidate.contentFile === contentFile
|
|
345
|
-
? 'Run norna content:sync to move it
|
|
346
|
-
: `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-
|
|
347
|
+
? 'Run norna content:sync to move it directly into the current page image root.'
|
|
348
|
+
: `Run norna content:sync to move it from ${getRootLabel(sourceCandidate.contentFile)} to ${getRootLabel(contentFile)}. Cross-page sync requires a clean git working tree when files are moved.`,
|
|
347
349
|
});
|
|
348
350
|
}
|
|
349
351
|
|
|
@@ -412,11 +414,27 @@ for (const contentFile of contentFiles) {
|
|
|
412
414
|
validateFrontmatterIndentation(frontmatter, addIssue);
|
|
413
415
|
validateContentFrontmatterStructure(frontmatter, addIssue);
|
|
414
416
|
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
const
|
|
417
|
+
const { headings, pageHeadings, prelude, sections } = await getBodySections(body);
|
|
418
|
+
const headingIdentifierIssues = getHeadingIdentifierIssues(headings);
|
|
419
|
+
const invalidHeadingLines = new Set();
|
|
420
|
+
for (const issue of headingIdentifierIssues) {
|
|
421
|
+
invalidHeadingLines.add(issue.heading.line);
|
|
422
|
+
if (issue.otherHeading) invalidHeadingLines.add(issue.otherHeading.line);
|
|
423
|
+
const line = bodyLineOffset + issue.heading.line;
|
|
424
|
+
const relatedLine = issue.otherHeading
|
|
425
|
+
? ` The other heading is on line ${bodyLineOffset + issue.otherHeading.line}.`
|
|
426
|
+
: '';
|
|
427
|
+
addIssue({
|
|
428
|
+
severity: 'error',
|
|
429
|
+
message: `${contentFile.contentLabel} line ${line}: ${issue.message}${relatedLine}`,
|
|
430
|
+
fix: issue.fix,
|
|
431
|
+
sectionId: `${contentFile.contentLabel}:heading:${issue.heading.line}`,
|
|
432
|
+
sectionLabel: `${contentFile.contentLabel} line ${line}`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
418
435
|
const sectionsById = new Map();
|
|
419
|
-
const
|
|
436
|
+
const blockResultsBySection = new Map();
|
|
437
|
+
const validSections = new Set();
|
|
420
438
|
const imageCandidatesByName = await getImageCandidatesByName(contentFile.imagesDir);
|
|
421
439
|
|
|
422
440
|
for (const [imageName, candidates] of imageCandidatesByName) {
|
|
@@ -427,36 +445,71 @@ for (const contentFile of contentFiles) {
|
|
|
427
445
|
}
|
|
428
446
|
}
|
|
429
447
|
|
|
448
|
+
if (pageHeadings.length === 0) {
|
|
449
|
+
addContentIssue(contentFile, {
|
|
450
|
+
severity: 'error',
|
|
451
|
+
message: 'The page is missing its Markdown H1 title.',
|
|
452
|
+
fix: 'Add exactly one page title before any sections, for example "# About".',
|
|
453
|
+
});
|
|
454
|
+
} else if (pageHeadings.length > 1) {
|
|
455
|
+
addContentIssue(contentFile, {
|
|
456
|
+
severity: 'error',
|
|
457
|
+
message: `The page contains ${pageHeadings.length} Markdown H1 headings.`,
|
|
458
|
+
fix: 'Keep exactly one H1 page title. Use level 2 headings with explicit ids for sections.',
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (sections[0] && !sections[0].isPageTitle) {
|
|
463
|
+
addContentIssue(contentFile, {
|
|
464
|
+
severity: 'error',
|
|
465
|
+
message: `The first content heading is "${sections[0].heading}", but a Norna page must start with its H1 title.`,
|
|
466
|
+
fix: 'Add the page title first, for example "# About".',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (prelude.trim()) {
|
|
471
|
+
addContentIssue(contentFile, {
|
|
472
|
+
severity: 'error',
|
|
473
|
+
message: 'The page contains content before its first heading.',
|
|
474
|
+
fix: 'Make the Markdown H1 page title the first content after optional frontmatter.',
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
430
478
|
for (const section of sections) {
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
479
|
+
if (section.isPageTitle) {
|
|
480
|
+
if (
|
|
481
|
+
pageHeadings.length === 1
|
|
482
|
+
&& sections[0] === section
|
|
483
|
+
&& !invalidHeadingLines.has(section.line)
|
|
484
|
+
) {
|
|
485
|
+
validSections.add(section);
|
|
486
|
+
}
|
|
437
487
|
continue;
|
|
438
488
|
}
|
|
439
489
|
|
|
490
|
+
if (invalidHeadingLines.has(section.line) || !section.id) continue;
|
|
491
|
+
|
|
440
492
|
if (sectionsById.has(section.id)) {
|
|
441
493
|
addSectionIssue(contentFile, section, {
|
|
442
494
|
severity: 'error',
|
|
443
|
-
message: `Duplicate Markdown
|
|
444
|
-
fix: '
|
|
495
|
+
message: `Duplicate Markdown heading id "${section.id}".`,
|
|
496
|
+
fix: 'Add a unique explicit id to at least one heading.',
|
|
445
497
|
});
|
|
446
498
|
continue;
|
|
447
499
|
}
|
|
448
500
|
|
|
449
501
|
sectionsById.set(section.id, section);
|
|
502
|
+
validSections.add(section);
|
|
450
503
|
}
|
|
451
504
|
|
|
452
505
|
for (const section of sections) {
|
|
453
|
-
if (!
|
|
506
|
+
if (!validSections.has(section)) continue;
|
|
454
507
|
|
|
455
508
|
const blockResults = extractNornaMarkdownBlockDiagnostics(section.text, {
|
|
456
509
|
label: contentFile.contentLabel,
|
|
457
510
|
lineOffset: bodyLineOffset + section.line - 1,
|
|
458
511
|
});
|
|
459
|
-
|
|
512
|
+
blockResultsBySection.set(section, blockResults);
|
|
460
513
|
|
|
461
514
|
const inlineNoteResults = extractInlineNoteDiagnostics(section.text, {
|
|
462
515
|
label: contentFile.contentLabel,
|
|
@@ -470,17 +523,16 @@ for (const contentFile of contentFiles) {
|
|
|
470
523
|
}
|
|
471
524
|
|
|
472
525
|
for (const reference of getNornaBlockImageReferences(blockResults.blocks)) {
|
|
473
|
-
const expectedPath = getExpectedImagePath(contentFile,
|
|
526
|
+
const expectedPath = getExpectedImagePath(contentFile, reference.image);
|
|
474
527
|
addExpectedReference(globalExpectedReferencesByPath, expectedPath, { contentFile, section, reference });
|
|
475
528
|
}
|
|
476
529
|
}
|
|
477
530
|
|
|
478
531
|
contentFileContexts.push({
|
|
479
532
|
contentFile,
|
|
480
|
-
frontmatterIds,
|
|
481
533
|
sections,
|
|
482
|
-
|
|
483
|
-
|
|
534
|
+
validSections,
|
|
535
|
+
blockResultsBySection,
|
|
484
536
|
body,
|
|
485
537
|
});
|
|
486
538
|
}
|
|
@@ -488,23 +540,12 @@ for (const contentFile of contentFiles) {
|
|
|
488
540
|
for (const context of contentFileContexts) {
|
|
489
541
|
const {
|
|
490
542
|
contentFile,
|
|
491
|
-
frontmatterIds,
|
|
492
543
|
sections,
|
|
493
|
-
|
|
494
|
-
|
|
544
|
+
validSections,
|
|
545
|
+
blockResultsBySection,
|
|
495
546
|
body,
|
|
496
547
|
} = context;
|
|
497
548
|
|
|
498
|
-
for (const id of frontmatterIds) {
|
|
499
|
-
if (!sectionsById.has(id)) {
|
|
500
|
-
addContentIssue(contentFile, {
|
|
501
|
-
severity: 'error',
|
|
502
|
-
message: `Section metadata "${id}" does not match any Markdown section.`,
|
|
503
|
-
fix: `Add a level 2 Markdown heading, for example "## Heading {#${id}}", or remove sections.${id}.`,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
549
|
for (const styleName of new Set(getDeprecatedInlineStyleReferences(body))) {
|
|
509
550
|
addContentIssue(contentFile, {
|
|
510
551
|
severity: 'error',
|
|
@@ -514,7 +555,7 @@ for (const context of contentFileContexts) {
|
|
|
514
555
|
}
|
|
515
556
|
|
|
516
557
|
for (const section of sections) {
|
|
517
|
-
if (!
|
|
558
|
+
if (!validSections.has(section)) continue;
|
|
518
559
|
|
|
519
560
|
const markdownImages = extractMarkdownImageReferences(section.text);
|
|
520
561
|
for (const reference of markdownImages) {
|
|
@@ -525,7 +566,7 @@ for (const context of contentFileContexts) {
|
|
|
525
566
|
});
|
|
526
567
|
}
|
|
527
568
|
|
|
528
|
-
const { blocks, errors: blockErrors } =
|
|
569
|
+
const { blocks, errors: blockErrors } = blockResultsBySection.get(section) ?? { blocks: [], errors: [] };
|
|
529
570
|
for (const error of blockErrors) {
|
|
530
571
|
addSectionIssue(contentFile, section, {
|
|
531
572
|
severity: 'error',
|
|
@@ -601,5 +642,5 @@ if (!canWrite) {
|
|
|
601
642
|
for (const move of imageMoves) {
|
|
602
643
|
await mkdir(path.dirname(move.to), { recursive: true });
|
|
603
644
|
await rename(move.from, move.to);
|
|
604
|
-
console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}
|
|
645
|
+
console.log(`Moved image "${move.imageName}" to ${move.contentFile.imagesLabel}/.`);
|
|
605
646
|
}
|
|
@@ -8,7 +8,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'
|
|
|
8
8
|
const nornaBin = path.join(repoRoot, 'bin', 'norna.mjs');
|
|
9
9
|
const tempParent = path.join(repoRoot, 'node_modules', '.cache');
|
|
10
10
|
await mkdir(tempParent, { recursive: true });
|
|
11
|
-
const tempRoot = await mkdtemp(path.join(tempParent, 'norna-
|
|
11
|
+
const tempRoot = await mkdtemp(path.join(tempParent, 'norna-banner-visibility-'));
|
|
12
12
|
const siteDir = path.join(tempRoot, 'site');
|
|
13
13
|
|
|
14
14
|
const runCli = (args, env = {}) => {
|
|
@@ -34,7 +34,7 @@ const runCli = (args, env = {}) => {
|
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
try {
|
|
37
|
-
await mkdir(path.join(siteDir, 'images'
|
|
37
|
+
await mkdir(path.join(siteDir, 'images'), { recursive: true });
|
|
38
38
|
await mkdir(path.join(siteDir, 'public'), { recursive: true });
|
|
39
39
|
await writeFile(path.join(siteDir, 'public', 'robots.txt'), 'User-agent: *\nAllow: /\n');
|
|
40
40
|
await writeFile(path.join(siteDir, 'config.yaml'), 'url: https://example.com/\n');
|
|
@@ -56,28 +56,13 @@ palette: dark
|
|
|
56
56
|
text: Active banner text.
|
|
57
57
|
`);
|
|
58
58
|
await writeFile(path.join(siteDir, 'content.md'), `---
|
|
59
|
-
|
|
60
|
-
description: Test site for temporary
|
|
61
|
-
sections:
|
|
62
|
-
expired:
|
|
63
|
-
visible:
|
|
64
|
-
until: "2026-01-01"
|
|
65
|
-
active:
|
|
66
|
-
visible:
|
|
67
|
-
from: "2026-01-01"
|
|
68
|
-
until: "2026-12-31"
|
|
69
|
-
always: {}
|
|
70
|
-
|
|
59
|
+
page:
|
|
60
|
+
description: Test site for temporary banners.
|
|
71
61
|
---
|
|
72
|
-
## Expired {#expired}
|
|
73
|
-
|
|
74
|
-
Expired section text.
|
|
75
|
-
|
|
76
|
-
## Active {#active}
|
|
77
62
|
|
|
78
|
-
|
|
63
|
+
# Temporary Visibility Test
|
|
79
64
|
|
|
80
|
-
##
|
|
65
|
+
## Content {#content}
|
|
81
66
|
|
|
82
67
|
Always visible section text.
|
|
83
68
|
`);
|
|
@@ -85,13 +70,11 @@ Always visible section text.
|
|
|
85
70
|
runCli(['build'], { NORNA_TODAY: '2026-06-15' });
|
|
86
71
|
|
|
87
72
|
const html = await readFile(path.join(tempRoot, 'dist', 'index.html'), 'utf8');
|
|
88
|
-
assert.match(html, /Active section text/);
|
|
89
73
|
assert.match(html, /Always visible section text/);
|
|
90
|
-
assert.doesNotMatch(html, /Expired section text/);
|
|
91
74
|
assert.match(html, /Active banner text/);
|
|
92
75
|
assert.doesNotMatch(html, /Expired banner text/);
|
|
93
76
|
|
|
94
|
-
console.log('Temporary visibility test passed.');
|
|
77
|
+
console.log('Temporary banner visibility test passed.');
|
|
95
78
|
} finally {
|
|
96
79
|
await rm(tempRoot, { force: true, recursive: true });
|
|
97
80
|
}
|
|
@@ -10,7 +10,7 @@ const tempParent = path.join(repoRoot, 'node_modules', '.cache');
|
|
|
10
10
|
await mkdir(tempParent, { recursive: true });
|
|
11
11
|
const tempRoot = await mkdtemp(path.join(tempParent, 'norna-client-javascript-'));
|
|
12
12
|
const siteDir = path.join(tempRoot, 'site');
|
|
13
|
-
const
|
|
13
|
+
const pageDir = path.join(siteDir, 'pages', '010-details');
|
|
14
14
|
const configPath = path.join(siteDir, 'config.yaml');
|
|
15
15
|
|
|
16
16
|
const runBuild = () => {
|
|
@@ -59,17 +59,14 @@ try {
|
|
|
59
59
|
await writeFile(path.join(siteDir, 'theme.yaml'), `typography:
|
|
60
60
|
profile: reading
|
|
61
61
|
palette: paper
|
|
62
|
-
`);
|
|
63
|
-
await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `navigation:
|
|
64
|
-
label: Test site
|
|
65
62
|
`);
|
|
66
63
|
await writeFile(path.join(siteDir, 'content.md'), `---
|
|
67
|
-
|
|
68
|
-
description: A single page with sticky section navigation.
|
|
69
|
-
sections:
|
|
70
|
-
intro: {}
|
|
71
|
-
more: {}
|
|
64
|
+
page:
|
|
65
|
+
description: A single page with sticky section navigation.
|
|
72
66
|
---
|
|
67
|
+
|
|
68
|
+
# Section navigation
|
|
69
|
+
|
|
73
70
|
## Intro {#intro}
|
|
74
71
|
|
|
75
72
|
The section heading is a native anchor target.
|
|
@@ -104,27 +101,26 @@ The navigation enhancement keeps native anchors clear of the sticky header.
|
|
|
104
101
|
|
|
105
102
|
await writeConfig();
|
|
106
103
|
|
|
107
|
-
await mkdir(path.join(
|
|
108
|
-
await writeSvg(path.join(
|
|
109
|
-
await writeSvg(path.join(
|
|
104
|
+
await mkdir(path.join(pageDir, 'images'), { recursive: true });
|
|
105
|
+
await writeSvg(path.join(pageDir, 'images', 'first.svg'), '#7b8f78');
|
|
106
|
+
await writeSvg(path.join(pageDir, 'images', 'second.svg'), '#a7664b');
|
|
110
107
|
await writeFile(path.join(siteDir, 'content.md'), `---
|
|
111
|
-
|
|
112
|
-
description: A page without interactive features.
|
|
113
|
-
sections:
|
|
114
|
-
intro: {}
|
|
108
|
+
page:
|
|
109
|
+
description: A page without interactive features.
|
|
115
110
|
---
|
|
111
|
+
|
|
112
|
+
# Static baseline
|
|
113
|
+
|
|
116
114
|
## Intro {#intro}
|
|
117
115
|
|
|
118
116
|
Ordinary Markdown needs no client-side JavaScript.
|
|
119
117
|
`);
|
|
120
|
-
await writeFile(path.join(
|
|
121
|
-
|
|
122
|
-
description: Static managed images.
|
|
123
|
-
sections:
|
|
124
|
-
details: {}
|
|
118
|
+
await writeFile(path.join(pageDir, 'content.md'), `---
|
|
119
|
+
page:
|
|
120
|
+
description: Static managed images.
|
|
125
121
|
---
|
|
126
|
-
## Details {#details}
|
|
127
122
|
|
|
123
|
+
# Details
|
|
128
124
|
Static image stacks remain static.
|
|
129
125
|
|
|
130
126
|
\`\`\`norna-image-stack
|
|
@@ -148,23 +144,25 @@ Static image stacks remain static.
|
|
|
148
144
|
);
|
|
149
145
|
|
|
150
146
|
await writeFile(path.join(siteDir, 'content.md'), `---
|
|
151
|
-
|
|
152
|
-
description: A page with an enhanced sidenote.
|
|
153
|
-
sections:
|
|
154
|
-
intro: {}
|
|
147
|
+
page:
|
|
148
|
+
description: A page with an enhanced sidenote.
|
|
155
149
|
---
|
|
150
|
+
|
|
151
|
+
# Notes
|
|
152
|
+
|
|
156
153
|
## Intro {#intro}
|
|
157
154
|
|
|
158
155
|
This sentence has additional context.{note-ref}
|
|
159
156
|
|
|
160
157
|
{note: The note remains readable without JavaScript, while alignment is enhanced when JavaScript runs.}
|
|
161
158
|
`);
|
|
162
|
-
await writeFile(path.join(
|
|
163
|
-
|
|
164
|
-
description: An interactive image carousel.
|
|
165
|
-
sections:
|
|
166
|
-
details: {}
|
|
159
|
+
await writeFile(path.join(pageDir, 'content.md'), `---
|
|
160
|
+
page:
|
|
161
|
+
description: An interactive image carousel.
|
|
167
162
|
---
|
|
163
|
+
|
|
164
|
+
# Carousel
|
|
165
|
+
|
|
168
166
|
## Details {#details}
|
|
169
167
|
|
|
170
168
|
\`\`\`norna-image-carousel
|
|
@@ -185,24 +183,23 @@ sections:
|
|
|
185
183
|
assert.match(getScripts(carouselHtml)[0], /\ssrc=/, 'Carousel JavaScript should be emitted as a module asset.');
|
|
186
184
|
|
|
187
185
|
await writeFile(path.join(siteDir, 'content.md'), `---
|
|
188
|
-
|
|
189
|
-
description: A page with a dismissible banner.
|
|
190
|
-
sections:
|
|
191
|
-
intro: {}
|
|
186
|
+
page:
|
|
187
|
+
description: A page with a dismissible banner.
|
|
192
188
|
---
|
|
189
|
+
|
|
190
|
+
# Banner
|
|
191
|
+
|
|
193
192
|
## Intro {#intro}
|
|
194
193
|
|
|
195
194
|
The only interactive feature is the banner.
|
|
196
195
|
`);
|
|
197
|
-
await writeFile(path.join(
|
|
198
|
-
|
|
199
|
-
description: A second plain page.
|
|
200
|
-
sections:
|
|
201
|
-
details: {}
|
|
196
|
+
await writeFile(path.join(pageDir, 'content.md'), `---
|
|
197
|
+
page:
|
|
198
|
+
description: A second plain page.
|
|
202
199
|
---
|
|
203
|
-
## Details {#details}
|
|
204
200
|
|
|
205
|
-
|
|
201
|
+
# Details
|
|
202
|
+
Plain page content.
|
|
206
203
|
`);
|
|
207
204
|
await writeFile(path.join(siteDir, 'sitewide-content.yaml'), `banners:
|
|
208
205
|
- id: test-banner
|