@janga/norna 0.7.22 → 0.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +14 -18
  2. package/astro.config.mjs +15 -0
  3. package/bin/norna-cli.mjs +8 -2
  4. package/package.json +28 -3
  5. package/schemas/category.schema.json +2 -2
  6. package/schemas/config.schema.json +52 -24
  7. package/schemas/content-frontmatter.schema.json +25 -5
  8. package/schemas/page-theme.schema.json +53 -25
  9. package/schemas/sitewide-content.schema.json +19 -18
  10. package/schemas/theme.schema.json +385 -252
  11. package/scripts/build-site.mjs +1 -0
  12. package/scripts/check-config.mjs +36 -5
  13. package/scripts/deploy-site.mjs +0 -1
  14. package/scripts/dev-local.mjs +175 -112
  15. package/scripts/generate-search-index.mjs +57 -0
  16. package/scripts/init-site.mjs +1 -0
  17. package/scripts/lib/code-fence-metadata.mjs +223 -0
  18. package/scripts/lib/content-sync-apply.mjs +34 -0
  19. package/scripts/lib/content-sync-plan.mjs +179 -0
  20. package/scripts/lib/edit-source-link.mjs +49 -0
  21. package/scripts/lib/editor-language-service.mjs +160 -12
  22. package/scripts/lib/heading-ids.mjs +1 -1
  23. package/scripts/lib/image-presentation.mjs +6 -0
  24. package/scripts/lib/markdown-links.mjs +182 -0
  25. package/scripts/lib/navigation-model.mjs +44 -12
  26. package/scripts/lib/navigation-review.mjs +396 -0
  27. package/scripts/lib/norna-markdown-blocks.mjs +74 -26
  28. package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
  29. package/scripts/lib/page-aliases.mjs +184 -0
  30. package/scripts/lib/page-markdown.mjs +88 -7
  31. package/scripts/lib/page-move-plan.mjs +659 -0
  32. package/scripts/lib/presentation-palette-metadata.mjs +1 -1
  33. package/scripts/lib/presentation.mjs +10 -37
  34. package/scripts/lib/project-config.mjs +146 -19
  35. package/scripts/lib/public-asset-conventions.mjs +36 -2
  36. package/scripts/lib/schema-definitions.mjs +43 -15
  37. package/scripts/lib/schema-editor-metadata.mjs +60 -32
  38. package/scripts/lib/schema-value-definitions.mjs +6 -2
  39. package/scripts/lib/semantic-callouts.mjs +128 -0
  40. package/scripts/lib/site-content.mjs +28 -5
  41. package/scripts/lib/site-link-graph.mjs +357 -0
  42. package/scripts/lib/site-navigation-tree.mjs +85 -0
  43. package/scripts/lib/site-page-urls.mjs +10 -0
  44. package/scripts/lib/sitemap.mjs +34 -0
  45. package/scripts/lib/social-image-assets.mjs +30 -0
  46. package/scripts/lib/theme-presets.mjs +53 -25
  47. package/scripts/lib/theme-profiles.mjs +6 -8
  48. package/scripts/lib/yaml-config.mjs +6 -2
  49. package/scripts/move-site-page.mjs +256 -0
  50. package/scripts/review-navigation.mjs +32 -0
  51. package/scripts/sync-content-sections.mjs +182 -216
  52. package/scripts/sync-site-public.mjs +43 -3
  53. package/src/components/CodeBlockCopyScript.astro +97 -0
  54. package/src/components/DisplaySettings.astro +4 -4
  55. package/src/components/EditSourceLink.astro +17 -0
  56. package/src/components/ImageCarousel.astro +6 -3
  57. package/src/components/NavigationPageTree.astro +81 -86
  58. package/src/components/NavigationTreeControls.astro +77 -0
  59. package/src/components/PageAliasRedirect.astro +49 -0
  60. package/src/components/PageContentsNavigation.astro +21 -0
  61. package/src/components/PageList.astro +33 -0
  62. package/src/components/PageSequenceNavigation.astro +37 -0
  63. package/src/components/SearchPage.astro +151 -0
  64. package/src/components/SectionNavigationScript.astro +62 -6
  65. package/src/components/SiteNavigation.astro +50 -11
  66. package/src/components/SitePage.astro +113 -20
  67. package/src/components/SiteSection.astro +16 -3
  68. package/src/components/SiteTreeNavigation.astro +8 -2
  69. package/src/components/TreeNavigationScript.astro +207 -26
  70. package/src/content.config.ts +5 -2
  71. package/src/layouts/BaseLayout.astro +71 -10
  72. package/src/lib/readerPreferencesScript.mjs +2 -2
  73. package/src/lib/sectionContent.ts +32 -3
  74. package/src/lib/siteNavigation.ts +31 -52
  75. package/src/lib/sitePages.ts +3 -2
  76. package/src/lib/sitePublicAssets.ts +1 -0
  77. package/src/pages/404.astro +110 -0
  78. package/src/pages/[...slug].astro +45 -8
  79. package/src/styles/content.css +718 -0
  80. package/src/styles/foundations.css +284 -0
  81. package/src/styles/global.css +6 -2510
  82. package/src/styles/media.css +518 -0
  83. package/src/styles/navigation.css +526 -0
  84. package/src/styles/page-layout.css +786 -0
  85. package/src/styles/responsive.css +594 -0
  86. package/starters/basic/README.md +6 -19
  87. package/starters/basic/package.json +1 -0
  88. package/starters/basic/site/pages/000-home/content.md +13 -50
