@janga/norna 0.7.25 → 0.7.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/astro.config.mjs +26 -12
- package/bin/norna-cli.mjs +12 -0
- package/package.json +14 -5
- package/schemas/card-list.schema.json +121 -0
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +306 -32
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/image-carousel.schema.json +43 -0
- package/schemas/image-stack.schema.json +43 -0
- package/schemas/manifest.json +6 -1
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -262
- package/scripts/dev-local.mjs +24 -2
- package/scripts/generate-schemas.mjs +15 -3
- package/scripts/lib/category-destinations.mjs +48 -0
- package/scripts/lib/code-fence-metadata.mjs +14 -37
- package/scripts/lib/content-tabs.mjs +155 -0
- package/scripts/lib/details-headings.mjs +58 -0
- package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
- package/scripts/lib/editor-language-service.mjs +238 -116
- package/scripts/lib/locale-registry.mjs +597 -0
- package/scripts/lib/locales/az-Latn.mjs +78 -0
- package/scripts/lib/locales/be.mjs +78 -0
- package/scripts/lib/locales/bg.mjs +78 -0
- package/scripts/lib/locales/bs.mjs +78 -0
- package/scripts/lib/locales/ca.mjs +78 -0
- package/scripts/lib/locales/cnr.mjs +78 -0
- package/scripts/lib/locales/cs.mjs +78 -0
- package/scripts/lib/locales/da.mjs +78 -0
- package/scripts/lib/locales/de.mjs +78 -0
- package/scripts/lib/locales/el.mjs +78 -0
- package/scripts/lib/locales/en.mjs +78 -0
- package/scripts/lib/locales/es.mjs +78 -0
- package/scripts/lib/locales/et.mjs +78 -0
- package/scripts/lib/locales/fi.mjs +78 -0
- package/scripts/lib/locales/fil.mjs +78 -0
- package/scripts/lib/locales/fr.mjs +78 -0
- package/scripts/lib/locales/ga.mjs +78 -0
- package/scripts/lib/locales/ha.mjs +78 -0
- package/scripts/lib/locales/hr.mjs +78 -0
- package/scripts/lib/locales/hu.mjs +78 -0
- package/scripts/lib/locales/id.mjs +78 -0
- package/scripts/lib/locales/is.mjs +78 -0
- package/scripts/lib/locales/it.mjs +78 -0
- package/scripts/lib/locales/jv.mjs +78 -0
- package/scripts/lib/locales/lb.mjs +78 -0
- package/scripts/lib/locales/lt.mjs +78 -0
- package/scripts/lib/locales/lv.mjs +78 -0
- package/scripts/lib/locales/mk.mjs +78 -0
- package/scripts/lib/locales/mt.mjs +78 -0
- package/scripts/lib/locales/nb.mjs +78 -0
- package/scripts/lib/locales/nl.mjs +78 -0
- package/scripts/lib/locales/nn.mjs +78 -0
- package/scripts/lib/locales/pcm.mjs +78 -0
- package/scripts/lib/locales/pl.mjs +78 -0
- package/scripts/lib/locales/pt.mjs +78 -0
- package/scripts/lib/locales/ro.mjs +78 -0
- package/scripts/lib/locales/ru.mjs +78 -0
- package/scripts/lib/locales/sk.mjs +78 -0
- package/scripts/lib/locales/sl.mjs +78 -0
- package/scripts/lib/locales/sq.mjs +78 -0
- package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
- package/scripts/lib/locales/sr-Latn.mjs +78 -0
- package/scripts/lib/locales/sv.mjs +78 -0
- package/scripts/lib/locales/sw.mjs +78 -0
- package/scripts/lib/locales/tr.mjs +78 -0
- package/scripts/lib/locales/uk.mjs +78 -0
- package/scripts/lib/locales/vi.mjs +78 -0
- package/scripts/lib/markdown-links.mjs +4 -25
- package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
- package/scripts/lib/markdown-notes.mjs +165 -0
- package/scripts/lib/norna-markdown-blocks.mjs +32 -447
- package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
- package/scripts/lib/page-aliases.mjs +12 -1
- package/scripts/lib/page-markdown.mjs +63 -16
- package/scripts/lib/page-move-plan.mjs +22 -5
- package/scripts/lib/pagefind-translations.mjs +620 -0
- package/scripts/lib/presentation-contract.mjs +6 -1
- package/scripts/lib/presentation.mjs +29 -30
- package/scripts/lib/project-config.mjs +2 -173
- package/scripts/lib/schema-definitions.mjs +3 -10
- package/scripts/lib/schema-editor-metadata.mjs +22 -43
- package/scripts/lib/schema-value-definitions.mjs +4 -8
- package/scripts/lib/semantic-callouts.mjs +4 -2
- package/scripts/lib/site-content.mjs +4 -2
- package/scripts/lib/site-link-graph.mjs +16 -6
- package/scripts/lib/site-navigation-tree.mjs +0 -11
- package/scripts/lib/sitemap.mjs +5 -3
- package/scripts/lib/structured-blocks.mjs +108 -0
- package/scripts/lib/table-render-plugin.mjs +46 -3
- package/scripts/lib/table-row-headers.mjs +218 -0
- package/scripts/lib/theme-presets.mjs +3 -10
- package/scripts/lib/yaml-config.mjs +2 -6
- package/scripts/migrate-check.mjs +19 -0
- package/scripts/sync-content-sections.mjs +39 -10
- package/scripts/sync-site-public.mjs +7 -1
- package/src/components/CodeBlockCopyScript.astro +47 -5
- package/src/components/ContentBlocks.astro +39 -0
- package/src/components/ContentTabsScript.astro +60 -0
- package/src/components/DisplaySettings.astro +17 -19
- package/src/components/NavigationPageTree.astro +1 -1
- package/src/components/NoteNavigationScript.astro +4 -0
- package/src/components/PageAliasRedirect.astro +5 -4
- package/src/components/SearchNavigationScript.astro +4 -0
- package/src/components/SearchPage.astro +43 -2
- package/src/components/SectionNavigationScript.astro +43 -11
- package/src/components/SiteBreadcrumbs.astro +1 -1
- package/src/components/SiteNavigation.astro +29 -20
- package/src/components/SitePage.astro +40 -15
- package/src/components/SiteSection.astro +5 -81
- package/src/components/SiteTreeNavigation.astro +2 -2
- package/src/components/TableOverflowScript.astro +386 -58
- package/src/components/TopPageMenu.astro +47 -0
- package/src/components/TreeNavigationScript.astro +10 -3
- package/src/layouts/BaseLayout.astro +8 -12
- package/src/lib/navigationFollowing.ts +142 -0
- package/src/lib/noteNavigation.ts +50 -0
- package/src/lib/searchNavigation.ts +107 -0
- package/src/lib/sectionContent.ts +20 -90
- package/src/lib/siteNavigation.ts +0 -5
- package/src/lib/sitePages.ts +13 -2
- package/src/lib/table-sort-tooltip.ts +109 -0
- package/src/pages/[...slug].astro +16 -6
- package/src/styles/content.css +394 -132
- package/src/styles/foundations.css +9 -0
- package/src/styles/media.css +5 -0
- package/src/styles/navigation.css +63 -74
- package/src/styles/page-layout.css +7 -0
- package/src/styles/responsive.css +2 -7
|
@@ -1,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 ?
|
|
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
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
486
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
638
|
-
'image
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
+
));
|