@janga/norna 0.7.25 → 0.7.26

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 (131) hide show
  1. package/README.md +3 -2
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/dev-local.mjs +24 -2
  16. package/scripts/generate-schemas.mjs +15 -3
  17. package/scripts/lib/category-destinations.mjs +48 -0
  18. package/scripts/lib/code-fence-metadata.mjs +14 -37
  19. package/scripts/lib/content-tabs.mjs +155 -0
  20. package/scripts/lib/details-headings.mjs +58 -0
  21. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  22. package/scripts/lib/editor-language-service.mjs +238 -116
  23. package/scripts/lib/locale-registry.mjs +597 -0
  24. package/scripts/lib/locales/az-Latn.mjs +78 -0
  25. package/scripts/lib/locales/be.mjs +78 -0
  26. package/scripts/lib/locales/bg.mjs +78 -0
  27. package/scripts/lib/locales/bs.mjs +78 -0
  28. package/scripts/lib/locales/ca.mjs +78 -0
  29. package/scripts/lib/locales/cnr.mjs +78 -0
  30. package/scripts/lib/locales/cs.mjs +78 -0
  31. package/scripts/lib/locales/da.mjs +78 -0
  32. package/scripts/lib/locales/de.mjs +78 -0
  33. package/scripts/lib/locales/el.mjs +78 -0
  34. package/scripts/lib/locales/en.mjs +78 -0
  35. package/scripts/lib/locales/es.mjs +78 -0
  36. package/scripts/lib/locales/et.mjs +78 -0
  37. package/scripts/lib/locales/fi.mjs +78 -0
  38. package/scripts/lib/locales/fil.mjs +78 -0
  39. package/scripts/lib/locales/fr.mjs +78 -0
  40. package/scripts/lib/locales/ga.mjs +78 -0
  41. package/scripts/lib/locales/ha.mjs +78 -0
  42. package/scripts/lib/locales/hr.mjs +78 -0
  43. package/scripts/lib/locales/hu.mjs +78 -0
  44. package/scripts/lib/locales/id.mjs +78 -0
  45. package/scripts/lib/locales/is.mjs +78 -0
  46. package/scripts/lib/locales/it.mjs +78 -0
  47. package/scripts/lib/locales/jv.mjs +78 -0
  48. package/scripts/lib/locales/lb.mjs +78 -0
  49. package/scripts/lib/locales/lt.mjs +78 -0
  50. package/scripts/lib/locales/lv.mjs +78 -0
  51. package/scripts/lib/locales/mk.mjs +78 -0
  52. package/scripts/lib/locales/mt.mjs +78 -0
  53. package/scripts/lib/locales/nb.mjs +78 -0
  54. package/scripts/lib/locales/nl.mjs +78 -0
  55. package/scripts/lib/locales/nn.mjs +78 -0
  56. package/scripts/lib/locales/pcm.mjs +78 -0
  57. package/scripts/lib/locales/pl.mjs +78 -0
  58. package/scripts/lib/locales/pt.mjs +78 -0
  59. package/scripts/lib/locales/ro.mjs +78 -0
  60. package/scripts/lib/locales/ru.mjs +78 -0
  61. package/scripts/lib/locales/sk.mjs +78 -0
  62. package/scripts/lib/locales/sl.mjs +78 -0
  63. package/scripts/lib/locales/sq.mjs +78 -0
  64. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  65. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  66. package/scripts/lib/locales/sv.mjs +78 -0
  67. package/scripts/lib/locales/sw.mjs +78 -0
  68. package/scripts/lib/locales/tr.mjs +78 -0
  69. package/scripts/lib/locales/uk.mjs +78 -0
  70. package/scripts/lib/locales/vi.mjs +78 -0
  71. package/scripts/lib/markdown-links.mjs +4 -25
  72. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  73. package/scripts/lib/markdown-notes.mjs +165 -0
  74. package/scripts/lib/norna-markdown-blocks.mjs +32 -447
  75. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  76. package/scripts/lib/page-aliases.mjs +12 -1
  77. package/scripts/lib/page-markdown.mjs +63 -16
  78. package/scripts/lib/page-move-plan.mjs +22 -5
  79. package/scripts/lib/pagefind-translations.mjs +620 -0
  80. package/scripts/lib/presentation-contract.mjs +6 -1
  81. package/scripts/lib/presentation.mjs +29 -30
  82. package/scripts/lib/project-config.mjs +2 -173
  83. package/scripts/lib/schema-definitions.mjs +3 -10
  84. package/scripts/lib/schema-editor-metadata.mjs +22 -43
  85. package/scripts/lib/schema-value-definitions.mjs +4 -8
  86. package/scripts/lib/semantic-callouts.mjs +4 -2
  87. package/scripts/lib/site-content.mjs +4 -2
  88. package/scripts/lib/site-link-graph.mjs +16 -6
  89. package/scripts/lib/site-navigation-tree.mjs +0 -11
  90. package/scripts/lib/sitemap.mjs +5 -3
  91. package/scripts/lib/structured-blocks.mjs +108 -0
  92. package/scripts/lib/table-render-plugin.mjs +46 -3
  93. package/scripts/lib/table-row-headers.mjs +218 -0
  94. package/scripts/lib/theme-presets.mjs +3 -10
  95. package/scripts/lib/yaml-config.mjs +2 -6
  96. package/scripts/migrate-check.mjs +19 -0
  97. package/scripts/sync-content-sections.mjs +39 -10
  98. package/scripts/sync-site-public.mjs +7 -1
  99. package/src/components/CodeBlockCopyScript.astro +47 -5
  100. package/src/components/ContentBlocks.astro +39 -0
  101. package/src/components/ContentTabsScript.astro +60 -0
  102. package/src/components/DisplaySettings.astro +17 -19
  103. package/src/components/NavigationPageTree.astro +1 -1
  104. package/src/components/NoteNavigationScript.astro +4 -0
  105. package/src/components/PageAliasRedirect.astro +5 -4
  106. package/src/components/SearchNavigationScript.astro +4 -0
  107. package/src/components/SearchPage.astro +43 -2
  108. package/src/components/SectionNavigationScript.astro +43 -11
  109. package/src/components/SiteBreadcrumbs.astro +1 -1
  110. package/src/components/SiteNavigation.astro +29 -20
  111. package/src/components/SitePage.astro +40 -15
  112. package/src/components/SiteSection.astro +5 -81
  113. package/src/components/SiteTreeNavigation.astro +2 -2
  114. package/src/components/TableOverflowScript.astro +386 -58
  115. package/src/components/TopPageMenu.astro +47 -0
  116. package/src/components/TreeNavigationScript.astro +10 -3
  117. package/src/layouts/BaseLayout.astro +8 -12
  118. package/src/lib/navigationFollowing.ts +142 -0
  119. package/src/lib/noteNavigation.ts +50 -0
  120. package/src/lib/searchNavigation.ts +107 -0
  121. package/src/lib/sectionContent.ts +20 -90
  122. package/src/lib/siteNavigation.ts +0 -5
  123. package/src/lib/sitePages.ts +13 -2
  124. package/src/lib/table-sort-tooltip.ts +109 -0
  125. package/src/pages/[...slug].astro +16 -6
  126. package/src/styles/content.css +394 -132
  127. package/src/styles/foundations.css +9 -0
  128. package/src/styles/media.css +5 -0
  129. package/src/styles/navigation.css +63 -74
  130. package/src/styles/page-layout.css +7 -0
  131. package/src/styles/responsive.css +2 -7
