@janga/norna 0.7.25 → 0.7.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +3 -2
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/dev-local.mjs +24 -2
  16. package/scripts/generate-schemas.mjs +15 -3
  17. package/scripts/lib/category-destinations.mjs +48 -0
  18. package/scripts/lib/code-fence-metadata.mjs +14 -37
  19. package/scripts/lib/content-tabs.mjs +155 -0
  20. package/scripts/lib/details-headings.mjs +58 -0
  21. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  22. package/scripts/lib/editor-language-service.mjs +238 -116
  23. package/scripts/lib/locale-registry.mjs +597 -0
  24. package/scripts/lib/locales/az-Latn.mjs +78 -0
  25. package/scripts/lib/locales/be.mjs +78 -0
  26. package/scripts/lib/locales/bg.mjs +78 -0
  27. package/scripts/lib/locales/bs.mjs +78 -0
  28. package/scripts/lib/locales/ca.mjs +78 -0
  29. package/scripts/lib/locales/cnr.mjs +78 -0
  30. package/scripts/lib/locales/cs.mjs +78 -0
  31. package/scripts/lib/locales/da.mjs +78 -0
  32. package/scripts/lib/locales/de.mjs +78 -0
  33. package/scripts/lib/locales/el.mjs +78 -0
  34. package/scripts/lib/locales/en.mjs +78 -0
  35. package/scripts/lib/locales/es.mjs +78 -0
  36. package/scripts/lib/locales/et.mjs +78 -0
  37. package/scripts/lib/locales/fi.mjs +78 -0
  38. package/scripts/lib/locales/fil.mjs +78 -0
  39. package/scripts/lib/locales/fr.mjs +78 -0
  40. package/scripts/lib/locales/ga.mjs +78 -0
  41. package/scripts/lib/locales/ha.mjs +78 -0
  42. package/scripts/lib/locales/hr.mjs +78 -0
  43. package/scripts/lib/locales/hu.mjs +78 -0
  44. package/scripts/lib/locales/id.mjs +78 -0
  45. package/scripts/lib/locales/is.mjs +78 -0
  46. package/scripts/lib/locales/it.mjs +78 -0
  47. package/scripts/lib/locales/jv.mjs +78 -0
  48. package/scripts/lib/locales/lb.mjs +78 -0
  49. package/scripts/lib/locales/lt.mjs +78 -0
  50. package/scripts/lib/locales/lv.mjs +78 -0
  51. package/scripts/lib/locales/mk.mjs +78 -0
  52. package/scripts/lib/locales/mt.mjs +78 -0
  53. package/scripts/lib/locales/nb.mjs +78 -0
  54. package/scripts/lib/locales/nl.mjs +78 -0
  55. package/scripts/lib/locales/nn.mjs +78 -0
  56. package/scripts/lib/locales/pcm.mjs +78 -0
  57. package/scripts/lib/locales/pl.mjs +78 -0
  58. package/scripts/lib/locales/pt.mjs +78 -0
  59. package/scripts/lib/locales/ro.mjs +78 -0
  60. package/scripts/lib/locales/ru.mjs +78 -0
  61. package/scripts/lib/locales/sk.mjs +78 -0
  62. package/scripts/lib/locales/sl.mjs +78 -0
  63. package/scripts/lib/locales/sq.mjs +78 -0
  64. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  65. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  66. package/scripts/lib/locales/sv.mjs +78 -0
  67. package/scripts/lib/locales/sw.mjs +78 -0
  68. package/scripts/lib/locales/tr.mjs +78 -0
  69. package/scripts/lib/locales/uk.mjs +78 -0
  70. package/scripts/lib/locales/vi.mjs +78 -0
  71. package/scripts/lib/markdown-links.mjs +4 -25
  72. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  73. package/scripts/lib/markdown-notes.mjs +165 -0
  74. package/scripts/lib/norna-markdown-blocks.mjs +32 -447
  75. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  76. package/scripts/lib/page-aliases.mjs +12 -1
  77. package/scripts/lib/page-markdown.mjs +63 -16
  78. package/scripts/lib/page-move-plan.mjs +22 -5
  79. package/scripts/lib/pagefind-translations.mjs +620 -0
  80. package/scripts/lib/presentation-contract.mjs +6 -1
  81. package/scripts/lib/presentation.mjs +29 -30
  82. package/scripts/lib/project-config.mjs +2 -173
  83. package/scripts/lib/schema-definitions.mjs +3 -10
  84. package/scripts/lib/schema-editor-metadata.mjs +22 -43
  85. package/scripts/lib/schema-value-definitions.mjs +4 -8
  86. package/scripts/lib/semantic-callouts.mjs +4 -2
  87. package/scripts/lib/site-content.mjs +4 -2
  88. package/scripts/lib/site-link-graph.mjs +16 -6
  89. package/scripts/lib/site-navigation-tree.mjs +0 -11
  90. package/scripts/lib/sitemap.mjs +5 -3
  91. package/scripts/lib/structured-blocks.mjs +108 -0
  92. package/scripts/lib/table-render-plugin.mjs +46 -3
  93. package/scripts/lib/table-row-headers.mjs +218 -0
  94. package/scripts/lib/theme-presets.mjs +3 -10
  95. package/scripts/lib/yaml-config.mjs +2 -6
  96. package/scripts/migrate-check.mjs +19 -0
  97. package/scripts/sync-content-sections.mjs +39 -10
  98. package/scripts/sync-site-public.mjs +7 -1
  99. package/src/components/CodeBlockCopyScript.astro +47 -5
  100. package/src/components/ContentBlocks.astro +39 -0
  101. package/src/components/ContentTabsScript.astro +60 -0
  102. package/src/components/DisplaySettings.astro +17 -19
  103. package/src/components/NavigationPageTree.astro +1 -1
  104. package/src/components/NoteNavigationScript.astro +4 -0
  105. package/src/components/PageAliasRedirect.astro +5 -4
  106. package/src/components/SearchNavigationScript.astro +4 -0
  107. package/src/components/SearchPage.astro +43 -2
  108. package/src/components/SectionNavigationScript.astro +43 -11
  109. package/src/components/SiteBreadcrumbs.astro +1 -1
  110. package/src/components/SiteNavigation.astro +29 -20
  111. package/src/components/SitePage.astro +40 -15
  112. package/src/components/SiteSection.astro +5 -81
  113. package/src/components/SiteTreeNavigation.astro +2 -2
  114. package/src/components/TableOverflowScript.astro +386 -58
  115. package/src/components/TopPageMenu.astro +47 -0
  116. package/src/components/TreeNavigationScript.astro +10 -3
  117. package/src/layouts/BaseLayout.astro +8 -12
  118. package/src/lib/navigationFollowing.ts +142 -0
  119. package/src/lib/noteNavigation.ts +50 -0
  120. package/src/lib/searchNavigation.ts +107 -0
  121. package/src/lib/sectionContent.ts +20 -90
  122. package/src/lib/siteNavigation.ts +0 -5
  123. package/src/lib/sitePages.ts +13 -2
  124. package/src/lib/table-sort-tooltip.ts +109 -0
  125. package/src/pages/[...slug].astro +16 -6
  126. package/src/styles/content.css +394 -132
  127. package/src/styles/foundations.css +9 -0
  128. package/src/styles/media.css +5 -0
  129. package/src/styles/navigation.css +63 -74
  130. package/src/styles/page-layout.css +7 -0
  131. package/src/styles/responsive.css +2 -7
