@janga/norna 0.7.22 → 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 (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. 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 = {}) => {
@@ -495,6 +533,7 @@ const parseCardListBlock = (source, options = {}) => {
495
533
  }
496
534
 
497
535
  current[entry.key] = entry.value;
536
+ if (entry.key === 'link') current.linkLine = lineNumber;
498
537
  }
499
538
 
500
539
  for (const card of cards) {
@@ -516,15 +555,23 @@ const parseCardListBlock = (source, options = {}) => {
516
555
  return { type: 'card-list', layout, flow, size, width, cards };
517
556
  };
518
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
+
519
566
  export const parseNornaMarkdownBlock = (type, source, options = {}) => {
520
567
  if (!nornaBlockTypes.has(type)) {
521
568
  fail(getUnknownNornaBlockMessage(type), options);
522
569
  }
523
570
 
524
571
  const parseOptions = { ...options, type: blockTypeLabels[type] };
525
- return type === 'norna-card-list'
526
- ? parseCardListBlock(source, parseOptions)
527
- : 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);
528
575
  };
529
576
 
530
577
  const getLineNumber = (source, index) => source.slice(0, index).split(/\r?\n/).length;
@@ -630,6 +677,7 @@ export const getNornaBlockImageReferences = (blocks) =>
630
677
  line: card.line ?? block.line,
631
678
  }));
632
679
  }
680
+ if (block.type === 'page-list') return [];
633
681
 