@@ -1,4 +1,5 @@
1
1
  import { documentationLink } from './documentation-links.mjs';
2
+ import { createStructuredBlockSchema, parseStructuredBlock } from './structured-blocks.mjs';
2
3
 
3
4
  const field = (description, options = {}) => Object.freeze({ description, ...options });
4
5
  const value = (title, description) => Object.freeze({ title, description });
@@ -16,6 +17,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
16
17
  }),
17
18
  }),
18
19
  'image-carousel': Object.freeze({
20
+ minItems: 2,
19
21
  description: 'Display two or more managed images in an interactive carousel.',
20
22
  documentation: documentationLink('Image carousel reference', 'content.md', 'image-carousel'),
21
23
  item: Object.freeze({
@@ -27,7 +29,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
27
29
  }),
28
30
  }),
29
31
  'card-list': Object.freeze({
30
- description: 'Display a structured list of cards.',
32
+ description: 'Show related choices as cards with optional images and links. Set link to make the whole card clickable.',
31
33
  documentation: documentationLink('Card list reference', 'content.md', 'card-list'),
32
34
  options: Object.freeze({
33
35
  layout: field('Place each card image above, left, or right of its text. Defaults to image-top.', {
@@ -98,16 +100,17 @@ const blockTypeLabels = {
98
100
  };
99
101
 
100
102
  const knownBlockTypeList = Array.from(nornaBlockTypes).join(', ');
101
- const imageNameRegex = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
102
103
  const imageStackExample = [
103
104
  '```image-stack',
104
- '- image: filename.jpg',
105
+ 'items:',
106
+ ' - image: filename.jpg',
105
107
  '```',
106
108
  ].join('\n');
107
109
  const carouselExample = [
108
110
  '```image-carousel',
109
- '- image: first.jpg',
110
- '- image: second.jpg',
111
+ 'items:',
112
+ ' - image: first.jpg',
113
+ ' - image: second.jpg',
111
114
  '```',
112
115
  ].join('\n');
113
116
  const cardListExample = [
@@ -115,12 +118,12 @@ const cardListExample = [
115
118
  'layout: image-top',
116
119
  'flow: grid',
117
120
  'size: m',
118
- '',
119
- '- title: Adopt',
120
- ' text: Give a dog a new home.',
121
- ' image: adopt.jpg',
122
- ' link: /adopt/',
123
- ' badge-text: Recommended',
121
+ 'items:',
122
+ ' - title: Adopt',
123
+ ' text: Give a dog a new home.',
124
+ ' image: adopt.jpg',
125
+ ' link: /adopt/',
126
+ ' badge-text: Recommended',
124
127
  '```',
125
128
  ].join('\n');
126
129
  const pageListExample = [
@@ -134,10 +137,6 @@ const blockExamples = Object.freeze({
134
137
  'page-list': pageListExample,
135
138
  });
136
139
  const cardListDefinition = nornaMarkdownBlockDefinitions['card-list'];
137
- const cardListLayouts = new Set(Object.keys(cardListDefinition.options.layout.values));
138
- const cardListFlows = new Set(Object.keys(cardListDefinition.options.flow.values));
139
- const cardListSizes = new Set(Object.keys(cardListDefinition.options.size.values));
140
- const cardListWidths = new Set(Object.keys(cardListDefinition.options.width.values));
141
140
 
142
141
  const formatLocation = ({ label, line } = {}) => [
143
142
  label,
@@ -166,44 +165,6 @@ const getUnknownNornaBlockMessage = (type) => [
166
165
  blockExamples[renamedNornaBlockTypes[type] ?? type] ?? imageStackExample,
167
166
  ].filter(Boolean).join(' ');
168
167
 
169
- const decodeScalar = (value) => {
170
- const trimmed = value.trim();
171
- const quoted = trimmed.match(/^(['"])([\s\S]*)\1$/);
172
- return quoted ? quoted[2] : trimmed;
173
- };
174
-
175
- const parseKeyValue = (line, options) => {
176
- const match = line.match(/^(\s*)([a-z][a-z0-9-]*):\s*(.*?)\s*$/);
177
- if (!match) {
178
- fail(`Invalid ${options.type} entry "${line.trim()}". Use "key: value" lines.`, options);
179
- }
180
-
181
- return {
182
- indent: match[1].length,
183
- key: match[2],
184
- value: decodeScalar(match[3]),
185
- };
186
- };
187
-
188
- const validateImage = (image, options) => {
189
- if (!image.image) {
190
- fail(`${options.type} image is missing "image".`, options);
191
- }
192
-
193
- if (!imageNameRegex.test(image.image)) {
194
- if (/\.(?:jpe?g|png|svg)\s+[a-z][a-z0-9-]*:/i.test(image.image)) {
195
- fail(`Image entry "${image.image}" looks like multiple fields on one line. Put image, alt and caption on separate lines. Example:\n${imageStackExample}`, options);
196
- }
197
-
198
- fail(`Image reference "${image.image}" must be a filename ending in jpg, jpeg, png, or svg.`, options);
199
- }
200
- };
201
-
202
- const validateOptionalImage = (image, options) => {
203
- if (!image) return;
204
- validateImage({ image }, options);
205
- };
206
-
207
168
  const normalizeLines = (source) => source.replace(/\r\n?/g, '\n').split('\n');
208
169
 
209
170
  const getFenceInfo = (line) => {
@@ -407,152 +368,27 @@ const failMessage = (message, options) => {
407
368
  return location ? `${location}: ${message}` : message;
408
369
  };
409
370
 
410
- const parseImageListBlock = (source, options = {}) => {
411
- const images = [];
412
- const allowedKeys = new Set(['alt', 'caption']);
413
- let current = null;
414
-
415
- for (const [index, line] of normalizeLines(source).entries()) {
416
- if (!line.trim()) continue;
417
-
418
- const lineNumber = (options.line ?? 1) + index;
419
- const itemMatch = line.match(/^(\s*)-\s+image:\s*(.*?)\s*$/);
420
- if (itemMatch) {
421
- current = { image: decodeScalar(itemMatch[2]), line: lineNumber };
422
- images.push(current);
423
- continue;
424
- }
371
+ const blockSchemas = new Map(Object.entries(nornaMarkdownBlockDefinitions)
372
+ .map(([type, definition]) => [type, createStructuredBlockSchema(definition)]));
425
373
 
426
- if (!current) {
427
- fail(`Invalid ${options.type} entry "${line.trim()}". Start each image with "- image: filename.jpg". Example:\n${imageStackExample}`, { ...options, line: lineNumber });
428
- }
429
-
430
- const entry = parseKeyValue(line, { ...options, line: lineNumber });
431
- if (entry.indent !== 2) {
432
- fail(`Invalid indentation in ${options.type}. Use two spaces before optional image fields such as alt and caption. Example:\n${imageStackExample}`, { ...options, line: lineNumber });
433
- }
434
-
435
- if (!allowedKeys.has(entry.key)) {
436
- fail(`Unknown ${options.type} field "${entry.key}".`, { ...options, line: lineNumber });
437
- }
374
+ export const getNornaBlockSchema = (type) => blockSchemas.get(type) ?? null;
438
375
 
439
- current[entry.key] = entry.value;
440
- }
441
-
442
- for (const image of images) {
443
- validateImage(image, options);
444
- }
445
-
446
- if (images.length === 0) {
447
- fail(`${options.type} must contain at least one image. Example:\n${blockExamples[options.type]}`, options);
448
- }
449
-
450
- return { type: options.type === 'image-carousel' ? 'image-carousel' : 'image-stack', images };
376
+ const parseImageListBlock = (source, options = {}) => {
377
+ const { items } = parseStructuredBlock(source, getNornaBlockSchema(options.type), options);
378
+ return { type: options.type, images: items };
451
379
  };
452
380
 
453
381
  const parseCardListBlock = (source, options = {}) => {
454
- const cards = [];
455
- const allowedKeys = new Set(['text', 'image', 'link', 'badge-text']);
456
- let layout = cardListDefinition.options.layout.default;
457
- let flow = cardListDefinition.options.flow.default;
458
- let size = cardListDefinition.options.size.default;
459
- let width;
460
- let current = null;
461
-
462
- for (const [index, line] of normalizeLines(source).entries()) {
463
- if (!line.trim()) continue;
464
-
465
- const lineNumber = (options.line ?? 1) + index;
466
- const itemMatch = line.match(/^(\s*)-\s+title:\s*(.*?)\s*$/);
467
- if (itemMatch) {
468
- current = { title: decodeScalar(itemMatch[2]), line: lineNumber };
469
- cards.push(current);
470
- continue;
471
- }
472
-
473
- if (!current) {
474
- if (/^\s*-\s+/.test(line)) {
475
- fail(`Invalid ${options.type} entry "${line.trim()}". Start each card with "- title: Card title". Example:\n${cardListExample}`, { ...options, line: lineNumber });
476
- }
477
-
478
- const optionEntry = parseKeyValue(line, { ...options, line: lineNumber });
479
- if (optionEntry.indent !== 0) {
480
- fail(`Invalid ${options.type} entry "${line.trim()}". Start each card with "- title: Card title". Example:\n${cardListExample}`, { ...options, line: lineNumber });
481
- }
482
-
483
- if (optionEntry.key === 'layout') {
484
- if (!cardListLayouts.has(optionEntry.value)) {
485
- fail(`Invalid ${options.type} layout "${optionEntry.value}". Use one of: ${Array.from(cardListLayouts).join(', ')}.`, { ...options, line: lineNumber });
486
- }
487
-
488
- layout = optionEntry.value;
489
- continue;
490
- }
491
-
492
- if (optionEntry.key === 'flow') {
493
- if (!cardListFlows.has(optionEntry.value)) {
494
- fail(`Invalid ${options.type} flow "${optionEntry.value}". Use one of: ${Array.from(cardListFlows).join(', ')}.`, { ...options, line: lineNumber });
495
- }
496
-
497
- flow = optionEntry.value;
498
- continue;
499
- }
500
-
501
- if (optionEntry.key === 'size') {
502
- if (!cardListSizes.has(optionEntry.value)) {
503
- fail(`Invalid ${options.type} size "${optionEntry.value}". Use one of: ${Array.from(cardListSizes).join(', ')}.`, { ...options, line: lineNumber });
504
- }
505
-
506
- size = optionEntry.value;
507
- continue;
508
- }
509
-
510
- if (optionEntry.key === 'width') {
511
- if (!cardListWidths.has(optionEntry.value)) {
512
- fail(`Invalid ${options.type} width "${optionEntry.value}". Use one of: ${Array.from(cardListWidths).join(', ')}.`, { ...options, line: lineNumber });
513
- }
514
-
515
- width = optionEntry.value;
516
- continue;
517
- }
518
-
519
- fail(`Unknown ${options.type} option "${optionEntry.key}". Use layout, flow, size, width, or start the first card with "- title: Card title".`, { ...options, line: lineNumber });
520
- }
521
-
522
- const entry = parseKeyValue(line, { ...options, line: lineNumber });
523
- if (entry.indent === 0 && (entry.key === 'layout' || entry.key === 'flow' || entry.key === 'size' || entry.key === 'width')) {
524
- fail(`${options.type} option "${entry.key}" must appear before the first card.`, { ...options, line: lineNumber });
525
- }
526
-
527
- if (entry.indent !== 2) {
528
- fail(`Invalid indentation in ${options.type}. Use two spaces before card fields such as text, image, link and badge-text. Example:\n${cardListExample}`, { ...options, line: lineNumber });
529
- }
530
-
531
- if (!allowedKeys.has(entry.key)) {
532
- fail(`Unknown ${options.type} field "${entry.key}". Use title, text, image, link, or badge-text.`, { ...options, line: lineNumber });
533
- }
534
-
535
- current[entry.key] = entry.value;
536
- if (entry.key === 'link') current.linkLine = lineNumber;
537
- }
538
-
539
- for (const card of cards) {
540
- if (!card.title) {
541
- fail(`${options.type} card is missing "title".`, options);
542
- }
543
-
544
- if (!card.text && !card.image && !card.link) {
545
- fail(`${options.type} card "${card.title}" must specify at least one of text, image, or link.`, options);
546
- }
547
-
548
- validateOptionalImage(card.image, options);
549
- }
550
-
551
- if (cards.length === 0) {
552
- fail(`${options.type} must contain at least one card. Example:\n${cardListExample}`, options);
553
- }
554
-
555
- return { type: 'card-list', layout, flow, size, width, cards };
382
+ const { items, ...data } = parseStructuredBlock(source, getNornaBlockSchema('card-list'), options);
383
+ return {
384
+ type: 'card-list',
385
+ layout: cardListDefinition.options.layout.default,
386
+ flow: cardListDefinition.options.flow.default,
387
+ size: cardListDefinition.options.size.default,
388
+ width: undefined,
389
+ ...data,
390
+ cards: items,
391
+ };
556
392
  };
557
393
 
558
394
  const parsePageListBlock = (source, options = {}) => {
@@ -597,7 +433,7 @@ export const extractNornaMarkdownBlockDiagnostics = (markdown, options = {}) =>
597
433
  errors.push({
598
434
  blockType: block.blockType,
599
435
  code: 'invalid-norna-block',
600
- line: block.line,
436
+ line: error?.line ?? block.line,
601
437
  source: block.source,
602
438
  message: error instanceof Error ? error.message : String(error),
603
439
  });
@@ -778,255 +614,4 @@ export const getNornaBlockImageReferences = (blocks) =>
778
614
  }));
779
615
  });
780
616
 
781
- const inlineNoteReferenceRegex = /\{note-ref\}/g;
782
- const inlineNoteDeclarationOpeningRegex = /^\s*\{note:\s*(.*)$/;
783
- const inlineNoteDeclarationClosingRegex = /^(.*?)\}\s*$/;
784
- const inlineNoteDeclarationStartRegex = /\{note(?:\s*:|\s*\})/;
785
-
786
- const maskInlineCodeAndComments = (source) => source.replace(
787
- /<!--[\s\S]*?-->|(`+)([\s\S]*?)\1/g,
788
- (match) => match.replace(/[^\r\n]/g, ' '),
789
- );
790
-
791
- const countInlineNoteReferences = (line) => Array.from(line.matchAll(inlineNoteReferenceRegex)).length;
792
-
793
- const formatInlineNoteError = (message, options, line) => ({
794
- blockType: 'inline-note',
795
- line,
796
- message: failMessage(message, { ...options, line }),
797
- });
798
-
799
- const getInlineNoteErrorMessage = () => 'Write the note on its own line as "{note: Short text.}", or end a multiline note with "}".';
800
-
801
- const findUnescapedInlineNoteConstruct = (source) => {
802
- const constructRegex = /\{note(?:-ref\}|\s*:|\s*\})/g;
803
-
804
- for (const match of source.matchAll(constructRegex)) {
805
- const index = match.index ?? 0;
806
- let precedingBackslashes = 0;
807
- for (let cursor = index - 1; cursor >= 0 && source[cursor] === '\\'; cursor -= 1) {
808
- precedingBackslashes += 1;
809
- }
810
-
811
- if (precedingBackslashes % 2 === 0) {
812
- return match[0].startsWith('{note-ref') ? 'reference' : 'note';
813
- }
814
- }
815
-
816
- return null;
817
- };
818
-
819
- const collectInlineNoteDeclaration = (lines, maskedLines, startIndex) => {
820
- const openingMatch = (lines[startIndex] ?? '').match(inlineNoteDeclarationOpeningRegex);
821
- if (!openingMatch) return null;
822
- const maskedOpeningMatch = (maskedLines[startIndex] ?? '').match(inlineNoteDeclarationOpeningRegex);
823
-
824
- const textLines = [];
825
- let currentLine = openingMatch[1] ?? '';
826
- let currentMaskedLine = maskedOpeningMatch?.[1] ?? '';
827
-
828
- for (let index = startIndex; index < lines.length; index += 1) {
829
- const conflictType = findUnescapedInlineNoteConstruct(currentMaskedLine);
830
- if (conflictType) {
831
- return {
832
- closed: false,
833
- conflictIndex: index,
834
- conflictType,
835
- endIndex: index,
836
- text: textLines.map((line) => line.trim()).filter(Boolean).join(' '),
837
- };
838
- }
839
-
840
- const closingMatch = currentLine.match(inlineNoteDeclarationClosingRegex);
841
- if (closingMatch) {
842
- textLines.push(closingMatch[1]);
843
- return {
844
- closed: true,
845
- endIndex: index,
846
- text: textLines.map((line) => line.trim()).filter(Boolean).join(' '),
847
- };
848
- }
849
-
850
- textLines.push(currentLine);
851
- currentLine = lines[index + 1] ?? '';
852
- currentMaskedLine = maskedLines[index + 1] ?? '';
853
- }
854
-
855
- return {
856
- closed: false,
857
- endIndex: lines.length - 1,
858
- text: textLines.map((line) => line.trim()).filter(Boolean).join(' '),
859
- };
860
- };
861
-
862
- const getUnclosedInlineNoteMessage = (noteDeclaration, startLine, lineOffset = 0) => {
863
- if (noteDeclaration.conflictType === 'note') {
864
- const conflictLine = lineOffset + noteDeclaration.conflictIndex + 1;
865
- return `The note starting on line ${startLine} is not closed before another note starts on line ${conflictLine}. Close the first note with "}".`;
866
- }
867
-
868
- if (noteDeclaration.conflictType === 'reference') {
869
- const conflictLine = lineOffset + noteDeclaration.conflictIndex + 1;
870
- return `The note starting on line ${startLine} contains "{note-ref}" on line ${conflictLine}. Close the note before adding a note reference.`;
871
- }
872
-
873
- return `The note starting on line ${startLine} is not closed. End it with "}" on its own line or at the end of the note text.`;
874
- };
875
-
876
- /**
877
- * Finds the deliberately small inline-note syntax while ignoring fenced and
878
- * inline code. Notes are paired by position, so no user-authored IDs are
879
- * needed. The caller decides whether diagnostics should be fatal.
880
- */
881
- export const extractInlineNoteDiagnostics = (markdown, options = {}) => {
882
- const lines = normalizeLines(markdown);
883
- const maskedLines = maskInlineCodeAndComments(maskFencedCodeBlocks(markdown)).split('\n');
884
- const notes = [];
885
- const errors = [];
886
- let paragraph = null;
887
- let pendingParagraph = null;
888
- let noteJustSeen = false;
889
-
890
- const addError = (message, line) => errors.push(formatInlineNoteError(message, options, line));
891
-
892
- const finishParagraph = () => {
893
- if (!paragraph) return;
894
- if (paragraph.referenceCount > 0) {
895
- pendingParagraph = paragraph;
896
- }
897
- paragraph = null;
898
- };
899
-
900
- const reportMissingNote = () => {
901
- if (!pendingParagraph) return;
902
- addError(
903
- `Paragraph on line ${pendingParagraph.referenceLine} contains "{note-ref}" but has no following "{note: ...}".`,
904
- pendingParagraph.referenceLine,
905
- );
906
- pendingParagraph = null;
907
- };
908
-
909
- const attachNote = (text, lineNumber) => {
910
- if (!text) {
911
- addError(`The note on line ${lineNumber} is empty. ${getInlineNoteErrorMessage()}`, lineNumber);
912
- pendingParagraph = null;
913
- return;
914
- }
915
-
916
- if (/!\[[^\]]*\]\([^)]*\)|<img\b/i.test(text)) {
917
- addError('Inline notes cannot contain images. Use a Norna image block with a caption instead.', lineNumber);
918
- pendingParagraph = null;
919
- return;
920
- }
921
-
922
- notes.push({
923
- markdown: text,
924
- referenceLine: pendingParagraph.referenceLine,
925
- noteLine: lineNumber,
926
- });
927
- pendingParagraph = null;
928
- noteJustSeen = true;
929
- };
930
-
931
- for (let index = 0; index < lines.length; index += 1) {
932
- const line = lines[index] ?? '';
933
- const maskedLine = maskedLines[index] ?? line;
934
- const lineNumber = (options.lineOffset ?? 0) + index + 1;
935
- const fence = getFenceInfo(line);
936
-
937
- if (fence && fence.length >= 3) {
938
- finishParagraph();
939
- reportMissingNote();
940
- let closingIndex = -1;
941
- for (let candidateIndex = index + 1; candidateIndex < lines.length; candidateIndex += 1) {
942
- if (getFenceCloseInfo(lines[candidateIndex], fence)) {
943
- closingIndex = candidateIndex;
944
- break;
945
- }
946
- }
947
- index = closingIndex === -1 ? lines.length : closingIndex;
948
- noteJustSeen = false;
949
- continue;
950
- }
951
-
952
- const trimmed = line.trim();
953
- const maskedTrimmed = maskedLine.trim();
954
- const startsNoteDeclaration = inlineNoteDeclarationOpeningRegex.test(maskedLine);
955
- const noteDeclaration = startsNoteDeclaration
956
- ? collectInlineNoteDeclaration(lines, maskedLines, index)
957
- : null;
958
- const looksLikeNoteDeclaration = inlineNoteDeclarationStartRegex.test(maskedTrimmed);
959
- const referenceCount = countInlineNoteReferences(maskedLine);
960
- const isHeading = /^#{1,6}(?:\s|$)/.test(trimmed);
961
-
962
- if (!trimmed) {
963
- finishParagraph();
964
- noteJustSeen = false;
965
- continue;
966
- }
967
-
968
- if (pendingParagraph) {
969
- if (noteDeclaration) {
970
- if (!noteDeclaration.closed) {
971
- addError(getUnclosedInlineNoteMessage(noteDeclaration, lineNumber, options.lineOffset), lineNumber);
972
- pendingParagraph = null;
973
- } else {
974
- attachNote(noteDeclaration.text, lineNumber);
975
- }
976
- index = noteDeclaration.endIndex;
977
- continue;
978
- }
979
-
980
- if (looksLikeNoteDeclaration) {
981
- addError(`Invalid note syntax on line ${lineNumber}. ${getInlineNoteErrorMessage()}`, lineNumber);
982
- pendingParagraph = null;
983
- } else {
984
- reportMissingNote();
985
- }
986
- }
987
-
988
- if (noteJustSeen) {
989
- addError(`Add a blank line after the note on the previous line before starting more text.`, lineNumber);
990
- noteJustSeen = false;
991
- }
992
-
993
- if (noteDeclaration || looksLikeNoteDeclaration) {
994
- addError(
995
- noteDeclaration
996
- ? noteDeclaration.closed
997
- ? `A "{note: ...}" requires a preceding paragraph containing "{note-ref}".`
998
- : getUnclosedInlineNoteMessage(noteDeclaration, lineNumber, options.lineOffset)
999
- : `Invalid note syntax on line ${lineNumber}. ${getInlineNoteErrorMessage()}`,
1000
- lineNumber,
1001
- );
1002
- if (noteDeclaration) index = noteDeclaration.endIndex;
1003
- continue;
1004
- }
1005
-
1006
- if (isHeading) {
1007
- if (referenceCount > 0) {
1008
- addError('Place "{note-ref}" in a paragraph, not in a heading.', lineNumber);
1009
- }
1010
- finishParagraph();
1011
- reportMissingNote();
1012
- continue;
1013
- }
1014
-
1015
- if (!paragraph) {
1016
- paragraph = {
1017
- referenceCount: 0,
1018
- referenceLine: lineNumber,
1019
- };
1020
- }
1021
-
1022
- paragraph.referenceCount += referenceCount;
1023
- if (referenceCount > 0 && paragraph.referenceCount > 1) {
1024
- addError('A paragraph may contain only one "{note-ref}".', lineNumber);
1025
- }
1026
- }
1027
-
1028
- finishParagraph();
1029
- reportMissingNote();
1030
-
1031
- return { notes, errors };
1032
- };
617
+ export { extractInlineNoteDiagnostics } from './markdown-notes.mjs';
@@ -2,6 +2,7 @@ import { defineMdastPlugin } from 'satteri';
2
2
  import { nornaBlockTypes } from './norna-markdown-blocks.mjs';
3
3
  import projectConfig from './project-config.mjs';
4
4
  import { getSemanticCalloutMarker } from './semantic-callouts.mjs';
5
+ import { applyTableRowHeaderDeclaration } from './table-row-headers.mjs';
5
6
 
6
7
  const stateKey = 'nornaMarkdownRender';
7
8
 
@@ -97,4 +98,7 @@ export const nornaMarkdownRenderPlugin = defineMdastPlugin({
97
98
  value: `<norna-region data-index="${regionIndex}"></norna-region>`,
98
99
  });
99
100
  },
101
+ table(node, context) {
102
+ applyTableRowHeaderDeclaration(node, context);
103
+ },
100
104
  });
@@ -103,7 +103,18 @@ export const createPageAliasModel = ({
103
103
  }));
104
104
  }
105
105
  for (const file of publicFiles) {
106
- for (const identity of getPublicFileIdentities(file)) addIdentity(identity);
106
+ for (const identity of getPublicFileIdentities(file)) {
107
+ const conflict = identitiesByPathname.get(identity.pathname);
108
+ if (conflict?.kind === 'category') {
109
+ diagnostics.push({
110
+ code: 'category-route-collision',
111
+ severity: 'error',
112
+ message: `Public file ${file.label} conflicts with category URL ${identity.pathname}.`,
113
+ fix: 'Remove or move the public file. Norna generates category destinations.',
114
+ });
115
+ }
116
+ addIdentity(identity);
117
+ }
107
118
  }
108
119
  for (const route of generatedRoutes) {
109
120
  const conflict = identitiesByPathname.get(route.pathname);