@janga/norna 0.7.23 → 0.7.24

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.
Files changed (71) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +12 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +13 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +45 -15
  7. package/schemas/content-frontmatter.schema.json +7 -7
  8. package/schemas/page-theme.schema.json +26 -26
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +246 -246
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +12 -1
  13. package/scripts/generate-search-index.mjs +57 -0
  14. package/scripts/init-site.mjs +1 -0
  15. package/scripts/lib/code-fence-metadata.mjs +223 -0
  16. package/scripts/lib/edit-source-link.mjs +49 -0
  17. package/scripts/lib/editor-language-service.mjs +32 -12
  18. package/scripts/lib/image-presentation.mjs +4 -0
  19. package/scripts/lib/navigation-model.mjs +34 -13
  20. package/scripts/lib/navigation-review.mjs +396 -0
  21. package/scripts/lib/norna-markdown-blocks.mjs +73 -26
  22. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  23. package/scripts/lib/page-aliases.mjs +21 -1
  24. package/scripts/lib/page-markdown.mjs +32 -1
  25. package/scripts/lib/page-move-plan.mjs +659 -0
  26. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  27. package/scripts/lib/presentation.mjs +1 -10
  28. package/scripts/lib/project-config.mjs +111 -5
  29. package/scripts/lib/public-asset-conventions.mjs +36 -2
  30. package/scripts/lib/schema-definitions.mjs +22 -5
  31. package/scripts/lib/schema-editor-metadata.mjs +19 -4
  32. package/scripts/lib/schema-value-definitions.mjs +1 -1
  33. package/scripts/lib/semantic-callouts.mjs +128 -0
  34. package/scripts/lib/site-content.mjs +2 -1
  35. package/scripts/lib/site-link-graph.mjs +33 -2
  36. package/scripts/lib/site-navigation-tree.mjs +85 -0
  37. package/scripts/lib/social-image-assets.mjs +30 -0
  38. package/scripts/lib/theme-presets.mjs +2 -2
  39. package/scripts/lib/theme-profiles.mjs +0 -4
  40. package/scripts/move-site-page.mjs +256 -0
  41. package/scripts/review-navigation.mjs +32 -0
  42. package/scripts/sync-content-sections.mjs +67 -4
  43. package/scripts/sync-site-public.mjs +24 -5
  44. package/src/components/CodeBlockCopyScript.astro +9 -4
  45. package/src/components/EditSourceLink.astro +17 -0
  46. package/src/components/ImageCarousel.astro +5 -6
  47. package/src/components/NavigationPageTree.astro +66 -55
  48. package/src/components/NavigationTreeControls.astro +77 -0
  49. package/src/components/PageAliasRedirect.astro +1 -1
  50. package/src/components/PageList.astro +33 -0
  51. package/src/components/PageSequenceNavigation.astro +37 -0
  52. package/src/components/SearchPage.astro +151 -0
  53. package/src/components/SiteNavigation.astro +35 -8
  54. package/src/components/SitePage.astro +79 -26
  55. package/src/components/SiteSection.astro +11 -3
  56. package/src/components/SiteTreeNavigation.astro +8 -1
  57. package/src/components/TreeNavigationScript.astro +207 -26
  58. package/src/layouts/BaseLayout.astro +57 -2
  59. package/src/lib/sectionContent.ts +32 -3
  60. package/src/lib/siteNavigation.ts +31 -52
  61. package/src/lib/sitePublicAssets.ts +1 -0
  62. package/src/pages/404.astro +110 -0
  63. package/src/pages/[...slug].astro +15 -5
  64. package/src/styles/content.css +242 -18
  65. package/src/styles/media.css +0 -5
  66. package/src/styles/navigation.css +31 -0
  67. package/src/styles/page-layout.css +438 -89
  68. package/src/styles/responsive.css +77 -8
  69. package/starters/basic/README.md +6 -19
  70. package/starters/basic/package.json +1 -0
  71. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -2,6 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import {
4
4
  createPageAliasModel,
5
+ generatedSiteRoutes,
5
6
  } from './page-aliases.mjs';
