@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.
- package/README.md +14 -18
- package/astro.config.mjs +15 -0
- package/bin/norna-cli.mjs +8 -2
- package/package.json +28 -3
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +52 -24
- package/schemas/content-frontmatter.schema.json +25 -5
- package/schemas/page-theme.schema.json +53 -25
- package/schemas/sitewide-content.schema.json +19 -18
- package/schemas/theme.schema.json +385 -252
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +36 -5
- package/scripts/deploy-site.mjs +0 -1
- package/scripts/dev-local.mjs +175 -112
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +1 -0
- package/scripts/lib/code-fence-metadata.mjs +223 -0
- package/scripts/lib/content-sync-apply.mjs +34 -0
- package/scripts/lib/content-sync-plan.mjs +179 -0
- package/scripts/lib/edit-source-link.mjs +49 -0
- package/scripts/lib/editor-language-service.mjs +160 -12
- package/scripts/lib/heading-ids.mjs +1 -1
- package/scripts/lib/image-presentation.mjs +6 -0
- package/scripts/lib/markdown-links.mjs +182 -0
- package/scripts/lib/navigation-model.mjs +44 -12
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +74 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +184 -0
- package/scripts/lib/page-markdown.mjs +88 -7
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +10 -37
- package/scripts/lib/project-config.mjs +146 -19
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +43 -15
- package/scripts/lib/schema-editor-metadata.mjs +60 -32
- package/scripts/lib/schema-value-definitions.mjs +6 -2
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +28 -5
- package/scripts/lib/site-link-graph.mjs +357 -0
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/site-page-urls.mjs +10 -0
- package/scripts/lib/sitemap.mjs +34 -0
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/theme-presets.mjs +53 -25
- package/scripts/lib/theme-profiles.mjs +6 -8
- package/scripts/lib/yaml-config.mjs +6 -2
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +182 -216
- package/scripts/sync-site-public.mjs +43 -3
- package/src/components/CodeBlockCopyScript.astro +97 -0
- package/src/components/DisplaySettings.astro +4 -4
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +6 -3
- package/src/components/NavigationPageTree.astro +81 -86
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +49 -0
- package/src/components/PageContentsNavigation.astro +21 -0
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SectionNavigationScript.astro +62 -6
- package/src/components/SiteNavigation.astro +50 -11
- package/src/components/SitePage.astro +113 -20
- package/src/components/SiteSection.astro +16 -3
- package/src/components/SiteTreeNavigation.astro +8 -2
- package/src/components/TreeNavigationScript.astro +207 -26
- package/src/content.config.ts +5 -2
- package/src/layouts/BaseLayout.astro +71 -10
- package/src/lib/readerPreferencesScript.mjs +2 -2
- package/src/lib/sectionContent.ts +32 -3
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePages.ts +3 -2
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +45 -8
- package/src/styles/content.css +718 -0
- package/src/styles/foundations.css +284 -0
- package/src/styles/global.css +6 -2510
- package/src/styles/media.css +518 -0
- package/src/styles/navigation.css +526 -0
- package/src/styles/page-layout.css +786 -0
- package/src/styles/responsive.css +594 -0
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- 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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
82
|
-
'
|
|
83
|
-
'
|
|
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
|
-
'```
|
|
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
|
-
'```
|
|
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
|
|
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
|
-
|
|
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
|
|
157
|
+
? 'Use image-stack for one or more stacked images.'
|
|
126
158
|
: null,
|
|
127
|
-
type === 'norna-carousel'
|
|
128
|
-
? 'Use
|
|
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
|
|
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})(
|
|
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
|
|
237
|
-
type
|
|
273
|
+
marker,
|
|
274
|
+
type,
|
|
238
275
|
};
|
|
239
276
|
};
|
|
240
277
|
|
|
241
278
|
const getNornaFenceStartMessage = (type, marker = '') => {
|
|
242
|
-
const
|
|
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${
|
|
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 === '
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
};
|