@janga/norna 0.7.13 → 0.7.15

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 (146) hide show
  1. package/README.md +15 -9
  2. package/bin/norna-cli.mjs +11 -4
  3. package/docs/README.md +71 -43
  4. package/docs/commands.md +34 -20
  5. package/docs/configuration.md +43 -62
  6. package/docs/content.md +34 -7
  7. package/docs/design/command-organization.md +15 -8
  8. package/docs/design/norna-diagram-design.md +6 -6
  9. package/docs/design/site-examples-structure.md +1 -1
  10. package/docs/engine-development.md +22 -9
  11. package/docs/getting-started.md +29 -16
  12. package/docs/how-to/embedded-site.md +1 -1
  13. package/docs/images-and-metadata.md +3 -3
  14. package/docs/local-development.md +11 -3
  15. package/docs/public-files.md +105 -0
  16. package/docs/publishing.md +4 -8
  17. package/docs/routes.md +6 -7
  18. package/docs/site-files.md +144 -0
  19. package/docs/sitewide-content.md +47 -33
  20. package/docs/theme.md +37 -49
  21. package/docs/typography.md +25 -31
  22. package/examples/complete-sites/dog-shelter-multi-page/README.md +8 -0
  23. package/examples/complete-sites/{dog-shelter-single-page/site/config.md → dog-shelter-multi-page/site/config.yaml} +0 -2
  24. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.yaml +6 -0
  25. package/examples/complete-sites/dog-shelter-multi-page/site/{theme.md → theme.yaml} +0 -2
  26. package/examples/complete-sites/dog-shelter-single-page/README.md +8 -0
  27. package/examples/complete-sites/{dog-shelter-multi-page/site/config.md → dog-shelter-single-page/site/config.yaml} +0 -2
  28. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.yaml +6 -0
  29. package/examples/complete-sites/dog-shelter-single-page/site/{theme.md → theme.yaml} +0 -2
  30. package/examples/feature-demos/media-and-surfaces/.astro/data-store.json +1 -1
  31. package/examples/feature-demos/media-and-surfaces/README.md +1 -1
  32. package/examples/feature-demos/media-and-surfaces/site/{config.md → config.yaml} +0 -2
  33. package/examples/feature-demos/media-and-surfaces/site/content.md +2 -2
  34. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/{route-content.md → content.md} +3 -7
  35. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.yaml +3 -0
  36. package/examples/feature-demos/media-and-surfaces/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  37. package/examples/feature-demos/media-and-surfaces/site/{theme.md → theme.yaml} +0 -2
  38. package/examples/feature-demos/sitewide-content/README.md +8 -0
  39. package/examples/feature-demos/sitewide-content/site/{config.md → config.yaml} +0 -2
  40. package/examples/feature-demos/sitewide-content/site/routes/010-identity/{route-content.md → content.md} +3 -3
  41. package/examples/feature-demos/sitewide-content/site/routes/020-notices/{route-content.md → content.md} +1 -1
  42. package/examples/feature-demos/sitewide-content/site/routes/030-footer/{route-content.md → content.md} +1 -1
  43. package/examples/feature-demos/sitewide-content/site/{sitewide-content.md → sitewide-content.yaml} +1 -11
  44. package/examples/feature-demos/sitewide-content/site/{theme.md → theme.yaml} +0 -2
  45. package/examples/feature-demos/theme-presets/site/config.yaml +1 -0
  46. package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{theme.md → theme.yaml} +0 -2
  47. package/examples/feature-demos/theme-presets/site/routes/020-documentation/{theme.md → theme.yaml} +0 -2
  48. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.yaml +1 -0
  49. package/examples/feature-demos/theme-presets/site/routes/040-statement/{route-content.md → content.md} +1 -1
  50. package/examples/feature-demos/theme-presets/site/routes/040-statement/{theme.md → theme.yaml} +0 -2
  51. package/examples/feature-demos/theme-presets/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  52. package/examples/feature-demos/theme-presets/site/theme.yaml +1 -0
  53. package/fixtures/basic/site/{config.md → config.yaml} +0 -2
  54. package/fixtures/basic/site/content.md +3 -3
  55. package/fixtures/basic/site/{sitewide-content.md → sitewide-content.yaml} +0 -2
  56. package/fixtures/basic/site/{theme.md → theme.yaml} +3 -6
  57. package/fixtures/content-model-v2/site/{config.md → config.yaml} +0 -2
  58. package/fixtures/content-model-v2/site/theme.yaml +4 -0
  59. package/package.json +10 -3
  60. package/schemas/config.schema.json +77 -0
  61. package/schemas/content-frontmatter.schema.json +81 -0
  62. package/schemas/manifest.json +9 -0
  63. package/schemas/sitewide-content.schema.json +215 -0
  64. package/schemas/theme.schema.json +1208 -0
  65. package/scripts/check-config.mjs +18 -11
  66. package/scripts/export-theme-preset.mjs +1 -1
  67. package/scripts/generate-schemas.mjs +283 -0
  68. package/scripts/init-site.mjs +22 -27
  69. package/scripts/lib/documentation-links.mjs +8 -0
  70. package/scripts/lib/editor-language-service.mjs +609 -0
  71. package/scripts/lib/example-sites.mjs +2 -2
  72. package/scripts/lib/git-status.mjs +7 -0
  73. package/scripts/lib/logo-assets.mjs +15 -10
  74. package/scripts/lib/norna-markdown-blocks.mjs +90 -11
  75. package/scripts/lib/presentation.mjs +23 -12
  76. package/scripts/lib/project-config.mjs +17 -41
  77. package/scripts/lib/public-asset-conventions.mjs +112 -0
  78. package/scripts/lib/schema-definitions.mjs +213 -0
  79. package/scripts/lib/schema-value-definitions.mjs +86 -0
  80. package/scripts/lib/site-content.mjs +24 -18
  81. package/scripts/lib/site-paths.mjs +4 -8
  82. package/scripts/lib/sitewide-content.mjs +24 -0
  83. package/scripts/lib/theme-config.mjs +15 -35
  84. package/scripts/lib/theme-presets.mjs +76 -66
  85. package/scripts/lib/typography.mjs +15 -15
  86. package/scripts/lib/yaml-config.mjs +50 -0
  87. package/scripts/list-theme-presets.mjs +15 -0
  88. package/scripts/release.mjs +84 -8
  89. package/scripts/show-typography.mjs +22 -46
  90. package/scripts/sync-content-sections.mjs +2 -2
  91. package/scripts/test-client-javascript.mjs +28 -28
  92. package/scripts/test-content-check.mjs +21 -26
  93. package/scripts/test-content-model-v2.mjs +72 -40
  94. package/scripts/test-documentation.mjs +70 -4
  95. package/scripts/test-editor-language-service.mjs +158 -0
  96. package/scripts/test-engine-commands.mjs +65 -34
  97. package/scripts/test-package-check.mjs +37 -30
  98. package/scripts/test-project-config.mjs +15 -20
  99. package/scripts/test-schemas.mjs +102 -0
  100. package/scripts/test-temporary-visibility.mjs +5 -13
  101. package/scripts/test-theme-presets.mjs +64 -37
  102. package/src/components/CardList.astro +2 -5
  103. package/src/components/ImageCarousel.astro +21 -21
  104. package/src/components/{GalleryGrid.astro → ImageStack.astro} +12 -12
  105. package/src/components/SectionNavigationScript.astro +28 -0
  106. package/src/components/SiteNavigation.astro +6 -5
  107. package/src/components/SitePage.astro +8 -8
  108. package/src/components/SiteSection.astro +18 -21
  109. package/src/content.config.ts +17 -209
  110. package/src/layouts/BaseLayout.astro +9 -9
  111. package/src/lib/{galleryImages.ts → imagePresentation.ts} +7 -7
  112. package/src/lib/routeThemes.ts +9 -29
  113. package/src/lib/sectionContent.ts +3 -3
  114. package/src/lib/sitePublicAssets.ts +5 -21
  115. package/src/styles/global.css +73 -73
  116. package/starters/basic/.github/workflows/deploy.yml +1 -1
  117. package/starters/basic/README.md +16 -13
  118. package/starters/basic/package.json +3 -2
  119. package/starters/basic/site/{config.md → config.yaml} +0 -2
  120. package/starters/basic/site/content.md +1 -1
  121. package/starters/basic/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  122. package/starters/basic/site/{theme.md → theme.yaml} +8 -15
  123. package/starters/project/.github/workflows/deploy.yml +1 -1
  124. package/starters/project/README.md +22 -15
  125. package/starters/project/package.json +3 -2
  126. package/starters/project/site/{config.md → config.yaml} +0 -2
  127. package/starters/project/site/{sitewide-content.md → sitewide-content.yaml} +1 -3
  128. package/starters/project/site/{theme.md → theme.yaml} +4 -8
  129. package/docs/site-structure.md +0 -98
  130. package/examples/complete-sites/dog-shelter-multi-page/site/sitewide-content.md +0 -16
  131. package/examples/complete-sites/dog-shelter-single-page/site/sitewide-content.md +0 -16
  132. package/examples/feature-demos/media-and-surfaces/site/routes/020-surfaces/theme.md +0 -8
  133. package/examples/feature-demos/theme-presets/site/config.md +0 -3
  134. package/examples/feature-demos/theme-presets/site/routes/030-project/theme.md +0 -3
  135. package/examples/feature-demos/theme-presets/site/theme.md +0 -3
  136. package/fixtures/content-model-v2/site/theme.md +0 -7
  137. package/scripts/lib/sitewide-config.mjs +0 -34
  138. package/src/components/MobileSectionNavigationScript.astro +0 -14
  139. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/010-dogs/{route-content.md → content.md} +0 -0
  140. /package/examples/complete-sites/dog-shelter-multi-page/site/routes/020-adopt/{route-content.md → content.md} +0 -0
  141. /package/examples/feature-demos/media-and-surfaces/site/routes/010-media/{route-content.md → content.md} +0 -0
  142. /package/examples/feature-demos/theme-presets/site/routes/010-portfolio/{route-content.md → content.md} +0 -0
  143. /package/examples/feature-demos/theme-presets/site/routes/020-documentation/{route-content.md → content.md} +0 -0
  144. /package/examples/feature-demos/theme-presets/site/routes/030-project/{route-content.md → content.md} +0 -0
  145. /package/fixtures/content-model-v2/site/routes/010-guide/{route-content.md → content.md} +0 -0
  146. /package/starters/project/site/routes/010-guide/{route-content.md → content.md} +0 -0