@@ -0,0 +1,223 @@
1
+ const titlePrefix = 'title="';
2
+ const lineSelectorPattern = /^\{(\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*)\}$/;
3
+
4
+ const invalidMetadata = (message, fix) => ({
5
+ error: {
6
+ code: 'invalid-code-fence-metadata',
7
+ fix,
8
+ message,
9
+ },
10
+ });
11
+
12
+ const parseTitle = (source) => {
13
+ let title = '';
14
+ let index = titlePrefix.length;
15
+
16
+ while (index < source.length) {
17
+ const character = source[index];
18
+ if (character === '"') {
19
+ return {
20
+ rest: source.slice(index + 1),
21
+ title,
22
+ };
23
+ }
24
+
25
+ if (character === '\\') {
26
+ const escaped = source[index + 1];
27
+ if (escaped !== '"' && escaped !== '\\') {
28
+ return invalidMetadata(
29
+ `Code title contains unsupported escape "\\${escaped ?? ''}".`,
30
+ 'Only escape a double quote (\\") or backslash (\\\\) inside the title.',
31
+ );
32
+ }
33
+ title += escaped;
34
+ index += 2;
35
+ continue;
36
+ }
37
+
38
+ title += character;
39
+ index += 1;
40
+ }
41
+
42
+ return invalidMetadata(
43
+ 'Code title is missing its closing double quote.',
44
+ 'Close the title, for example title="src/config.js".',
45
+ );
46
+ };
47
+
48
+ const parseHighlightedLines = (selector, lineCount) => {
49
+ const match = selector.match(lineSelectorPattern);
50
+ if (!match) {
51
+ return invalidMetadata(
52
+ `Invalid code line selector "${selector}".`,
53
+ 'Use positive line numbers and inclusive ranges without spaces, for example {2,4-6}.',
54
+ );
55
+ }
56
+
57
+ const highlightedLines = new Set();
58
+ for (const part of match[1].split(',')) {
59
+ const [startSource, endSource = startSource] = part.split('-');
60
+ const start = Number.parseInt(startSource, 10);
61
+ const end = Number.parseInt(endSource, 10);
62
+
63
+ if (start < 1 || end < start) {
64
+ return invalidMetadata(
65
+ `Invalid code line range "${part}".`,
66
+ 'Use positive line numbers with the lower number first, for example {2,4-6}.',
67
+ );
68
+ }
69
+ if (Number.isInteger(lineCount) && end > lineCount) {
70
+ return invalidMetadata(
71
+ `Code line selector "${part}" refers to line ${end}, but the block has ${lineCount} ${lineCount === 1 ? 'line' : 'lines'}.`,
72
+ `Select only lines 1-${lineCount}.`,
73
+ );
74
+ }
75
+
76
+ for (let line = start; line <= end; line += 1) {
77
+ if (highlightedLines.has(line)) {
78
+ return invalidMetadata(
79
+ `Code line ${line} is selected more than once.`,
80
+ 'Remove overlapping or repeated line numbers from the selector.',
81
+ );
82
+ }
83
+ highlightedLines.add(line);
84
+ }
85
+ }
86
+
87
+ return { highlightedLines: [...highlightedLines].sort((left, right) => left - right) };
88
+ };
89
+
90
+ export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
91
+ const source = String(rawMetadata ?? '').trim();
92
+ if (!source) return { highlightedLines: [], title: null };
93
+
94
+ let rest = source;
95
+ let title = null;
96
+ if (rest.startsWith(titlePrefix)) {
97
+ const titleResult = parseTitle(rest);
98
+ if (titleResult.error) return titleResult;
99
+ if (!titleResult.title.trim()) {
100
+ return invalidMetadata(
101
+ 'Code title cannot be empty.',
102
+ 'Remove title="" or provide a short filename or label.',
103
+ );
104
+ }
105
+ title = titleResult.title;
106
+ rest = titleResult.rest;
107
+ if (rest && !rest.startsWith(' ')) {
108
+ return invalidMetadata(
109
+ 'Code title must be separated from the line selector by one space.',
110
+ 'Write metadata as title="src/config.js" {2,4-6}.',
111
+ );
112
+ }
113
+ rest = rest.trim();
114
+ }
115
+
116
+ if (!rest) return { highlightedLines: [], title };
117
+ if (!rest.startsWith('{')) {
118
+ return invalidMetadata(
119
+ `Unknown code fence metadata "${rest}".`,
120
+ 'Use an optional title followed by an optional line selector: title="src/config.js" {2,4-6}.',
121
+ );
122
+ }
123
+ if (/^\{[^}]*\}\s+/.test(rest)) {
124
+ return invalidMetadata(
125
+ 'The code line selector must come after the optional title.',
126
+ 'Write metadata as title="src/config.js" {2,4-6}.',
127
+ );
128
+ }
129
+
130
+ const linesResult = parseHighlightedLines(rest, options.lineCount);
131
+ if (linesResult.error) return linesResult;
132
+ return {
133
+ highlightedLines: linesResult.highlightedLines,
134
+ title,
135
+ };
136
+ };
137
+
138
+ const formatDiagnostic = ({ error, label, line, offset }) => ({
139
+ ...error,
140
+ line,
141
+ message: `${label} line ${line}: ${error.message}`,
142
+ offset,
143
+ });
144
+
145
+ export const getCodeFenceMetadataDiagnostics = (tree, options = {}) => {
146
+ const diagnostics = [];
147
+ const label = options.label ?? 'Markdown';
148
+ const lineOffset = options.lineOffset ?? 0;
149
+ const excludedLanguages = options.excludedLanguages ?? new Set();
150
+
151
+ const visit = (node) => {
152
+ if (!node || typeof node !== 'object') return;
153
+ if (node.type === 'code' && !excludedLanguages.has(node.lang)) {
154
+ const metadataWithoutLanguage = typeof node.lang === 'string'
155
+ && (node.lang.startsWith('title=') || node.lang.startsWith('{'));
156
+ const result = metadataWithoutLanguage
157
+ ? invalidMetadata(
158
+ 'Code fence metadata requires a language before it.',
159
+ 'Add a language first, for example ```js title="src/config.js" {2}.',
160
+ )
161
+ : parseCodeFenceMetadata(node.meta, {
162
+ lineCount: String(node.value ?? '').split('\n').length,
163
+ });
164
+ if (result.error) {
165
+ const line = lineOffset + (node.position?.start.line ?? 1);
166
+ diagnostics.push(formatDiagnostic({
167
+ error: result.error,
168
+ label,
169
+ line,
170
+ offset: node.position?.start.offset ?? 0,
171
+ }));
172
+ }
173
+ }
174
+
175
+ for (const child of node.children ?? []) visit(child);
176
+ };
177
+
178
+ visit(tree);
179
+ return diagnostics;
180
+ };
181
+
182
+ const getTransformerMetadata = (context) => {
183
+ if (!context.meta.nornaCodeFence) {
184
+ const result = parseCodeFenceMetadata(context.options.meta?.__raw, {
185
+ lineCount: context.tokens?.length,
186
+ });
187
+ if (result.error) throw new Error(`${result.error.message} ${result.error.fix}`);
188
+ context.meta.nornaCodeFence = result;
189
+ }
190
+ return context.meta.nornaCodeFence;
191
+ };
192
+
193
+ export const nornaCodeFenceTransformer = {
194
+ name: 'norna-code-fence-metadata',
195
+ line(node, line) {
196
+ const metadata = getTransformerMetadata(this);
197
+ if (!metadata.highlightedLines.includes(line)) return;
198
+ this.addClassToHast(node, 'norna-code-line-highlighted');
199
+ node.properties.dataLine = String(line);
200
+ },
201
+ pre(node) {
202
+ const metadata = getTransformerMetadata(this);
203
+ if (metadata.highlightedLines.length > 0) {
204
+ this.addClassToHast(node, 'norna-code-has-highlighted-lines');
205
+ }
206
+ if (!metadata.title) return;
207
+
208
+ return {
209
+ type: 'element',
210
+ tagName: 'figure',
211
+ properties: { className: ['norna-code-example'] },
212
+ children: [
213
+ {
214
+ type: 'element',
215
+ tagName: 'figcaption',
216
+ properties: { className: ['norna-code-title'] },
217
+ children: [{ type: 'text', value: metadata.title }],
218
+ },
219
+ node,
220
+ ],
221
+ };
222
+ },
223
+ };
@@ -0,0 +1,34 @@
1
+ import { mkdir, rename } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ export const applyImageSyncPlan = async (
5
+ moves,
6
+ {
7
+ makeDirectory = (directoryPath) => mkdir(directoryPath, { recursive: true }),
8
+ moveFile = rename,
9
+ } = {},
10
+ ) => {
11
+ const completedMoves = [];
12
+
13
+ for (const [index, move] of moves.entries()) {
14
+ try {
15
+ await makeDirectory(path.dirname(move.to));
16
+ await moveFile(move.from, move.to);
17
+ completedMoves.push(move);
18
+ } catch (error) {
19
+ return {
20
+ completedMoves,
21
+ error,
22
+ failedMove: move,
23
+ remainingMoves: moves.slice(index),
24
+ };
25
+ }
26
+ }
27
+
28
+ return {
29
+ completedMoves,
30
+ error: null,
31
+ failedMove: null,
32
+ remainingMoves: [],
33
+ };
34
+ };
@@ -0,0 +1,179 @@
1
+ import path from 'node:path';
2
+
3
+ import { toPosixPath } from './site-content.mjs';
4
+
5
+ export const getExpectedImagePath = (contentFile, imageName) =>
6
+ path.join(contentFile.imagesDir, imageName);
7
+
8
+ export const getExpectedImageLabel = (contentFile, imageName) =>
9
+ `${contentFile.imagesLabel}/${imageName}`;
10
+
11
+ export const getImageRootLabel = (contentFile) => contentFile.imagesLabel.replace(/\/images$/, '');
12
+
13
+ export const getImageCandidateLabel = ({ contentFile, imagePath }) =>
14
+ `${contentFile.imagesLabel}/${toPosixPath(path.relative(contentFile.imagesDir, imagePath))}`;
15
+
16
+ const getReferenceLabel = (contentFile, section) =>
17
+ `${contentFile.contentLabel} [${section.id ?? 'page title'}]`;
18
+
19
+ const addMapEntry = (map, key, value) => {
20
+ if (!map.has(key)) map.set(key, []);
21
+ map.get(key).push(value);
22
+ };
23
+
24
+ const addReferenceIssue = (issues, entry, issue) => {
25
+ issues.push({ ...entry, issue, phase: 'reference' });
26
+ };
27
+
28
+ const addConflictIssue = (issues, move, issue) => {
29
+ issues.push({
30
+ contentFile: move.contentFile,
31
+ section: move.section,
32
+ reference: move.reference,
33
+ issue,
34
+ phase: 'conflict',
35
+ });
36
+ };
37
+
38
+ const addMoveConflicts = (moves, issues) => {
39
+ const movesBySource = new Map();
40
+ const movesByDestination = new Map();
41
+
42
+ for (const move of moves) {
43
+ addMapEntry(movesBySource, move.from, move);
44
+ addMapEntry(movesByDestination, move.to, move);
45
+ }
46
+
47
+ for (const sourceMoves of movesBySource.values()) {
48
+ const destinations = new Set(sourceMoves.map((move) => move.to));
49
+ if (destinations.size <= 1) continue;
50
+
51
+ const firstMove = sourceMoves[0];
52
+ addConflictIssue(issues, firstMove, {
53
+ severity: 'error',
54
+ message: `Cannot relocate "${firstMove.imageName}" because the same source file is referenced from multiple destinations: ${sourceMoves.map((move) => getExpectedImageLabel(move.contentFile, move.imageName)).join(', ')}.`,
55
+ fix: 'Duplicate the image manually or rename one of the image files so each move has a single destination.',
56
+ });
57
+ }
58
+
59
+ for (const destinationMoves of movesByDestination.values()) {
60
+ const sources = new Set(destinationMoves.map((move) => move.from));
61
+ if (sources.size <= 1) continue;
62
+
63
+ const firstMove = destinationMoves[0];
64
+ addConflictIssue(issues, firstMove, {
65
+ severity: 'error',
66
+ message: `Cannot relocate "${firstMove.imageName}" because multiple source files would move to ${getExpectedImageLabel(firstMove.contentFile, firstMove.imageName)}: ${destinationMoves.map((move) => getImageCandidateLabel({ contentFile: move.sourceContentFile, imagePath: move.from })).join(', ')}.`,
67
+ fix: 'Move the intended file manually or rename files so the destination is unambiguous.',
68
+ });
69
+ }
70
+ };
71
+
72
+ export const createImageSyncPlan = ({
73
+ imageCandidates,
74
+ references,
75
+ reportMisplaced,
76
+ }) => {
77
+ const candidatesByName = new Map();
78
+ const expectedReferencesByPath = new Map();
79
+ const issues = [];
80
+ const moves = [];
81
+ const referencedImagePaths = new Set();
82
+ const resolvedPathByReference = new Map();
83
+
84
+ for (const candidate of imageCandidates) {
85
+ addMapEntry(candidatesByName, candidate.imageName, candidate);
86
+ }
87
+
88
+ for (const candidates of candidatesByName.values()) {
89
+ candidates.sort((left, right) => left.imagePath.localeCompare(right.imagePath, 'sv'));
90
+ }
91
+
92
+ for (const entry of references) {
93
+ const expectedPath = getExpectedImagePath(entry.contentFile, entry.reference.image);
94
+ addMapEntry(expectedReferencesByPath, expectedPath, entry);
95
+ }
96
+
97
+ for (const entry of references) {
98
+ const { contentFile, reference, section } = entry;
99
+ const imageName = reference.image;
100
+ const expectedPath = getExpectedImagePath(contentFile, imageName);
101
+ const expectedLabel = getExpectedImageLabel(contentFile, imageName);
102
+ const candidates = (candidatesByName.get(imageName) ?? [])
103
+ .filter(({ imagePath }) => imagePath !== expectedPath);
104
+ const expectedCandidate = (candidatesByName.get(imageName) ?? [])
105
+ .find(({ imagePath }) => imagePath === expectedPath);
106
+
107
+ if (expectedCandidate) {
108
+ referencedImagePaths.add(expectedPath);
109
+ resolvedPathByReference.set(reference, expectedPath);
110
+ continue;
111
+ }
112
+
113
+ if (candidates.length === 0) {
114
+ addReferenceIssue(issues, entry, {
115
+ severity: 'error',
116
+ message: `Image "${imageName}" does not exist at ${expectedLabel} or anywhere under any page image root.`,
117
+ fix: `Add the source image directly to ${contentFile.imagesLabel}/ or remove the Norna-managed image reference.`,
118
+ });
119
+ continue;
120
+ }
121
+
122
+ if (candidates.length > 1) {
123
+ addReferenceIssue(issues, entry, {
124
+ severity: 'error',
125
+ message: `Cannot relocate "${imageName}". Multiple files with this filename were found: ${candidates.map(getImageCandidateLabel).join(', ')}.`,
126
+ fix: 'Move the intended file manually or rename files so the move is unambiguous.',
127
+ });
128
+ continue;
129
+ }
130
+
131
+ const sourceCandidate = candidates[0];
132
+ const sourcePath = sourceCandidate.imagePath;
133
+ const referencesAtCurrentLocation = (expectedReferencesByPath.get(sourcePath) ?? [])
134
+ .filter((expectedReference) => expectedReference.contentFile !== contentFile);
135
+
136
+ if (referencesAtCurrentLocation.length > 0) {
137
+ addReferenceIssue(issues, entry, {
138
+ severity: 'error',
139
+ message: `Cannot relocate "${imageName}" from ${getImageCandidateLabel(sourceCandidate)} because it is still referenced from ${referencesAtCurrentLocation.map((expectedReference) => getReferenceLabel(expectedReference.contentFile, expectedReference.section)).join(', ')}.`,
140
+ fix: 'Remove the extra reference, duplicate the image file manually, or rename one of the image files so the intended move is unambiguous.',
141
+ });
142
+ continue;
143
+ }
144
+
145
+ if (!moves.some((move) => move.from === sourcePath && move.to === expectedPath)) {
146
+ moves.push({
147
+ imageName,
148
+ from: sourcePath,
149
+ to: expectedPath,
150
+ contentFile,
151
+ sourceContentFile: sourceCandidate.contentFile,
152
+ section,
153
+ reference,
154
+ });
155
+ }
156
+
157
+ referencedImagePaths.add(sourcePath);
158
+ resolvedPathByReference.set(reference, sourcePath);
159
+
160
+ if (reportMisplaced) {
161
+ addReferenceIssue(issues, entry, {
162
+ severity: 'error',
163
+ message: `Image "${imageName}" is used here but is located in ${getImageCandidateLabel(sourceCandidate)}.`,
164
+ fix: sourceCandidate.contentFile === contentFile
165
+ ? 'Run norna content:sync to move it directly into the current page image root.'
166
+ : `Run norna content:sync to move it from ${getImageRootLabel(sourceCandidate.contentFile)} to ${getImageRootLabel(contentFile)}.`,
167
+ });
168
+ }
169
+ }
170
+
171
+ addMoveConflicts(moves, issues);
172
+
173
+ return {
174
+ issues,
175
+ moves,
176
+ referencedImagePaths,
177
+ resolvedPathByReference,
178
+ };
179
+ };
@@ -0,0 +1,49 @@
1
+ const assertHttpUrl = (value, label) => {
2
+ if (typeof value !== 'string' || value.trim() === '') {
3
+ throw new Error(`${label} must be a non-empty absolute URL.`);
4
+ }
5
+
6
+ let url;
7
+ try {
8
+ url = new URL(value.trim());
9
+ } catch {
10
+ throw new Error(`${label} must be an absolute URL such as "https://github.com/owner/repository/edit/main/".`);
11
+ }
12
+
13
+ if (!['http:', 'https:'].includes(url.protocol)) {
14
+ throw new Error(`${label} must use http or https.`);
15
+ }
16
+ if (url.username || url.password) {
17
+ throw new Error(`${label} must not contain credentials.`);
18
+ }
19
+ if (url.search || url.hash) {
20
+ throw new Error(`${label} must not contain a query string or fragment.`);
21
+ }
22
+
23
+ return url;
24
+ };
25
+
26
+ export const normalizeEditLinkBaseUrl = (value, label = 'editLink.baseUrl') => {
27
+ const url = assertHttpUrl(value, label);
28
+ if (!url.pathname.endsWith('/')) url.pathname = `${url.pathname}/`;
29
+ return url.href;
30
+ };
31
+
32
+ export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
33
+ const normalizedBaseUrl = normalizeEditLinkBaseUrl(baseUrl);
34
+ if (typeof sourcePath !== 'string' || sourcePath.trim() === '') {
35
+ throw new Error('Edit-source path must be a non-empty project-relative path.');
36
+ }
37
+
38
+ const normalizedSourcePath = sourcePath.trim().replaceAll('\\', '/');
39
+ const segments = normalizedSourcePath.split('/');
40
+ if (
41
+ normalizedSourcePath.startsWith('/')
42
+ || segments.some((segment) => segment === '' || segment === '.' || segment === '..')
43
+ ) {
44
+ throw new Error(`Edit-source path "${sourcePath}" must be a project-relative path without traversal segments.`);
45
+ }
46
+
47
+ const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
48
+ return new URL(encodedPath, normalizedBaseUrl).href;
49
+ };
@@ -5,9 +5,15 @@ import {
5
5
  getOpenMarkdownFenceAtLine,
6
6
  nornaMarkdownBlockDefinitions,
7
7
  } from './norna-markdown-blocks.mjs';
