@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
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from './site-paths.mjs';
|
|
18
18
|
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
19
19
|
import { getSiteStructure } from './site-structure.mjs';
|
|
20
|
+
import { createCategoryDestinationModel } from './category-destinations.mjs';
|
|
20
21
|
|
|
21
22
|
const internalUrlOrigin = 'https://norna.invalid';
|
|
22
23
|
const externalSchemePattern = /^[a-z][a-z0-9+.-]*:/i;
|
|
@@ -161,6 +162,13 @@ export const createSiteLinkGraph = ({
|
|
|
161
162
|
return createPageRecord(pageDocument);
|
|
162
163
|
});
|
|
163
164
|
const pagesByPathname = new Map(pages.map((page) => [page.pathname, page]));
|
|
165
|
+
const pagesByDirectory = new Map(pages.map((page) => [page.contentFile.pageDirectory, page]));
|
|
166
|
+
const categoryModel = createCategoryDestinationModel(siteStructure.nodes.map((node) => ({
|
|
167
|
+
...node,
|
|
168
|
+
navigation: node.kind === 'page'
|
|
169
|
+
? pagesByDirectory.get(node.pageDirectory).navigation
|
|
170
|
+
: { listed: true },
|
|
171
|
+
})));
|
|
164
172
|
const categoriesByPathname = new Map(siteStructure.categories.map((category) => [
|
|
165
173
|
getSiteNodePathname(category),
|
|
166
174
|
{ ...category, pathname: getSiteNodePathname(category) },
|
|
@@ -185,7 +193,7 @@ export const createSiteLinkGraph = ({
|
|
|
185
193
|
|
|
186
194
|
const references = [];
|
|
187
195
|
const referencesByTarget = new Map();
|
|
188
|
-
const diagnostics = [...aliasModel.diagnostics];
|
|
196
|
+
const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics];
|
|
189
197
|
|
|
190
198
|
for (const page of pages) {
|
|
191
199
|
for (const sourceReference of page.document.links) {
|
|
@@ -278,13 +286,14 @@ export const createSiteLinkGraph = ({
|
|
|
278
286
|
const targetCategory = target.pageLookupPathname
|
|
279
287
|
? categoriesByPathname.get(target.pageLookupPathname)
|
|
280
288
|
: null;
|
|
281
|
-
if (targetCategory) {
|
|
289
|
+
if (targetCategory && categoryModel.byPathname.has(targetCategory.pathname)) {
|
|
282
290
|
reference.resolution = { category: targetCategory, kind: 'category', pathname: targetCategory.pathname };
|
|
283
|
-
|
|
291
|
+
const destination = categoryModel.byPathname.get(targetCategory.pathname);
|
|
292
|
+
if (target.fragment && !(destination.kind === 'listing' && target.fragment === 'page-title')) diagnostics.push(createIssue(
|
|
284
293
|
reference,
|
|
285
|
-
'
|
|
286
|
-
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to
|
|
287
|
-
'Link to
|
|
294
|
+
'missing-internal-anchor',
|
|
295
|
+
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to an unavailable category anchor.`,
|
|
296
|
+
'Link to the category without a fragment, or use the content page URL for a heading link.',
|
|
288
297
|
));
|
|
289
298
|
continue;
|
|
290
299
|
}
|
|
@@ -318,6 +327,7 @@ export const createSiteLinkGraph = ({
|
|
|
318
327
|
}
|
|
319
328
|
|
|
320
329
|
return {
|
|
330
|
+
categoryModel,
|
|
321
331
|
aliasModel,
|
|
322
332
|
aliases: aliasModel.aliases,
|
|
323
333
|
aliasesByPathname: aliasModel.aliasesByPathname,
|
|
@@ -37,17 +37,6 @@ export const getListedSiteNavigationTree = (entries) => {
|
|
|
37
37
|
return filterListed(getSiteNavigationTree(entries));
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
export const getFirstPageInNavigationNode = (node) => {
|
|
41
|
-
if (node.node.kind === 'page') return node.node;
|
|
42
|
-
|
|
43
|
-
for (const child of node.children) {
|
|
44
|
-
const page = getFirstPageInNavigationNode(child);
|
|
45
|
-
if (page) return page;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return null;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
40
|
export const flattenSiteNavigationTree = (nodes) => nodes.flatMap((node) => [
|
|
52
41
|
node,
|
|
53
42
|
...flattenSiteNavigationTree(node.children),
|
package/scripts/lib/sitemap.mjs
CHANGED
|
@@ -11,9 +11,11 @@ const escapeXml = (value) => String(value)
|
|
|
11
11
|
|
|
12
12
|
const compareStrings = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
13
13
|
|
|
14
|
-
export const getSitemapUrls = ({ siteStructure, siteUrl }) => (
|
|
15
|
-
|
|
16
|
-
.map((page) => getSiteNodePathname(page))
|
|
14
|
+
export const getSitemapUrls = ({ siteStructure, siteUrl, categoryDestinations = [] }) => (
|
|
15
|
+
[
|
|
16
|
+
...siteStructure.contentFiles.map((page) => getSiteNodePathname(page)),
|
|
17
|
+
...categoryDestinations.filter(({ kind }) => kind === 'listing').map(({ pathname }) => pathname),
|
|
18
|
+
]
|
|
17
19
|
.sort(compareStrings)
|
|
18
20
|
.map((pathname) => getAbsolutePageUrl(siteUrl, pathname))
|
|
19
21
|
);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from 'yaml';
|
|
2
|
+
|
|
3
|
+
const stringSchema = (name, definition) => ({
|
|
4
|
+
type: 'string',
|
|
5
|
+
description: definition.description,
|
|
6
|
+
...(definition.default !== undefined ? { default: definition.default } : {}),
|
|
7
|
+
...(definition.values ? { enum: Object.keys(definition.values) } : {}),
|
|
8
|
+
...(name === 'alt' ? {} : { minLength: 1 }),
|
|
9
|
+
pattern: name === 'image'
|
|
10
|
+
? '^[a-zA-Z0-9][a-zA-Z0-9.-]*\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[sS][vV][gG])$(?![\\s\\S])'
|
|
11
|
+
: name === 'caption' || name === 'text'
|
|
12
|
+
? '^(?=[\\s\\S]*\\S)[^\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]*$(?![\\s\\S])'
|
|
13
|
+
: name === 'alt'
|
|
14
|
+
? '^(?:|(?=.*\\S)[^\\x00-\\x1F\\x7F-\\x9F\\u2028\\u2029]+)$(?![\\s\\S])'
|
|
15
|
+
: '^(?=.*\\S)[^\\x00-\\x1F\\x7F-\\x9F\\u2028\\u2029]+$(?![\\s\\S])',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const createStructuredBlockSchema = (definition) => {
|
|
19
|
+
if (!definition?.item) return null;
|
|
20
|
+
const fields = { [definition.item.start.key]: definition.item.start, ...definition.item.fields };
|
|
21
|
+
const item = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
required: [definition.item.start.key],
|
|
25
|
+
properties: Object.fromEntries(Object.entries(fields).map(([key, field]) => [key, stringSchema(key, field)])),
|
|
26
|
+
...(definition.item.start.key === 'title' ? {
|
|
27
|
+
anyOf: ['text', 'image', 'link'].map((key) => ({ required: [key] })),
|
|
28
|
+
} : {}),
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
32
|
+
description: definition.description,
|
|
33
|
+
type: 'object',
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
required: ['items'],
|
|
36
|
+
properties: {
|
|
37
|
+
...Object.fromEntries(Object.entries(definition.options ?? {}).map(([key, field]) => [key, stringSchema(key, field)])),
|
|
38
|
+
items: { type: 'array', minItems: definition.minItems ?? 1, items: item },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Validate the AST directly so errors and later link edits retain exact source ranges.
|
|
44
|
+
export const parseStructuredBlock = (source, schema, options = {}) => {
|
|
45
|
+
const lines = new LineCounter();
|
|
46
|
+
const document = parseDocument(source, { schema: 'core', version: '1.2', uniqueKeys: true, lineCounter: lines });
|
|
47
|
+
const lineAt = (node) => (options.line ?? 1) + lines.linePos(node?.range?.[0] ?? 0).line - 1;
|
|
48
|
+
const fail = (message, node) => {
|
|
49
|
+
const line = lineAt(node);
|
|
50
|
+
throw Object.assign(new Error(`${options.label ? `${options.label} ` : ''}line ${line}: ${options.type}: ${message}`), { line });
|
|
51
|
+
};
|
|
52
|
+
if (document.errors.length) {
|
|
53
|
+
const error = document.errors[0];
|
|
54
|
+
fail(`Invalid YAML: ${error.message.split('\n')[0]}`, { range: error.pos });
|
|
55
|
+
}
|
|
56
|
+
if (document.directives.yaml.version !== '1.2') fail('Use YAML 1.2 Core; other YAML versions are not supported.', document.contents);
|
|
57
|
+
const validate = (node, rule, path, depth = 0) => {
|
|
58
|
+
if (depth > 16) fail('YAML nesting is too deep.', node);
|
|
59
|
+
if (node?.anchor || node?.tag || isAlias(node)) fail('YAML anchors, aliases, and explicit tags are not allowed.', node);
|
|
60
|
+
if (rule.type === 'object') {
|
|
61
|
+
if (!isMap(node)) fail(`${path} must be a YAML mapping${path === 'Block' ? ' with an items list' : ''}.`, node);
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const pair of node.items) {
|
|
64
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== 'string') fail(`${path} field names must be strings.`, pair.key);
|
|
65
|
+
if (pair.key.tag || pair.key.anchor) fail('YAML anchors and explicit tags are not allowed.', pair.key);
|
|
66
|
+
const key = pair.key.value;
|
|
67
|
+
if (!Object.hasOwn(rule.properties, key)) fail(`Unknown ${path} field "${key}".`, pair.key);
|
|
68
|
+
result[key] = validate(pair.value, rule.properties[key], `${path}.${key}`, depth + 1);
|
|
69
|
+
}
|
|
70
|
+
for (const key of rule.required ?? []) {
|
|
71
|
+
if (!Object.hasOwn(result, key)) fail(`${path} is missing "${key}".`, node);
|
|
72
|
+
}
|
|
73
|
+
if (rule.anyOf && !rule.anyOf.some((alternative) => alternative.required.every((key) => Object.hasOwn(result, key)))) {
|
|
74
|
+
fail(`${path} must specify at least one of text, image, or link.`, node);
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
if (rule.type === 'array') {
|
|
79
|
+
if (!isSeq(node)) fail(`${path} must be a YAML list.`, node);
|
|
80
|
+
if (node.items.length < rule.minItems) fail(`${path} must contain at least ${rule.minItems} item.`, node);
|
|
81
|
+
return node.items.map((item, index) => validate(item, rule.items, `${path}[${index + 1}]`, depth + 1));
|
|
82
|
+
}
|
|
83
|
+
if (!isScalar(node) || typeof node.value !== 'string') fail(`${path} must be a string; quote values that YAML would interpret as numbers, booleans, or null.`, node);
|
|
84
|
+
if (rule.enum && !rule.enum.includes(node.value)) fail(`Invalid ${path} "${node.value}". Use one of: ${rule.enum.join(', ')}.`, node);
|
|
85
|
+
if (!new RegExp(rule.pattern).test(node.value)) {
|
|
86
|
+
fail(path.endsWith('.image')
|
|
87
|
+
? 'Image reference must be a filename ending in jpg, jpeg, png, or svg.'
|
|
88
|
+
: `${path} must ${path.endsWith('.alt') ? 'be empty or contain single-line text' : 'contain non-empty text'}${/\.(caption|text)$/.test(path) ? '' : ' without line breaks or control characters'}.`, node);
|
|
89
|
+
}
|
|
90
|
+
return node.value;
|
|
91
|
+
};
|
|
92
|
+
const data = validate(document.contents, schema, 'Block');
|
|
93
|
+
const items = document.get('items', true).items;
|
|
94
|
+
data.items = data.items.map((item, index) => {
|
|
95
|
+
const node = items[index];
|
|
96
|
+
const link = node.get('link', true);
|
|
97
|
+
return {
|
|
98
|
+
...item,
|
|
99
|
+
line: lineAt(node),
|
|
100
|
+
...(link ? {
|
|
101
|
+
linkLine: lineAt(link),
|
|
102
|
+
// A block scalar owns its trailing newline; leave that separator in place when editing the value.
|
|
103
|
+
linkRange: { start: link.range[0], end: source.slice(0, link.range[1]).trimEnd().length },
|
|
104
|
+
} : {}),
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
return data;
|
|
108
|
+
};
|
|
@@ -1,16 +1,59 @@
|
|
|
1
1
|
import { defineHastPlugin } from 'satteri';
|
|
2
2
|
|
|
3
|
+
const transformTableSection = (section, { columnHeaders = false, rowHeaders = false } = {}) => ({
|
|
4
|
+
...section,
|
|
5
|
+
children: (section.children ?? []).map((child) => {
|
|
6
|
+
if (child.type !== 'element' || child.tagName !== 'tr') return child;
|
|
7
|
+
let columnIndex = 0;
|
|
8
|
+
return {
|
|
9
|
+
...child,
|
|
10
|
+
children: (child.children ?? []).map((cell) => {
|
|
11
|
+
if (cell.type !== 'element' || (cell.tagName !== 'th' && cell.tagName !== 'td')) return cell;
|
|
12
|
+
const currentColumn = columnIndex;
|
|
13
|
+
columnIndex += 1;
|
|
14
|
+
if (columnHeaders) {
|
|
15
|
+
return {
|
|
16
|
+
...cell,
|
|
17
|
+
properties: { ...(cell.properties ?? {}), scope: 'col' },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (rowHeaders && currentColumn === 0) {
|
|
21
|
+
return {
|
|
22
|
+
...cell,
|
|
23
|
+
tagName: 'th',
|
|
24
|
+
properties: { ...(cell.properties ?? {}), scope: 'row' },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return cell;
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const transformRowHeaderTable = (node) => ({
|
|
34
|
+
...node,
|
|
35
|
+
children: (node.children ?? []).map((child) => {
|
|
36
|
+
if (child.type !== 'element') return child;
|
|
37
|
+
if (child.tagName === 'thead') return transformTableSection(child, { columnHeaders: true });
|
|
38
|
+
if (child.tagName === 'tbody') return transformTableSection(child, { rowHeaders: true });
|
|
39
|
+
return child;
|
|
40
|
+
}),
|
|
41
|
+
});
|
|
42
|
+
|
|
3
43
|
export const nornaTableRenderPlugin = defineHastPlugin({
|
|
4
44
|
name: 'norna-table-render',
|
|
5
45
|
element: {
|
|
6
46
|
filter: ['table'],
|
|
7
47
|
visit(node, context) {
|
|
48
|
+
const hasRowHeaders = node.properties?.['data-row-headers'] === 'true';
|
|
49
|
+
const table = hasRowHeaders ? transformRowHeaderTable(node) : node;
|
|
8
50
|
context.replaceNode(node, {
|
|
9
51
|
type: 'element',
|
|
10
52
|
tagName: 'div',
|
|
11
53
|
properties: {
|
|
12
54
|
className: ['norna-table-frame', 'content-block-note-lane-boundary'],
|
|
13
55
|
'data-table-frame': '',
|
|
56
|
+
...(hasRowHeaders ? { 'data-table-row-headers': 'true' } : {}),
|
|
14
57
|
},
|
|
15
58
|
children: [{
|
|
16
59
|
type: 'element',
|
|
@@ -22,9 +65,9 @@ export const nornaTableRenderPlugin = defineHastPlugin({
|
|
|
22
65
|
},
|
|
23
66
|
children: [{
|
|
24
67
|
type: 'element',
|
|
25
|
-
tagName:
|
|
26
|
-
properties: { ...(
|
|
27
|
-
children: [...(
|
|
68
|
+
tagName: table.tagName,
|
|
69
|
+
properties: { ...(table.properties ?? {}) },
|
|
70
|
+
children: [...(table.children ?? [])],
|
|
28
71
|
}],
|
|
29
72
|
}],
|
|
30
73
|
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
export const tableRowHeaderMarker = '{row-header}';
|
|
2
|
+
|
|
3
|
+
const trailingMarkerPattern = /\s*\{row-header\}\s*$/u;
|
|
4
|
+
|
|
5
|
+
const getNodeText = (node) => {
|
|
6
|
+
if (!node || typeof node !== 'object') return '';
|
|
7
|
+
if (node.type === 'text' || node.type === 'inlineCode') return node.value ?? '';
|
|
8
|
+
if (node.type === 'image') return node.alt ?? '';
|
|
9
|
+
if (!Array.isArray(node.children)) return '';
|
|
10
|
+
return node.children.map(getNodeText).join('');
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const getUnescapedMarkerIndexes = (source) => {
|
|
14
|
+
const indexes = [];
|
|
15
|
+
let searchFrom = 0;
|
|
16
|
+
while (searchFrom < source.length) {
|
|
17
|
+
const index = source.indexOf(tableRowHeaderMarker, searchFrom);
|
|
18
|
+
if (index < 0) break;
|
|
19
|
+
let backslashes = 0;
|
|
20
|
+
for (let cursor = index - 1; cursor >= 0 && source[cursor] === '\\'; cursor -= 1) {
|
|
21
|
+
backslashes += 1;
|
|
22
|
+
}
|
|
23
|
+
if (backslashes % 2 === 0) indexes.push(index);
|
|
24
|
+
searchFrom = index + tableRowHeaderMarker.length;
|
|
25
|
+
}
|
|
26
|
+
return indexes;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const getTextNodeMarkerCount = (node, source) => {
|
|
30
|
+
if (node.type !== 'text' || !node.value?.includes(tableRowHeaderMarker)) return 0;
|
|
31
|
+
const start = node.position?.start.offset;
|
|
32
|
+
const end = node.position?.end.offset;
|
|
33
|
+
const raw = Number.isInteger(start) && Number.isInteger(end)
|
|
34
|
+
? source.slice(start, end)
|
|
35
|
+
: node.value;
|
|
36
|
+
return getUnescapedMarkerIndexes(raw).length;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const collectCellMarkers = (node, source, markers, location) => {
|
|
40
|
+
if (!node || typeof node !== 'object') return;
|
|
41
|
+
const count = getTextNodeMarkerCount(node, source);
|
|
42
|
+
for (let index = 0; index < count; index += 1) {
|
|
43
|
+
markers.push({ ...location, node });
|
|
44
|
+
}
|
|
45
|
+
for (const child of node.children ?? []) {
|
|
46
|
+
collectCellMarkers(child, source, markers, location);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const normalizeLabel = (value) => value.trim().replace(/\s+/gu, ' ').toLowerCase();
|
|
51
|
+
|
|
52
|
+
const issue = (code, node, message, fix, otherNode = null) => ({
|
|
53
|
+
code,
|
|
54
|
+
fix,
|
|
55
|
+
message,
|
|
56
|
+
node,
|
|
57
|
+
otherNode,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const inspectTableRowHeaders = (table, source = '') => {
|
|
61
|
+
if (table?.type !== 'table') return { declared: false, issues: [], valid: false };
|
|
62
|
+
|
|
63
|
+
const rows = table.children ?? [];
|
|
64
|
+
const markers = [];
|
|
65
|
+
for (const [rowIndex, row] of rows.entries()) {
|
|
66
|
+
for (const [columnIndex, cell] of (row.children ?? []).entries()) {
|
|
67
|
+
collectCellMarkers(cell, source, markers, { cell, columnIndex, rowIndex });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (markers.length === 0) return { declared: false, issues: [], valid: false };
|
|
72
|
+
|
|
73
|
+
const issues = [];
|
|
74
|
+
const declaration = markers[0];
|
|
75
|
+
for (const repeated of markers.slice(1)) {
|
|
76
|
+
issues.push(issue(
|
|
77
|
+
'repeated-table-row-header-marker',
|
|
78
|
+
repeated.cell,
|
|
79
|
+
`A table may contain ${tableRowHeaderMarker} only once.`,
|
|
80
|
+
`Keep ${tableRowHeaderMarker} only in the first column heading.`,
|
|
81
|
+
declaration.cell,
|
|
82
|
+
));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const firstHeadingCell = rows[0]?.children?.[0];
|
|
86
|
+
if (declaration.rowIndex !== 0 || declaration.columnIndex !== 0) {
|
|
87
|
+
issues.push(issue(
|
|
88
|
+
'invalid-table-row-header-column',
|
|
89
|
+
declaration.cell,
|
|
90
|
+
`${tableRowHeaderMarker} must appear in the first column heading.`,
|
|
91
|
+
`Move ${tableRowHeaderMarker} to the end of the first heading cell.`,
|
|
92
|
+
));
|
|
93
|
+
} else if (!getNodeText(firstHeadingCell).trim().endsWith(tableRowHeaderMarker)) {
|
|
94
|
+
issues.push(issue(
|
|
95
|
+
'invalid-table-row-header-marker-position',
|
|
96
|
+
firstHeadingCell,
|
|
97
|
+
`${tableRowHeaderMarker} must be the final content in the first column heading.`,
|
|
98
|
+
`Move ${tableRowHeaderMarker} to the end of the first heading cell.`,
|
|
99
|
+
));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const headingLabel = getNodeText(firstHeadingCell).replace(trailingMarkerPattern, '').trim();
|
|
103
|
+
if (!headingLabel) {
|
|
104
|
+
issues.push(issue(
|
|
105
|
+
'empty-table-row-header-heading',
|
|
106
|
+
firstHeadingCell ?? table,
|
|
107
|
+
'The row-header column needs a visible column heading.',
|
|
108
|
+
`Write a heading before ${tableRowHeaderMarker}, for example "Feature ${tableRowHeaderMarker}".`,
|
|
109
|
+
));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (rows.length < 2) {
|
|
113
|
+
issues.push(issue(
|
|
114
|
+
'empty-table-row-header-body',
|
|
115
|
+
table,
|
|
116
|
+
'A table with a row-header declaration needs at least one body row.',
|
|
117
|
+
'Add a body row or remove the row-header declaration.',
|
|
118
|
+
));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const labels = new Map();
|
|
122
|
+
for (const row of rows.slice(1)) {
|
|
123
|
+
const cell = row.children?.[0];
|
|
124
|
+
if (!cell) {
|
|
125
|
+
issues.push(issue(
|
|
126
|
+
'missing-table-row-header',
|
|
127
|
+
row,
|
|
128
|
+
'This table row has no cell in the row-header column.',
|
|
129
|
+
'Add a unique row label in the first column.',
|
|
130
|
+
));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const label = getNodeText(cell).trim().replace(/\s+/gu, ' ');
|
|
135
|
+
if (!label) {
|
|
136
|
+
issues.push(issue(
|
|
137
|
+
'empty-table-row-header',
|
|
138
|
+
cell,
|
|
139
|
+
'This table row has an empty row header.',
|
|
140
|
+
'Add a unique row label in the first column.',
|
|
141
|
+
));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const normalizedLabel = normalizeLabel(label);
|
|
146
|
+
const existing = labels.get(normalizedLabel);
|
|
147
|
+
if (existing) {
|
|
148
|
+
issues.push(issue(
|
|
149
|
+
'duplicate-table-row-header',
|
|
150
|
+
cell,
|
|
151
|
+
`Row header "${label}" is repeated.`,
|
|
152
|
+
'Give every row a unique label so its header association is unambiguous.',
|
|
153
|
+
existing,
|
|
154
|
+
));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
labels.set(normalizedLabel, cell);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
declared: true,
|
|
162
|
+
firstHeadingCell,
|
|
163
|
+
issues,
|
|
164
|
+
markerNode: declaration.node,
|
|
165
|
+
valid: issues.length === 0,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const formatDiagnostic = (entry, options) => {
|
|
170
|
+
const lineOffset = options.lineOffset ?? 0;
|
|
171
|
+
const line = lineOffset + (entry.node?.position?.start.line ?? 1);
|
|
172
|
+
const otherLine = entry.otherNode
|
|
173
|
+
? lineOffset + (entry.otherNode.position?.start.line ?? 1)
|
|
174
|
+
: null;
|
|
175
|
+
const related = otherLine && otherLine !== line ? ` The first occurrence is on line ${otherLine}.` : '';
|
|
176
|
+
const label = options.label ?? 'Markdown';
|
|
177
|
+
return {
|
|
178
|
+
code: entry.code,
|
|
179
|
+
fix: entry.fix,
|
|
180
|
+
line,
|
|
181
|
+
message: `${label} line ${line}: ${entry.message}${related}`,
|
|
182
|
+
offset: entry.node?.position?.start.offset ?? 0,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export const getTableRowHeaderDiagnostics = (tree, options = {}) => {
|
|
187
|
+
const diagnostics = [];
|
|
188
|
+
const source = options.source ?? '';
|
|
189
|
+
const visit = (node) => {
|
|
190
|
+
if (!node || typeof node !== 'object') return;
|
|
191
|
+
if (node.type === 'table') {
|
|
192
|
+
const inspection = inspectTableRowHeaders(node, source);
|
|
193
|
+
diagnostics.push(...inspection.issues.map((entry) => formatDiagnostic(entry, options)));
|
|
194
|
+
}
|
|
195
|
+
for (const child of node.children ?? []) visit(child);
|
|
196
|
+
};
|
|
197
|
+
visit(tree);
|
|
198
|
+
return diagnostics;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const applyTableRowHeaderDeclaration = (table, context) => {
|
|
202
|
+
const inspection = inspectTableRowHeaders(table, context.source);
|
|
203
|
+
if (!inspection.valid || !inspection.markerNode) return false;
|
|
204
|
+
|
|
205
|
+
context.setProperty(
|
|
206
|
+
inspection.markerNode,
|
|
207
|
+
'value',
|
|
208
|
+
inspection.markerNode.value.replace(trailingMarkerPattern, ''),
|
|
209
|
+
);
|
|
210
|
+
context.setProperty(table, 'data', {
|
|
211
|
+
...(table.data ?? {}),
|
|
212
|
+
hProperties: {
|
|
213
|
+
...(table.data?.hProperties ?? {}),
|
|
214
|
+
'data-row-headers': 'true',
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
@@ -50,25 +50,21 @@ export const themePresetDefinitions = Object.freeze({
|
|
|
50
50
|
title: 'Portfolio',
|
|
51
51
|
description: 'For portfolios and image-led sites, with restrained typography and generous space for images.',
|
|
52
52
|
recipe: themePresetRecipes.portfolio,
|
|
53
|
-
readerControls: Object.freeze({ appearance: true }),
|
|
54
53
|
}),
|
|
55
54
|
documentation: Object.freeze({
|
|
56
55
|
title: 'Documentation',
|
|
57
56
|
description: 'For guides and reference material, with reading-focused typography and compact spacing.',
|
|
58
57
|
recipe: themePresetRecipes.documentation,
|
|
59
|
-
readerControls: Object.freeze({ appearance: true, focusReading: true }),
|
|
60
58
|
}),
|
|
61
59
|
project: Object.freeze({
|
|
62
60
|
title: 'Project',
|
|
63
61
|
description: 'For project and product sites that balance explanation, code, cards, and images.',
|
|
64
62
|
recipe: themePresetRecipes.project,
|
|
65
|
-
readerControls: Object.freeze({ appearance: true, focusReading: true }),
|
|
66
63
|
}),
|
|
67
64
|
statement: Object.freeze({
|
|
68
65
|
title: 'Statement',
|
|
69
66
|
description: 'For short, expressive sites, with larger typography, airy spacing, and stronger section emphasis.',
|
|
70
67
|
recipe: themePresetRecipes.statement,
|
|
71
|
-
readerControls: Object.freeze({ appearance: true }),
|
|
72
68
|
}),
|
|
73
69
|
});
|
|
74
70
|
|
|
@@ -82,7 +78,6 @@ export const themePresets = Object.freeze(Object.fromEntries(
|
|
|
82
78
|
themePresetDefinitions[name].recipe,
|
|
83
79
|
`${name} theme preset`,
|
|
84
80
|
),
|
|
85
|
-
readerControls: themePresetDefinitions[name].readerControls,
|
|
86
81
|
}),
|
|
87
82
|
]),
|
|
88
83
|
));
|
|
@@ -179,7 +174,7 @@ const responsiveValueLines = (label, value, indent = 2) => {
|
|
|
179
174
|
export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml') => {
|
|
180
175
|
const preset = getThemePreset(presetName, sourceLabel);
|
|
181
176
|
const metadata = getThemePresetMetadata(presetName);
|
|
182
|
-
const { appearance,
|
|
177
|
+
const { appearance, corners, layout, images, blocks, typography, palette, sections } = preset;
|
|
183
178
|
|
|
184
179
|
return [
|
|
185
180
|
`# Original values for Norna's "${presetName}" theme preset.`,
|
|
@@ -193,10 +188,8 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
|
|
|
193
188
|
'appearance:',
|
|
194
189
|
` default: ${appearance.default}`,
|
|
195
190
|
'',
|
|
196
|
-
'#
|
|
197
|
-
'
|
|
198
|
-
` appearance: ${readerControls.appearance === true}`,
|
|
199
|
-
` focusReading: ${readerControls.focusReading === true}`,
|
|
191
|
+
'# Appearance and reading width are always available in the Display panel.',
|
|
192
|
+
'# Focus reading is available automatically when navigation resolves to tree.',
|
|
200
193
|
'',
|
|
201
194
|
'# Alternatives: square, rounded.',
|
|
202
195
|
`corners: ${corners}`,
|
|
@@ -36,12 +36,8 @@ const getLegacyThemeHint = (issue, data) => {
|
|
|
36
36
|
return 'Theme setting "colorMode" was replaced by "appearance".';
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
if (location === 'readerControls'
|
|
40
|
-
return '
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (location === 'readerControls' && issue.keys?.includes('readingWidth')) {
|
|
44
|
-
return 'Reader control "readingWidth" was removed because reading width is now always available. Remove this field.';
|
|
39
|
+
if (location === 'readerControls') {
|
|
40
|
+
return 'Theme setting "readerControls" was removed. Appearance and reading width are always available; Focus reading follows tree navigation. Remove this block.';
|
|
45
41
|
}
|
|
46
42
|
|
|
47
43
|
return undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
auditDocusaurusProject,
|
|
3
|
+
migrationCheckUsage,
|
|
4
|
+
parseMigrationCheckArgs,
|
|
5
|
+
writeMigrationReport,
|
|
6
|
+
} from './lib/docusaurus-migration-audit.mjs';
|
|
7
|
+
|
|
8
|
+
const parsed = parseMigrationCheckArgs(process.argv.slice(2));
|
|
9
|
+
if (parsed.help) {
|
|
10
|
+
console.log(migrationCheckUsage);
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const report = await auditDocusaurusProject({ sourceRoot: parsed.sourceRoot });
|
|
15
|
+
await writeMigrationReport(report, parsed.reportDir, { sourceRoot: parsed.sourceRoot });
|
|
16
|
+
console.log('Docusaurus migration audit written to ' + parsed.reportDir);
|
|
17
|
+
console.log('Source pages: ' + report.summary.pages);
|
|
18
|
+
console.log('Pages requiring attention: ' + report.summary.pagesWithFindings);
|
|
19
|
+
console.log('Findings: ' + report.summary.findings);
|
|
@@ -233,6 +233,7 @@ try {
|
|
|
233
233
|
const siteStructure = await getSiteStructure();
|
|
234
234
|
const contentFiles = siteStructure.contentFiles;
|
|
235
235
|
for (const warning of siteStructure.warnings) {
|
|
236
|
+
if (warning.code === 'empty-category') continue;
|
|
236
237
|
addIssue({
|
|
237
238
|
severity: 'warning',
|
|
238
239
|
message: warning.message,
|
|
@@ -272,6 +273,14 @@ for (const contentFile of contentFiles) {
|
|
|
272
273
|
prelude,
|
|
273
274
|
regions: sections,
|
|
274
275
|
} = page;
|
|
276
|
+
for (const issue of page.diagnostics.filter((candidate) => candidate.code === 'heading-inside-details')) {
|
|
277
|
+
addContentIssue(contentFile, issue);
|
|
278
|
+
}
|
|
279
|
+
for (const issue of page.noteDiagnostics) {
|
|
280
|
+
const section = sections.find((candidate) => issue.offset >= candidate.startOffset && issue.offset < candidate.endOffset);
|
|
281
|
+
if (section) addSectionIssue(contentFile, section, issue);
|
|
282
|
+
else addContentIssue(contentFile, issue);
|
|
283
|
+
}
|
|
275
284
|
const invalidHeadingLines = new Set();
|
|
276
285
|
for (const issue of headingIdentifierIssues) {
|
|
277
286
|
invalidHeadingLines.add(issue.heading.line);
|
|
@@ -362,14 +371,19 @@ for (const contentFile of contentFiles) {
|
|
|
362
371
|
const blockResults = { blocks: section.blocks, errors: section.blockErrors };
|
|
363
372
|
blockResultsBySection.set(section, blockResults);
|
|
364
373
|
|
|
365
|
-
for (const error of section.
|
|
374
|
+
for (const error of section.tabErrors) {
|
|
375
|
+
addSectionIssue(contentFile, section, { severity: 'error', message: error.message });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const error of section.calloutErrors) {
|
|
366
379
|
addSectionIssue(contentFile, section, {
|
|
367
|
-
severity: 'error',
|
|
380
|
+
severity: error.severity ?? 'error',
|
|
368
381
|
message: error.message,
|
|
382
|
+
fix: error.fix,
|
|
369
383
|
});
|
|
370
384
|
}
|
|
371
385
|
|
|
372
|
-
for (const error of section.
|
|
386
|
+
for (const error of section.codeFenceErrors) {
|
|
373
387
|
addSectionIssue(contentFile, section, {
|
|
374
388
|
severity: 'error',
|
|
375
389
|
message: error.message,
|
|
@@ -377,7 +391,7 @@ for (const contentFile of contentFiles) {
|
|
|
377
391
|
});
|
|
378
392
|
}
|
|
379
393
|
|
|
380
|
-
for (const error of section.
|
|
394
|
+
for (const error of section.tableErrors) {
|
|
381
395
|
addSectionIssue(contentFile, section, {
|
|
382
396
|
severity: 'error',
|
|
383
397
|
message: error.message,
|
|
@@ -421,17 +435,32 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
|
|
|
421
435
|
|
|
422
436
|
for (const context of contentFileContexts) {
|
|
423
437
|
const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
|
|
424
|
-
|
|
438
|
+
const warnedChildren = new Set();
|
|
425
439
|
|
|
426
440
|
for (const section of context.sections) {
|
|
427
441
|
if (!context.validSections.has(section)) continue;
|
|
428
442
|
for (const block of section.blocks) {
|
|
429
443
|
if (block.type !== 'page-list') continue;
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
444
|
+
if (childPages.length === 0) {
|
|
445
|
+
addSectionIssue(context.contentFile, section, {
|
|
446
|
+
severity: 'error',
|
|
447
|
+
message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
|
|
448
|
+
fix: 'Add a listed direct child page or remove the block.',
|
|
449
|
+
});
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
for (const child of childPages) {
|
|
453
|
+
const childContext = contentContextByPageDirectory.get(child.pageDirectory);
|
|
454
|
+
if (!childContext || warnedChildren.has(child.pageDirectory)) continue;
|
|
455
|
+
const description = childContext.frontmatterData.page?.description;
|
|
456
|
+
if (typeof description === 'string' && description.trim()) continue;
|
|
457
|
+
warnedChildren.add(child.pageDirectory);
|
|
458
|
+
addSectionIssue(context.contentFile, section, {
|
|
459
|
+
severity: 'warning',
|
|
460
|
+
message: `page-list on line ${block.line} includes ${childContext.contentFile.contentLabel} without a non-empty page.description.`,
|
|
461
|
+
fix: 'Add page.description to that child file\'s frontmatter. Explain what the reader will find or when to choose this page, rather than repeating its title.',
|
|
462
|
+
});
|
|
463
|
+
}
|
|
435
464
|
}
|
|
436
465
|
}
|
|
437
466
|
}
|