@janga/norna 0.7.23 → 0.7.25

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 (82) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +14 -0
  3. package/bin/norna-cli.mjs +6 -0
  4. package/package.json +25 -2
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +80 -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 +16 -1
  13. package/scripts/dev-local.mjs +106 -11
  14. package/scripts/generate-search-index.mjs +57 -0
  15. package/scripts/init-site.mjs +3 -0
  16. package/scripts/lib/code-fence-metadata.mjs +228 -0
  17. package/scripts/lib/edit-source-link.mjs +102 -0
  18. package/scripts/lib/editor-language-service.mjs +32 -12
  19. package/scripts/lib/image-presentation.mjs +4 -0
  20. package/scripts/lib/navigation-model.mjs +34 -13
  21. package/scripts/lib/navigation-review.mjs +396 -0
  22. package/scripts/lib/norna-markdown-blocks.mjs +164 -26
  23. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  24. package/scripts/lib/page-aliases.mjs +21 -1
  25. package/scripts/lib/page-markdown.mjs +32 -1
  26. package/scripts/lib/page-move-plan.mjs +659 -0
  27. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  28. package/scripts/lib/presentation.mjs +1 -10
  29. package/scripts/lib/project-config.mjs +141 -5
  30. package/scripts/lib/public-asset-conventions.mjs +36 -2
  31. package/scripts/lib/schema-definitions.mjs +30 -5
  32. package/scripts/lib/schema-editor-metadata.mjs +36 -4
  33. package/scripts/lib/schema-value-definitions.mjs +4 -1
  34. package/scripts/lib/semantic-callouts.mjs +128 -0
  35. package/scripts/lib/site-content.mjs +2 -1
  36. package/scripts/lib/site-link-graph.mjs +33 -2
  37. package/scripts/lib/site-navigation-tree.mjs +85 -0
  38. package/scripts/lib/site-paths.mjs +14 -2
  39. package/scripts/lib/social-image-assets.mjs +30 -0
  40. package/scripts/lib/table-render-plugin.mjs +33 -0
  41. package/scripts/lib/theme-presets.mjs +2 -2
  42. package/scripts/lib/theme-profiles.mjs +0 -4
  43. package/scripts/move-site-page.mjs +256 -0
  44. package/scripts/review-navigation.mjs +32 -0
  45. package/scripts/sync-content-sections.mjs +67 -4
  46. package/scripts/sync-site-public.mjs +24 -5
  47. package/src/components/CardList.astro +1 -0
  48. package/src/components/CodeBlockCopyScript.astro +11 -5
  49. package/src/components/EditSourceLink.astro +17 -0
  50. package/src/components/ImageCarousel.astro +9 -7
  51. package/src/components/ImageStack.astro +34 -9
  52. package/src/components/ImageStackEnhancement.astro +331 -0
  53. package/src/components/NavigationPageTree.astro +66 -55
  54. package/src/components/NavigationTreeControls.astro +77 -0
  55. package/src/components/PageAliasRedirect.astro +1 -1
  56. package/src/components/PageContentsNavigation.astro +2 -3
  57. package/src/components/PageList.astro +33 -0
  58. package/src/components/PageSequenceNavigation.astro +37 -0
  59. package/src/components/SearchPage.astro +151 -0
  60. package/src/components/SectionNavigationScript.astro +45 -7
  61. package/src/components/SiteNavigation.astro +102 -51
  62. package/src/components/SitePage.astro +107 -39
  63. package/src/components/SiteSection.astro +11 -3
  64. package/src/components/SiteTreeNavigation.astro +11 -1
  65. package/src/components/TableOverflowScript.astro +251 -0
  66. package/src/components/TreeNavigationScript.astro +271 -34
  67. package/src/layouts/BaseLayout.astro +83 -2
  68. package/src/lib/generatedImages.ts +18 -0
  69. package/src/lib/sectionContent.ts +36 -30
  70. package/src/lib/siteNavigation.ts +31 -52
  71. package/src/lib/sitePages.ts +6 -3
  72. package/src/lib/sitePublicAssets.ts +1 -0
  73. package/src/pages/404.astro +110 -0
  74. package/src/pages/[...slug].astro +15 -5
  75. package/src/styles/content.css +538 -39
  76. package/src/styles/media.css +187 -8
  77. package/src/styles/navigation.css +199 -3
  78. package/src/styles/page-layout.css +481 -100
  79. package/src/styles/responsive.css +144 -28
  80. package/starters/basic/README.md +6 -19
  81. package/starters/basic/package.json +1 -0
  82. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -4,7 +4,7 @@ const field = (description, options = {}) => Object.freeze({ description, ...opt
4
4
  const value = (title, description) => Object.freeze({ title, description });
5
5
 
6
6
  export const nornaMarkdownBlockDefinitions = Object.freeze({
7
- 'norna-image-stack': Object.freeze({
7
+ 'image-stack': Object.freeze({
8
8
  description: 'Display one or more managed images in a vertical stack.',
9
9
  documentation: documentationLink('Image stack reference', 'content.md', 'image-stack'),
10
10
  item: Object.freeze({
@@ -15,7 +15,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
15
15
  }),
16
16
  }),
17
17
  }),
18
- 'norna-image-carousel': Object.freeze({
18
+ 'image-carousel': Object.freeze({
19
19
  description: 'Display two or more managed images in an interactive carousel.',
20
20
  documentation: documentationLink('Image carousel reference', 'content.md', 'image-carousel'),
21
21
  item: Object.freeze({
@@ -26,7 +26,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
26
26
  }),
27
27
  }),
28
28
  }),
29
- 'norna-card-list': Object.freeze({
29
+ 'card-list': Object.freeze({
30
30
  description: 'Display a structured list of cards.',
31
31
  documentation: documentationLink('Card list reference', 'content.md', 'card-list'),
32
32
  options: Object.freeze({
@@ -73,25 +73,45 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
73
73
  }),
74
74
  }),
75
75
  }),
76
+ 'page-list': Object.freeze({
77
+ description: 'Display the current page\'s listed direct child pages in navigation order.',
78
+ documentation: documentationLink('Child page list reference', 'content.md', 'child-page-list'),
79
+ }),
76
80
  });
77
81
 
78
82
  export const nornaBlockTypes = new Set(Object.keys(nornaMarkdownBlockDefinitions));
79
83
 
84
+ const renamedNornaBlockTypes = Object.freeze({
85
+ 'norna-image-stack': 'image-stack',
86
+ 'norna-image-carousel': 'image-carousel',
87
+ 'norna-carousel': 'image-carousel',
88
+ carousel: 'image-carousel',
89
+ 'norna-card-list': 'card-list',
90
+ 'norna-page-list': 'page-list',
91
+ });
92
+
80
93
  const blockTypeLabels = {
81
- 'norna-image-stack': 'norna-image-stack',
82
- 'norna-image-carousel': 'norna-image-carousel',
83
- 'norna-card-list': 'norna-card-list',
94
+ 'image-stack': 'image-stack',
95
+ 'image-carousel': 'image-carousel',
96
+ 'card-list': 'card-list',
97
+ 'page-list': 'page-list',
84
98
  };
85
99
 
86
100
  const knownBlockTypeList = Array.from(nornaBlockTypes).join(', ');
87
101
  const imageNameRegex = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
88
102
  const imageStackExample = [
89
- '```norna-image-stack',
103
+ '```image-stack',
90
104
  '- image: filename.jpg',
91
105
  '```',
92
106
  ].join('\n');
107
+ const carouselExample = [
108
+ '```image-carousel',
109
+ '- image: first.jpg',
110
+ '- image: second.jpg',
111
+ '```',
112
+ ].join('\n');
93
113
  const cardListExample = [
94
- '```norna-card-list',
114
+ '```card-list',
95
115
  'layout: image-top',
96
116
  'flow: grid',
97
117
  'size: m',
@@ -103,7 +123,17 @@ const cardListExample = [
103
123
  ' badge-text: Recommended',
104
124
  '```',
105
125
  ].join('\n');
106
- const cardListDefinition = nornaMarkdownBlockDefinitions['norna-card-list'];
126
+ const pageListExample = [
127
+ '```page-list',
128
+ '```',
129
+ ].join('\n');
130
+ const blockExamples = Object.freeze({
131
+ 'image-stack': imageStackExample,
132
+ 'image-carousel': carouselExample,
133
+ 'card-list': cardListExample,
134
+ 'page-list': pageListExample,
135
+ });
136
+ const cardListDefinition = nornaMarkdownBlockDefinitions['card-list'];
107
137
  const cardListLayouts = new Set(Object.keys(cardListDefinition.options.layout.values));
108
138
  const cardListFlows = new Set(Object.keys(cardListDefinition.options.flow.values));
109
139
  const cardListSizes = new Set(Object.keys(cardListDefinition.options.size.values));
@@ -120,18 +150,20 @@ const fail = (message, options) => {
120
150
  };
121
151
 
122
152
  const getUnknownNornaBlockMessage = (type) => [
123
- `Unknown Norna block "${type}". Use one of: ${knownBlockTypeList}.`,
153
+ renamedNornaBlockTypes[type]
154
+ ? `Norna block "${type}" was renamed to "${renamedNornaBlockTypes[type]}".`
155
+ : `Unknown Norna block "${type}". Use one of: ${knownBlockTypeList}.`,
124
156
  type === 'norna-gallery-stack'
125
- ? 'Use norna-image-stack for one or more stacked images.'
157
+ ? 'Use image-stack for one or more stacked images.'
126
158
  : null,
127
- type === 'norna-carousel'
128
- ? 'Use norna-image-carousel for an image carousel.'
159
+ type === 'norna-image-carousel' || type === 'norna-carousel' || type === 'carousel'
160
+ ? 'Use image-carousel for an image carousel.'
129
161
  : null,
130
162
  type === 'norna-image'
131
- ? 'Use norna-image-stack for a single image or a stacked list of images.'
163
+ ? 'Use image-stack for a single image or a stacked list of images.'
132
164
  : null,
133
165
  'Example:',
134
- imageStackExample,
166
+ blockExamples[renamedNornaBlockTypes[type] ?? type] ?? imageStackExample,
135
167
  ].filter(Boolean).join(' ');
136
168
 
137
169
  const decodeScalar = (value) => {
@@ -229,17 +261,23 @@ export const getOpenMarkdownFenceAtLine = (markdown, lineIndex) => {
229
261
  };
230
262
 
231
263
  const getNornaLineAttempt = (line) => {
232
- const match = line.match(/^ {0,3}([`~]{0,2})(norna-[a-z0-9-]+)\s*$/);
264
+ const match = line.match(/^ {0,3}([`~]{0,2})([a-z][a-z0-9-]+)\s*$/);
233
265
  if (!match) return null;
266
+ const marker = match[1];
267
+ const type = match[2];
268
+ const isNamespacedAttempt = type.startsWith('norna-');
269
+ if (!isNamespacedAttempt && !nornaBlockTypes.has(type) && !renamedNornaBlockTypes[type]) return null;
270
+ if (!marker && type === 'carousel') return null;
234
271
 
235
272
  return {
236
- marker: match[1],
237
- type: match[2],
273
+ marker,
274
+ type,
238
275
  };
239
276
  };
240
277
 
241
278
  const getNornaFenceStartMessage = (type, marker = '') => {
242
- const example = type === 'norna-card-list' ? cardListExample : `\`\`\`${type}\n- image: filename.jpg\n\`\`\``;
279
+ const supportedType = renamedNornaBlockTypes[type] ?? type;
280
+ const example = blockExamples[supportedType] ?? imageStackExample;
243
281
  if (marker) {
244
282
  return `Invalid Norna block start for "${type}". Use three backticks or three tildes. Example:\n${example}`;
245
283
  }
@@ -317,7 +355,7 @@ const scanMarkdownFencedBlocks = (markdown, options = {}) => {
317
355
  }
318
356
  }
319
357
 
320
- const isNornaLike = type.startsWith('norna-');
358
+ const isNornaLike = type.startsWith('norna-') || nornaBlockTypes.has(type) || Boolean(renamedNornaBlockTypes[type]);
321
359
  if (closingIndex === -1) {
322
360
  if (isNornaLike) {
323
361
  errors.push({
@@ -350,7 +388,7 @@ const scanMarkdownFencedBlocks = (markdown, options = {}) => {
350
388
  } else {
351
389
  errors.push({
352
390
  blockType: type,
353
- code: 'unknown-norna-block',
391
+ code: renamedNornaBlockTypes[type] ? 'renamed-norna-block' : 'unknown-norna-block',
354
392
  line: lineNumber,
355
393
  source,
356
394
  message: failMessage(getUnknownNornaBlockMessage(type), { ...options, line: lineNumber }),
@@ -406,10 +444,10 @@ const parseImageListBlock = (source, options = {}) => {
406
444
  }
407
445
 
408
446
  if (images.length === 0) {
409
- fail(`${options.type} must contain at least one image. Example:\n${imageStackExample}`, options);
447
+ fail(`${options.type} must contain at least one image. Example:\n${blockExamples[options.type]}`, options);
410
448
  }
411
449
 
412
- return { type: options.type === 'norna-image-carousel' ? 'image-carousel' : 'image-stack', images };
450
+ return { type: options.type === 'image-carousel' ? 'image-carousel' : 'image-stack', images };
413
451
  };
414
452
 
415
453
  const parseCardListBlock = (source, options = {}) => {
@@ -517,15 +555,23 @@ const parseCardListBlock = (source, options = {}) => {
517
555
  return { type: 'card-list', layout, flow, size, width, cards };
518
556
  };
519
557
 
558
+ const parsePageListBlock = (source, options = {}) => {
559
+ if (source.trim()) {
560
+ fail(`${options.type} does not accept options or items. Leave the block empty. Example:\n${pageListExample}`, options);
561
+ }
562
+
563
+ return { type: 'page-list' };
564
+ };
565
+
520
566
  export const parseNornaMarkdownBlock = (type, source, options = {}) => {
521
567
  if (!nornaBlockTypes.has(type)) {
522
568
  fail(getUnknownNornaBlockMessage(type), options);
523
569
  }
524
570
 
525
571
  const parseOptions = { ...options, type: blockTypeLabels[type] };
526
- return type === 'norna-card-list'
527
- ? parseCardListBlock(source, parseOptions)
528
- : parseImageListBlock(source, parseOptions);
572
+ if (type === 'card-list') return parseCardListBlock(source, parseOptions);
573
+ if (type === 'page-list') return parsePageListBlock(source, parseOptions);
574
+ return parseImageListBlock(source, parseOptions);
529
575
  };
530
576
 
531
577
  const getLineNumber = (source, index) => source.slice(0, index).split(/\r?\n/).length;
@@ -561,6 +607,97 @@ export const extractNornaMarkdownBlockDiagnostics = (markdown, options = {}) =>
561
607
  return { blocks, errors };
562
608
  };
563
609
 
610
+ const stripRenderedTags = (value) => value.replace(/<[^>]*>/g, '');
611
+ const decodeRenderedEntities = (value) => value
612
+ .replace(/&amp;/g, '&')
613
+ .replace(/&lt;/g, '<')
614
+ .replace(/&gt;/g, '>')
615
+ .replace(/&quot;/g, '"')
616
+ .replace(/&#39;/g, "'");
617
+ const normalizeRenderedBlockSource = (value) => value.replace(/\r\n?/g, '\n').trim();
618
+
619
+ /**
620
+ * Splits rendered HTML at Norna markers. During an Astro content hot reload,
621
+ * custom code nodes can occasionally arrive as highlighted code instead of
622
+ * their marker; match that output against the already validated source block.
623
+ *
624
+ * @template {{ type: string, source?: string }} Block
625
+ * @param {string} html
626
+ * @param {Block[]} blocks
627
+ * @returns {Array<{ type: 'html', html: string } | Block>}
628
+ */
629
+ export const splitNornaRenderedBlocks = (html, blocks) => {
630
+ const markerRegex = /<norna-block\s+data-index="(\d+)"\s*><\/norna-block>/g;
631
+ const markerMatches = [...html.matchAll(markerRegex)];
632
+ let replacements;
633
+
634
+ if (markerMatches.length > 0) {
635
+ const seen = new Set();
636
+ replacements = markerMatches.map((match) => {
637
+ const blockIndex = Number.parseInt(match[1] ?? '', 10);
638
+ if (!blocks[blockIndex]) {
639
+ throw new Error(`Rendered Norna block ${blockIndex + 1} has no matching parsed block.`);
640
+ }
641
+ if (seen.has(blockIndex)) {
642
+ throw new Error(`Rendered Norna block ${blockIndex + 1} appears more than once.`);
643
+ }
644
+ seen.add(blockIndex);
645
+ return { match, blockIndex };
646
+ });
647
+
648
+ if (seen.size !== blocks.length) {
649
+ throw new Error(`Rendered Markdown contains ${seen.size} Norna block markers, but ${blocks.length} blocks were parsed.`);
650
+ }
651
+ } else {
652
+ const codeRegex = /<pre\b[^>]*>\s*<code\b[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/g;
653
+ const candidates = [...html.matchAll(codeRegex)].map((match) => ({
654
+ match,
655
+ source: normalizeRenderedBlockSource(decodeRenderedEntities(stripRenderedTags(match[1] ?? ''))),
656
+ }));
657
+ const solutions = [];
658
+ const findSolutions = (blockIndex, candidateIndex, selected) => {
659
+ if (solutions.length > 1) return;
660
+ if (blockIndex === blocks.length) {
661
+ solutions.push([...selected]);
662
+ return;
663
+ }
664
+
665
+ const source = normalizeRenderedBlockSource(blocks[blockIndex]?.source ?? '');
666
+ for (let index = candidateIndex; index < candidates.length; index += 1) {
667
+ if (candidates[index].source !== source) continue;
668
+ selected.push(index);
669
+ findSolutions(blockIndex + 1, index + 1, selected);
670
+ selected.pop();
671
+ }
672
+ };
673
+ findSolutions(0, 0, []);
674
+
675
+ if (solutions.length === 0) {
676
+ throw new Error(`Rendered Markdown contains 0 Norna block markers, but ${blocks.length} blocks were parsed.`);
677
+ }
678
+ if (solutions.length > 1) {
679
+ throw new Error('Rendered Markdown contains ambiguous plain-code matches for Norna blocks. Restart the local preview to rebuild the Markdown content cache.');
680
+ }
681
+
682
+ replacements = solutions[0].map((candidateIndex, blockIndex) => ({
683
+ match: candidates[candidateIndex].match,
684
+ blockIndex,
685
+ }));
686
+ }
687
+
688
+ const result = [];
689
+ let cursor = 0;
690
+ for (const { match, blockIndex } of replacements) {
691
+ const start = match.index ?? 0;
692
+ if (start > cursor) result.push({ type: 'html', html: html.slice(cursor, start) });
693
+ result.push(blocks[blockIndex]);
694
+ cursor = start + match[0].length;
695
+ }
696
+ if (cursor < html.length) result.push({ type: 'html', html: html.slice(cursor) });
697
+
698
+ return result.filter((block) => block.type !== 'html' || block.html.trim());
699
+ };
700
+
564
701
  const maskFencedCodeBlocks = (markdown) => {
565
702
  const lines = normalizeLines(markdown);
566
703
  const maskedLines = [];
@@ -631,6 +768,7 @@ export const getNornaBlockImageReferences = (blocks) =>
631
768
  line: card.line ?? block.line,
632
769
  }));
633
770
  }
771
+ if (block.type === 'page-list') return [];
634
772
 
635
773
  return block.images.map((image) => ({
636
774
  ...image,
@@ -1,17 +1,80 @@
1
1
  import { defineMdastPlugin } from 'satteri';
2
2
  import { nornaBlockTypes } from './norna-markdown-blocks.mjs';
3
+ import projectConfig from './project-config.mjs';
4
+ import { getSemanticCalloutMarker } from './semantic-callouts.mjs';
3
5
 
4
6
  const stateKey = 'nornaMarkdownRender';
5
7
 
6
8
  const getState = (context) => {
7
9
  if (!context.data[stateKey]) {
8
- context.data[stateKey] = { blockIndex: 0, regionIndex: 0 };
10
+ context.data[stateKey] = { blockIndex: 0, calloutIndex: 0, regionIndex: 0 };
9
11
  }
10
12
  return context.data[stateKey];
11
13
  };
12
14
 
15
+ const calloutLabelKeys = Object.freeze({
16
+ CAUTION: 'calloutCaution',
17
+ DANGER: 'calloutDanger',
18
+ IMPORTANT: 'calloutImportant',
19
+ NOTE: 'calloutNote',
20
+ TIP: 'calloutTip',
21
+ WARNING: 'calloutWarning',
22
+ });
23
+
24
+ const hasBlockquoteAncestor = (node, context) => {
25
+ let parent = context.parent(node);
26
+ while (parent) {
27
+ if (parent.type === 'blockquote') return true;
28
+ parent = context.parent(parent);
29
+ }
30
+ return false;
31
+ };
32
+
13
33
  export const nornaMarkdownRenderPlugin = defineMdastPlugin({
14
34
  name: 'norna-markdown-render',
35
+ blockquote(node, context) {
36
+ const marker = getSemanticCalloutMarker(node);
37
+ if (
38
+ !marker
39
+ || marker.malformed
40
+ || !marker.supported
41
+ || marker.rawType !== marker.type
42
+ || marker.title
43
+ || !marker.hasBody
44
+ || hasBlockquoteAncestor(node, context)
45
+ ) return;
46
+
47
+ const state = getState(context);
48
+ const labelKey = calloutLabelKeys[marker.type];
49
+ const label = projectConfig.locale.labels[labelKey];
50
+ const labelId = `norna-callout-label-${state.calloutIndex}`;
51
+ state.calloutIndex += 1;
52
+
53
+ context.setProperty(node, 'data', {
54
+ hName: 'aside',
55
+ hProperties: {
56
+ 'aria-labelledby': labelId,
57
+ className: ['norna-callout', `norna-callout-${marker.type.toLowerCase()}`],
58
+ role: 'note',
59
+ },
60
+ });
61
+
62
+ if (!marker.firstTextRemainder && marker.firstParagraph.children.length === 1) {
63
+ context.removeChildAt(node, 0);
64
+ } else {
65
+ context.setProperty(marker.firstText, 'value', marker.firstTextRemainder);
66
+ }
67
+ context.prependChild(node, {
68
+ type: 'paragraph',
69
+ data: {
70
+ hProperties: {
71
+ className: ['norna-callout-label'],
72
+ id: labelId,
73
+ },
74
+ },
75
+ children: [{ type: 'text', value: label }],
76
+ });
77
+ },
15
78
  code(node, context) {
16
79
  if (!nornaBlockTypes.has(node.lang)) return;
17
80
 
@@ -1,14 +1,21 @@
1
1
  import { getSiteNodePathname } from './site-page-urls.mjs';
2
2
  import { sitemapFilename } from './sitemap.mjs';
3
3
 
4
- export const generatedSiteRoutes = Object.freeze([
4
+ export const getGeneratedSiteRoutes = ({ searchEnabled = false } = {}) => Object.freeze([
5
5
  Object.freeze({
6
6
  kind: 'generated-route',
7
7
  label: `Norna generated ${sitemapFilename}`,
8
8
  pathname: `/${sitemapFilename}`,
9
9
  }),
10
+ ...(searchEnabled ? [Object.freeze({
11
+ kind: 'generated-route',
12
+ label: 'Norna generated search page',
13
+ pathname: '/search/',
14
+ })] : []),
10
15
  ]);
11
16
 
17
+ export const generatedSiteRoutes = getGeneratedSiteRoutes();
18
+
12
19
  const getPagePathname = (page) => page.pathname ?? getSiteNodePathname(page.contentFile ?? page);
13
20
  const getPageLabel = (page) => page.contentLabel ?? page.contentFile?.contentLabel ?? page.entry?.id ?? getPagePathname(page);
14
21
  const getPageAliases = (page) => page.aliases ?? page.data?.page?.aliases ?? [];
@@ -99,6 +106,19 @@ export const createPageAliasModel = ({
99
106
  for (const identity of getPublicFileIdentities(file)) addIdentity(identity);
100
107
  }
101
108
  for (const route of generatedRoutes) {
109
+ const conflict = identitiesByPathname.get(route.pathname);
110
+ if (conflict) {
111
+ diagnostics.push({
112
+ code: 'generated-route-collision',
113
+ contentFile: conflict.kind === 'page'
114
+ ? conflict.source.contentFile ?? conflict.source
115
+ : undefined,
116
+ fix: `Move or rename the conflicting source. ${route.label} owns ${route.pathname} while the feature is enabled.`,
117
+ message: `Generated route "${route.pathname}" from ${route.label} conflicts with ${describeIdentity(conflict)}.`,
118
+ severity: 'error',
119
+ });
120
+ continue;
121
+ }
102
122
  addIdentity(createIdentity({ ...route, source: route }));
103
123
  }
104
124
 
@@ -1,3 +1,4 @@
1
+ import { getCodeFenceMetadataDiagnostics } from './code-fence-metadata.mjs';
1
2
  import {
2
3
  getHeadingIdentifierIssues,
3
4
  getMarkdownHeadings,
@@ -11,7 +12,9 @@ import {
11
12
  extractMarkdownImageReferences,
12
13
  extractNornaMarkdownBlockDiagnostics,
13
14
  getNornaBlockImageReferences,
15
+ nornaBlockTypes,
14
16
  } from './norna-markdown-blocks.mjs';
17
+ import { getSemanticCalloutDiagnostics } from './semantic-callouts.mjs';
15
18
 
16
19
  const normalizeMarkdownWithOffsets = (source) => {
17
20
  const input = String(source);
@@ -172,7 +175,7 @@ const getRegionContent = (regionMarkdown, blocks, regionOffset) => {
172
175
  return content.filter((item) => item.kind !== 'markdown' || item.markdown.length > 0);
173
176
  };
174
177
 
175
- const createRegion = ({ heading, nextHeading, headings, source, label, lineOffset }) => {
178
+ const createRegion = ({ calloutErrors, codeFenceErrors, heading, nextHeading, headings, source, label, lineOffset }) => {
176
179
  const endOffset = nextHeading?.index ?? source.length;
177
180
  const markdown = source.slice(heading.index, endOffset).trimEnd();
178
181
  const regionLineOffset = lineOffset + heading.line - 1;
@@ -197,6 +200,8 @@ const createRegion = ({ heading, nextHeading, headings, source, label, lineOffse
197
200
  bodyMarkdown: source.slice(heading.index + heading.source.length, endOffset).trimEnd(),
198
201
  blocks: blockResult.blocks,
199
202
  blockErrors: blockResult.errors,
203
+ calloutErrors: calloutErrors.filter((error) => error.offset >= heading.index && error.offset < endOffset),
204
+ codeFenceErrors: codeFenceErrors.filter((error) => error.offset >= heading.index && error.offset < endOffset),
200
205
  content: getRegionContent(markdown, blockResult.blocks, heading.index),
201
206
  endOffset,
202
207
  heading,
@@ -220,12 +225,20 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
220
225
  const label = options.label ?? 'Markdown';
221
226
  const lineOffset = options.lineOffset ?? 0;
222
227
  const { headings, tree } = await getMarkdownHeadings(source);
228
+ const calloutErrors = getSemanticCalloutDiagnostics(tree, { label, lineOffset });
229
+ const codeFenceErrors = getCodeFenceMetadataDiagnostics(tree, {
230
+ excludedLanguages: nornaBlockTypes,
231
+ label,
232
+ lineOffset,
233
+ });
223
234
  const structuralHeadings = headings.filter((heading) => heading.depth <= 2);
224
235
  const prelude = structuralHeadings.length > 0
225
236
  ? source.slice(0, structuralHeadings[0].index)
226
237
  : source;
227
238
  const pageHeadings = structuralHeadings.filter((heading) => heading.depth === 1);
228
239
  const regions = structuralHeadings.map((heading, index) => createRegion({
240
+ calloutErrors,
241
+ codeFenceErrors,
229
242
  heading,
230
243
  nextHeading: structuralHeadings[index + 1],
231
244
  headings,
@@ -250,6 +263,22 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
250
263
  regionId: region.id,
251
264
  severity: 'error',
252
265
  })));
266
+ const calloutDiagnostics = regions.flatMap((region) => region.calloutErrors.map((error) => ({
267
+ code: error.code,
268
+ fix: error.fix,
269
+ line: error.line,
270
+ message: error.message,
271
+ regionId: region.id,
272
+ severity: 'error',
273
+ })));
274
+ const codeFenceDiagnostics = regions.flatMap((region) => region.codeFenceErrors.map((error) => ({
275
+ code: error.code,
276
+ fix: error.fix,
277
+ line: error.line,
278
+ message: error.message,
279
+ regionId: region.id,
280
+ severity: 'error',
281
+ })));
253
282
  const blocks = regions.flatMap((region) => region.blocks);
254
283
  const links = [
255
284
  ...extractMarkdownLinks({ source, tree, lineOffset }),
@@ -279,6 +308,8 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
279
308
  ...headingDiagnostics,
280
309
  ...blockDiagnostics,
281
310
  ...noteDiagnostics,
311
+ ...calloutDiagnostics,
312
+ ...codeFenceDiagnostics,
282
313
  ],
283
314
  headings,
284
315
  headingIssues,