8
- import { inspectPublicAssetFilenames, logoAssetFilenames } from './public-asset-conventions.mjs';
8
+ import { resolveNavigationModel } from './navigation-model.mjs';
9
+ import {
10
+ inspectPublicAssetFilenames,
11
+ logoAssetFilenames,
12
+ socialImageAssetFilenames,
13
+ } from './public-asset-conventions.mjs';
9
14
  import { parsePageMarkdownSource } from './page-markdown.mjs';
10
15
  import { siteSchema } from './schema-definitions.mjs';
16
+ import { homePageDirectory } from './site-conventions.mjs';
11
17
 
12
18
  const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png', '.svg']);
13
19
  const siteConfigNames = ['config.yaml'];
@@ -33,6 +39,132 @@ export const findNornaSiteRoot = async (documentPath) => {
33
39
  }
34
40
  };
35
41
 
42
+ const readEditorYaml = async (filePath) => {
43
+ try {
44
+ const source = await readFile(filePath, 'utf8');
45
+ const data = load(source) ?? {};
46
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
47
+ } catch {
48
+ return null;
49
+ }
50
+ };
51
+
52
+ const readContentFrontmatterForEditor = (source) => {
53
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
54
+ if (!match) return {};
55
+ try {
56
+ const data = load(match[1]) ?? {};
57
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ };
62
+
63
+ const getNavigationNodesForEditor = async (siteRoot) => {
64
+ const nodes = [];
65
+
66
+ const visit = async (pagesDirectory, depth = 1) => {
67
+ const entries = (await readdir(pagesDirectory, { withFileTypes: true }).catch((error) => {
68
+ if (error?.code === 'ENOENT') return [];
69
+ throw error;
70
+ }))
71
+ .filter((entry) => entry.isDirectory())
72
+ .sort((left, right) => left.name.localeCompare(right.name, 'en'));
73
+
74
+ for (const entry of entries) {
75
+ const nodeDirectory = path.join(pagesDirectory, entry.name);
76
+ const contentPath = path.join(nodeDirectory, 'content.md');
77
+ const categoryPath = path.join(nodeDirectory, 'category.yaml');
78
+ const [hasContent, hasCategory] = await Promise.all([
79
+ fileExists(contentPath),
80
+ fileExists(categoryPath),
81
+ ]);
82
+
83
+ if (hasContent !== hasCategory) {
84
+ if (hasCategory) {
85
+ nodes.push({
86
+ depth,
87
+ headings: [],
88
+ isHome: false,
89
+ kind: 'category',
90
+ listed: true,
91
+ });
92
+ } else {
93
+ const source = await readFile(contentPath, 'utf8');
94
+ const data = readContentFrontmatterForEditor(source);
95
+ const document = await parsePageMarkdownSource(source, { label: contentPath });
96
+ nodes.push({
97
+ depth,
98
+ headings: document.navigationHeadings,
99
+ isHome: depth === 1 && entry.name === homePageDirectory,
100
+ kind: 'page',
101
+ listed: data.navigation?.listed !== false,
102
+ });
103
+ }
104
+ }
105
+
106
+ await visit(path.join(nodeDirectory, 'pages'), depth + 1);
107
+ }
108
+ };
109
+
110
+ await visit(path.join(siteRoot, 'pages'));
111
+ return nodes;
112
+ };
113
+
114
+ const getNavigationModeForEditor = async (siteRoot) => {
115
+ const config = await readEditorYaml(path.join(siteRoot, 'config.yaml'));
116
+ if (!config) return null;
117
+ const requestedMode = config.navigation?.mode ?? 'automatic';
118
+ if (requestedMode !== 'automatic') return requestedMode;
119
+
120
+ try {
121
+ return resolveNavigationModel({
122
+ mode: requestedMode,
123
+ nodes: await getNavigationNodesForEditor(siteRoot),
124
+ }).mode;
125
+ } catch {
126
+ return null;
127
+ }
128
+ };
129
+
130
+ const findNestedYamlPropertyLine = (source, parentKey, propertyKey) => {
131
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
132
+ let insideParent = false;
133
+ for (let index = 0; index < lines.length; index += 1) {
134
+ const line = lines[index];
135
+ if (new RegExp(`^${parentKey}:\\s*(?:#.*)?$`).test(line)) {
136
+ insideParent = true;
137
+ continue;
138
+ }
139
+ if (!insideParent) continue;
140
+ if (/^[A-Za-z][A-Za-z0-9-]*:/.test(line)) return 1;
141
+ if (new RegExp(`^ ${propertyKey}:`).test(line)) return index + 1;
142
+ }
143
+ return 1;
144
+ };
145
+
146
+ export const getThemeDiagnostics = async ({ documentPath, source }) => {
147
+ let theme;
148
+ try {
149
+ theme = load(source) ?? {};
150
+ } catch {
151
+ return [];
152
+ }
153
+ if (!theme || typeof theme !== 'object' || Array.isArray(theme)) return [];
154
+
155
+ const backgroundPattern = theme.sections?.backgroundPattern;
156
+ if (backgroundPattern === undefined || backgroundPattern === 'uniform') return [];
157
+ const siteRoot = await findNornaSiteRoot(documentPath);
158
+ if (!siteRoot || await getNavigationModeForEditor(siteRoot) !== 'tree') return [];
159
+
160
+ return [{
161
+ code: 'tree-section-background-pattern',
162
+ line: findNestedYamlPropertyLine(source, 'sections', 'backgroundPattern'),
163
+ message: `sections.backgroundPattern "${backgroundPattern}" cannot be used because this site resolves to tree navigation. Tree navigation uses one uniform reading surface so the navigation rail and page content remain distinct. Remove sections.backgroundPattern or set it to uniform.`,
164
+ severity: 'error',
165
+ }];
166
+ };
167
+
36
168
  const readSitewideLogoForEditor = async (siteRoot) => {
37
169
  const filename = await findFile(siteRoot, ['sitewide-content.yaml']);
38
170
  if (!filename) return null;
@@ -89,6 +221,18 @@ export const getSitePublicAssetStatus = async (documentPath) => {
89
221
  });
90
222
  }