@@ -1,7 +1,12 @@
1
1
  import { access, readdir, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
3
4
  import { load } from 'js-yaml';
5
+ import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
6
+ import { markdownToMdast } from 'satteri';
7
+ import { parseContentTabs } from './content-tabs.mjs';
4
8
  import {
9
+ getNornaBlockSchema,
5
10
  getOpenMarkdownFenceAtLine,
6
11
  nornaMarkdownBlockDefinitions,
7
12
  } from './norna-markdown-blocks.mjs';
@@ -269,26 +274,73 @@ const getPageContext = (siteRoot, documentPath) => {
269
274
  };
270
275
  };
271
276
 
272
- const getBlockField = (definition, key, itemField = false) => (
273
- itemField ? definition.item?.fields?.[key] : definition.options?.[key]
274
- );
277
+ const getBlockField = (definition, schema, key, itemField = false) => {
278
+ const property = (itemField ? schema?.properties?.items?.items?.properties : schema?.properties)?.[key];
279
+ if (!property) return null;
280
+ const metadata = itemField
281
+ ? key === definition.item?.start.key ? definition.item.start : definition.item?.fields?.[key]
282
+ : definition.options?.[key];
283
+ return { ...property, ...metadata };
284
+ };
275
285
 
276
- const getExistingKeys = (lines, startLine, endLine, indent) => {
277
- const keys = new Set();
278
- for (let index = startLine; index < endLine; index += 1) {
279
- const match = lines[index]?.match(/^(\s*)(?:-\s+)?([a-z][a-z0-9-]*):/);
280
- if (match && match[1].length === indent) keys.add(match[2]);
281
- }
282
- return keys;
286
+ const getEmbeddedYaml = (source, line) => {
287
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
288
+ const fence = getOpenMarkdownFenceAtLine(source, line);
289
+ const definition = fence ? nornaMarkdownBlockDefinitions[fence.type] : null;
290
+ if (!definition || line <= fence.line) return null;
291
+ const close = new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`);
292
+ let end = fence.line + 1;
293
+ while (end < lines.length && !close.test(lines[end])) end += 1;
294
+ const bodyLines = lines.slice(fence.line + 1, end);
295
+ const bodyLine = line - fence.line - 1;
296
+ return { bodyLines, bodyLine, definition, fence, schema: getNornaBlockSchema(fence.type) };
283
297
  };
284
298
 
285
- const findCurrentItemLine = (lines, fence, lineIndex, itemKey) => {
286
- for (let index = lineIndex; index > fence.line; index -= 1) {
287
- if (new RegExp(`^\\s*-\\s+${itemKey}:`).test(lines[index] ?? '')) return index;
288
- }
289
- return null;
299
+ export const getMarkdownCompletionScope = ({ source, line, character }) => {
300
+ const normalized = source.replace(/\r\n?/g, '\n');
301
+ const lines = normalized.split('\n');
302
+ const current = lines[line] ?? '';
303
+ const offset = lines.slice(0, line).reduce((sum, text) => sum + text.length + 1, 0)
304
+ + Math.min(character ?? current.length, current.length);
305
+ const nodes = [];
306
+ const visit = (node) => {
307
+ if (node.position && (offset < node.position.start.offset || offset > node.position.end.offset)) return;
308
+ nodes.push(node);
309
+ for (const child of node.children ?? []) visit(child);
310
+ };
311
+ visit(markdownToMdast(normalized, { features: { gfm: true, frontmatter: true } }));
312
+ const frontmatterEnd = lines.findIndex((text, index) => index > 0 && /^(?:---|\.\.\.)\s*$/.test(text));
313
+ const frontmatter = lines[0]?.trim() === '---' && line > 0 && (frontmatterEnd === -1 || line < frontmatterEnd);
314
+ const code = nodes.find((node) => node.type === 'code');
315
+ const literal = frontmatter || nodes.some((node) => ['html', 'inlineCode', 'yaml', 'toml'].includes(node.type));
316
+ const nested = nodes.some((node) => ['blockquote', 'list', 'listItem', 'table', 'heading'].includes(node.type));
317
+ const opening = !literal && !nested && code?.position.start.line === line + 1
318
+ && /^ {0,3}(?:`{3}|~{3})[a-z-]*$/.test(current);
319
+ const embedded = !literal && !nested && code && code.position.start.line < line + 1
320
+ && Object.hasOwn(nornaMarkdownBlockDefinitions, code.lang ?? '')
321
+ && !/^ {0,3}(?:`{3,}|~{3,})\s*$/.test(current);
322
+ const tabs = /:{3,}\s*tabs\b/.test(normalized) ? parseContentTabs(normalized).groups : [];
323
+ const inTabs = tabs.some((group) => offset >= group.start && offset < (group.end ?? normalized.length + 1));
324
+ return {
325
+ frontmatter,
326
+ insertBlocks: /^ {0,3}$/.test(current) && !literal && !code && !nested && !inTabs,
327
+ blocks: Boolean(opening),
328
+ embedded: Boolean(embedded),
329
+ callouts: !literal && !code,
330
+ notes: !literal && !code && !nested && !inTabs,
331
+ };
290
332
  };
291
333
 
334
+ const getBlockMaps = (document) => {
335
+ if (!isMap(document.contents)) return [];
336
+ const maps = [{ map: document.contents, itemField: false }];
337
+ const items = document.contents.get('items', true);
338
+ if (isSeq(items)) maps.push(...items.items.filter(isMap).map((map) => ({ map, itemField: true })));
339
+ return maps;
340
+ };
341
+
342
+ const parseEmbeddedYaml = (source) => parseDocument(source, { schema: 'core', prettyErrors: false });
343
+
292
344
  const fieldCandidate = (key, fieldDefinition, options = {}) => ({
293
345
  default: fieldDefinition.default,
294
346
  description: fieldDefinition.description,
@@ -296,86 +348,114 @@ const fieldCandidate = (key, fieldDefinition, options = {}) => ({
296
348
  kind: options.kind ?? 'field',
297
349
  prefix: options.prefix ?? '',
298
350
  values: fieldDefinition.values,
351
+ snippet: options.snippet,
299
352
  });
300
353
 
301
- export const getNornaBlockCompletionContext = ({ source, line }) => {
302
- const lines = source.replace(/\r\n?/g, '\n').split('\n');
303
- const fence = getOpenMarkdownFenceAtLine(source, line);
304
- const definition = fence ? nornaMarkdownBlockDefinitions[fence.type] : null;
305
- if (!fence || !definition) return null;
306
-
307
- const currentLine = lines[line] ?? '';
308
- const keyMatch = currentLine.match(/^(\s*)(?:-\s+)?([a-z][a-z0-9-]*):\s*(.*)$/);
309
- if (keyMatch) {
310
- const indent = keyMatch[1].length;
311
- const isItemField = indent === 2 || currentLine.trimStart().startsWith('- ');
312
- const selectedField = currentLine.trimStart().startsWith('- ')
313
- ? keyMatch[2] === definition.item?.start.key ? definition.item.start : null
314
- : getBlockField(definition, keyMatch[2], isItemField);
315
- if (selectedField?.values) {
316
- return {
317
- candidates: Object.entries(selectedField.values).map(([valueName, valueDefinition]) => ({
318
- description: valueDefinition.description,
319
- kind: 'value',
320
- label: valueName,
321
- title: valueDefinition.title,
322
- })),
323
- definition,
324
- fence,
325
- mode: 'value',
326
- value: keyMatch[3],
327
- };
328
- }
329
- }
330
-
331
- const indent = currentLine.match(/^\s*/)?.[0].length ?? 0;
332
- const itemKey = definition.item?.start.key;
333
- const currentItemLine = itemKey ? findCurrentItemLine(lines, fence, line, itemKey) : null;
334
- let candidates = [];
335
-
336
- if (indent >= 2 && currentItemLine !== null) {
337
- const existing = getExistingKeys(lines, currentItemLine + 1, line, 2);
338
- candidates = Object.entries(definition.item.fields)
339
- .filter(([key]) => !existing.has(key))
340
- .map(([key, fieldDefinition]) => fieldCandidate(key, fieldDefinition));
341
- } else if (indent === 0) {
342
- const hasItem = currentItemLine !== null;
343
- if (!hasItem && definition.options) {
344
- const existing = getExistingKeys(lines, fence.line + 1, line, 0);
345
- candidates.push(...Object.entries(definition.options)
346
- .filter(([key]) => !existing.has(key))
347
- .map(([key, fieldDefinition]) => fieldCandidate(key, fieldDefinition)));
348
- }
349
- if (definition.item?.start) {
350
- candidates.push(fieldCandidate(itemKey, definition.item.start, {
351
- kind: 'item',
352
- prefix: definition.item.start.prefix,
353
- }));
354
- }
355
- }
356
-
354
+ const newBlockItemCandidate = ({ bodyLines, bodyLine, definition, schema }) => {
355
+ const current = bodyLines[bodyLine] ?? '';
356
+ if (!definition.item || !/^ *$/.test(current)) return null;
357
+ const source = bodyLines.join('\n');
358
+ const marker = '__norna_editor_new_item__';
359
+ if (source.includes(marker)) return null;
360
+ const original = parseEmbeddedYaml(source);
361
+ if (original.errors.length || !isMap(original.contents)) return null;
362
+ const itemsPair = original.contents.items.find((pair) => pair.key?.value === 'items');
363
+ if (!itemsPair) return null;
364
+ const sequence = itemsPair.value;
365
+ if (!(isSeq(sequence) && !sequence.flow) && !(isScalar(sequence) && sequence.value === null)) return null;
366
+ const probeLines = [...bodyLines];
367
+ probeLines[bodyLine] = `${current}${marker}: null`;
368
+ const probe = parseEmbeddedYaml(probeLines.join('\n'));
369
+ if (getBlockMaps(probe).some(({ map }) => map.items.some(({ value }) =>
370
+ isScalar(value) && typeof value.value === 'string' && value.value.includes(marker)))) return null;
371
+ const offset = bodyLines.slice(0, bodyLine).reduce((sum, line) => sum + line.length + 1, 0);
372
+ if (offset <= itemsPair.key.range[0]) return null;
373
+ const position = isSeq(sequence) && sequence.items.length ? sequence.range[0] : itemsPair.key.range[0];
374
+ const column = position - (source.lastIndexOf('\n', position - 1) + 1);
375
+ const indent = ' '.repeat(column + (isSeq(sequence) && sequence.items.length ? 0 : 2));
376
+ const insertion = [...bodyLines];
377
+ insertion[bodyLine] = `${indent}- ${marker}: null`;
378
+ const proposed = parseEmbeddedYaml(insertion.join('\n'));
379
+ if (proposed.errors.length || !isMap(proposed.contents)) return null;
380
+ const updated = proposed.contents.get('items', true);
381
+ if (!isSeq(updated)) return null;
382
+ const addedIndex = updated.items.findIndex((item) => isMap(item) && item.has(marker));
383
+ if (addedIndex < 0 || updated.items[addedIndex].items.length !== 1) return null;
384
+ // Removing the proposed item must recover the original data exactly. This
385
+ // prevents splitting a card or attaching later fields to the new image.
386
+ const before = original.toJS();
387
+ const after = proposed.toJS();
388
+ after.items.splice(addedIndex, 1);
389
+ if (before.items === null) after.items = null;
390
+ if (!isDeepStrictEqual(before, after)) return null;
391
+ const image = definition.item.start.key === 'image';
357
392
  return {
358
- candidates,
359
- definition,
360
- fence,
361
- mode: 'field',
393
+ key: image ? 'Add image' : 'Add card',
394
+ kind: 'new-item',
395
+ description: image ? 'Insert another image with alternative text and a caption.' : 'Insert another card with text, an image, and a link.',
396
+ snippet: blockItemSnippetLines(schema, indent).join('\n'),
362
397
  };
363
398
  };
364
399
 
365
- export const getNornaBlockFieldContext = ({ source, line }) => {
366
- const lines = source.replace(/\r\n?/g, '\n').split('\n');
367
- const fence = getOpenMarkdownFenceAtLine(source, line);
368
- const definition = fence ? nornaMarkdownBlockDefinitions[fence.type] : null;
369
- if (!fence || !definition) return null;
370
- const currentLine = lines[line] ?? '';
371
- const match = currentLine.match(/^(\s*)(-\s+)?([a-z][a-z0-9-]*):/);
372
- if (!match) return null;
400
+ export const getNornaBlockCompletionContext = ({ source, line }) => {
401
+ const embedded = getEmbeddedYaml(source, line);
402
+ if (!embedded) return null;
403
+ const { bodyLines, bodyLine, definition, fence, schema } = embedded;
404
+ const fieldContext = getNornaBlockFieldContext({ source, line });
405
+ if (fieldContext?.field.values) return {
406
+ candidates: Object.entries(fieldContext.field.values).map(([label, value]) => ({ ...value, kind: 'value', label })),
407
+ definition, fence, mode: 'value', value: fieldContext.value, valueRange: fieldContext.valueRange,
408
+ };
409
+ const result = { candidates: [], definition, fence, mode: 'field' };
410
+ const newItem = newBlockItemCandidate(embedded);
411
+ if (newItem) result.candidates.push(newItem);
412
+ const currentLine = bodyLines[bodyLine] ?? '';
413
+ const prefix = currentLine.match(/^( *)(-\s*)?(?:[a-z][a-z0-9-]*)?$/);
414
+ if (!prefix) return result;
415
+ // A temporary key lets YAML, not indentation guesses, identify the owning map.
416
+ const sentinel = '__norna_editor_completion__';
417
+ bodyLines[bodyLine] = `${prefix[1]}${prefix[2] ? '- ' : ''}${sentinel}: null`;
418
+ let document = parseEmbeddedYaml(bodyLines.join('\n'));
419
+ let owner = getBlockMaps(document).find(({ map }) => map.has(sentinel));
420
+ let itemPrefix = Boolean(prefix[2]);
421
+ if ((!owner || document.errors.length) && !itemPrefix && prefix[1]) {
422
+ bodyLines[bodyLine] = `${prefix[1]}- ${sentinel}: null`;
423
+ document = parseEmbeddedYaml(bodyLines.join('\n'));
424
+ owner = getBlockMaps(document).find(({ map, itemField }) => itemField && map.has(sentinel));
425
+ itemPrefix = true;
426
+ }
427
+ if (document.errors.length) return result;
428
+ if (!owner) return result;
429
+ const properties = owner.itemField ? schema?.properties?.items?.items?.properties : schema?.properties;
430
+ result.candidates.unshift(...Object.keys(properties ?? {})
431
+ .filter((key) => !owner.map.has(key))
432
+ .map((key) => fieldCandidate(key, getBlockField(definition, schema, key, owner.itemField), {
433
+ prefix: itemPrefix ? '- ' : '',
434
+ kind: itemPrefix ? 'item' : 'field',
435
+ snippet: key === 'items' ? `items:\n - ${definition.item.start.key}: \${1:${definition.item.start.key === 'image' ? 'filename.jpg' : 'Card title'}}` : undefined,
436
+ })));
437
+ return result;
438
+ };
373
439
 
374
- const key = match[3];
375
- const fieldDefinition = match[2]
376
- ? key === definition.item?.start.key ? definition.item.start : null
377
- : getBlockField(definition, key, match[1].length === 2);
378
- return fieldDefinition ? { definition, field: fieldDefinition, fence, key } : null;
440
+ export const getNornaBlockFieldContext = ({ source, line }) => {
441
+ const embedded = getEmbeddedYaml(source, line);
442
+ if (!embedded) return null;
443
+ const { bodyLines, bodyLine, definition, fence, schema } = embedded;
444
+ const start = bodyLines.slice(0, bodyLine).reduce((sum, text) => sum + text.length + 1, 0);
445
+ const end = start + (bodyLines[bodyLine]?.length ?? 0);
446
+ for (const { map, itemField } of getBlockMaps(parseEmbeddedYaml(bodyLines.join('\n')))) {
447
+ for (const pair of map.items) {
448
+ if (!isScalar(pair.key) || pair.key.range[0] < start || pair.key.range[0] > end) continue;
449
+ const key = pair.key.value;
450
+ const field = getBlockField(definition, schema, key, itemField);
451
+ if (field) return {
452
+ definition, field, fence, key, value: isScalar(pair.value) ? pair.value.value : null,
453
+ valueRange: isScalar(pair.value) && pair.value.range?.[1] <= end
454
+ ? { start: pair.value.range[0] - start, end: pair.value.range[1] - start } : null,
455
+ };
456
+ }
457
+ }
458
+ return null;
379
459
  };
380
460
 
381
461
  const collectImageFiles = async (directory, imageRoot, pageLabel, files) => {
@@ -474,6 +554,22 @@ export const createSiteImageIndex = async (siteRoot) => {
474
554
  return { files, filesByName, referencesByFilename };
475
555
  };
476
556
 
557
+ const getEditorImageReferences = (source) => {
558
+ const names = new Set();
559
+ const visit = (node) => {
560
+ if (node.type === 'code' && ['image-stack', 'image-carousel', 'card-list'].includes(node.lang)) {
561
+ // Read partial YAML too: an unfinished image field must not hide earlier items.
562
+ for (const { map, itemField } of getBlockMaps(parseEmbeddedYaml(node.value))) {
563
+ const image = itemField ? map.get('image', true) : null;
564
+ if (isScalar(image) && typeof image.value === 'string') names.add(image.value);
565
+ }
566
+ }
567
+ for (const child of node.children ?? []) visit(child);
568
+ };
569
+ visit(markdownToMdast(source, { features: { gfm: true, frontmatter: true } }));
570
+ return names;
571
+ };
572
+
477
573
  export const getImageCompletionContext = async ({ documentPath, source, line }) => {
478
574
  const siteRoot = await findNornaSiteRoot(documentPath);
479
575
  if (!siteRoot) return null;
@@ -482,29 +578,40 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
482
578
  const fence = getOpenMarkdownFenceAtLine(source, line);
483
579
  if (!fence || !['image-stack', 'image-carousel', 'card-list'].includes(fence.type)) return null;
484
580
 
485
- const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
486
- if (!/^\s*(?:-\s+)?image:\s*[^\s]*$/.test(currentLine)) return null;
581
+ const fieldContext = getNornaBlockFieldContext({ source, line });
582
+ if (fieldContext?.key !== 'image' || !fieldContext.valueRange) return null;
487
583
 
488
584
  const expectedDirectory = page.imagesRoot;
489
585
  const index = await createSiteImageIndex(siteRoot);
586
+ const currentReferences = getEditorImageReferences(source);
587
+ const currentDocument = toPosixPath(path.relative(siteRoot, documentPath));
490
588
  const candidates = index.files.map((file) => {
491
589
  const isExpected = path.dirname(file.absolutePath) === expectedDirectory;
492
590
  const duplicateCount = index.filesByName.get(file.filename)?.length ?? 0;
493
- const referencedBy = index.referencesByFilename.get(file.filename) ?? [];
591
+ const localMatch = index.filesByName.get(file.filename)?.some((match) => path.dirname(match.absolutePath) === expectedDirectory);
592
+ const referenced = currentReferences.has(file.filename);
593
+ const usage = referenced && !localMatch && duplicateCount > 1 ? 'ambiguous'
594
+ : referenced && (isExpected || !localMatch && duplicateCount === 1) ? 'used' : 'unused';
595
+ const referencedBy = [...new Set(index.referencesByFilename.get(file.filename) ?? [])]
596
+ .filter((reference) => reference !== currentDocument);
597
+ if (usage === 'used') referencedBy.push(currentDocument);
598
+ const sortText = `${isExpected ? '0' : '1'}-${{ unused: '0', used: '1', ambiguous: '2' }[usage]}-${file.filename}-${toPosixPath(file.absolutePath)}`;
494
599
  return {
495
600
  ...file,
496
601
  duplicateCount,
497
602
  isExpected,
498
603
  referencedBy,
604
+ usage,
605
+ sortText,
499
606
  siteRelativePath: toPosixPath(path.relative(siteRoot, file.absolutePath)),
500
607
  };
501
608
  }).sort((left, right) => {
502
- if (left.isExpected !== right.isExpected) return left.isExpected ? -1 : 1;
503
- return left.filename.localeCompare(right.filename, 'en');
609
+ return left.sortText.localeCompare(right.sortText, 'en');
504
610
  });
505
611
 
506
612
  return {
507
613
  candidates,
614
+ valueRange: fieldContext.valueRange,
508
615
  expectedDirectory,
509
616
  page,
510
617
  siteRoot,
@@ -512,11 +619,10 @@ export const getImageCompletionContext = async ({ documentPath, source, line })
512
619
  };
513
620
 
514
621
  export const getImageDefinitionContext = async ({ documentPath, source, line }) => {
515
- const currentLine = source.replace(/\r\n?/g, '\n').split('\n')[line] ?? '';
516
- const match = currentLine.match(/^\s*(?:-\s+)?image:\s*([^\s#]+)\s*(?:#.*)?$/);
517
- if (!match) return null;
518
-
519
- const filename = match[1].replace(/^['"]|['"]$/g, '');
622
+ const context = getNornaBlockFieldContext({ source, line });
623
+ if (context?.key !== 'image' || typeof context.value !== 'string') return null;
624
+ const filename = context.value;
625
+ if (!/^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i.test(filename)) return null;
520
626
  const siteRoot = await findNornaSiteRoot(documentPath);
521
627
  const page = siteRoot ? getPageContext(siteRoot, documentPath) : null;
522
628
  if (!siteRoot || !page) return null;
@@ -634,21 +740,37 @@ export const getMarkdownDiagnostics = async ({ documentPath, source }) => {
634
740
  return diagnostics;
635
741
  };
636
742
 
637
- export const nornaBlockDefinitions = Object.freeze({
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```',
645
- }),
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```',
649
- }),
650
- 'page-list': Object.freeze({
651
- ...nornaMarkdownBlockDefinitions['page-list'],
652
- snippet: '```page-list\n```',
653
- }),
654
- });
743
+ const blockItemSnippetLines = (schema, indent = ' ', startIndex = 0, item = 0) => {
744
+ const examples = { title: 'Card title', text: 'Card text', image: 'filename.jpg', alt: 'Alternative text', caption: 'Caption', link: '/destination/' };
745
+ const properties = schema.properties.items.items.properties;
746
+ return Object.keys(properties).filter((key) => Object.hasOwn(examples, key)).map((key, index) => {
747
+ const example = key === 'image' && item > 0 ? `image-${item + 1}.jpg` : examples[key];
748
+ return `${indent}${index === 0 ? '- ' : ' '}${key}: \${${startIndex + index + 1}:${example}}`;
749
+ });
750
+ };
751
+
752
+ const blockSnippet = (type, definition) => {
753
+ const schema = getNornaBlockSchema(type);
754
+ const lines = [`\`\`\`${type}`];
755
+ let index = 0;
756
+ for (const [key, option] of Object.entries(definition.options ?? {})) {
757
+ if (option.default === undefined) continue;
758
+ lines.push(`${key}: \${${++index}|${Object.keys(option.values).join(',')}|}`);
759
+ }
760
+ if (definition.item) {
761
+ lines.push('items:');
762
+ for (let item = 0; item < (schema.properties.items.minItems ?? 1); item += 1) {
763
+ const itemLines = blockItemSnippetLines(schema, ' ', index, item);
764
+ lines.push(...itemLines);
765
+ index += itemLines.length;
766
+ }
767
+ }
768
+ return [...lines, '```'].join('\n');
769
+ };
770
+
771
+ export const nornaBlockDefinitions = Object.freeze(Object.fromEntries(
772
+ Object.entries(nornaMarkdownBlockDefinitions).map(([type, definition]) => [type, Object.freeze({
773
+ ...definition,
774
+ snippet: blockSnippet(type, definition),
775
+ })]),
776
+ ));