6
7
  import { parsePageMarkdownSource } from './page-markdown.mjs';
7
8
  import {
@@ -117,6 +118,9 @@ const createPageRecord = ({ contentFile, data = {}, document }) => {
117
118
  anchors,
118
119
  contentFile,
119
120
  document,
121
+ navigation: {
122
+ listed: data.navigation?.listed ?? true,
123
+ },
120
124
  pathname,
121
125
  title: document.pageTitle?.title ?? contentFile.pageId,
122
126
  };
@@ -140,7 +144,13 @@ const looksLikePublicFile = (pathname) => {
140
144
  return (pathname.split('/').at(-1) ?? '').includes('.');
141
145
  };
142
146
 
143
- export const createSiteLinkGraph = ({ siteStructure, pageDocuments, publicFiles = [] }) => {
147
+ export const createSiteLinkGraph = ({
148
+ siteStructure,
149
+ pageDocuments,
150
+ publicFiles = [],
151
+ generatedRoutes,
152
+ }) => {
153
+ const activeGeneratedRoutes = generatedRoutes ?? generatedSiteRoutes;
144
154
  const documentsByDirectory = new Map(pageDocuments.map(({ contentFile, data, document }) => [
145
155
  contentFile.pageDirectory,
146
156
  { contentFile, data, document },
@@ -167,7 +177,11 @@ export const createSiteLinkGraph = ({ siteStructure, pageDocuments, publicFiles
167
177
  categories: siteStructure.categories,
168
178
  pages,
169
179
  publicFiles,
180
+ generatedRoutes: activeGeneratedRoutes,
170
181
  });
182
+ const generatedRoutesByPathname = new Map(
183
+ activeGeneratedRoutes.map((route) => [route.pathname, route]),
184
+ );
171
185
 
172
186
  const references = [];
173
187
  const referencesByTarget = new Map();
@@ -249,6 +263,18 @@ export const createSiteLinkGraph = ({ siteStructure, pageDocuments, publicFiles
249
263
  continue;
250
264
  }
251
265
 
266
+ const generatedRoute = generatedRoutesByPathname.get(
267
+ target.pageLookupPathname ?? target.pathname,
268
+ );
269
+ if (generatedRoute && !target.fragment) {
270
+ reference.resolution = {
271
+ kind: 'generated-route',
272
+ pathname: generatedRoute.pathname,
273
+ route: generatedRoute,
274
+ };
275
+ continue;
276
+ }
277
+
252
278
  const targetCategory = target.pageLookupPathname
253
279
  ? categoriesByPathname.get(target.pageLookupPathname)
254
280
  : null;
@@ -320,7 +346,12 @@ export const getSiteLinkGraph = async (options = {}) => {
320
346
  readPublicFiles(options.publicDir ?? sitePublicDir),
321
347
  ]);
322
348
 
323
- return createSiteLinkGraph({ pageDocuments, publicFiles, siteStructure });
349
+ return createSiteLinkGraph({
350
+ pageDocuments,
351
+ publicFiles,
352
+ siteStructure,
353
+ generatedRoutes: options.generatedRoutes,
354
+ });
324
355
  };
325
356
 
326
357
  export const getSitePublicFiles = async (publicDir = sitePublicDir) => readPublicFiles(publicDir);
@@ -0,0 +1,85 @@
1
+ export const getSiteNavigationTree = (entries) => {
2
+ const nodes = new Map();
3
+ const roots = [];
4
+
5
+ for (const entry of entries) {
6
+ nodes.set(entry.node.pagePath, { ...entry, children: [] });
7
+ }
8
+
9
+ for (const entry of entries) {
10
+ const node = nodes.get(entry.node.pagePath);
11
+ if (!node) continue;
12
+
13
+ if (entry.node.isHome || entry.node.parentPagePath === null) {
14
+ roots.push(node);
15
+ continue;
16
+ }
17
+
18
+ const parent = nodes.get(entry.node.parentPagePath);
19
+ if (!parent) {
20
+ throw new Error(`Site node "${entry.node.pagePath}" has no parent node "${entry.node.parentPagePath}".`);
21
+ }
22
+
23
+ parent.children.push(node);
24
+ }
25
+
26
+ return roots;
27
+ };
28
+
29
+ export const getListedSiteNavigationTree = (entries) => {
30
+ const filterListed = (nodes) => nodes.flatMap((node) => {
31
+ if (!node.node.isHome && !node.node.navigation.listed) return [];
32
+ const children = filterListed(node.children);
33
+ if (node.node.kind === 'category' && children.length === 0) return [];
34
+ return [{ ...node, children }];
35
+ });
36
+
37
+ return filterListed(getSiteNavigationTree(entries));
38
+ };
39
+
40
+ export const getFirstPageInNavigationNode = (node) => {
41
+ if (node.node.kind === 'page') return node.node;
42
+
43
+ for (const child of node.children) {
44
+ const page = getFirstPageInNavigationNode(child);
45
+ if (page) return page;
46
+ }
47
+
48
+ return null;
49
+ };
50
+
51
+ export const flattenSiteNavigationTree = (nodes) => nodes.flatMap((node) => [
52
+ node,
53
+ ...flattenSiteNavigationTree(node.children),
54
+ ]);
55
+
56
+ export const getSequentialPageNavigation = (nodes, currentPagePath) => {
57
+ const activeRoot = nodes.find((root) => (
58
+ flattenSiteNavigationTree([root]).some(({ node }) => node.pagePath === currentPagePath)
59
+ ));
60
+ if (!activeRoot) return { previous: null, next: null };
61
+
62
+ const pages = flattenSiteNavigationTree([activeRoot])
63
+ .filter(({ node }) => node.kind === 'page')
64
+ .map(({ node }) => node);
65
+ const currentIndex = pages.findIndex(({ pagePath }) => pagePath === currentPagePath);
66
+ if (currentIndex < 0) return { previous: null, next: null };
67
+
68
+ return {
69
+ previous: pages[currentIndex - 1] ?? null,
70
+ next: pages[currentIndex + 1] ?? null,
71
+ };
72
+ };
73
+
74
+ export const getDirectChildPages = (nodes, currentPagePath) => {
75
+ const current = flattenSiteNavigationTree(nodes)
76
+ .find(({ node }) => node.pagePath === currentPagePath);
77
+ if (!current) return [];
78
+ const children = current.node.isHome
79
+ ? nodes.filter(({ node }) => !node.isHome)
80
+ : current.children;
81
+
82
+ return children
83
+ .filter(({ node }) => node.kind === 'page')
84
+ .map(({ node }) => node);
85
+ };
@@ -0,0 +1,30 @@
1
+ import { getPublicAssetInspection } from './logo-assets.mjs';
2
+ import {
3
+ socialImageAssetDefinitions,
4
+ socialImageAssetFilenames,
5
+ } from './public-asset-conventions.mjs';
6
+ import { sitePublicLabel } from './site-paths.mjs';
7
+
8
+ const definitionsByFilename = new Map(
9
+ socialImageAssetDefinitions.map((definition) => [definition.filename, definition]),
10
+ );
11
+
12
+ export const getSocialImageAssets = () => getPublicAssetInspection().socialImages
13
+ .map((filename) => ({
14
+ ...definitionsByFilename.get(filename),
15
+ filename,
16
+ href: `/${filename}`,
17
+ }));
18
+
19
+ export const getSocialImageAsset = () => {
20
+ const assets = getSocialImageAssets();
21
+
22
+ if (assets.length > 1) {
23
+ throw new Error([
24
+ `Found multiple social sharing images in ${sitePublicLabel}. Keep exactly one of ${socialImageAssetFilenames.join(', ')}.`,
25
+ ...assets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
26
+ ].join('\n'));
27
+ }
28
+
29
+ return assets[0] ?? null;
30
+ };
@@ -4,7 +4,7 @@ import { resolveThemeProfileRecipe } from './theme-profiles.mjs';
4
4
 
5
5
  export const themePresetRecipes = Object.freeze({
6
6
  portfolio: Object.freeze({
7
- color: 'near-monochrome-dark',
7
+ color: 'near-monochrome-adaptive',
8
8
  typography: 'restrained-sans',
9
9
  rhythm: 'balanced',
10
10
  geometry: 'image-led',
@@ -231,7 +231,7 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
231
231
  'blocks:',
232
232
  ' cardList:',
233
233
  ' # Alternatives: text, narrow, normal, wide.',
234
- ' # A width written in a norna-card-list block overrides this default.',
234
+ ' # A width written in a card-list block overrides this default.',
235
235
  ` width: ${blocks.cardList.width}`,
236
236
  '',
237
237
  'typography:',
@@ -2,10 +2,6 @@ import { freezeDeep, mergeDeep } from './object.mjs';
2
2
 
3
3
  export const themeProfileDefinitions = freezeDeep({
4
4
  color: {
5
- 'near-monochrome-dark': {
6
- appearance: { default: 'dark' },
7
- palette: 'near-monochrome',
8
- },
9
5
  'near-monochrome-adaptive': {
10
6
  appearance: { default: 'system' },
11
7
  palette: 'near-monochrome',
@@ -0,0 +1,256 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import {
3
+ access,
4
+ mkdir,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ rmdir,
9
+ writeFile,
10
+ } from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {
13
+ createPageMovePlan,
14
+ formatPageMovePlan,
15
+ } from './lib/page-move-plan.mjs';
16
+ import {
17
+ getSiteLinkGraph,
18
+ getSitePublicFiles,
19
+ } from './lib/site-link-graph.mjs';
20
+ import {
21
+ sitePagesDir,
22
+ sitePagesLabel,
23
+ siteProjectRoot,
24
+ } from './lib/site-paths.mjs';
25
+ import { getSiteStructure } from './lib/site-structure.mjs';
26
+
27
+ const usage = `
28
+ Usage:
29
+ norna page:move <old-url> <new-url> [--order <NNN>] [--no-aliases] [--write]
30
+
31
+ Examples:
32
+ norna page:move /guides/install/ /guides/installation/
33
+ norna page:move /guides/install/ /reference/install/ --write
34
+
35
+ The command is a dry run unless --write is present. If the old page has
36
+ already been moved by hand, the same command reconciles its links and aliases.
37
+ `.trim();
38
+
39
+ const parseOrder = (value) => {
40
+ if (!/^\d{1,3}$/.test(value ?? '')) {
41
+ throw new Error(`Invalid --order "${value ?? ''}". Use an integer from 1 to 999.\n${usage}`);
42
+ }
43
+ const order = Number.parseInt(value, 10);
44
+ if (order < 1 || order > 999) {
45
+ throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.\n${usage}`);
46
+ }
47
+ return order;
48
+ };
49
+
50
+ const parseArgs = (rawArgs) => {
51
+ const options = {
52
+ help: false,
53
+ order: null,
54
+ preserveAliases: true,
55
+ urls: [],
56
+ write: false,
57
+ };
58
+ const seen = new Set();
59
+
60
+ for (let index = 0; index < rawArgs.length; index += 1) {
61
+ const arg = rawArgs[index];
62
+ if (arg === '-h' || arg === '--help') {
63
+ options.help = true;
64
+ continue;
65
+ }
66
+ if (arg === '--write') {
67
+ if (seen.has('write')) throw new Error(`--write may be specified only once.\n${usage}`);
68
+ seen.add('write');
69
+ options.write = true;
70
+ continue;
71
+ }
72
+ if (arg === '--no-aliases') {
73
+ if (seen.has('aliases')) throw new Error(`--no-aliases may be specified only once.\n${usage}`);
74
+ seen.add('aliases');
75
+ options.preserveAliases = false;
76
+ continue;
77
+ }
78
+ if (arg === '--order' || arg.startsWith('--order=')) {
79
+ if (seen.has('order')) throw new Error(`--order may be specified only once.\n${usage}`);
80
+ seen.add('order');
81
+ const inlineValue = arg.startsWith('--order=') ? arg.slice('--order='.length) : null;
82
+ const value = inlineValue ?? rawArgs[index + 1];
83
+ if (inlineValue === null) index += 1;
84
+ options.order = parseOrder(value);
85
+ continue;
86
+ }
87
+ if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
88
+ options.urls.push(arg);
89
+ }
90
+
91
+ if (!options.help && options.urls.length !== 2) {
92
+ throw new Error(`page:move requires an old URL and a new URL.\n${usage}`);
93
+ }
94
+ return options;
95
+ };
96
+
97
+ const toDisplayPath = (filePath) => {
98
+ const relativePath = path.relative(siteProjectRoot, filePath);
99
+ return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
100
+ ? relativePath
101
+ : filePath).split(path.sep).join('/');
102
+ };
103
+
104
+ const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
105
+
106
+ const writeFileAtomically = async (filePath, source, mode) => {
107
+ const temporaryPath = path.join(
108
+ path.dirname(filePath),
109
+ `.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
110
+ );
111
+ try {
112
+ await writeFile(temporaryPath, source, { mode: mode & 0o777 });
113
+ await rename(temporaryPath, filePath);
114
+ } finally {
115
+ await rm(temporaryPath, { force: true });
116
+ }
117
+ };
118
+
119
+ const assertSourcesUnchanged = async (plan) => {
120
+ for (const file of plan.sourceFiles) {
121
+ const currentSource = await readFile(file.contentPath, 'utf8');
122
+ if (currentSource !== file.source) {
123
+ throw new Error([
124
+ `Page move stopped because ${file.contentLabel} changed after the plan was created.`,
125
+ 'Run the command again to build a fresh plan. No file was changed.',
126
+ ].join('\n'));
127
+ }
128
+ }
129
+ };
130
+
131
+ const assertFinalSite = async () => {
132
+ const structure = await getSiteStructure();
133
+ const graph = await getSiteLinkGraph({ siteStructure: structure });
134
+ if (graph.diagnostics.length === 0) return;
135
+ throw new Error([
136
+ 'Post-move link validation failed.',
137
+ ...graph.diagnostics.flatMap((diagnostic) => [
138
+ `- ${diagnostic.message}`,
139
+ ...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
140
+ ]),
141
+ ].join('\n'));
142
+ };
143
+
144
+ const applyPlan = async (plan) => {
145
+ await assertSourcesUnchanged(plan);
146
+ let collectionCreated = false;
147
+ let directoryMoved = false;
148
+ let fileWritten = false;
149
+
150
+ try {
151
+ if (plan.mode === 'move') {
152
+ if (!plan.destinationCollectionExists) {
153
+ await mkdir(plan.destinationCollectionDirectory);
154
+ collectionCreated = true;
155
+ }
156
+ try {
157
+ await rename(plan.sourceDirectory, plan.destinationDirectory);
158
+ directoryMoved = true;
159
+ } catch (error) {
160
+ if (error?.code === 'EXDEV') {
161
+ throw new Error('Page directories must stay on one filesystem so Norna can move the complete subtree atomically.');
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ for (const change of plan.fileChanges) {
168
+ await writeFileAtomically(change.finalPath, change.updatedSource, change.mode);
169
+ fileWritten = true;
170
+ }
171
+ await assertFinalSite();
172
+ } catch (error) {
173
+ const changesStarted = directoryMoved || fileWritten;
174
+ const rollbackErrors = [];
175
+ if (directoryMoved) {
176
+ try {
177
+ await rename(plan.destinationDirectory, plan.sourceDirectory);
178
+ directoryMoved = false;
179
+ } catch (rollbackError) {
180
+ rollbackErrors.push(`Could not restore the page directory: ${rollbackError.message}`);
181
+ }
182
+ }
183
+
184
+ for (const change of plan.fileChanges) {
185
+ try {
186
+ const restorePath = directoryMoved && change.finalPath !== change.originalPath
187
+ ? change.finalPath
188
+ : change.originalPath;
189
+ await writeFileAtomically(restorePath, change.originalSource, change.mode);
190
+ } catch (rollbackError) {
191
+ rollbackErrors.push(`Could not restore ${change.contentLabel}: ${rollbackError.message}`);
192
+ }
193
+ }
194
+
195
+ if (collectionCreated && !directoryMoved) {
196
+ try {
197
+ await rmdir(plan.destinationCollectionDirectory);
198
+ } catch (rollbackError) {
199
+ if (rollbackError?.code !== 'ENOENT' && rollbackError?.code !== 'ENOTEMPTY') {
200
+ rollbackErrors.push(`Could not remove the newly created pages directory: ${rollbackError.message}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ throw new Error([
206
+ error instanceof Error ? error.message : String(error),
207
+ rollbackErrors.length === 0
208
+ ? (changesStarted
209
+ ? 'The original page directory and content files were restored.'
210
+ : 'No source file was changed.')
211
+ : 'Rollback was incomplete. Inspect these paths before running page:move again:',
212
+ ...rollbackErrors.map((message) => `- ${message}`),
213
+ ].join('\n'));
214
+ }
215
+ };
216
+
217
+ const main = async () => {
218
+ const options = parseArgs(process.argv.slice(2));
219
+ if (options.help) {
220
+ console.log(usage);
221
+ return;
222
+ }
223
+
224
+ const siteStructure = await getSiteStructure();
225
+ const [graph, publicFiles] = await Promise.all([
226
+ getSiteLinkGraph({ siteStructure }),
227
+ getSitePublicFiles(),
228
+ ]);
229
+ const plan = await createPageMovePlan({
230
+ from: options.urls[0],
231
+ graph,
232
+ order: options.order,
233
+ preserveAliases: options.preserveAliases,
234
+ publicFiles,
235
+ sitePagesDir,
236
+ sitePagesLabel,
237
+ siteStructure,
238
+ to: options.urls[1],
239
+ });
240
+
241
+ console.log(formatPageMovePlan(plan, toDisplayPath).join('\n'));
242
+ if (!options.write) {
243
+ console.log('\nDry run only. Run the same command with --write to apply this plan.');
244
+ return;
245
+ }
246
+
247
+ await applyPlan(plan);
248
+ console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
249
+ };
250
+
251
+ try {
252
+ await main();
253
+ } catch (error) {
254
+ console.error(error instanceof Error ? error.message : String(error));
255
+ process.exit(1);
256
+ }
@@ -0,0 +1,32 @@
1
+ import {
2
+ formatNavigationReviewText,
3
+ getNavigationReview,
4
+ parseNavigationReviewArgs,
5
+ } from './lib/navigation-review.mjs';
6
+
7
+ const usage = `
8
+ Usage: norna navigation:review [--format text|json]
9
+
10
+ Read the selected site and report its page hierarchy, heading outlines,
11
+ internal page links, effective navigation modes, and structural review prompts.
12
+ No files are changed.
13
+ `.trim();
14
+
15
+ try {
16
+ const { format, help } = parseNavigationReviewArgs(process.argv.slice(2));
17
+ if (help) {
18
+ console.log(usage);
19
+ } else {
20
+ const { projectConfig } = await import('./lib/project-config.mjs');
21
+ const review = await getNavigationReview({
22
+ requestedNavigationMode: projectConfig.navigation.mode,
23
+ });
24
+ process.stdout.write(format === 'json'
25
+ ? `${JSON.stringify(review, null, 2)}\n`
26
+ : formatNavigationReviewText(review));
27
+ if (review.errors.length > 0) process.exitCode = 1;
28
+ }
29
+ } catch (error) {
30
+ console.error(error instanceof Error ? error.message : String(error));
31
+ process.exitCode = 1;
32
+ }
@@ -21,10 +21,16 @@ import {
21
21
  import { getSiteStructure } from './lib/site-structure.mjs';
22
22
  import { readImageDimensions } from './lib/image-dimensions.mjs';
23
23
  import { parsePageMarkdownSource } from './lib/page-markdown.mjs';
24
+ import {
25
+ getDirectChildPages,
26
+ getListedSiteNavigationTree,
27
+ } from './lib/site-navigation-tree.mjs';
24
28
  import {
25
29
  createSiteLinkGraph,
26
30
  getSitePublicFiles,
27
31
  } from './lib/site-link-graph.mjs';
32
+ import { getGeneratedSiteRoutes } from './lib/page-aliases.mjs';
33
+ import projectConfig from './lib/project-config.mjs';
28
34
  import {
29
35
  siteThemeLabel,
30
36
  siteThemePath,
@@ -363,6 +369,22 @@ for (const contentFile of contentFiles) {
363
369
  });
364
370
  }
365
371
 
372
+ for (const error of section.calloutErrors) {
373
+ addSectionIssue(contentFile, section, {
374
+ severity: 'error',
375
+ message: error.message,
376
+ fix: error.fix,
377
+ });
378
+ }
379
+
380
+ for (const error of section.codeFenceErrors) {
381
+ addSectionIssue(contentFile, section, {
382
+ severity: 'error',
383
+ message: error.message,
384
+ fix: error.fix,
385
+ });
386
+ }
387
+
366
388
  for (const reference of section.managedImages) {
367
389
  managedImageReferences.push({ contentFile, section, reference });
368
390
  }
@@ -379,6 +401,41 @@ for (const contentFile of contentFiles) {
379
401
  });
380
402
  }
381
403
 
404
+ const contentContextByPageDirectory = new Map(contentFileContexts.map((context) => [
405
+ context.contentFile.pageDirectory,
406
+ context,
407
+ ]));
408
+ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map((node) => {
409
+ const context = contentContextByPageDirectory.get(node.pageDirectory);
410
+ return {
411
+ node: {
412
+ ...node,
413
+ navigation: {
414
+ listed: node.kind === 'category'
415
+ ? true
416
+ : context?.frontmatterData.navigation?.listed ?? true,
417
+ },
418
+ },
419
+ };
420
+ }));
421
+
422
+ for (const context of contentFileContexts) {
423
+ const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
424
+ if (childPages.length > 0) continue;
425
+
426
+ for (const section of context.sections) {
427
+ if (!context.validSections.has(section)) continue;
428
+ for (const block of section.blocks) {
429
+ if (block.type !== 'page-list') continue;
430
+ addSectionIssue(context.contentFile, section, {
431
+ severity: 'error',
432
+ message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
433
+ fix: 'Add a listed direct child page or remove the block.',
434
+ });
435
+ }
436
+ }
437
+ }
438
+
382
439
  const siteLinkGraph = createSiteLinkGraph({
383
440
  pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
384
441
  contentFile,
@@ -387,9 +444,15 @@ const siteLinkGraph = createSiteLinkGraph({
387
444
  })),
388
445
  publicFiles: await getSitePublicFiles(),
389
446
  siteStructure,
447
+ generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
390
448
  });
391
449
  for (const issue of siteLinkGraph.diagnostics) {
392
- addContentIssue(issue.reference?.sourceContentFile ?? issue.contentFile, issue);
450
+ const issueContentFile = issue.reference?.sourceContentFile ?? issue.contentFile;
451
+ if (issueContentFile) {
452
+ addContentIssue(issueContentFile, issue);
453
+ } else {
454
+ addIssue(issue);
455
+ }
393
456
  }
394
457
 
395
458
  const imageSyncPlan = createImageSyncPlan({
@@ -441,7 +504,7 @@ for (const context of contentFileContexts) {
441
504
  addSectionIssue(contentFile, section, {
442
505
  severity: 'warning',
443
506
  message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
444
- fix: 'Use a norna-image-stack, norna-image-carousel, or norna-card-list block for site images that should be validated, processed and synced.',
507
+ fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
445
508
  });
446
509
  }
447
510
 
@@ -457,8 +520,8 @@ for (const context of contentFileContexts) {
457
520
  if (block.type === 'image-carousel' && block.images.length < 2) {
458
521
  addSectionIssue(contentFile, section, {
459
522
  severity: 'error',
460
- message: `norna-image-carousel on line ${block.line} contains ${block.images.length} image. A carousel needs at least two images.`,
461
- fix: 'Add another image entry, or use norna-image-stack for a single image.',
523
+ message: `image-carousel on line ${block.line} contains ${block.images.length} image. An image carousel needs at least two images.`,
524
+ fix: 'Add another image entry, or use image-stack for a single image.',
462
525
  });
463
526
  }
464
527
  }
@@ -10,7 +10,10 @@ import {
10
10
  } from './lib/site-paths.mjs';
11
11
  import { getSiteStructure } from './lib/site-structure.mjs';
12
12
 
13
- const keepAstroPublicEntries = new Set(['images']);
13
+ const keepAstroPublicEntries = new Set([
14
+ 'images',
15
+ ...(projectConfig.search.enabled ? ['pagefind'] : []),
16
+ ]);
14
17
 
15
18
  const readDirectory = async (directory) => {
16
19
  try {
@@ -25,11 +28,27 @@ const readDirectory = async (directory) => {
25
28
  };
26
29
 
27
30
  const sourceEntries = await readDirectory(sitePublicDir);
28
- const sitemapConflict = sourceEntries.find(({ name }) => name.toLowerCase() === sitemapFilename);
29
- if (sitemapConflict) {
31
+ const generatedPublicFiles = [
32
+ {
33
+ filename: sitemapFilename,
34
+ explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
35
+ },
36
+ {
37
+ filename: '404.html',
38
+ explanation: 'Norna generates 404.html as the site\'s localized missing-page response.',
39
+ },
40
+ ...(projectConfig.search.enabled ? [{
41
+ filename: 'pagefind',
42
+ explanation: 'Norna generates pagefind/ from the rendered editorial content when search is enabled.',
43
+ }] : []),
44
+ ];
45
+ for (const generatedFile of generatedPublicFiles) {
46
+ const conflict = sourceEntries.find(({ name }) => name.toLowerCase() === generatedFile.filename);
47
+ if (!conflict) continue;
48
+
30
49
  throw new Error([
31
- `${sitePublicLabel}/${sitemapConflict.name} conflicts with Norna's generated ${sitemapFilename}.`,
32
- `Remove that source file. Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
50
+ `${sitePublicLabel}/${conflict.name} conflicts with Norna's generated ${generatedFile.filename}.`,
51
+ `Remove that source file. ${generatedFile.explanation}`,
33
52
  ].join('\n'));
34
53
  }
35
54
 
@@ -65,10 +65,15 @@ const labels = {
65
65
  const pre = code.parentElement;
66
66
  if (!pre || pre.parentElement?.classList.contains('code-block')) return;
67
67
 
68
- const wrapper = document.createElement('div');
69
- wrapper.className = 'code-block';
70
- pre.before(wrapper);
71
- wrapper.append(pre);
68
+ const codeExample = pre.parentElement?.classList.contains('norna-code-example')
69
+ ? pre.parentElement
70
+ : null;
71
+ const wrapper = codeExample ?? document.createElement('div');
72
+ wrapper.classList.add('code-block');
73
+ if (!codeExample) {
74
+ pre.before(wrapper);
75
+ wrapper.append(pre);
76
+ }
72
77
 
73
78
  const button = template.content.firstElementChild?.cloneNode(true);
74
79
  if (!(button instanceof HTMLButtonElement)) return;