91
223
  }
224
+ if (inspection.socialImages.length > 1) {
225
+ const message = `Multiple social sharing images were found: ${inspection.socialImages.join(', ')}. Keep exactly one of ${socialImageAssetFilenames.join(', ')} in site/public.`;
226
+ for (const filename of inspection.socialImages) {
227
+ issues.push({
228
+ absolutePath: path.join(publicDirectory, filename),
229
+ code: 'multiple-social-image-files',
230
+ filename,
231
+ message,
232
+ severity: 'error',
233
+ });
234
+ }
235
+ }
92
236
  if (inspection.logos.length === 0 && sitewideLogo?.logoConfigured) {
93
237
  const publicLabel = toPosixPath(path.relative(path.dirname(siteRoot), publicDirectory));
94
238
  issues.push({
@@ -336,7 +480,7 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
336
480
  const page = getPageContext(siteRoot, documentPath);
337
481
  if (!page) return null;
338
482
  const fence = getOpenMarkdownFenceAtLine(source, line);
339
- if (!fence || !['norna-image-stack', 'norna-image-carousel', 'norna-card-list'].includes(fence.type)) return null;
483
+ if (!fence || !['image-stack', 'image-carousel', 'card-list'].includes(fence.type)) return null;
340
484
 
341
485
  const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
342
486
  if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
@@ -435,7 +579,7 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
435
579
  code: 'local-markdown-image',
436
580
  severity: 'warning',
437
581
  line: reference.line,
438
- message: `Local Markdown image "${reference.target}" is not managed by Norna. Use norna-image-stack, norna-image-carousel, or norna-card-list for validated and synchronized site images.`,
582
+ message: `Local Markdown image "${reference.target}" is not managed by Norna. Use image-stack, image-carousel, or card-list for validated and synchronized site images.`,
439
583
  });
440
584
  }