@@ -0,0 +1,609 @@
1
+ import { access, readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { load } from 'js-yaml';
4
+ import {
5
+ extractInlineNoteDiagnostics,
6
+ extractMarkdownImageReferences,
7
+ extractNornaMarkdownBlockDiagnostics,
8
+ getNornaBlockImageReferences,
9
+ nornaMarkdownBlockDefinitions,
10
+ } from './norna-markdown-blocks.mjs';
11
+ import { inspectPublicAssetFilenames, logoAssetFilenames } from './public-asset-conventions.mjs';
12
+ import { siteSchema } from './schema-definitions.mjs';
13
+
14
+ const supportedImageExtensions = new Set(['.jpg', '.jpeg', '.png', '.svg']);
15
+ const siteConfigNames = ['config.yaml'];
16
+ const headingIdPattern = /^##\s+.+?\s*\{#([a-z0-9-]+)\}\s*$/;
17
+
18
+ const fileExists = (filePath) => access(filePath).then(() => true, () => false);
19
+ const toPosixPath = (filePath) => filePath.split(path.sep).join('/');
20
+
21
+ const findFile = async (directory, names) => {
22
+ for (const name of names) {
23
+ if (await fileExists(path.join(directory, name))) return name;
24
+ }
25
+ return null;
26
+ };
27
+
28
+ export const findNornaSiteRoot = async (documentPath) => {
29
+ let current = path.dirname(path.resolve(documentPath));
30
+
31
+ while (true) {
32
+ if (await findFile(current, siteConfigNames)) return current;
33
+
34
+ const parent = path.dirname(current);
35
+ if (parent === current) return null;
36
+ current = parent;
37
+ }
38
+ };
39
+
40
+ const readSitewideNavigationForEditor = async (siteRoot) => {
41
+ const filename = await findFile(siteRoot, ['sitewide-content.yaml']);
42
+ if (!filename) return null;
43
+ const absolutePath = path.join(siteRoot, filename);
44
+ const source = await readFile(absolutePath, 'utf8');
45
+
46
+ try {
47
+ const data = load(source) ?? {};
48
+ const navigation = data && typeof data === 'object' && data.navigation && typeof data.navigation === 'object'
49
+ ? data.navigation
50
+ : {};
51
+ const logoLine = source.replace(/\r\n?/g, '\n').split('\n')
52
+ .findIndex((line) => /^\s+logo:\s*(?:#.*)?$/.test(line)) + 1;
53
+ return {
54
+ absolutePath,
55
+ label: typeof navigation.label === 'string' ? navigation.label : null,
56
+ logoConfigured: Object.hasOwn(navigation, 'logo'),
57
+ logoLine: logoLine > 0 ? logoLine : 1,
58
+ };
59
+ } catch {
60
+ return null;
61
+ }
62
+ };
63
+
64
+ export const getSitePublicAssetStatus = async (documentPath) => {
65
+ const siteRoot = await findNornaSiteRoot(documentPath);
66
+ if (!siteRoot) return null;
67
+
68
+ const publicDirectory = path.join(siteRoot, 'public');
69
+ const entries = await readdir(publicDirectory, { withFileTypes: true }).catch((error) => {
70
+ if (error?.code === 'ENOENT') return [];
71
+ throw error;
72
+ });
73
+ const inspection = inspectPublicAssetFilenames(
74
+ entries.filter((entry) => entry.isFile()).map((entry) => entry.name),
75
+ );
76
+ const navigation = await readSitewideNavigationForEditor(siteRoot);
77
+ const issues = inspection.suspicious.map((issue) => ({
78
+ ...issue,
79
+ absolutePath: path.join(publicDirectory, issue.filename),
80
+ severity: 'warning',
81
+ }));
82
+
83
+ if (inspection.logos.length > 1) {
84
+ const message = `Multiple navigation logos were found: ${inspection.logos.join(', ')}. Keep exactly one of ${logoAssetFilenames.join(', ')} in site/public.`;
85
+ for (const filename of inspection.logos) {
86
+ issues.push({
87
+ absolutePath: path.join(publicDirectory, filename),
88
+ code: 'multiple-logo-files',
89
+ filename,
90
+ message,
91
+ severity: 'error',
92
+ });
93
+ }
94
+ }
95
+ if (inspection.logos.length === 0 && navigation?.logoConfigured) {
96
+ const publicLabel = toPosixPath(path.relative(path.dirname(siteRoot), publicDirectory));
97
+ issues.push({
98
+ absolutePath: navigation.absolutePath,
99
+ code: 'missing-logo-file',
100
+ filename: path.basename(navigation.absolutePath),
101
+ line: navigation.logoLine,
102
+ message: `Navigation logo settings are configured, but no logo file was found. Add exactly one of ${logoAssetFilenames.map((filename) => `${publicLabel}/${filename}`).join(', ')}, or remove navigation.logo.`,
103
+ severity: 'error',
104
+ });
105
+ }
106
+
107
+ return {
108
+ ...inspection,
109
+ issues,
110
+ navigation,
111
+ publicDirectory,
112
+ siteRoot,
113
+ };
114
+ };
115
+
116
+ const getPageContext = (siteRoot, documentPath) => {
117
+ const absoluteDocumentPath = path.resolve(documentPath);
118
+ const relativePath = toPosixPath(path.relative(siteRoot, absoluteDocumentPath));
119
+ if (relativePath === 'content.md') {
120
+ return {
121
+ contentPath: absoluteDocumentPath,
122
+ imagesRoot: path.join(siteRoot, 'images'),
123
+ pageLabel: 'Home',
124
+ routeDirectory: null,
125
+ };
126
+ }
127
+
128
+ const match = relativePath.match(/^routes\/(.+)\/content\.md$/);
129
+ if (!match) return null;
130
+
131
+ const routeDirectory = match[1];
132
+ return {
133
+ contentPath: absoluteDocumentPath,
134
+ imagesRoot: path.join(siteRoot, 'routes', ...routeDirectory.split('/'), 'images'),
135
+ pageLabel: routeDirectory,
136
+ routeDirectory,
137
+ };
138
+ };
139
+
140
+ const getSectionAtLine = (source, lineIndex) => {
141
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
142
+ let section = null;
143
+
144
+ for (let index = 0; index <= Math.min(lineIndex, lines.length - 1); index += 1) {
145
+ const match = lines[index].match(headingIdPattern);
146
+ if (match) section = { id: match[1], line: index };
147
+ }
148
+
149
+ return section;
150
+ };
151
+
152
+ const getOpenFenceAtLine = (source, lineIndex) => {
153
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
154
+ let open = null;
155
+
156
+ for (let index = 0; index <= Math.min(lineIndex, lines.length - 1); index += 1) {
157
+ const match = lines[index].match(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)$/);
158
+ if (!match) continue;
159
+
160
+ if (!open) {
161
+ open = {
162
+ character: match[1][0],
163
+ length: match[1].length,
164
+ line: index,
165
+ type: match[2].trim().split(/\s+/)[0] ?? '',
166
+ };
167
+ continue;
168
+ }
169
+
170
+ if (match[1][0] === open.character && match[1].length >= open.length && !match[2].trim()) {
171
+ open = null;
172
+ }
173
+ }
174
+
175
+ return open;
176
+ };
177
+
178
+ const getBlockField = (definition, key, itemField = false) => (
179
+ itemField ? definition.item?.fields?.[key] : definition.options?.[key]
180
+ );
181
+
182
+ const getExistingKeys = (lines, startLine, endLine, indent) => {
183
+ const keys = new Set();
184
+ for (let index = startLine; index < endLine; index += 1) {
185
+ const match = lines[index]?.match(/^(\s*)(?:-\s+)?([a-z][a-z0-9-]*):/);
186
+ if (match && match[1].length === indent) keys.add(match[2]);
187
+ }
188
+ return keys;
189
+ };
190
+
191
+ const findCurrentItemLine = (lines, fence, lineIndex, itemKey) => {
192
+ for (let index = lineIndex; index > fence.line; index -= 1) {
193
+ if (new RegExp(`^\\s*-\\s+${itemKey}:`).test(lines[index] ?? '')) return index;
194
+ }
195
+ return null;
196
+ };
197
+
198
+ const fieldCandidate = (key, fieldDefinition, options = {}) => ({
199
+ default: fieldDefinition.default,
200
+ description: fieldDefinition.description,
201
+ key,
202
+ kind: options.kind ?? 'field',
203
+ prefix: options.prefix ?? '',
204
+ values: fieldDefinition.values,
205
+ });
206
+
207
+ export const getNornaBlockCompletionContext = ({ source, line }) => {
208
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
209
+ const fence = getOpenFenceAtLine(source, line);
210
+ const definition = fence ? nornaMarkdownBlockDefinitions[fence.type] : null;
211
+ if (!fence || !definition) return null;
212
+
213
+ const currentLine = lines[line] ?? '';
214
+ const keyMatch = currentLine.match(/^(\s*)(?:-\s+)?([a-z][a-z0-9-]*):\s*(.*)$/);
215
+ if (keyMatch) {
216
+ const indent = keyMatch[1].length;
217
+ const isItemField = indent === 2 || currentLine.trimStart().startsWith('- ');
218
+ const selectedField = currentLine.trimStart().startsWith('- ')
219
+ ? keyMatch[2] === definition.item?.start.key ? definition.item.start : null
220
+ : getBlockField(definition, keyMatch[2], isItemField);
221
+ if (selectedField?.values) {
222
+ return {
223
+ candidates: Object.entries(selectedField.values).map(([valueName, valueDefinition]) => ({
224
+ description: valueDefinition.description,
225
+ kind: 'value',
226
+ label: valueName,
227
+ title: valueDefinition.title,
228
+ })),
229
+ definition,
230
+ fence,
231
+ mode: 'value',
232
+ value: keyMatch[3],
233
+ };
234
+ }
235
+ }
236
+
237
+ const indent = currentLine.match(/^\s*/)?.[0].length ?? 0;
238
+ const itemKey = definition.item?.start.key;
239
+ const currentItemLine = itemKey ? findCurrentItemLine(lines, fence, line, itemKey) : null;
240
+ let candidates = [];
241
+
242
+ if (indent >= 2 && currentItemLine !== null) {
243
+ const existing = getExistingKeys(lines, currentItemLine + 1, line, 2);
244
+ candidates = Object.entries(definition.item.fields)
245
+ .filter(([key]) => !existing.has(key))
246
+ .map(([key, fieldDefinition]) => fieldCandidate(key, fieldDefinition));
247
+ } else if (indent === 0) {
248
+ const hasItem = currentItemLine !== null;
249
+ if (!hasItem && definition.options) {
250
+ const existing = getExistingKeys(lines, fence.line + 1, line, 0);
251
+ candidates.push(...Object.entries(definition.options)
252
+ .filter(([key]) => !existing.has(key))
253
+ .map(([key, fieldDefinition]) => fieldCandidate(key, fieldDefinition)));
254
+ }
255
+ if (definition.item?.start) {
256
+ candidates.push(fieldCandidate(itemKey, definition.item.start, {
257
+ kind: 'item',
258
+ prefix: definition.item.start.prefix,
259
+ }));
260
+ }
261
+ }
262
+
263
+ return {
264
+ candidates,
265
+ definition,
266
+ fence,
267
+ mode: 'field',
268
+ };
269
+ };
270
+
271
+ export const getNornaBlockFieldContext = ({ source, line }) => {
272
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
273
+ const fence = getOpenFenceAtLine(source, line);
274
+ const definition = fence ? nornaMarkdownBlockDefinitions[fence.type] : null;
275
+ if (!fence || !definition) return null;
276
+ const currentLine = lines[line] ?? '';
277
+ const match = currentLine.match(/^(\s*)(-\s+)?([a-z][a-z0-9-]*):/);
278
+ if (!match) return null;
279
+
280
+ const key = match[3];
281
+ const fieldDefinition = match[2]
282
+ ? key === definition.item?.start.key ? definition.item.start : null
283
+ : getBlockField(definition, key, match[1].length === 2);
284
+ return fieldDefinition ? { definition, field: fieldDefinition, fence, key } : null;
285
+ };
286
+
287
+ const collectImageFiles = async (directory, imageRoot, pageLabel, files) => {
288
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
289
+ if (error?.code === 'ENOENT') return [];
290
+ throw error;
291
+ });
292
+
293
+ for (const entry of entries) {
294
+ const absolutePath = path.join(directory, entry.name);
295
+ if (entry.isDirectory()) {
296
+ await collectImageFiles(absolutePath, imageRoot, pageLabel, files);
297
+ continue;
298
+ }
299
+ if (!entry.isFile() || !supportedImageExtensions.has(path.extname(entry.name).toLowerCase())) continue;
300
+
301
+ const relativeToImages = toPosixPath(path.relative(imageRoot, absolutePath));
302
+ files.push({
303
+ absolutePath,
304
+ filename: entry.name,
305
+ pageLabel,
306
+ relativeToImages,
307
+ sectionId: relativeToImages.split('/')[0] ?? '',
308
+ });
309
+ }
310
+ };
311
+
312
+ const collectRouteImageRoots = async (directory, siteRoot, roots) => {
313
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
314
+ if (error?.code === 'ENOENT') return [];
315
+ throw error;
316
+ });
317
+
318
+ for (const entry of entries) {
319
+ if (!entry.isDirectory()) continue;
320
+ const absolutePath = path.join(directory, entry.name);
321
+ if (entry.name === 'images') {
322
+ const routeDirectory = toPosixPath(path.relative(path.join(siteRoot, 'routes'), path.dirname(absolutePath)));
323
+ roots.push({ imageRoot: absolutePath, pageLabel: routeDirectory });
324
+ continue;
325
+ }
326
+ await collectRouteImageRoots(absolutePath, siteRoot, roots);
327
+ }
328
+ };
329
+
330
+ const getContentFiles = async (siteRoot) => {
331
+ const files = [];
332
+ const homePath = path.join(siteRoot, 'content.md');
333
+ if (await fileExists(homePath)) files.push(homePath);
334
+
335
+ const visit = async (directory) => {
336
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
337
+ if (error?.code === 'ENOENT') return [];
338
+ throw error;
339
+ });
340
+ for (const entry of entries) {
341
+ const absolutePath = path.join(directory, entry.name);
342
+ if (entry.isDirectory()) await visit(absolutePath);
343
+ else if (entry.isFile() && entry.name === 'content.md') files.push(absolutePath);
344
+ }
345
+ };
346
+
347
+ await visit(path.join(siteRoot, 'routes'));
348
+ return files;
349
+ };
350
+
351
+ const getReferencesByFilename = async (siteRoot) => {
352
+ const references = new Map();
353
+ for (const contentPath of await getContentFiles(siteRoot)) {
354
+ const source = await readFile(contentPath, 'utf8');
355
+ const { blocks } = extractNornaMarkdownBlockDiagnostics(source, { label: toPosixPath(path.relative(siteRoot, contentPath)) });
356
+ for (const reference of getNornaBlockImageReferences(blocks)) {
357
+ if (!references.has(reference.image)) references.set(reference.image, []);
358
+ references.get(reference.image).push(toPosixPath(path.relative(siteRoot, contentPath)));
359
+ }
360
+ }
361
+ return references;
362
+ };
363
+
364
+ export const createSiteImageIndex = async (siteRoot) => {
365
+ const files = [];
366
+ const homeImageRoot = path.join(siteRoot, 'images');
367
+ await collectImageFiles(homeImageRoot, homeImageRoot, 'Home', files);
368
+
369
+ const routeRoots = [];
370
+ await collectRouteImageRoots(path.join(siteRoot, 'routes'), siteRoot, routeRoots);
371
+ for (const root of routeRoots) {
372
+ await collectImageFiles(root.imageRoot, root.imageRoot, root.pageLabel, files);
373
+ }
374
+
375
+ const referencesByFilename = await getReferencesByFilename(siteRoot);
376
+ const filesByName = new Map();
377
+ for (const file of files) {
378
+ if (!filesByName.has(file.filename)) filesByName.set(file.filename, []);
379
+ filesByName.get(file.filename).push(file);
380
+ }
381
+
382
+ for (const candidates of filesByName.values()) {
383
+ candidates.sort((left, right) => left.absolutePath.localeCompare(right.absolutePath, 'en'));
384
+ }
385
+
386
+ return { files, filesByName, referencesByFilename };
387
+ };
388
+
389
+ export const getImageCompletionContext = async ({ documentPath, source, line }) => {
390
+ const siteRoot = await findNornaSiteRoot(documentPath);
391
+ if (!siteRoot) return null;
392
+ const page = getPageContext(siteRoot, documentPath);
393
+ if (!page) return null;
394
+ const fence = getOpenFenceAtLine(source, line);
395
+ if (!fence || !['norna-image-stack', 'norna-image-carousel', 'norna-card-list'].includes(fence.type)) return null;
396
+
397
+ const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
398
+ if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
399
+
400
+ const section = getSectionAtLine(source, line);
401
+ if (!section) return null;
402
+ const expectedDirectory = path.join(page.imagesRoot, section.id);
403
+ const index = await createSiteImageIndex(siteRoot);
404
+ const candidates = index.files.map((file) => {
405
+ const isExpected = path.dirname(file.absolutePath) === expectedDirectory;
406
+ const duplicateCount = index.filesByName.get(file.filename)?.length ?? 0;
407
+ const referencedBy = index.referencesByFilename.get(file.filename) ?? [];
408
+ return {
409
+ ...file,
410
+ duplicateCount,
411
+ isExpected,
412
+ referencedBy,
413
+ siteRelativePath: toPosixPath(path.relative(siteRoot, file.absolutePath)),
414
+ };
415
+ }).sort((left, right) => {
416
+ if (left.isExpected !== right.isExpected) return left.isExpected ? -1 : 1;
417
+ return left.filename.localeCompare(right.filename, 'en');
418
+ });
419
+
420
+ return {
421
+ candidates,
422
+ expectedDirectory,
423
+ page,
424
+ section,
425
+ siteRoot,
426
+ };
427
+ };
428
+
429
+ export const getImageDefinitionContext = async ({ documentPath, source, line }) => {
430
+ const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
431
+ const match = currentLine.match(/^\s*(?:-\s+)?image:\s*([^\s#]+)\s*(?:#.*)?$/);
432
+ if (!match) return null;
433
+
434
+ const filename = match[1].replace(/^['"]|['"]$/g, '');
435
+ const siteRoot = await findNornaSiteRoot(documentPath);
436
+ const page = siteRoot ? getPageContext(siteRoot, documentPath) : null;
437
+ const section = getSectionAtLine(source, line);
438
+ if (!siteRoot || !page || !section) return null;
439
+
440
+ const expectedPath = path.join(page.imagesRoot, section.id, filename);
441
+ if (await fileExists(expectedPath)) return { files: [expectedPath], filename, siteRoot };
442
+
443
+ const index = await createSiteImageIndex(siteRoot);
444
+ return {
445
+ files: (index.filesByName.get(filename) ?? []).map((candidate) => candidate.absolutePath),
446
+ filename,
447
+ siteRoot,
448
+ };
449
+ };
450
+
451
+ const getLineFromMessage = (message, fallback = 1) => {
452
+ const match = String(message).match(/\bline\s+(\d+)\b/i);
453
+ return match ? Number.parseInt(match[1], 10) : fallback;
454
+ };
455
+
456
+ const getImageReferenceLines = (source) => {
457
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
458
+ const references = [];
459
+ let fence = null;
460
+
461
+ for (const [index, line] of lines.entries()) {
462
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})([^\r\n]*)$/);
463
+ if (fenceMatch) {
464
+ if (!fence) {
465
+ fence = { character: fenceMatch[1][0], length: fenceMatch[1].length, type: fenceMatch[2].trim().split(/\s+/)[0] ?? '' };
466
+ } else if (fenceMatch[1][0] === fence.character && fenceMatch[1].length >= fence.length && !fenceMatch[2].trim()) {
467
+ fence = null;
468
+ }
469
+ continue;
470
+ }
471
+ if (!fence || !fence.type.startsWith('norna-')) continue;
472
+
473
+ const match = line.match(/^\s*(?:-\s+)?image:\s*([^\s#]+)\s*(?:#.*)?$/);
474
+ if (match) references.push({ filename: match[1].replace(/^['"]|['"]$/g, ''), line: index + 1 });
475
+ }
476
+
477
+ return references;
478
+ };
479
+
480
+ const getContentFrontmatterDiagnostics = (source) => {
481
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
482
+ if (!match) {
483
+ return [{ severity: 'error', line: 1, message: 'Norna content files must start with YAML frontmatter delimited by ---.' }];
484
+ }
485
+
486
+ let data;
487
+ try {
488
+ data = load(match[1]) ?? {};
489
+ } catch (error) {
490
+ return [{
491
+ severity: 'error',
492
+ line: (error?.mark?.line ?? 0) + 2,
493
+ message: `Invalid YAML frontmatter: ${error instanceof Error ? error.message : String(error)}`,
494
+ }];
495
+ }
496
+
497
+ const parsed = siteSchema.safeParse(data);
498
+ if (parsed.success) return [];
499
+
500
+ return parsed.error.issues.map((issue) => ({
501
+ severity: 'error',
502
+ line: 2,
503
+ message: `${issue.path.join('.') || 'frontmatter'}: ${issue.message}`,
504
+ }));
505
+ };
506
+
507
+ const getSectionHeadingDiagnostics = (source) => source.replace(/\r\n?/g, '\n').split('\n')
508
+ .flatMap((line, index) => {
509
+ if (!/^##(?:\s|$)/.test(line) || headingIdPattern.test(line)) return [];
510
+ return [{
511
+ code: 'missing-section-id',
512
+ severity: 'error',
513
+ line: index + 1,
514
+ message: 'Level 2 Norna section headings require an explicit id, for example "## Introduction {#introduction}".',
515
+ }];
516
+ });
517
+
518
+ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
519
+ const diagnostics = [
520
+ ...getContentFrontmatterDiagnostics(source),
521
+ ...getSectionHeadingDiagnostics(source),
522
+ ];
523
+ const blockResult = extractNornaMarkdownBlockDiagnostics(source, { label: path.basename(documentPath) });
524
+ for (const error of blockResult.errors) {
525
+ diagnostics.push({
526
+ code: error.message.includes('but not closed') ? 'unclosed-norna-block' : 'invalid-norna-block',
527
+ severity: 'error',
528
+ line: getLineFromMessage(error.message, error.line),
529
+ message: error.message,
530
+ });
531
+ }
532
+ for (const error of extractInlineNoteDiagnostics(source, { label: path.basename(documentPath) }).errors) {
533
+ diagnostics.push({ severity: 'error', line: error.line ?? getLineFromMessage(error.message), message: error.message });
534
+ }
535
+ for (const reference of extractMarkdownImageReferences(source)) {
536
+ diagnostics.push({
537
+ code: 'local-markdown-image',
538
+ severity: 'warning',
539
+ line: reference.line,
540
+ 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.`,
541
+ });
542
+ }
543
+
544
+ const siteRoot = await findNornaSiteRoot(documentPath);
545
+ const page = siteRoot ? getPageContext(siteRoot, documentPath) : null;
546
+ if (!siteRoot || !page) return diagnostics;
547
+
548
+ const index = await createSiteImageIndex(siteRoot);
549
+ for (const reference of getImageReferenceLines(source)) {
550
+ const section = getSectionAtLine(source, reference.line - 1);
551
+ if (!section) continue;
552
+ const expectedPath = path.join(page.imagesRoot, section.id, reference.filename);
553
+ if (await fileExists(expectedPath)) continue;
554
+
555
+ const candidates = index.filesByName.get(reference.filename) ?? [];
556
+ if (candidates.length === 0) {
557
+ diagnostics.push({
558
+ code: 'missing-image',
559
+ severity: 'error',
560
+ line: reference.line,
561
+ message: `Image "${reference.filename}" was not found in the expected section folder or elsewhere in this Norna site.`,
562
+ });
563
+ continue;
564
+ }
565
+ if (candidates.length > 1) {
566
+ diagnostics.push({
567
+ code: 'ambiguous-image',
568
+ severity: 'warning',
569
+ line: reference.line,
570
+ message: `Image "${reference.filename}" is ambiguous. Found: ${candidates.map((candidate) => toPosixPath(path.relative(siteRoot, candidate.absolutePath))).join(', ')}.`,
571
+ });
572
+ continue;
573
+ }
574
+
575
+ const candidate = candidates[0];
576
+ const referencedBy = index.referencesByFilename.get(reference.filename) ?? [];
577
+ const currentRelativePath = toPosixPath(path.relative(siteRoot, documentPath));
578
+ const otherReferences = referencedBy.filter((contentPath) => contentPath !== currentRelativePath);
579
+ diagnostics.push({
580
+ code: otherReferences.length > 0 ? 'shared-image' : 'image-needs-sync',
581
+ data: {
582
+ imagePath: candidate.absolutePath,
583
+ siteRoot,
584
+ },
585
+ severity: 'warning',
586
+ line: reference.line,
587
+ message: otherReferences.length > 0
588
+ ? `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))} and is still referenced by ${otherReferences.join(', ')}. Norna cannot relocate it safely.`
589
+ : `Image "${reference.filename}" is stored at ${toPosixPath(path.relative(siteRoot, candidate.absolutePath))}. Run "norna content:sync" to relocate it to this section.`,
590
+ });
591
+ }
592
+
593
+ return diagnostics;
594
+ };
595
+
596
+ export const nornaBlockDefinitions = Object.freeze({
597
+ 'norna-image-stack': Object.freeze({
598
+ ...nornaMarkdownBlockDefinitions['norna-image-stack'],
599
+ snippet: '```norna-image-stack\n- image: ${1:filename.jpg}\n alt: ${2:Alternative text}\n caption: ${3:Caption}\n```',
600
+ }),
601
+ 'norna-image-carousel': Object.freeze({
602
+ ...nornaMarkdownBlockDefinitions['norna-image-carousel'],
603
+ 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```',
604
+ }),
605
+ 'norna-card-list': Object.freeze({
606
+ ...nornaMarkdownBlockDefinitions['norna-card-list'],
607
+ snippet: '```norna-card-list\nlayout: ${1|image-top,image-left,image-right|}\nflow: ${2|grid,stack|}\nsize: ${3|s,m,l,xl|}\nwidth: ${4|text,narrow,normal,wide|}\n\n- title: ${5:Card title}\n text: ${6:Card text}\n image: ${7:filename.jpg}\n```',
608
+ }),
609
+ });
@@ -13,8 +13,8 @@ export const getExampleSites = async (root) => {
13
13
 
14
14
  for (const entry of entries.filter((candidate) => candidate.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
15
15
  const siteDirectory = path.join(categoryDirectory, entry.name, 'site');
16
- if (!existsSync(path.join(siteDirectory, 'config.md'))) {
17
- throw new Error(`Example ${path.relative(root, siteDirectory)} is missing config.md.`);
16
+ if (!existsSync(path.join(siteDirectory, 'config.yaml'))) {
17
+ throw new Error(`Example ${path.relative(root, siteDirectory)} is missing config.yaml.`);
18
18
  }
19
19
  if (!existsSync(path.join(siteDirectory, 'content.md'))) {
20
20
  throw new Error(`Example ${path.relative(root, siteDirectory)} is missing content.md.`);
@@ -0,0 +1,7 @@
1
+ export const parseShortGitStatus = (output) => (
2
+ output.split(/\r?\n/u).filter((line) => line.length > 0)
3
+ );
4
+
5
+ export const getShortGitStatusPath = (line) => (
6
+ line.slice(3).split(' -> ').at(-1)
7
+ );
@@ -1,9 +1,11 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { sitePublicDir, sitePublicLabel } from './site-paths.mjs';
3
+ import {
4
+ inspectPublicAssetFilenames,
5
+ logoAssetFilenames,
6
+ } from './public-asset-conventions.mjs';
3
7
 
4
- const logoFilePattern = /^logo\.(svg|png|jpe?g)$/i;
5
-
6
- export const getLogoAssets = () => {
8
+ const getPublicAssetFilenames = () => {
7
9
  let entries;
8
10
 
9
11
  try {
@@ -13,20 +15,23 @@ export const getLogoAssets = () => {
13
15
  throw error;
14
16
  }
15
17
 
16
- return entries
17
- .filter((entry) => entry.isFile() && logoFilePattern.test(entry.name))
18
- .map((entry) => ({
19
- filename: entry.name,
20
- href: `/${entry.name}`,
21
- }));
18
+ return entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
22
19
  };
23
20
 
21
+ export const getPublicAssetInspection = () => inspectPublicAssetFilenames(getPublicAssetFilenames());
22
+
23
+ export const getLogoAssets = () => getPublicAssetInspection().logos
24
+ .map((entry) => ({
25
+ filename: entry,
26
+ href: `/${entry}`,
27
+ }));
28
+
24
29
  export const getLogoAsset = () => {
25
30
  const assets = getLogoAssets();
26
31
 
27
32
  if (assets.length > 1) {
28
33
  throw new Error([
29
- `Found multiple logo files in ${sitePublicLabel}. Keep exactly one of logo.svg, logo.png, logo.jpg, or logo.jpeg.`,
34
+ `Found multiple logo files in ${sitePublicLabel}. Keep exactly one of ${logoAssetFilenames.join(', ')}.`,
30
35
  ...assets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
31
36
  ].join('\n'));
32
37
  }