@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
package/scripts/dev-local.mjs
CHANGED
|
@@ -37,6 +37,7 @@ const statePath = path.join(stateDirectory, 'state.json');
|
|
|
37
37
|
const legacyStatePath = path.join(astroCacheDir, 'dev-local.json');
|
|
38
38
|
const astroStatePath = path.join(astroCacheDir, 'dev.json');
|
|
39
39
|
const logPath = path.join(astroCacheDir, 'dev.log');
|
|
40
|
+
const preparationLogPath = path.join(stateDirectory, 'preparation.log');
|
|
40
41
|
const args = process.argv.slice(2);
|
|
41
42
|
const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
|
|
42
43
|
const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
|
|
@@ -399,8 +400,29 @@ const startServer = async ({ host = localHost, open = true, killBlockingPort = f
|
|
|
399
400
|
await terminatePortProcesses(host);
|
|
400
401
|
}
|
|
401
402
|
|
|
402
|
-
|
|
403
|
-
|
|
403
|
+
try {
|
|
404
|
+
await syncSitePublic();
|
|
405
|
+
await generateImages();
|
|
406
|
+
} catch (error) {
|
|
407
|
+
const output = [error.stdout, error.stderr].filter(Boolean).join('\n') || String(error);
|
|
408
|
+
const excerpt = getLogExcerpt(output)
|
|
409
|
+
.replace(/\n\s+at [\s\S]*$/u, '')
|
|
410
|
+
.replace(/\nNode\.js v[^\n]*$/u, '')
|
|
411
|
+
.trim();
|
|
412
|
+
let logHint;
|
|
413
|
+
try {
|
|
414
|
+
await mkdir(stateDirectory, { recursive: true });
|
|
415
|
+
await writeFile(preparationLogPath, output);
|
|
416
|
+
logHint = `Full log: ${preparationLogPath}`;
|
|
417
|
+
} catch (logError) {
|
|
418
|
+
logHint = `Could not save the preparation log: ${logError.message}`;
|
|
419
|
+
}
|
|
420
|
+
throw new Error([
|
|
421
|
+
`Could not prepare the site for the dev server at ${localUrl}.`,
|
|
422
|
+
excerpt,
|
|
423
|
+
logHint,
|
|
424
|
+
].join('\n\n'), { cause: error });
|
|
425
|
+
}
|
|
404
426
|
const previousLog = await readDevLog();
|
|
405
427
|
try {
|
|
406
428
|
await runAstroInherit(['dev', '--background', '--host', host, '--port', String(port)]);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
themeVisualSchema,
|
|
12
12
|
} from './lib/schema-definitions.mjs';
|
|
13
13
|
import { applySchemaEditorMetadata } from './lib/schema-editor-metadata.mjs';
|
|
14
|
+
import { getNornaBlockSchema } from './lib/norna-markdown-blocks.mjs';
|
|
14
15
|
|
|
15
16
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
16
17
|
const schemaDirectory = path.join(root, 'schemas');
|
|
@@ -32,9 +33,20 @@ for (const [filename, title, schema] of definitions) {
|
|
|
32
33
|
schemaFiles.set(filename, `${JSON.stringify(jsonSchema, null, 2)}\n`);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
const blockTypes = ['image-stack', 'image-carousel', 'card-list'];
|
|
37
|
+
for (const type of blockTypes) {
|
|
38
|
+
const filename = `${type}.schema.json`;
|
|
39
|
+
schemaFiles.set(filename, `${JSON.stringify({
|
|
40
|
+
...getNornaBlockSchema(type),
|
|
41
|
+
$id: `https://janga.github.io/norna/schemas/${filename}`,
|
|
42
|
+
title: `Norna ${type}`,
|
|
43
|
+
}, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
schemaFiles.set('manifest.json', `${JSON.stringify({
|
|
36
|
-
editorApiVersion:
|
|
47
|
+
editorApiVersion: 2,
|
|
37
48
|
schemaVersion: 3,
|
|
49
|
+
blockSchemas: Object.fromEntries(blockTypes.map((type) => [type, `${type}.schema.json`])),
|
|
38
50
|
files: {
|
|
39
51
|
category: 'category.schema.json',
|
|
40
52
|
config: 'config.schema.json',
|
|
@@ -64,12 +76,12 @@ if (checkOnly) {
|
|
|
64
76
|
throw new Error(`Generated Norna schemas are stale: ${stale.join(', ')}. Run "npm run schemas:generate".`);
|
|
65
77
|
}
|
|
66
78
|
|
|
67
|
-
console.log(`Norna schemas are up to date (${definitions.length} schemas).`);
|
|
79
|
+
console.log(`Norna schemas are up to date (${definitions.length + blockTypes.length} schemas).`);
|
|
68
80
|
} else {
|
|
69
81
|
await mkdir(schemaDirectory, { recursive: true });
|
|
70
82
|
for (const [filename, source] of schemaFiles) {
|
|
71
83
|
await writeFile(path.join(schemaDirectory, filename), source);
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
console.log(`Generated ${definitions.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
|
|
86
|
+
console.log(`Generated ${definitions.length + blockTypes.length} Norna schemas in ${path.relative(root, schemaDirectory)}.`);
|
|
75
87
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getSiteNavigationTree } from './site-navigation-tree.mjs';
|
|
2
|
+
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
3
|
+
|
|
4
|
+
// Accept the same normalized nodes as navigation; excluded ancestors exclude
|
|
5
|
+
// their whole subtree, including category destinations.
|
|
6
|
+
export const createCategoryDestinationModel = (nodes) => {
|
|
7
|
+
const destinations = [];
|
|
8
|
+
const diagnostics = [];
|
|
9
|
+
const visit = (branch) => {
|
|
10
|
+
const { node } = branch;
|
|
11
|
+
if (node.navigation?.listed === false) return false;
|
|
12
|
+
const children = branch.children.filter(({ node: child }) => child.navigation?.listed !== false);
|
|
13
|
+
const reachable = children.map(visit).some(Boolean);
|
|
14
|
+
if (node.kind !== 'category') return true;
|
|
15
|
+
if (!reachable) {
|
|
16
|
+
const source = node.categorySourceLabel ?? node.nodeLabel ?? node.pageDirectory;
|
|
17
|
+
diagnostics.push({
|
|
18
|
+
code: 'category-without-listed-content',
|
|
19
|
+
severity: 'error',
|
|
20
|
+
message: `${source} has no listed reachable content page.`,
|
|
21
|
+
fix: 'Add a listed page under this category, or remove the category.',
|
|
22
|
+
});
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const first = children[0].node;
|
|
26
|
+
destinations.push({
|
|
27
|
+
category: node,
|
|
28
|
+
pathname: getSiteNodePathname(node),
|
|
29
|
+
kind: first.kind === 'page' ? 'redirect' : 'listing',
|
|
30
|
+
target: first.kind === 'page' ? first : null,
|
|
31
|
+
children: children.map(({ node: child }) => child),
|
|
32
|
+
});
|
|
33
|
+
return true;
|
|
34
|
+
};
|
|
35
|
+
getSiteNavigationTree(nodes.map((node) => ({ node }))).forEach(visit);
|
|
36
|
+
return {
|
|
37
|
+
destinations,
|
|
38
|
+
byPathname: new Map(destinations.map((destination) => [destination.pathname, destination])),
|
|
39
|
+
diagnostics,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const assertCategoryDestinationModel = (model) => {
|
|
44
|
+
if (model.diagnostics.length) {
|
|
45
|
+
throw new Error(model.diagnostics.map(({ message, fix }) => `${message}\n${fix}`).join('\n'));
|
|
46
|
+
}
|
|
47
|
+
return model;
|
|
48
|
+
};
|
|
@@ -9,40 +9,17 @@ const invalidMetadata = (message, fix) => ({
|
|
|
9
9
|
},
|
|
10
10
|
});
|
|
11
11
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (character === '"') {
|
|
19
|
-
return {
|
|
20
|
-
rest: source.slice(index + 1),
|
|
21
|
-
title,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
if (character === '\\') {
|
|
26
|
-
const escaped = source[index + 1];
|
|
27
|
-
if (escaped !== '"' && escaped !== '\\') {
|
|
28
|
-
return invalidMetadata(
|
|
29
|
-
`Code title contains unsupported escape "\\${escaped ?? ''}".`,
|
|
30
|
-
'Only escape a double quote (\\") or backslash (\\\\) inside the title.',
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
title += escaped;
|
|
34
|
-
index += 2;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
title += character;
|
|
39
|
-
index += 1;
|
|
12
|
+
export const parseQuotedContentString = (source) => {
|
|
13
|
+
const quoted = source.match(/^"(?:[^"\\]|\\[\s\S])*"/)?.[0];
|
|
14
|
+
if (!quoted) return { error: 'Use a JSON double-quoted string with a closing double quote.' };
|
|
15
|
+
let value;
|
|
16
|
+
try { value = JSON.parse(quoted); } catch {
|
|
17
|
+
return { error: 'Use valid JSON escapes inside the double-quoted string.' };
|
|
40
18
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
19
|
+
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
|
|
20
|
+
return { error: 'Quoted labels and titles cannot contain control characters, including line breaks and tabs.' };
|
|
21
|
+
}
|
|
22
|
+
return { value, rest: source.slice(quoted.length) };
|
|
46
23
|
};
|
|
47
24
|
|
|
48
25
|
const parseHighlightedLines = (selector, lineCount) => {
|
|
@@ -94,15 +71,15 @@ export const parseCodeFenceMetadata = (rawMetadata, options = {}) => {
|
|
|
94
71
|
let rest = source;
|
|
95
72
|
let title = null;
|
|
96
73
|
if (rest.startsWith(titlePrefix)) {
|
|
97
|
-
const titleResult =
|
|
98
|
-
if (titleResult.error) return titleResult;
|
|
99
|
-
if (!titleResult.
|
|
74
|
+
const titleResult = parseQuotedContentString(rest.slice('title='.length));
|
|
75
|
+
if (titleResult.error) return invalidMetadata(`Invalid code title. ${titleResult.error}`, 'Write a single-line title, for example title="src/config.js".');
|
|
76
|
+
if (!titleResult.value.trim()) {
|
|
100
77
|
return invalidMetadata(
|
|
101
78
|
'Code title cannot be empty.',
|
|
102
79
|
'Remove title="" or provide a short filename or label.',
|
|
103
80
|
);
|
|
104
81
|
}
|
|
105
|
-
title = titleResult.
|
|
82
|
+
title = titleResult.value;
|
|
106
83
|
rest = titleResult.rest;
|
|
107
84
|
if (rest && !rest.startsWith(' ')) {
|
|
108
85
|
return invalidMetadata(
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { markdownToMdast } from 'satteri';
|
|
2
|
+
import { parseQuotedContentString } from './code-fence-metadata.mjs';
|
|
3
|
+
|
|
4
|
+
// Only delimiters belong to this scanner. Markdown parsing and source positions
|
|
5
|
+
// stay with Satteri, including code examples that happen to contain delimiters.
|
|
6
|
+
export const parseContentTabs = (source, { label = 'Markdown', lineOffset = 0 } = {}) => {
|
|
7
|
+
if (!/^\s*(?:(?:>\s*|[-+*]\s+))?:{3,}\s*tabs?\b/m.test(source)) return { groups: [], diagnostics: [], replacements: [], maskedSource: source };
|
|
8
|
+
const tree = markdownToMdast(source, { features: { gfm: true, frontmatter: true } });
|
|
9
|
+
const nodes = [];
|
|
10
|
+
const visit = (node, parent) => {
|
|
11
|
+
nodes.push({ node, parent });
|
|
12
|
+
for (const child of node.children ?? []) visit(child, node);
|
|
13
|
+
};
|
|
14
|
+
visit(tree, null);
|
|
15
|
+
const ignored = nodes.filter(({ node }) => ['code', 'inlineCode', 'html', 'yaml', 'toml'].includes(node.type));
|
|
16
|
+
const groups = [];
|
|
17
|
+
const diagnostics = [];
|
|
18
|
+
const replacements = [];
|
|
19
|
+
let group = null;
|
|
20
|
+
let panel = null;
|
|
21
|
+
const outerContainers = [];
|
|
22
|
+
let offset = 0;
|
|
23
|
+
let section = 'page introduction';
|
|
24
|
+
const error = (message, at = offset) => diagnostics.push({
|
|
25
|
+
code: 'invalid-content-tabs', severity: 'error', offset: at,
|
|
26
|
+
line: lineOffset + source.slice(0, at).split('\n').length,
|
|
27
|
+
message: `${label} line ${lineOffset + source.slice(0, at).split('\n').length}, section "${groups.find((item) => at >= item.start && at < (item.end ?? source.length))?.section ?? section}": ${message}`,
|
|
28
|
+
});
|
|
29
|
+
for (const [index, line] of source.split('\n').entries()) {
|
|
30
|
+
const start = offset;
|
|
31
|
+
offset += line.length + 1;
|
|
32
|
+
const contentStart = start + line.search(/\S|$/);
|
|
33
|
+
if (ignored.some(({ node }) => contentStart >= node.position.start.offset && contentStart < node.position.end.offset)) continue;
|
|
34
|
+
if (!group && /^#{1,3}\s/.test(line)) section = line.replace(/^#+\s*/, '').replace(/\s*\{#.*\}$/, '');
|
|
35
|
+
const match = line.match(/^(\s*)(:{3,})\s*(.*?)\s*$/);
|
|
36
|
+
if (!match) {
|
|
37
|
+
if (group && !panel && line.trim()) error('Put content inside a labelled tab, not directly inside tabs.', start);
|
|
38
|
+
if (/^\s*(?:>\s*|[-+*]\s+)(?::+)\s*tabs?\b/.test(line)) error('Place tabs directly in the section, outside lists and blockquotes.', start);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const [, indent, fence, declaration] = match;
|
|
42
|
+
const name = declaration.split(/\s/)[0];
|
|
43
|
+
if (!group && name !== 'tabs' && name !== 'tab') {
|
|
44
|
+
if (declaration) outerContainers.push(fence.length);
|
|
45
|
+
else if (outerContainers.length) outerContainers.pop();
|
|
46
|
+
else if (groups.length) error('This closing marker has no open tab group.', start);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const replace = (kind, data = {}) => replacements.push({ start, end: start + line.length, kind, ...data });
|
|
50
|
+
if (indent || (!group && nodes.some(({ node, parent }) => node.type === 'paragraph' && parent?.type !== 'root'
|
|
51
|
+
&& start >= node.position.start.offset && start < node.position.end.offset))) {
|
|
52
|
+
error('Place tabs directly in the section, outside other blocks.', start);
|
|
53
|
+
}
|
|
54
|
+
if (name === 'tabs') {
|
|
55
|
+
if (outerContainers.length) error('Place tabs directly in the section, outside other containers.', start);
|
|
56
|
+
if (group) { error('Tab groups cannot be nested.', start); continue; }
|
|
57
|
+
if (fence !== '::::' || declaration !== 'tabs') error('Open a tab group with ":::: tabs" on its own line.', start);
|
|
58
|
+
group = { index: groups.length, start, section, line: lineOffset + index + 1, panels: [] };
|
|
59
|
+
groups.push(group);
|
|
60
|
+
replace('group-start', { index: group.index });
|
|
61
|
+
} else if (name === 'tab') {
|
|
62
|
+
if (!group) { error('A tab must be inside a ":::: tabs" block.', start); continue; }
|
|
63
|
+
if (panel) error('Close the preceding tab with ":::" before starting another tab.', start);
|
|
64
|
+
const decoded = parseQuotedContentString(declaration.slice(3).trim());
|
|
65
|
+
let title = decoded.value ?? '';
|
|
66
|
+
if (fence !== ':::' || decoded.error || decoded.rest || !title.trim()) {
|
|
67
|
+
error(`Write a nonempty quoted label, for example ::: tab "Windows".${decoded.error ? ` ${decoded.error}` : ''}`, start);
|
|
68
|
+
title = '';
|
|
69
|
+
}
|
|
70
|
+
if (group.panels.some((item) => item.label.normalize('NFC') === title.trim().normalize('NFC'))) error(`Duplicate tab label "${title}". Use unique labels within this group.`, start);
|
|
71
|
+
panel = { label: title.trim(), start: offset, declarationStart: start, index: group.panels.length };
|
|
72
|
+
group.panels.push(panel);
|
|
73
|
+
replace('panel-start', { index: panel.index });
|
|
74
|
+
} else if (!declaration) {
|
|
75
|
+
if (fence === '::::') {
|
|
76
|
+
if (panel) error('Close the tab with ":::" before closing the group.', start);
|
|
77
|
+
if (group.panels.length < 2) error('A tab group needs at least two alternatives.', group.start);
|
|
78
|
+
group.end = offset;
|
|
79
|
+
replace('group-end');
|
|
80
|
+
group = panel = null;
|
|
81
|
+
} else if (fence !== ':::') {
|
|
82
|
+
error('Close tabs with ":::" and the group with "::::".', start);
|
|
83
|
+
} else if (panel) {
|
|
84
|
+
panel.end = start;
|
|
85
|
+
if (!source.slice(panel.start, start).replace(/<!--[\s\S]*?-->/g, '').trim()) error('A tab cannot be empty. Explain when the step does not apply.', panel.declarationStart);
|
|
86
|
+
replace('panel-end');
|
|
87
|
+
panel = null;
|
|
88
|
+
} else error('This closing marker has no open tab.', start);
|
|
89
|
+
} else error(`Unknown or misplaced tab marker "${declaration}". Write callouts as GitHub-style alerts, for example > [!TIP] followed by quoted body text.`, start);
|
|
90
|
+
}
|
|
91
|
+
if (group) error('The tab group is unclosed. Close each tab with ":::" and the group with "::::".', group.start);
|
|
92
|
+
let maskedSource = source;
|
|
93
|
+
for (const replacement of [...replacements].reverse()) maskedSource = maskedSource.slice(0, replacement.start)
|
|
94
|
+
+ ' '.repeat(replacement.end - replacement.start) + maskedSource.slice(replacement.end);
|
|
95
|
+
nodes.length = 0;
|
|
96
|
+
visit(markdownToMdast(maskedSource, { features: { gfm: true, frontmatter: true } }), null);
|
|
97
|
+
for (const item of groups) {
|
|
98
|
+
for (const tab of item.panels) {
|
|
99
|
+
const end = tab.end ?? item.end ?? source.length;
|
|
100
|
+
for (const { node } of nodes) {
|
|
101
|
+
if (!node.position || node.position.start.offset < tab.start || node.position.start.offset >= end) continue;
|
|
102
|
+
if (node.type === 'heading' || ['yaml', 'toml'].includes(node.type)
|
|
103
|
+
|| (node.type === 'html' && /<(?:h[1-6]|nav)\b/i.test(node.value))) error('Tabs cannot contain headings, frontmatter, or navigation definitions. Keep the shared headings outside the group.', node.position.start.offset);
|
|
104
|
+
}
|
|
105
|
+
if (/^---\s*\n[\s\S]*?\n---\s*(?:\n|$)/.test(source.slice(tab.start, end).trimStart())) error('Frontmatter belongs at the start of content.md, not inside a tab.', tab.declarationStart);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { groups, diagnostics, replacements, maskedSource };
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const prepareContentTabs = (source, options = {}) => {
|
|
112
|
+
const parsed = parseContentTabs(source, options);
|
|
113
|
+
if (parsed.diagnostics.length) throw new Error(parsed.diagnostics[0].message);
|
|
114
|
+
let result = source;
|
|
115
|
+
for (const marker of [...parsed.replacements].reverse()) {
|
|
116
|
+
const html = `<div data-norna-tabs-marker="${marker.kind}"${marker.index === undefined ? '' : ` data-index="${marker.index}"`}></div>`;
|
|
117
|
+
result = result.slice(0, marker.start) + '\n' + html + '\n' + result.slice(marker.end);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const groupRenderedTabs = (blocks, groups) => {
|
|
123
|
+
const result = [];
|
|
124
|
+
let group = null;
|
|
125
|
+
let panel = null;
|
|
126
|
+
const append = (block) => (panel ? panel.contentBlocks : result).push(block);
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
if (block.type !== 'html') { append(block); continue; }
|
|
129
|
+
const pattern = /<div data-norna-tabs-marker="([a-z-]+)"(?: data-index="(\d+)")?><\/div>/g;
|
|
130
|
+
let cursor = 0;
|
|
131
|
+
for (const match of block.html.matchAll(pattern)) {
|
|
132
|
+
const before = block.html.slice(cursor, match.index);
|
|
133
|
+
if (before.trim()) append({ type: 'html', html: before });
|
|
134
|
+
const kind = match[1];
|
|
135
|
+
if (kind === 'group-start') {
|
|
136
|
+
const source = groups.find((item) => item.index === Number(match[2]));
|
|
137
|
+
if (!source) throw new Error('Rendered tab group has no matching source.');
|
|
138
|
+
group = { type: 'tabs', index: source.index, panels: [], source };
|
|
139
|
+
} else if (kind === 'panel-start') {
|
|
140
|
+
panel = { label: group.source.panels[Number(match[2])].label, contentBlocks: [] };
|
|
141
|
+
group.panels.push(panel);
|
|
142
|
+
} else if (kind === 'panel-end') panel = null;
|
|
143
|
+
else if (kind === 'group-end') {
|
|
144
|
+
const { source, ...resolved } = group;
|
|
145
|
+
result.push(resolved);
|
|
146
|
+
group = null;
|
|
147
|
+
}
|
|
148
|
+
cursor = match.index + match[0].length;
|
|
149
|
+
}
|
|
150
|
+
const after = block.html.slice(cursor);
|
|
151
|
+
if (after.trim()) append({ type: 'html', html: after });
|
|
152
|
+
}
|
|
153
|
+
if (group || panel) throw new Error('Rendered tab group is incomplete.');
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parseFragment } from 'parse5';
|
|
2
|
+
|
|
3
|
+
export const getDetailsHeadingDiagnostics = (tree, { source, label, lineOffset = 0 }) => {
|
|
4
|
+
if (!/<details[\s/>]/i.test(source)) return [];
|
|
5
|
+
const lines = source.split('\n');
|
|
6
|
+
const lineStarts = [];
|
|
7
|
+
let offset = 0;
|
|
8
|
+
for (const line of lines) {
|
|
9
|
+
lineStarts.push(offset);
|
|
10
|
+
offset += line.length + 1;
|
|
11
|
+
}
|
|
12
|
+
const html = source.replace(/[^\n]/g, ' ').split('');
|
|
13
|
+
const headings = [];
|
|
14
|
+
const visitMarkdown = (node) => {
|
|
15
|
+
if (node.type === 'heading') headings.push(node.position.start.offset);
|
|
16
|
+
if (node.type === 'html') {
|
|
17
|
+
// Keep only parsed HTML, preserving source offsets and stripping list/quote
|
|
18
|
+
// prefixes. Literal HTML in Markdown code or escapes never reaches parse5.
|
|
19
|
+
for (const [index, value] of node.value.split('\n').entries()) {
|
|
20
|
+
const lineIndex = node.position.start.line - 1 + index;
|
|
21
|
+
const column = index === 0 ? node.position.start.column - 1 : lines[lineIndex].indexOf(value);
|
|
22
|
+
const start = lineStarts[lineIndex] + column;
|
|
23
|
+
for (let i = 0; i < value.length; i += 1) html[start + i] = value[i];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const child of node.children ?? []) visitMarkdown(child);
|
|
27
|
+
};
|
|
28
|
+
visitMarkdown(tree);
|
|
29
|
+
const fragment = parseFragment(html.join(''), { sourceCodeLocationInfo: true });
|
|
30
|
+
const disclosures = [];
|
|
31
|
+
const visitHtml = (node) => {
|
|
32
|
+
const location = node.sourceCodeLocation;
|
|
33
|
+
if (location && node.tagName === 'details') {
|
|
34
|
+
disclosures.push({
|
|
35
|
+
start: location.startTag.endOffset,
|
|
36
|
+
end: location.endTag?.startOffset ?? location.endOffset,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (location && /^h[1-6]$/.test(node.tagName)) headings.push(location.startOffset);
|
|
40
|
+
for (const child of node.childNodes ?? []) visitHtml(child);
|
|
41
|
+
if (node.content) visitHtml(node.content);
|
|
42
|
+
};
|
|
43
|
+
visitHtml(fragment);
|
|
44
|
+
const fix = 'Move the heading outside <details>, or use bold text inside it.';
|
|
45
|
+
return [...new Set(headings)].sort((a, b) => a - b)
|
|
46
|
+
.filter((start) => disclosures.some((details) => start >= details.start && start < details.end))
|
|
47
|
+
.map((start) => {
|
|
48
|
+
const line = lineOffset + source.slice(0, start).split('\n').length;
|
|
49
|
+
return {
|
|
50
|
+
code: 'heading-inside-details',
|
|
51
|
+
severity: 'error',
|
|
52
|
+
offset: start,
|
|
53
|
+
line,
|
|
54
|
+
message: `${label} line ${line}: Headings H1-H6 are not allowed inside <details>, including <summary>.`,
|
|
55
|
+
fix,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
};
|