441
585
 
@@ -491,16 +635,20 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
491
635
  };
492
636
 
493
637
  export const nornaBlockDefinitions = Object.freeze({
494
- 'norna-image-stack': Object.freeze({
495
- ...nornaMarkdownBlockDefinitions['norna-image-stack'],
496
- snippet: '```norna-image-stack\n- image: ${1:filename.jpg}\n alt: ${2:Alternative text}\n caption: ${3:Caption}\n```',
638
+ 'image-stack': Object.freeze({
639
+ ...nornaMarkdownBlockDefinitions['image-stack'],
640
+ snippet: '```image-stack\n- image: ${1:filename.jpg}\n alt: ${2:Alternative text}\n caption: ${3:Caption}\n```',
641
+ }),
642
+ 'image-carousel': Object.freeze({
643
+ ...nornaMarkdownBlockDefinitions['image-carousel'],
644
+ snippet: '```image-carousel\n- image: ${1:first.jpg}\n alt: ${2:Alternative text}\n- image: ${3:second.jpg}\n alt: ${4:Alternative text}\n```',
497
645
  }),
498
- 'norna-image-carousel': Object.freeze({
499
- ...nornaMarkdownBlockDefinitions['norna-image-carousel'],
500
- snippet: '```norna-image-carousel\n- image: ${1:first.jpg}\n alt: ${2:Alternative text}\n- image: ${3:second.jpg}\n alt: ${4:Alternative text}\n```',
646
+ 'card-list': Object.freeze({
647
+ ...nornaMarkdownBlockDefinitions['card-list'],
648
+ snippet: '```card-list\nlayout: ${1|image-top,image-left,image-right|}\nflow: ${2|grid,stack|}\nsize: ${3|s,m,l,xl|}\n\n- title: ${4:Card title}\n text: ${5:Card text}\n image: ${6:filename.jpg}\n```',
501
649
  }),
502
- 'norna-card-list': Object.freeze({
503
- ...nornaMarkdownBlockDefinitions['norna-card-list'],
504
- snippet: '```norna-card-list\nlayout: ${1|image-top,image-left,image-right|}\nflow: ${2|grid,stack|}\nsize: ${3|s,m,l,xl|}\n\n- title: ${4:Card title}\n text: ${5:Card text}\n image: ${6:filename.jpg}\n```',
650
+ 'page-list': Object.freeze({
651
+ ...nornaMarkdownBlockDefinitions['page-list'],
652
+ snippet: '```page-list\n```',
505
653
  }),
506
654
  });