@janga/norna 0.7.27 → 0.7.29

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 (136) hide show
  1. package/README.md +41 -29
  2. package/astro.config.mjs +33 -3
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +18 -12
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +81 -66
  7. package/schemas/content-frontmatter.schema.json +23 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +31 -250
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +51 -17
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +89 -70
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/generate-search-index.mjs +3 -2
  21. package/scripts/lib/attachment-render.mjs +23 -0
  22. package/scripts/lib/category-destinations.mjs +13 -12
  23. package/scripts/lib/dev-server-identity.mjs +30 -0
  24. package/scripts/lib/documentation-links.mjs +1 -1
  25. package/scripts/lib/documentation-routes.json +70 -62
  26. package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
  27. package/scripts/lib/editor-attachments.mjs +69 -0
  28. package/scripts/lib/editor-file-policy.mjs +20 -0
  29. package/scripts/lib/editor-language-service.mjs +74 -73
  30. package/scripts/lib/editor-page-addresses.mjs +57 -0
  31. package/scripts/lib/editor-page-placement.mjs +102 -0
  32. package/scripts/lib/editor-resource-actions.mjs +216 -0
  33. package/scripts/lib/editor-site-editing-tree.mjs +181 -0
  34. package/scripts/lib/editor-site-files.mjs +154 -0
  35. package/scripts/lib/editor-site-links.mjs +81 -0
  36. package/scripts/lib/editor-site-preview.mjs +149 -0
  37. package/scripts/lib/editor-site-tree.mjs +158 -0
  38. package/scripts/lib/editor-source-files.mjs +87 -0
  39. package/scripts/lib/editor-theme-help.mjs +43 -0
  40. package/scripts/lib/locale-registry.mjs +1 -1
  41. package/scripts/lib/locales/en.mjs +1 -1
  42. package/scripts/lib/logo-assets.mjs +2 -1
  43. package/scripts/lib/markdown-images.mjs +59 -0
  44. package/scripts/lib/markdown-links.mjs +29 -9
  45. package/scripts/lib/navigation-model.mjs +2 -44
  46. package/scripts/lib/navigation-review.mjs +14 -50
  47. package/scripts/lib/norna-markdown-blocks.mjs +1 -58
  48. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  49. package/scripts/lib/page-aliases.mjs +8 -1
  50. package/scripts/lib/page-attachments.mjs +59 -0
  51. package/scripts/lib/page-markdown.mjs +11 -7
  52. package/scripts/lib/page-model.mjs +19 -36
  53. package/scripts/lib/page-move-apply.mjs +119 -0
  54. package/scripts/lib/page-move-plan.mjs +64 -78
  55. package/scripts/lib/page-order-source.mjs +22 -0
  56. package/scripts/lib/page-order.mjs +45 -0
  57. package/scripts/lib/page-placement-cli.mjs +50 -0
  58. package/scripts/lib/presentation.mjs +3 -3
  59. package/scripts/lib/project-config.mjs +10 -8
  60. package/scripts/lib/publication-paths.mjs +72 -0
  61. package/scripts/lib/schema-definitions.mjs +15 -38
  62. package/scripts/lib/schema-editor-metadata.mjs +16 -60
  63. package/scripts/lib/schema-value-definitions.mjs +1 -1
  64. package/scripts/lib/site-content.mjs +8 -25
  65. package/scripts/lib/site-conventions.mjs +19 -1
  66. package/scripts/lib/site-link-graph.mjs +88 -18
  67. package/scripts/lib/site-navigation-tree.mjs +2 -7
  68. package/scripts/lib/site-node-create.mjs +182 -0
  69. package/scripts/lib/site-paths.mjs +24 -12
  70. package/scripts/lib/site-source-format.mjs +12 -0
  71. package/scripts/lib/site-structure.mjs +86 -95
  72. package/scripts/lib/site-upgrade.mjs +32 -0
  73. package/scripts/lib/social-image-assets.mjs +2 -1
  74. package/scripts/lib/source-files.mjs +34 -0
  75. package/scripts/lib/structured-blocks.mjs +3 -1
  76. package/scripts/lib/theme-config.mjs +26 -68
  77. package/scripts/lib/theme-packages.mjs +47 -0
  78. package/scripts/lib/theme-presets.mjs +20 -53
  79. package/scripts/lib/typography.mjs +2 -2
  80. package/scripts/lib/yaml-config.mjs +1 -1
  81. package/scripts/lib/yaml-source-edit.mjs +94 -0
  82. package/scripts/move-site-page.mjs +46 -254
  83. package/scripts/show-typography.mjs +32 -17
  84. package/scripts/sync-content-sections.mjs +17 -17
  85. package/scripts/sync-site-public.mjs +44 -66
  86. package/scripts/upgrade-site.mjs +17 -0
  87. package/src/components/AreaNavigationMenu.astro +24 -0
  88. package/src/components/AreaNavigationPanel.astro +55 -0
  89. package/src/components/AreaNavigationSwitcher.astro +30 -0
  90. package/src/components/ContentBlocks.astro +2 -1
  91. package/src/components/ImageStack.astro +7 -5
  92. package/src/components/ImageStackEnhancement.astro +0 -7
  93. package/src/components/NavigationContinuity.astro +26 -0
  94. package/src/components/NavigationPageTree.astro +13 -4
  95. package/src/components/NavigationState.astro +11 -0
  96. package/src/components/NavigationTreeControls.astro +3 -2
  97. package/src/components/SearchPage.astro +11 -2
  98. package/src/components/SectionNavigationScript.astro +106 -26
  99. package/src/components/SiteNavigation.astro +45 -20
  100. package/src/components/SitePage.astro +38 -61
  101. package/src/components/SiteSection.astro +1 -0
  102. package/src/components/SiteTreeNavigation.astro +38 -18
  103. package/src/components/TopPageMenu.astro +4 -3
  104. package/src/components/TreeNavigationScript.astro +50 -57
  105. package/src/content.config.ts +11 -12
  106. package/src/layouts/BaseLayout.astro +15 -39
  107. package/src/lib/areaNavigation.ts +42 -0
  108. package/src/lib/imagePresentation.ts +3 -0
  109. package/src/lib/markdownImages.ts +101 -0
  110. package/src/lib/navigationContinuity.ts +212 -0
  111. package/src/lib/navigationFollowing.ts +28 -18
  112. package/src/lib/navigationState.mjs +88 -0
  113. package/src/lib/navigationStateScript.mjs +76 -0
  114. package/src/lib/navigationVisibility.ts +9 -0
  115. package/src/lib/pageThemes.ts +5 -42
  116. package/src/lib/sectionContent.ts +67 -21
  117. package/src/lib/siteNavigation.ts +6 -0
  118. package/src/lib/sitePages.ts +13 -36
  119. package/src/lib/sitePublicAssets.ts +2 -1
  120. package/src/pages/404.astro +1 -1
  121. package/src/pages/[...slug].astro +7 -16
  122. package/src/pages/index.astro +1 -3
  123. package/src/styles/area-navigation.css +408 -0
  124. package/src/styles/content.css +8 -29
  125. package/src/styles/media.css +24 -0
  126. package/src/styles/navigation-continuity.css +163 -0
  127. package/src/styles/page-layout.css +57 -68
  128. package/src/styles/responsive.css +0 -4
  129. package/starters/basic/README.md +9 -8
  130. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  131. package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
  132. package/schemas/category.schema.json +0 -20
  133. package/src/components/PageContentsNavigation.astro +0 -20
  134. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  135. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  136. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -1,6 +1,7 @@