634
682
  return block.images.map((image) => ({
635
683
  ...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
 
@@ -0,0 +1,184 @@
1
+ import { getSiteNodePathname } from './site-page-urls.mjs';
2
+ import { sitemapFilename } from './sitemap.mjs';
3
+
4
+ export const getGeneratedSiteRoutes = ({ searchEnabled = false } = {}) => Object.freeze([
5
+ Object.freeze({
6
+ kind: 'generated-route',
7
+ label: `Norna generated ${sitemapFilename}`,
8
+ pathname: `/${sitemapFilename}`,
9
+ }),
10
+ ...(searchEnabled ? [Object.freeze({
11
+ kind: 'generated-route',
12
+ label: 'Norna generated search page',
13
+ pathname: '/search/',
14
+ })] : []),
15
+ ]);
16
+
17
+ export const generatedSiteRoutes = getGeneratedSiteRoutes();
18
+
19
+ const getPagePathname = (page) => page.pathname ?? getSiteNodePathname(page.contentFile ?? page);
20
+ const getPageLabel = (page) => page.contentLabel ?? page.contentFile?.contentLabel ?? page.entry?.id ?? getPagePathname(page);
21
+ const getPageAliases = (page) => page.aliases ?? page.data?.page?.aliases ?? [];
22
+
23
+ const createIdentity = ({ kind, label, pathname, source }) => ({
24
+ kind,
25
+ label,
26
+ pathname,
27
+ source,
28
+ });
29
+
30
+ const getPublicFileIdentities = (file) => {
31
+ const identities = [createIdentity({
32
+ kind: 'public-file',
33
+ label: file.label,
34
+ pathname: file.pathname,
35
+ source: file,
36
+ })];
37
+
38
+ if (file.pathname === '/index.html') {
39
+ identities.push(createIdentity({
40
+ kind: 'public-file',
41
+ label: file.label,
42
+ pathname: '/',
43
+ source: file,
44
+ }));
45
+ } else if (file.pathname.endsWith('/index.html')) {
46
+ identities.push(createIdentity({
47
+ kind: 'public-file',
48
+ label: file.label,
49
+ pathname: file.pathname.slice(0, -'index.html'.length),
50
+ source: file,
51
+ }));
52
+ }
53
+
54
+ return identities;
55
+ };
56
+
57
+ const describeIdentity = (identity) => {
58
+ if (identity.kind === 'page') return `page URL from ${identity.label}`;
59
+ if (identity.kind === 'category') return `navigation category path from ${identity.label}`;
60
+ if (identity.kind === 'public-file') return `public file ${identity.label}`;
61
+ if (identity.kind === 'generated-route') return `generated route ${identity.label}`;
62
+ if (identity.kind === 'page-alias') return `page alias declared in ${identity.label}`;
63
+ return `${identity.kind} from ${identity.label}`;
64
+ };
65
+
66
+ const compareAliases = (left, right) => (
67
+ left.pathname.localeCompare(right.pathname, 'en')
68
+ || left.targetPathname.localeCompare(right.targetPathname, 'en')
69
+ || left.contentLabel.localeCompare(right.contentLabel, 'en')
70
+ );
71
+
72
+ export const createPageAliasModel = ({
73
+ pages,
74
+ categories = [],
75
+ publicFiles = [],
76
+ generatedRoutes = generatedSiteRoutes,
77
+ }) => {
78
+ const identitiesByPathname = new Map();
79
+ const aliases = [];
80
+ const aliasesByPathname = new Map();
81
+ const diagnostics = [];
82
+
83
+ const addIdentity = (identity) => {
84
+ if (!identitiesByPathname.has(identity.pathname)) {
85
+ identitiesByPathname.set(identity.pathname, identity);
86
+ }
87
+ };
88
+
89
+ for (const page of pages) {
90
+ addIdentity(createIdentity({
91
+ kind: 'page',
92
+ label: getPageLabel(page),
93
+ pathname: getPagePathname(page),
94
+ source: page,
95
+ }));
96
+ }
97
+ for (const category of categories) {
98
+ addIdentity(createIdentity({
99
+ kind: 'category',
100
+ label: category.categorySourceLabel ?? category.nodeLabel ?? getSiteNodePathname(category),
101
+ pathname: getSiteNodePathname(category),
102
+ source: category,
103
+ }));
104
+ }
105
+ for (const file of publicFiles) {
106
+ for (const identity of getPublicFileIdentities(file)) addIdentity(identity);
107
+ }
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
+ }
122
+ addIdentity(createIdentity({ ...route, source: route }));
123
+ }
124
+
125
+ for (const page of pages) {
126
+ const contentFile = page.contentFile ?? page;
127
+ const contentLabel = getPageLabel(page);
128
+ const targetPathname = getPagePathname(page);
129
+
130
+ for (const pathname of getPageAliases(page)) {
131
+ const alias = {
132
+ contentFile,
133
+ contentLabel,
134
+ page,
135
+ pathname,
136
+ targetPathname,
137
+ };
138
+ const conflict = identitiesByPathname.get(pathname);
139
+ if (conflict) {
140
+ diagnostics.push({
141
+ alias,
142
+ code: 'page-alias-collision',
143
+ contentFile,
144
+ fix: conflict.kind === 'page-alias'
145
+ ? 'Keep the old URL on exactly one current page.'
146
+ : 'Remove or change the alias. A public URL can identify only one site resource.',
147
+ message: `Page alias "${pathname}" in ${contentLabel} conflicts with ${describeIdentity(conflict)}.`,
148
+ severity: 'error',
149
+ });
150
+ continue;
151
+ }
152
+
153
+ aliases.push(alias);
154
+ aliasesByPathname.set(pathname, alias);
155
+ addIdentity(createIdentity({
156
+ kind: 'page-alias',
157
+ label: contentLabel,
158
+ pathname,
159
+ source: alias,
160
+ }));
161
+ }
162
+ }
163
+
164
+ aliases.sort(compareAliases);
165
+
166
+ return {
167
+ aliases,
168
+ aliasesByPathname,
169
+ diagnostics,
170
+ identitiesByPathname,
171
+ };
172
+ };
173
+
174
+ export const assertPageAliasModel = (model) => {
175
+ if (model.diagnostics.length === 0) return model;
176
+
177
+ throw new Error([
178
+ 'Page alias validation failed.',
179
+ ...model.diagnostics.flatMap((diagnostic) => [
180
+ `- ${diagnostic.message}`,
181
+ ` Fix: ${diagnostic.fix}`,
182
+ ]),
183
+ ].join('\n'));
184
+ };
@@ -1,15 +1,43 @@
1
+ import { getCodeFenceMetadataDiagnostics } from './code-fence-metadata.mjs';
1
2
  import {
2
3
  getHeadingIdentifierIssues,
3
4
  getMarkdownHeadings,
4
5
  } from './heading-ids.mjs';
6
+ import {
7
+ extractMarkdownLinks,
8
+ extractNornaBlockLinks,
9
+ } from './markdown-links.mjs';
5
10
  import {
6
11
  extractInlineNoteDiagnostics,
7
12
  extractMarkdownImageReferences,
8
13
  extractNornaMarkdownBlockDiagnostics,
9
14
  getNornaBlockImageReferences,
15
+ nornaBlockTypes,
10
16
  } from './norna-markdown-blocks.mjs';
17
+ import { getSemanticCalloutDiagnostics } from './semantic-callouts.mjs';
18
+
19
+ const normalizeMarkdownWithOffsets = (source) => {
20
+ const input = String(source);
21
+ let normalized = '';
22
+ const originalOffsets = [0];
23
+
24
+ for (let index = 0; index < input.length;) {
25
+ if (input[index] === '\r') {
26
+ index += input[index + 1] === '\n' ? 2 : 1;
27
+ normalized += '\n';
28
+ originalOffsets.push(index);
29
+ continue;
30
+ }
11
31
 
12
- const normalizeMarkdown = (source) => source.replace(/\r\n?/g, '\n');
32
+ normalized += input[index];
33
+ index += 1;
34
+ originalOffsets.push(index);
35
+ }
36
+
37
+ return { normalized, originalOffsets };
38
+ };
39
+
40
+ const normalizeMarkdown = (source) => normalizeMarkdownWithOffsets(source).normalized;
13
41
 
14
42
  const hasContent = (source) => source.trim().length > 0;
15
43
 
@@ -147,7 +175,7 @@ const getRegionContent = (regionMarkdown, blocks, regionOffset) => {
147
175
  return content.filter((item) => item.kind !== 'markdown' || item.markdown.length > 0);
148
176
  };
149
177
 
150
- const createRegion = ({ heading, nextHeading, headings, source, label, lineOffset }) => {
178
+ const createRegion = ({ calloutErrors, codeFenceErrors, heading, nextHeading, headings, source, label, lineOffset }) => {
151
179
  const endOffset = nextHeading?.index ?? source.length;
152
180
  const markdown = source.slice(heading.index, endOffset).trimEnd();
153
181
  const regionLineOffset = lineOffset + heading.line - 1;
@@ -172,6 +200,8 @@ const createRegion = ({ heading, nextHeading, headings, source, label, lineOffse
172
200
  bodyMarkdown: source.slice(heading.index + heading.source.length, endOffset).trimEnd(),
173
201
  blocks: blockResult.blocks,
174
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),
175
205
  content: getRegionContent(markdown, blockResult.blocks, heading.index),
176
206
  endOffset,
177
207
  heading,
@@ -194,13 +224,21 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
194
224
  const source = normalizeMarkdown(markdown);
195
225
  const label = options.label ?? 'Markdown';
196
226
  const lineOffset = options.lineOffset ?? 0;
197
- const { headings } = await getMarkdownHeadings(source);
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
+ });
198
234
  const structuralHeadings = headings.filter((heading) => heading.depth <= 2);
199
235
  const prelude = structuralHeadings.length > 0
200
236
  ? source.slice(0, structuralHeadings[0].index)
201
237
  : source;
202
238
  const pageHeadings = structuralHeadings.filter((heading) => heading.depth === 1);
203
239
  const regions = structuralHeadings.map((heading, index) => createRegion({
240
+ calloutErrors,
241
+ codeFenceErrors,
204
242
  heading,
205
243
  nextHeading: structuralHeadings[index + 1],
206
244
  headings,
@@ -225,6 +263,30 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
225
263
  regionId: region.id,
226
264
  severity: 'error',
227
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
+ })));
282
+ const blocks = regions.flatMap((region) => region.blocks);
283
+ const links = [
284
+ ...extractMarkdownLinks({ source, tree, lineOffset }),
285
+ ...extractNornaBlockLinks({ source, blocks, lineOffset }),
286
+ ].sort((left, right) => (
287
+ left.line - right.line
288
+ || (left.range?.start ?? 0) - (right.range?.start ?? 0)
289
+ ));
228
290
  const navigationHeadings = [];
229
291
  let parentId = null;
230
292
  for (const heading of headings) {
@@ -240,18 +302,21 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
240
302
  }
241
303
 
242
304
  return {
243
- blocks: regions.flatMap((region) => region.blocks),
305
+ blocks,
244
306
  diagnostics: [
245
307
  ...structureDiagnostics,
246
308
  ...headingDiagnostics,
247
309
  ...blockDiagnostics,
248
310
  ...noteDiagnostics,
311
+ ...calloutDiagnostics,
312
+ ...codeFenceDiagnostics,
249
313
  ],
250
314
  headings,
251
315
  headingIssues,
252
316
  intro: regions.find((region) => region.kind === 'page-intro') ?? null,
253
317
  label,
254
318
  lineOffset,
319
+ links,
255
320
  managedImages: regions.flatMap((region) => region.managedImages),
256
321
  markdownImages: regions.flatMap((region) => region.markdownImages),
257
322
  navigationHeadings,
@@ -266,17 +331,33 @@ export const parsePageMarkdown = async (markdown, options = {}) => {
266
331
  };
267
332
 
268
333
  export const parsePageMarkdownSource = async (source, options = {}) => {
269
- const split = splitPageMarkdownSource(source);
334
+ const normalization = normalizeMarkdownWithOffsets(source);
335
+ const split = splitPageMarkdownSource(normalization.normalized);
270
336
  const model = await parsePageMarkdown(split.body, {
271
337
  ...options,
272
338
  lineOffset: (options.lineOffset ?? 0) + split.lineOffset,
273
339
  });
274
340
 
341
+ const toOriginalRange = (range) => {
342
+ if (!range) return null;
343
+ const normalizedStart = split.bodyOffset + range.start;
344
+ const normalizedEnd = split.bodyOffset + range.end;
345
+ return {
346
+ start: normalization.originalOffsets[normalizedStart] ?? normalizedStart,
347
+ end: normalization.originalOffsets[normalizedEnd] ?? normalizedEnd,
348
+ };
349
+ };
350
+
275
351
  return {
276
352
  ...model,
277
- bodyOffset: split.bodyOffset,
353
+ bodyOffset: normalization.originalOffsets[split.bodyOffset] ?? split.bodyOffset,
278
354
  frontmatter: split.frontmatter,
279
355
  frontmatterUnclosed: split.frontmatterUnclosed,
280
- fullSource: normalizeMarkdown(source),
356
+ fullSource: String(source),
357
+ links: model.links.map((link) => ({
358
+ ...link,
359
+ range: toOriginalRange(link.range),
360
+ targetRange: toOriginalRange(link.targetRange),
361
+ })),
281
362
  };
282
363
  };