@janga/norna 0.7.25 → 0.7.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +3 -2
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/dev-local.mjs +24 -2
  16. package/scripts/generate-schemas.mjs +15 -3
  17. package/scripts/lib/category-destinations.mjs +48 -0
  18. package/scripts/lib/code-fence-metadata.mjs +14 -37
  19. package/scripts/lib/content-tabs.mjs +155 -0
  20. package/scripts/lib/details-headings.mjs +58 -0
  21. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  22. package/scripts/lib/editor-language-service.mjs +238 -116
  23. package/scripts/lib/locale-registry.mjs +597 -0
  24. package/scripts/lib/locales/az-Latn.mjs +78 -0
  25. package/scripts/lib/locales/be.mjs +78 -0
  26. package/scripts/lib/locales/bg.mjs +78 -0
  27. package/scripts/lib/locales/bs.mjs +78 -0
  28. package/scripts/lib/locales/ca.mjs +78 -0
  29. package/scripts/lib/locales/cnr.mjs +78 -0
  30. package/scripts/lib/locales/cs.mjs +78 -0
  31. package/scripts/lib/locales/da.mjs +78 -0
  32. package/scripts/lib/locales/de.mjs +78 -0
  33. package/scripts/lib/locales/el.mjs +78 -0
  34. package/scripts/lib/locales/en.mjs +78 -0
  35. package/scripts/lib/locales/es.mjs +78 -0
  36. package/scripts/lib/locales/et.mjs +78 -0
  37. package/scripts/lib/locales/fi.mjs +78 -0
  38. package/scripts/lib/locales/fil.mjs +78 -0
  39. package/scripts/lib/locales/fr.mjs +78 -0
  40. package/scripts/lib/locales/ga.mjs +78 -0
  41. package/scripts/lib/locales/ha.mjs +78 -0
  42. package/scripts/lib/locales/hr.mjs +78 -0
  43. package/scripts/lib/locales/hu.mjs +78 -0
  44. package/scripts/lib/locales/id.mjs +78 -0
  45. package/scripts/lib/locales/is.mjs +78 -0
  46. package/scripts/lib/locales/it.mjs +78 -0
  47. package/scripts/lib/locales/jv.mjs +78 -0
  48. package/scripts/lib/locales/lb.mjs +78 -0
  49. package/scripts/lib/locales/lt.mjs +78 -0
  50. package/scripts/lib/locales/lv.mjs +78 -0
  51. package/scripts/lib/locales/mk.mjs +78 -0
  52. package/scripts/lib/locales/mt.mjs +78 -0
  53. package/scripts/lib/locales/nb.mjs +78 -0
  54. package/scripts/lib/locales/nl.mjs +78 -0
  55. package/scripts/lib/locales/nn.mjs +78 -0
  56. package/scripts/lib/locales/pcm.mjs +78 -0
  57. package/scripts/lib/locales/pl.mjs +78 -0
  58. package/scripts/lib/locales/pt.mjs +78 -0
  59. package/scripts/lib/locales/ro.mjs +78 -0
  60. package/scripts/lib/locales/ru.mjs +78 -0
  61. package/scripts/lib/locales/sk.mjs +78 -0
  62. package/scripts/lib/locales/sl.mjs +78 -0
  63. package/scripts/lib/locales/sq.mjs +78 -0
  64. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  65. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  66. package/scripts/lib/locales/sv.mjs +78 -0
  67. package/scripts/lib/locales/sw.mjs +78 -0
  68. package/scripts/lib/locales/tr.mjs +78 -0
  69. package/scripts/lib/locales/uk.mjs +78 -0
  70. package/scripts/lib/locales/vi.mjs +78 -0
  71. package/scripts/lib/markdown-links.mjs +4 -25
  72. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  73. package/scripts/lib/markdown-notes.mjs +165 -0
  74. package/scripts/lib/norna-markdown-blocks.mjs +32 -447
  75. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  76. package/scripts/lib/page-aliases.mjs +12 -1
  77. package/scripts/lib/page-markdown.mjs +63 -16
  78. package/scripts/lib/page-move-plan.mjs +22 -5
  79. package/scripts/lib/pagefind-translations.mjs +620 -0
  80. package/scripts/lib/presentation-contract.mjs +6 -1
  81. package/scripts/lib/presentation.mjs +29 -30
  82. package/scripts/lib/project-config.mjs +2 -173
  83. package/scripts/lib/schema-definitions.mjs +3 -10
  84. package/scripts/lib/schema-editor-metadata.mjs +22 -43
  85. package/scripts/lib/schema-value-definitions.mjs +4 -8
  86. package/scripts/lib/semantic-callouts.mjs +4 -2
  87. package/scripts/lib/site-content.mjs +4 -2
  88. package/scripts/lib/site-link-graph.mjs +16 -6
  89. package/scripts/lib/site-navigation-tree.mjs +0 -11
  90. package/scripts/lib/sitemap.mjs +5 -3
  91. package/scripts/lib/structured-blocks.mjs +108 -0
  92. package/scripts/lib/table-render-plugin.mjs +46 -3
  93. package/scripts/lib/table-row-headers.mjs +218 -0
  94. package/scripts/lib/theme-presets.mjs +3 -10
  95. package/scripts/lib/yaml-config.mjs +2 -6
  96. package/scripts/migrate-check.mjs +19 -0
  97. package/scripts/sync-content-sections.mjs +39 -10
  98. package/scripts/sync-site-public.mjs +7 -1
  99. package/src/components/CodeBlockCopyScript.astro +47 -5
  100. package/src/components/ContentBlocks.astro +39 -0
  101. package/src/components/ContentTabsScript.astro +60 -0
  102. package/src/components/DisplaySettings.astro +17 -19
  103. package/src/components/NavigationPageTree.astro +1 -1
  104. package/src/components/NoteNavigationScript.astro +4 -0
  105. package/src/components/PageAliasRedirect.astro +5 -4
  106. package/src/components/SearchNavigationScript.astro +4 -0
  107. package/src/components/SearchPage.astro +43 -2
  108. package/src/components/SectionNavigationScript.astro +43 -11
  109. package/src/components/SiteBreadcrumbs.astro +1 -1
  110. package/src/components/SiteNavigation.astro +29 -20
  111. package/src/components/SitePage.astro +40 -15
  112. package/src/components/SiteSection.astro +5 -81
  113. package/src/components/SiteTreeNavigation.astro +2 -2
  114. package/src/components/TableOverflowScript.astro +386 -58
  115. package/src/components/TopPageMenu.astro +47 -0
  116. package/src/components/TreeNavigationScript.astro +10 -3
  117. package/src/layouts/BaseLayout.astro +8 -12
  118. package/src/lib/navigationFollowing.ts +142 -0
  119. package/src/lib/noteNavigation.ts +50 -0
  120. package/src/lib/searchNavigation.ts +107 -0
  121. package/src/lib/sectionContent.ts +20 -90
  122. package/src/lib/siteNavigation.ts +0 -5
  123. package/src/lib/sitePages.ts +13 -2
  124. package/src/lib/table-sort-tooltip.ts +109 -0
  125. package/src/pages/[...slug].astro +16 -6
  126. package/src/styles/content.css +394 -132
  127. package/src/styles/foundations.css +9 -0
  128. package/src/styles/media.css +5 -0
  129. package/src/styles/navigation.css +63 -74
  130. package/src/styles/page-layout.css +7 -0
  131. package/src/styles/responsive.css +2 -7
@@ -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
- diagnostics.push(createIssue(
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
- 'category-has-no-url',
286
- `Internal link "${sourceReference.target}" on line ${sourceReference.line} points to navigation category ${targetCategory.pathname}, which does not have its own page.`,
287
- 'Link to one of the category pages, or replace category.yaml with content.md when the collection needs its own page.',
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),
@@ -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
- siteStructure.contentFiles
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: node.tagName,
26
- properties: { ...(node.properties ?? {}) },
27
- children: [...(node.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, readerControls, corners, layout, images, blocks, typography, palette, sections } = preset;
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
- '# Optional reader controls shown with the always-available reading-width choice.',
197
- 'readerControls:',
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' && issue.keys?.includes('colorMode')) {
40
- return 'Reader control "colorMode" was replaced by "appearance".';
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.noteErrors) {
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.calloutErrors) {
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.codeFenceErrors) {
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
- if (childPages.length > 0) continue;
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
- addSectionIssue(context.contentFile, section, {
431
- severity: 'error',
432
- message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
433
- fix: 'Add a listed direct child page or remove the block.',
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
  }