1
+ import { getPageImageSourceKey } from './lib/site-conventions.mjs';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { createHash } from 'node:crypto';
3
- import { copyFile, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { copyFile, mkdir, readdir, readFile, rm, stat, lstat, writeFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
6
  import { promisify } from 'node:util';
6
7
  import {
@@ -23,7 +24,7 @@ import {
23
24
 
24
25
  const execFileAsync = promisify(execFile);
25
26
 
26
- const imageOutputVersion = 2;
27
+ const imageOutputVersion = 3;
27
28
  const sourceHashSlugLength = 8;
28
29
  const widths = [480, 768, 1080, 1440, 1920];
29
30
 
@@ -84,12 +85,12 @@ const getFilePathFromPublicPath = (publicPath) => path.join(astroPublicDir, publ
84
85
  const getSourceHashSlug = (sourceHash) => sourceHash.slice(0, sourceHashSlugLength);
85
86
 
86
87
  const getGeneratedPath = (sourceKey, sourceHash, width) => {
87
- const parsed = path.parse(sourceKey);
88
- return path.join(generatedImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
88
+ const parsed = path.parse(sourceKey.replace(/^root\//, ''));
89
+ return path.join(generatedImagesDir, parsed.dir, `${parsed.base}-${getSourceHashSlug(sourceHash)}-${width}.webp`);
89
90
  };
90
91
 
91
92
  const getOriginalPath = (sourceKey, sourceHash) => {
92
- const parsed = path.parse(sourceKey);
93
+ const parsed = path.parse(sourceKey.replace(/^root\//, ''));
93
94
  return path.join(originalImagesDir, parsed.dir, `${parsed.name}-${getSourceHashSlug(sourceHash)}${parsed.ext}`);
94
95
  };
95
96
 
@@ -97,11 +98,13 @@ const fail = (message) => {
97
98
  throw new Error(message);
98
99
  };
99
100
 
100
- const getImageSourceKey = (contentFile, image) => `pages/${contentFile.pageDirectory}/images/${image}`;
101
+ const getImageSourceKey = (contentFile, image) => getPageImageSourceKey(contentFile.pageDirectory, image);
101
102
 
102
103
  const getReferencedImages = async (contentFile) => {
103
104
  const { body } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
104
105
  const page = await parsePageMarkdown(body, { label: contentFile.contentLabel });
106
+ const imageError = page.diagnostics.find((issue) => issue.code === 'invalid-markdown-image');
107
+ if (imageError) throw new Error(imageError.message + ' ' + imageError.fix);
105
108
  const references = [];
106
109
 
107
110
  for (const region of page.regions) {
@@ -137,6 +140,7 @@ const getContentSourcePath = ({ image, sourceKey }) => {
137
140
  const getReferencedSources = async () => {
138
141
  const contentFiles = await getContentFiles();
139
142
  const seen = new Set();
143
+ const normalizedSources = new Map();
140
144
  const sources = [];
141
145
 
142
146
  for (const contentFile of contentFiles) {
@@ -151,7 +155,9 @@ const getReferencedSources = async () => {
151
155
  continue;
152
156
  }
153
157
 
154
- const fileStat = await stat(sourcePath).catch(() => null);
158
+ const fileStat = await lstat(sourcePath).catch(() => null);
159
+ const directoryStat = await lstat(contentFile.imagesDir).catch(() => null);
160
+ if (fileStat?.isSymbolicLink() || directoryStat?.isSymbolicLink()) fail(`Image ${sourcePath} must belong to a regular images/ directory; symbolic links are not supported.`);
155
161
  if (!fileStat?.isFile()) {
156
162
  fail(`Image file does not exist: ${contentFile.imagesLabel}/${siteImagePath}`);
157
163
  }
@@ -160,6 +166,10 @@ const getReferencedSources = async () => {
160
166
  fail(`Image "${imageName}" is used in ${reference.sectionLabel} but is not stored directly in ${contentFile.imagesLabel}/. Run norna content:sync, or npm run norna:sync in starter-style repositories, to move it.`);
161
167
  }
162
168
 
169
+ const normalizedKey = reference.sourceKey.normalize('NFC').toLowerCase();
170
+ const previous = normalizedSources.get(normalizedKey);
171
+ if (previous) fail(`Image sources ${previous} and ${sourcePath} conflict after case/Unicode normalization. Rename one image.`);
172
+ normalizedSources.set(normalizedKey, sourcePath);
163
173
  seen.add(reference.sourceKey);
164
174
  sources.push({ sourcePath, sourceKey: reference.sourceKey, reference });
165
175
  }
@@ -202,13 +212,13 @@ const validateCarouselOrientations = async (manifest) => {
202
212
  }
203
213
  };
204
214
 
205
- const identify = async (sourcePath) => {
215
+ const identify = async (sourcePath, imageMagick) => {
206
216
  const output = await imageMagick.identify(sourcePath);
207
217
  const [width, height] = output.split(' ').map(Number);
208
218
  return { width, height };
209
219
  };
210
220
 
211
- const convert = async (sourcePath, outputPath, width) => {
221
+ const convert = async (sourcePath, outputPath, width, imageMagick) => {
212
222
  await mkdir(path.dirname(outputPath), { recursive: true });
213
223
  await imageMagick.convert(sourcePath, outputPath, width);
214
224
  };
@@ -262,7 +272,7 @@ const hasGeneratedVariants = async (variants) => {
262
272
  return true;
263
273
  };
264
274
 
265
- const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
275
+ const getReusableEntry = async (sourceKey, previousEntry, sourceHash) => {
266
276
  if (
267
277
  previousEntry?.sourceHash !== sourceHash
268
278
  || previousEntry?.outputVersion !== imageOutputVersion
@@ -272,7 +282,7 @@ const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
272
282
  return null;
273
283
  }
274
284
 
275
- const variants = getVariants(sourcePath, sourceHash, getVariantWidths(previousEntry));
285
+ const variants = getVariants(sourceKey, sourceHash, getVariantWidths(previousEntry));
276
286
 
277
287
  if (!(await hasGeneratedVariants(variants))) {
278
288
  return null;
@@ -287,12 +297,12 @@ const getReusableEntry = async (sourcePath, previousEntry, sourceHash) => {
287
297
  };
288
298
  };
289
299
 
290
- const getReusableStaticEntry = async (previousEntry, sourceHash) => {
300
+ const getReusableStaticEntry = async (previousEntry, sourceHash, sourceKey) => {
291
301
  if (
292
302
  previousEntry?.kind !== 'static'
293
303
  || previousEntry?.sourceHash !== sourceHash
294
304
  || previousEntry?.outputVersion !== imageOutputVersion
295
- || typeof previousEntry?.src !== 'string'
305
+ || previousEntry?.src !== getPublicPath(getOriginalPath(sourceKey, sourceHash))
296
306
  || !(await fileExists(getFilePathFromPublicPath(previousEntry.src)))
297
307
  ) {
298
308
  return null;
@@ -348,80 +358,89 @@ const removeUnreferencedGeneratedFiles = async (manifest) => {
348
358
  }
349
359
  };
350
360
 
351
- await mkdir(generatedImagesDir, { recursive: true });
361
+ const main = async () => {
362
+ await mkdir(generatedImagesDir, { recursive: true });
352
363
 
353
- const previousManifest = await readManifest();
354
- const sources = await getReferencedSources();
355
- const manifest = {};
356
- const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
357
- const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
364
+ const previousManifest = await readManifest();
365
+ const sources = await getReferencedSources();
366
+ const manifest = {};
367
+ const isStaticSource = (sourcePath) => staticImageExtensions.has(path.extname(sourcePath).toLowerCase());
368
+ const isRasterSource = (sourcePath) => rasterImageExtensions.has(path.extname(sourcePath).toLowerCase());
358
369
 
359
- if (sources.length === 0) {
360
- await removeUnreferencedGeneratedFiles(manifest);
361
- await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
362
- await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
363
- process.exit(0);
364
- }
370
+ if (sources.length === 0) {
371
+ await removeUnreferencedGeneratedFiles(manifest);
372
+ await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
373
+ await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
374
+ return;
375
+ }
376
+
377
+ const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
378
+ ? await getImageMagick()
379
+ : null;
365
380
 
366
- const imageMagick = sources.some(({ sourcePath }) => isRasterSource(sourcePath))
367
- ? await getImageMagick()
368
- : null;
381
+ for (const { sourcePath, sourceKey } of sources) {
382
+ const sourceHash = await getSourceHash(sourcePath);
369
383
 
370
- for (const { sourcePath, sourceKey } of sources) {
371
- const sourceHash = await getSourceHash(sourcePath);
384
+ if (isStaticSource(sourcePath)) {
385
+ const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash, sourceKey);
372
386
 
373
- if (isStaticSource(sourcePath)) {
374
- const reusableEntry = await getReusableStaticEntry(previousManifest[sourceKey], sourceHash);
387
+ if (reusableEntry) {
388
+ manifest[sourceKey] = reusableEntry;
389
+ continue;
390
+ }
391
+
392
+ const outputPath = getOriginalPath(sourceKey, sourceHash);
393
+ await mkdir(path.dirname(outputPath), { recursive: true });
394
+ await copyFile(sourcePath, outputPath);
395
+
396
+ const dimensions = await readImageDimensions(sourcePath).catch(() => null);
397
+ manifest[sourceKey] = {
398
+ kind: 'static',
399
+ outputVersion: imageOutputVersion,
400
+ sourceHash,
401
+ src: getPublicPath(outputPath),
402
+ ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
403
+ };
404
+ continue;
405
+ }
406
+
407
+ const reusableEntry = await getReusableEntry(sourceKey, previousManifest[sourceKey], sourceHash);
375
408
 
376
409
  if (reusableEntry) {
377
410
  manifest[sourceKey] = reusableEntry;
378
411
  continue;
379
412
  }
380
413
 
381
- const outputPath = getOriginalPath(sourceKey, sourceHash);
382
- await mkdir(path.dirname(outputPath), { recursive: true });
383
- await copyFile(sourcePath, outputPath);
414
+ if (!imageMagick) {
415
+ throw new Error(`ImageMagick is missing for raster image: ${sourcePath}`);
416
+ }
417
+
418
+ const dimensions = await identify(sourcePath, imageMagick);
419
+ const variantWidths = getVariantWidths(dimensions);
420
+ const variants = getVariants(sourceKey, sourceHash, variantWidths);
421
+ for (const width of variantWidths) {
422
+ const outputPath = getGeneratedPath(sourceKey, sourceHash, width);
423
+ await convert(sourcePath, outputPath, width, imageMagick);
424
+ }
384
425
 
385
- const dimensions = await readImageDimensions(sourcePath).catch(() => null);
386
426
  manifest[sourceKey] = {
387
- kind: 'static',
388
427
  outputVersion: imageOutputVersion,
389
428
  sourceHash,
390
- src: getPublicPath(outputPath),
391
- ...(dimensions ? { width: dimensions.width, height: dimensions.height } : {}),
429
+ width: dimensions.width,
430
+ height: dimensions.height,
431
+ variants,
392
432
  };
393
- continue;
394
433
  }
395
434
 
396
- const reusableEntry = await getReusableEntry(sourcePath, previousManifest[sourceKey], sourceHash);
397
-
398
- if (reusableEntry) {
399
- manifest[sourceKey] = reusableEntry;
400
- continue;
401
- }
402
-
403
- if (!imageMagick) {
404
- throw new Error(`ImageMagick is missing for raster image: ${sourcePath}`);
405
- }
406
-
407
- const dimensions = await identify(sourcePath);
408
- const variantWidths = getVariantWidths(dimensions);
409
- const variants = getVariants(sourceKey, sourceHash, variantWidths);
410
- for (const width of variantWidths) {
411
- const outputPath = getGeneratedPath(sourceKey, sourceHash, width);
412
- await convert(sourcePath, outputPath, width);
413
- }
435
+ await validateCarouselOrientations(manifest);
436
+ await removeUnreferencedGeneratedFiles(manifest);
437
+ await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
438
+ await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
439
+ };
414
440
 
415
- manifest[sourceKey] = {
416
- outputVersion: imageOutputVersion,
417
- sourceHash,
418
- width: dimensions.width,
419
- height: dimensions.height,
420
- variants,
421
- };
441
+ try {
442
+ await main();
443
+ } catch (error) {
444
+ console.error(error.message);
445
+ process.exitCode = 1;
422
446
  }
423
-
424
- await validateCarouselOrientations(manifest);
425
- await removeUnreferencedGeneratedFiles(manifest);
426
- await mkdir(path.dirname(generatedImagesManifestPath), { recursive: true });
427
- await writeFile(generatedImagesManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
@@ -3,22 +3,23 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { z } from 'astro/zod';
5
5
  import {
6
- categorySchema,
7
6
  configSchema,
7
+ rootThemeSchema,
8
8
  pageThemeSchema,
9
9
  siteSchema,
10
10
  sitewideSchema,
11
11
  themeVisualSchema,
12
12
  } from './lib/schema-definitions.mjs';
13
13
  import { applySchemaEditorMetadata } from './lib/schema-editor-metadata.mjs';
14
+ import { sourceFileDefinitions, sourcePageDirectoryPattern } from './lib/source-files.mjs';
14
15
  import { getNornaBlockSchema } from './lib/norna-markdown-blocks.mjs';
15
16
 
16
17
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
18
  const schemaDirectory = path.join(root, 'schemas');
18
19
  const definitions = [
19
- ['category.schema.json', 'Norna page category', categorySchema],
20
20
  ['config.schema.json', 'Norna site configuration', configSchema],
21
- ['theme.schema.json', 'Norna visual theme', themeVisualSchema],
21
+ ['theme.schema.json', 'Norna inherited tree theme', themeVisualSchema],
22
+ ['root-theme.schema.json', 'Norna root tree theme', rootThemeSchema],
22
23
  ['page-theme.schema.json', 'Norna page theme', pageThemeSchema],
23
24
  ['sitewide-content.schema.json', 'Norna site-wide content', sitewideSchema],
24
25
  ['content-frontmatter.schema.json', 'Norna page frontmatter', siteSchema],
@@ -44,15 +45,17 @@ for (const type of blockTypes) {
44
45
  }
45
46
 
46
47
  schemaFiles.set('manifest.json', `${JSON.stringify({
47
- editorApiVersion: 2,
48
- schemaVersion: 3,
48
+ editorApiVersion: 3,
49
+ schemaVersion: 6,
50
+ sourceFiles: sourceFileDefinitions,
51
+ pageDirectoryPattern: sourcePageDirectoryPattern,
49
52
  blockSchemas: Object.fromEntries(blockTypes.map((type) => [type, `${type}.schema.json`])),
50
53
  files: {
51
- category: 'category.schema.json',
52
54
  config: 'config.schema.json',
53
55
  contentFrontmatter: 'content-frontmatter.schema.json',
54
56
  sitewideContent: 'sitewide-content.schema.json',
55
57
  theme: 'theme.schema.json',
58
+ rootTheme: 'root-theme.schema.json',
56
59
  pageTheme: 'page-theme.schema.json',
57
60
  },
58
61
  }, null, 2)}\n`);
@@ -1,3 +1,4 @@
1
+ import { searchOutputDirectory } from './lib/publication-paths.mjs';
1
2
  import { cp, mkdir, readdir, rm } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import * as pagefind from 'pagefind';
@@ -7,7 +8,7 @@ import {
7
8
  astroPublicDir,
8
9
  } from './lib/site-paths.mjs';
9
10
 
10
- const searchDirectoryName = 'pagefind';
11
+ const searchDirectoryName = searchOutputDirectory;
11
12
  const distSearchDirectory = path.join(astroDistDir, searchDirectoryName);
12
13
  const localSearchDirectory = path.join(astroPublicDir, searchDirectoryName);
13
14
 
@@ -44,7 +45,7 @@ if (!projectConfig.search.enabled) {
44
45
  }
45
46
 
46
47
  await rm(localSearchDirectory, { force: true, recursive: true });
47
- await mkdir(astroPublicDir, { recursive: true });
48
+ await mkdir(path.dirname(localSearchDirectory), { recursive: true });
48
49
  await cp(distSearchDirectory, localSearchDirectory, { recursive: true });
49
50
 
50
51
  console.log(`Generated static search index for ${indexedPageCount} page${indexedPageCount === 1 ? '' : 's'}.`);
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { getSourcePageLocation } from './source-files.mjs';
4
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
5
+ import { readPageAttachments, resolveAttachment, attachmentHref } from './page-attachments.mjs';
6
+
7
+ export const rewriteAttachmentLinks = async (source, fileURL, siteRoot) => {
8
+ if (!fileURL) return source;
9
+ const contentPath = fileURLToPath(fileURL);
10
+ const location = getSourcePageLocation(siteRoot, path.dirname(contentPath));
11
+ if (!location || path.basename(contentPath) !== 'content.md') return source;
12
+ const { files } = await readPageAttachments({ ...location, contentPath });
13
+ if (!files.length) return source;
14
+ const document = await parsePageMarkdownSource(source);
15
+ const edits = new Map();
16
+ for (const link of document.links) {
17
+ const file = resolveAttachment(link.target, location.isHome ? '/' : `/${location.pagePath}/`, files);
18
+ if (!file || !link.targetRange) continue;
19
+ const href = attachmentHref(file, link.target);
20
+ edits.set(link.targetRange.start, { ...link.targetRange, text: link.yamlScalar ? JSON.stringify(href) : href });
21
+ }
22
+ return [...edits.values()].sort((a,b) => b.start-a.start).reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), source);
23
+ };
@@ -9,28 +9,28 @@ export const createCategoryDestinationModel = (nodes) => {
9
9
  const visit = (branch) => {
10
10
  const { node } = branch;
11
11
  if (node.navigation?.listed === false) return false;
12
- const children = branch.children.filter(({ node: child }) => child.navigation?.listed !== false);
13
- const reachable = children.map(visit).some(Boolean);
12
+ // Empty child categories retain their URLs but do not become menu choices
13
+ // or redirect targets for a parent that still has reachable pages.
14
+ const children = branch.children.filter(visit);
14
15
  if (node.kind !== 'category') return true;
15
- if (!reachable) {
16
+ if (children.length === 0) {
16
17
  const source = node.categorySourceLabel ?? node.nodeLabel ?? node.pageDirectory;
17
18
  diagnostics.push({
18
19
  code: 'category-without-listed-content',
19
- severity: 'error',
20
+ severity: 'warning',
20
21
  message: `${source} has no listed reachable content page.`,
21
- fix: 'Add a listed page under this category, or remove the category.',
22
+ fix: 'Add a listed page to include this category in navigation, or leave it empty while reorganizing.',
22
23
  });
23
- return false;
24
24
  }
25
- const first = children[0].node;
25
+ const first = children[0]?.node;
26
26
  destinations.push({
27
27
  category: node,
28
28
  pathname: getSiteNodePathname(node),
29
- kind: first.kind === 'page' ? 'redirect' : 'listing',
30
- target: first.kind === 'page' ? first : null,
29
+ kind: first?.kind === 'page' ? 'redirect' : 'listing',
30
+ target: first?.kind === 'page' ? first : null,
31
31
  children: children.map(({ node: child }) => child),
32
32
  });
33
- return true;
33
+ return children.length > 0;
34
34
  };
35
35
  getSiteNavigationTree(nodes.map((node) => ({ node }))).forEach(visit);
36
36
  return {
@@ -41,8 +41,9 @@ export const createCategoryDestinationModel = (nodes) => {
41
41
  };
42
42
 
43
43
  export const assertCategoryDestinationModel = (model) => {
44
- if (model.diagnostics.length) {
45
- throw new Error(model.diagnostics.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
44
+ const errors = model.diagnostics.filter(({ severity }) => severity === 'error');
45
+ if (errors.length) {
46
+ throw new Error(errors.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
46
47
  }
47
48
  return model;
48
49
  };
@@ -0,0 +1,30 @@
1
+ import { realpath } from 'node:fs/promises';
2
+
3
+ export const identityPath = '/.well-known/norna-dev';
4
+ export const readDevServerIdentity = async (state, siteRoot) => {
5
+ if (!state?.token || !Number.isInteger(state.port) || state.port < 1 || state.port > 65535) return null;
6
+ try {
7
+ const response = await fetch(`http://127.0.0.1:${state.port}${identityPath}`, { signal: AbortSignal.timeout(1500), redirect: 'error' });
8
+ if (!response.ok) return null;
9
+ const identity = await response.json();
10
+ return identity.token === state.token && Number.isInteger(identity.pid) && identity.pid > 0 && identity.siteRoot === await realpath(siteRoot) ? identity : null;
11
+ } catch { return null; }
12
+ };
13
+ export const verifyDevServer = async (state, siteRoot) => Number.isInteger(state?.pid) && state.pid > 0
14
+ && (await readDevServerIdentity(state, siteRoot))?.pid === state.pid;
15
+
16
+ export const devServerIdentityPlugin = siteRoot => ({
17
+ name: 'norna-dev-server-identity',
18
+ async configureServer(server) {
19
+ const canonicalRoot = await realpath(siteRoot);
20
+ return () => {
21
+ server.middlewares.stack.unshift({ route: '', handle(request, response, next) {
22
+ if (request.url !== identityPath || request.method !== 'GET' || !process.env.NORNA_DEV_TOKEN) return next();
23
+ if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress)) return next();
24
+ response.setHeader('Content-Type', 'application/json');
25
+ response.setHeader('Cache-Control', 'no-store');
26
+ response.end(JSON.stringify({ siteRoot: canonicalRoot, token: process.env.NORNA_DEV_TOKEN, pid: process.pid }));
27
+ } });
28
+ };
29
+ },
30
+ });
@@ -27,7 +27,7 @@ export const documentationLinkForVersion = (version, label, file, anchor) => {
27
27
  const reference = version ? `v${version}` : 'main';
28
28
  const source = usesLegacyReference(version)
29
29
  ? `docs/${file}${anchor ? `#${anchor}` : ''}`
30
- : `${destination.source}${destination.anchor ? `#${destination.anchor}` : ''}`;
30
+ : `${version === '0.7.27' ? destination.source.replace('site/root/pages/', 'site/pages/') : destination.source}${destination.anchor ? `#${destination.anchor}` : ''}`;
31
31
  const sourceLabel = version ? `Norna ${version} source reference` : 'Reference source';
32
32
  return `[${label} (current)](${destination.url})\n\n[${sourceLabel}](${repositoryRoot}${reference}/${source})`;
33
33
  };