@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.
- package/README.md +3 -2
- package/astro.config.mjs +26 -12
- package/bin/norna-cli.mjs +12 -0
- package/package.json +14 -5
- package/schemas/card-list.schema.json +121 -0
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +306 -32
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/image-carousel.schema.json +43 -0
- package/schemas/image-stack.schema.json +43 -0
- package/schemas/manifest.json +6 -1
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -262
- package/scripts/dev-local.mjs +24 -2
- package/scripts/generate-schemas.mjs +15 -3
- package/scripts/lib/category-destinations.mjs +48 -0
- package/scripts/lib/code-fence-metadata.mjs +14 -37
- package/scripts/lib/content-tabs.mjs +155 -0
- package/scripts/lib/details-headings.mjs +58 -0
- package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
- package/scripts/lib/editor-language-service.mjs +238 -116
- package/scripts/lib/locale-registry.mjs +597 -0
- package/scripts/lib/locales/az-Latn.mjs +78 -0
- package/scripts/lib/locales/be.mjs +78 -0
- package/scripts/lib/locales/bg.mjs +78 -0
- package/scripts/lib/locales/bs.mjs +78 -0
- package/scripts/lib/locales/ca.mjs +78 -0
- package/scripts/lib/locales/cnr.mjs +78 -0
- package/scripts/lib/locales/cs.mjs +78 -0
- package/scripts/lib/locales/da.mjs +78 -0
- package/scripts/lib/locales/de.mjs +78 -0
- package/scripts/lib/locales/el.mjs +78 -0
- package/scripts/lib/locales/en.mjs +78 -0
- package/scripts/lib/locales/es.mjs +78 -0
- package/scripts/lib/locales/et.mjs +78 -0
- package/scripts/lib/locales/fi.mjs +78 -0
- package/scripts/lib/locales/fil.mjs +78 -0
- package/scripts/lib/locales/fr.mjs +78 -0
- package/scripts/lib/locales/ga.mjs +78 -0
- package/scripts/lib/locales/ha.mjs +78 -0
- package/scripts/lib/locales/hr.mjs +78 -0
- package/scripts/lib/locales/hu.mjs +78 -0
- package/scripts/lib/locales/id.mjs +78 -0
- package/scripts/lib/locales/is.mjs +78 -0
- package/scripts/lib/locales/it.mjs +78 -0
- package/scripts/lib/locales/jv.mjs +78 -0
- package/scripts/lib/locales/lb.mjs +78 -0
- package/scripts/lib/locales/lt.mjs +78 -0
- package/scripts/lib/locales/lv.mjs +78 -0
- package/scripts/lib/locales/mk.mjs +78 -0
- package/scripts/lib/locales/mt.mjs +78 -0
- package/scripts/lib/locales/nb.mjs +78 -0
- package/scripts/lib/locales/nl.mjs +78 -0
- package/scripts/lib/locales/nn.mjs +78 -0
- package/scripts/lib/locales/pcm.mjs +78 -0
- package/scripts/lib/locales/pl.mjs +78 -0
- package/scripts/lib/locales/pt.mjs +78 -0
- package/scripts/lib/locales/ro.mjs +78 -0
- package/scripts/lib/locales/ru.mjs +78 -0
- package/scripts/lib/locales/sk.mjs +78 -0
- package/scripts/lib/locales/sl.mjs +78 -0
- package/scripts/lib/locales/sq.mjs +78 -0
- package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
- package/scripts/lib/locales/sr-Latn.mjs +78 -0
- package/scripts/lib/locales/sv.mjs +78 -0
- package/scripts/lib/locales/sw.mjs +78 -0
- package/scripts/lib/locales/tr.mjs +78 -0
- package/scripts/lib/locales/uk.mjs +78 -0
- package/scripts/lib/locales/vi.mjs +78 -0
- package/scripts/lib/markdown-links.mjs +4 -25
- package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
- package/scripts/lib/markdown-notes.mjs +165 -0
- package/scripts/lib/norna-markdown-blocks.mjs +32 -447
- package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
- package/scripts/lib/page-aliases.mjs +12 -1
- package/scripts/lib/page-markdown.mjs +63 -16
- package/scripts/lib/page-move-plan.mjs +22 -5
- package/scripts/lib/pagefind-translations.mjs +620 -0
- package/scripts/lib/presentation-contract.mjs +6 -1
- package/scripts/lib/presentation.mjs +29 -30
- package/scripts/lib/project-config.mjs +2 -173
- package/scripts/lib/schema-definitions.mjs +3 -10
- package/scripts/lib/schema-editor-metadata.mjs +22 -43
- package/scripts/lib/schema-value-definitions.mjs +4 -8
- package/scripts/lib/semantic-callouts.mjs +4 -2
- package/scripts/lib/site-content.mjs +4 -2
- package/scripts/lib/site-link-graph.mjs +16 -6
- package/scripts/lib/site-navigation-tree.mjs +0 -11
- package/scripts/lib/sitemap.mjs +5 -3
- package/scripts/lib/structured-blocks.mjs +108 -0
- package/scripts/lib/table-render-plugin.mjs +46 -3
- package/scripts/lib/table-row-headers.mjs +218 -0
- package/scripts/lib/theme-presets.mjs +3 -10
- package/scripts/lib/yaml-config.mjs +2 -6
- package/scripts/migrate-check.mjs +19 -0
- package/scripts/sync-content-sections.mjs +39 -10
- package/scripts/sync-site-public.mjs +7 -1
- package/src/components/CodeBlockCopyScript.astro +47 -5
- package/src/components/ContentBlocks.astro +39 -0
- package/src/components/ContentTabsScript.astro +60 -0
- package/src/components/DisplaySettings.astro +17 -19
- package/src/components/NavigationPageTree.astro +1 -1
- package/src/components/NoteNavigationScript.astro +4 -0
- package/src/components/PageAliasRedirect.astro +5 -4
- package/src/components/SearchNavigationScript.astro +4 -0
- package/src/components/SearchPage.astro +43 -2
- package/src/components/SectionNavigationScript.astro +43 -11
- package/src/components/SiteBreadcrumbs.astro +1 -1
- package/src/components/SiteNavigation.astro +29 -20
- package/src/components/SitePage.astro +40 -15
- package/src/components/SiteSection.astro +5 -81
- package/src/components/SiteTreeNavigation.astro +2 -2
- package/src/components/TableOverflowScript.astro +386 -58
- package/src/components/TopPageMenu.astro +47 -0
- package/src/components/TreeNavigationScript.astro +10 -3
- package/src/layouts/BaseLayout.astro +8 -12
- package/src/lib/navigationFollowing.ts +142 -0
- package/src/lib/noteNavigation.ts +50 -0
- package/src/lib/searchNavigation.ts +107 -0
- package/src/lib/sectionContent.ts +20 -90
- package/src/lib/siteNavigation.ts +0 -5
- package/src/lib/sitePages.ts +13 -2
- package/src/lib/table-sort-tooltip.ts +109 -0
- package/src/pages/[...slug].astro +16 -6
- package/src/styles/content.css +394 -132
- package/src/styles/foundations.css +9 -0
- package/src/styles/media.css +5 -0
- package/src/styles/navigation.css +63 -74
- package/src/styles/page-layout.css +7 -0
- 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: '
|
|
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
|
-
'
|
|
105
|
+
'items:',
|
|
106
|
+
' - image: filename.jpg',
|
|
105
107
|
'```',
|
|
106
108
|
].join('\n');
|
|
107
109
|
const carouselExample = [
|
|
108
110
|
'```image-carousel',
|
|
109
|
-
'
|
|
110
|
-
'- image:
|
|
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
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
123
|
-
'
|
|
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
|
|
411
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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))
|
|
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);
|