@janga/norna 0.7.24 → 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 (142) hide show
  1. package/README.md +5 -4
  2. package/astro.config.mjs +27 -11
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +26 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +342 -33
  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/check-config.mjs +5 -1
  16. package/scripts/dev-local.mjs +130 -13
  17. package/scripts/generate-schemas.mjs +15 -3
  18. package/scripts/init-site.mjs +2 -0
  19. package/scripts/lib/category-destinations.mjs +48 -0
  20. package/scripts/lib/code-fence-metadata.mjs +25 -43
  21. package/scripts/lib/content-tabs.mjs +155 -0
  22. package/scripts/lib/details-headings.mjs +58 -0
  23. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  24. package/scripts/lib/edit-source-link.mjs +53 -0
  25. package/scripts/lib/editor-language-service.mjs +238 -116
  26. package/scripts/lib/locale-registry.mjs +597 -0
  27. package/scripts/lib/locales/az-Latn.mjs +78 -0
  28. package/scripts/lib/locales/be.mjs +78 -0
  29. package/scripts/lib/locales/bg.mjs +78 -0
  30. package/scripts/lib/locales/bs.mjs +78 -0
  31. package/scripts/lib/locales/ca.mjs +78 -0
  32. package/scripts/lib/locales/cnr.mjs +78 -0
  33. package/scripts/lib/locales/cs.mjs +78 -0
  34. package/scripts/lib/locales/da.mjs +78 -0
  35. package/scripts/lib/locales/de.mjs +78 -0
  36. package/scripts/lib/locales/el.mjs +78 -0
  37. package/scripts/lib/locales/en.mjs +78 -0
  38. package/scripts/lib/locales/es.mjs +78 -0
  39. package/scripts/lib/locales/et.mjs +78 -0
  40. package/scripts/lib/locales/fi.mjs +78 -0
  41. package/scripts/lib/locales/fil.mjs +78 -0
  42. package/scripts/lib/locales/fr.mjs +78 -0
  43. package/scripts/lib/locales/ga.mjs +78 -0
  44. package/scripts/lib/locales/ha.mjs +78 -0
  45. package/scripts/lib/locales/hr.mjs +78 -0
  46. package/scripts/lib/locales/hu.mjs +78 -0
  47. package/scripts/lib/locales/id.mjs +78 -0
  48. package/scripts/lib/locales/is.mjs +78 -0
  49. package/scripts/lib/locales/it.mjs +78 -0
  50. package/scripts/lib/locales/jv.mjs +78 -0
  51. package/scripts/lib/locales/lb.mjs +78 -0
  52. package/scripts/lib/locales/lt.mjs +78 -0
  53. package/scripts/lib/locales/lv.mjs +78 -0
  54. package/scripts/lib/locales/mk.mjs +78 -0
  55. package/scripts/lib/locales/mt.mjs +78 -0
  56. package/scripts/lib/locales/nb.mjs +78 -0
  57. package/scripts/lib/locales/nl.mjs +78 -0
  58. package/scripts/lib/locales/nn.mjs +78 -0
  59. package/scripts/lib/locales/pcm.mjs +78 -0
  60. package/scripts/lib/locales/pl.mjs +78 -0
  61. package/scripts/lib/locales/pt.mjs +78 -0
  62. package/scripts/lib/locales/ro.mjs +78 -0
  63. package/scripts/lib/locales/ru.mjs +78 -0
  64. package/scripts/lib/locales/sk.mjs +78 -0
  65. package/scripts/lib/locales/sl.mjs +78 -0
  66. package/scripts/lib/locales/sq.mjs +78 -0
  67. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  68. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  69. package/scripts/lib/locales/sv.mjs +78 -0
  70. package/scripts/lib/locales/sw.mjs +78 -0
  71. package/scripts/lib/locales/tr.mjs +78 -0
  72. package/scripts/lib/locales/uk.mjs +78 -0
  73. package/scripts/lib/locales/vi.mjs +78 -0
  74. package/scripts/lib/markdown-links.mjs +4 -25
  75. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  76. package/scripts/lib/markdown-notes.mjs +165 -0
  77. package/scripts/lib/navigation-model.mjs +1 -1
  78. package/scripts/lib/norna-markdown-blocks.mjs +123 -447
  79. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  80. package/scripts/lib/page-aliases.mjs +12 -1
  81. package/scripts/lib/page-markdown.mjs +63 -16
  82. package/scripts/lib/page-move-plan.mjs +22 -5
  83. package/scripts/lib/pagefind-translations.mjs +620 -0
  84. package/scripts/lib/presentation-contract.mjs +6 -1
  85. package/scripts/lib/presentation.mjs +29 -30
  86. package/scripts/lib/project-config.mjs +22 -163
  87. package/scripts/lib/schema-definitions.mjs +13 -12
  88. package/scripts/lib/schema-editor-metadata.mjs +42 -46
  89. package/scripts/lib/schema-value-definitions.mjs +7 -8
  90. package/scripts/lib/semantic-callouts.mjs +4 -2
  91. package/scripts/lib/site-content.mjs +4 -2
  92. package/scripts/lib/site-link-graph.mjs +16 -6
  93. package/scripts/lib/site-navigation-tree.mjs +0 -11
  94. package/scripts/lib/site-paths.mjs +14 -2
  95. package/scripts/lib/sitemap.mjs +5 -3
  96. package/scripts/lib/structured-blocks.mjs +108 -0
  97. package/scripts/lib/table-render-plugin.mjs +76 -0
  98. package/scripts/lib/table-row-headers.mjs +218 -0
  99. package/scripts/lib/theme-presets.mjs +3 -10
  100. package/scripts/lib/yaml-config.mjs +2 -6
  101. package/scripts/migrate-check.mjs +19 -0
  102. package/scripts/sync-content-sections.mjs +39 -10
  103. package/scripts/sync-site-public.mjs +7 -1
  104. package/src/components/CardList.astro +1 -0
  105. package/src/components/CodeBlockCopyScript.astro +49 -6
  106. package/src/components/ContentBlocks.astro +39 -0
  107. package/src/components/ContentTabsScript.astro +60 -0
  108. package/src/components/DisplaySettings.astro +17 -19
  109. package/src/components/ImageCarousel.astro +4 -1
  110. package/src/components/ImageStack.astro +34 -9
  111. package/src/components/ImageStackEnhancement.astro +331 -0
  112. package/src/components/NavigationPageTree.astro +1 -1
  113. package/src/components/NoteNavigationScript.astro +4 -0
  114. package/src/components/PageAliasRedirect.astro +5 -4
  115. package/src/components/PageContentsNavigation.astro +2 -3
  116. package/src/components/SearchNavigationScript.astro +4 -0
  117. package/src/components/SearchPage.astro +43 -2
  118. package/src/components/SectionNavigationScript.astro +86 -16
  119. package/src/components/SiteBreadcrumbs.astro +1 -1
  120. package/src/components/SiteNavigation.astro +98 -65
  121. package/src/components/SitePage.astro +73 -33
  122. package/src/components/SiteSection.astro +5 -81
  123. package/src/components/SiteTreeNavigation.astro +5 -2
  124. package/src/components/TableOverflowScript.astro +579 -0
  125. package/src/components/TopPageMenu.astro +47 -0
  126. package/src/components/TreeNavigationScript.astro +86 -23
  127. package/src/layouts/BaseLayout.astro +34 -12
  128. package/src/lib/generatedImages.ts +18 -0
  129. package/src/lib/navigationFollowing.ts +142 -0
  130. package/src/lib/noteNavigation.ts +50 -0
  131. package/src/lib/searchNavigation.ts +107 -0
  132. package/src/lib/sectionContent.ts +23 -116
  133. package/src/lib/siteNavigation.ts +0 -5
  134. package/src/lib/sitePages.ts +18 -4
  135. package/src/lib/table-sort-tooltip.ts +109 -0
  136. package/src/pages/[...slug].astro +16 -6
  137. package/src/styles/content.css +563 -26
  138. package/src/styles/foundations.css +9 -0
  139. package/src/styles/media.css +192 -3
  140. package/src/styles/navigation.css +214 -60
  141. package/src/styles/page-layout.css +55 -16
  142. package/src/styles/responsive.css +62 -20
@@ -62,6 +62,7 @@ const formatDiagnostic = ({ code, fix, label, line, message, offset }) => ({
62
62
  line,
63
63
  message: `${label} line ${line}: ${message}`,
64
64
  offset,
65
+ severity: 'error',
65
66
  });
66
67
 
67
68
  export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
@@ -101,7 +102,8 @@ export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
101
102
  issue = {
102
103
  code: 'unknown-semantic-callout-type',
103
104
  message: `Unknown semantic callout type "${marker.rawType}".`,
104
- fix: `Use one of: ${semanticCalloutTypes.join(', ')}.`,
105
+ fix: `Use one of: ${semanticCalloutTypes.join(', ')}. The block will remain a neutral blockquote until then.`,
106
+ severity: 'warning',
105
107
  };
106
108
  } else if (marker.title) {
107
109
  issue = {
@@ -117,7 +119,7 @@ export const getSemanticCalloutDiagnostics = (tree, options = {}) => {
117
119
  };
118
120
  }
119
121
 
120
- if (issue) diagnostics.push(formatDiagnostic({ ...issue, label, line, offset }));
122
+ if (issue) diagnostics.push({ ...formatDiagnostic({ ...issue, label, line, offset }), severity: issue.severity ?? 'error' });
121
123
  }
122
124
 
123
125
  for (const child of node.children ?? []) visit(child, nextBlockquoteDepth);
@@ -67,7 +67,6 @@ const knownNestedFrontmatterKeys = new Set([
67
67
  'palette',
68
68
  'preset',
69
69
  'profile',
70
- 'readerControls',
71
70
  'readingWidth',
72
71
  'rhythm',
73
72
  'sectionGap',
@@ -171,7 +170,10 @@ export const validateFrontmatterStructure = (frontmatter, addIssue, {
171
170
  fix = fileKind === 'page theme'
172
171
  ? 'Remove "colorMode:" from this page theme. Set "appearance:" in the root theme.yaml when an override is needed.'
173
172
  : 'Replace "colorMode:" with "appearance:".';
174
- } else if (fileKind === 'page theme' && ['preset', 'appearance', 'readerControls', 'corners', 'palette', 'typography'].includes(key)) {
173
+ } else if ((fileKind === 'theme' || fileKind === 'page theme') && key === 'readerControls') {
174
+ message = `Frontmatter line ${lineNumber}: Theme setting "readerControls" was removed. Appearance and reading width are always available; Focus reading follows tree navigation.`;
175
+ fix = 'Remove "readerControls:". Use "appearance.default" for the initial Appearance; reading width is always available and tree navigation provides Focus reading.';
176
+ } else if (fileKind === 'page theme' && ['preset', 'appearance', 'corners', 'palette', 'typography'].includes(key)) {
175
177
  message = `Frontmatter line ${lineNumber}: page themes may not define site-wide visual identity through "${key}".`;
176
178
  fix = `Move "${key}:" to the root theme.yaml. Page themes may set only layout.textWidth, layout.contentSpacing, images, and sections.backgroundPattern.`;
177
179
  } else if ((fileKind === 'theme' || fileKind === 'page theme') && ['logo', 'site'].includes(key)) {
@@ -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),
@@ -5,6 +5,7 @@ import { homePageDirectory } from './site-conventions.mjs';
5
5
 
6
6
  const siteDirectoryEnvName = 'NORNA_SITE_DIR';
7
7
  const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
8
+ const stateDirectoryEnvName = 'NORNA_INTERNAL_STATE_DIR';
8
9
  const defaultSiteDirectory = 'site';
9
10
  export { homePageDirectory };
10
11
 
@@ -16,12 +17,19 @@ export const siteDirectoryEnv = siteDirectoryEnvName;
16
17
 
17
18
  const normalizeSiteDirectory = (value) => String(value ?? '').trim();
18
19
  const normalizeInvocationRoot = (value) => String(value ?? '').trim();
20
+ const normalizeStateDirectory = (value) => String(value ?? '').trim();
19
21
 
20
22
  const configuredInvocationRoot = normalizeInvocationRoot(process.env[invocationRootEnvName]);
21
23
  export const invocationRoot = configuredInvocationRoot
22
24
  ? path.resolve(configuredInvocationRoot)
23
25
  : process.cwd();
24
26
 
27
+ const hasStateDirectoryEnv = Object.hasOwn(process.env, stateDirectoryEnvName);
28
+ const configuredStateDirectory = normalizeStateDirectory(process.env[stateDirectoryEnvName]);
29
+ if (hasStateDirectoryEnv && !configuredStateDirectory) {
30
+ throw new Error(`${stateDirectoryEnvName} must not be empty.`);
31
+ }
32
+
25
33
  const hasSiteFilesInDirectory = (siteDir) => {
26
34
  return (
27
35
  existsSync(path.join(siteDir, 'config.yaml'))
@@ -126,9 +134,13 @@ export const siteHomePageDir = path.join(sitePagesDir, homePageDirectory);
126
134
  export const siteContentPath = path.join(siteHomePageDir, 'content.md');
127
135
  export const siteImagesDir = path.join(siteHomePageDir, 'images');
128
136
  export const sitePublicDir = path.join(siteDir, 'public');
129
- export const siteStateDir = path.join(siteDir, '.norna');
137
+ export const siteStateDir = configuredStateDirectory
138
+ ? path.resolve(configuredStateDirectory)
139
+ : path.join(siteDir, '.norna');
130
140
  export const astroPublicDir = path.join(siteStateDir, 'public');
131
- export const astroDistDir = path.join(siteProjectRoot, 'dist');
141
+ export const astroDistDir = configuredStateDirectory
142
+ ? path.join(siteStateDir, 'dist')
143
+ : path.join(siteProjectRoot, 'dist');
132
144
  export const astroRootDir = siteStateDir;
133
145
  export const astroCacheDir = path.join(astroRootDir, '.astro');
134
146
  export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
@@ -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
+ };
@@ -0,0 +1,76 @@
1
+ import { defineHastPlugin } from 'satteri';
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
+
43
+ export const nornaTableRenderPlugin = defineHastPlugin({
44
+ name: 'norna-table-render',
45
+ element: {
46
+ filter: ['table'],
47
+ visit(node, context) {
48
+ const hasRowHeaders = node.properties?.['data-row-headers'] === 'true';
49
+ const table = hasRowHeaders ? transformRowHeaderTable(node) : node;
50
+ context.replaceNode(node, {
51
+ type: 'element',
52
+ tagName: 'div',
53
+ properties: {
54
+ className: ['norna-table-frame', 'content-block-note-lane-boundary'],
55
+ 'data-table-frame': '',
56
+ ...(hasRowHeaders ? { 'data-table-row-headers': 'true' } : {}),
57
+ },
58
+ children: [{
59
+ type: 'element',
60
+ tagName: 'div',
61
+ properties: {
62
+ className: ['norna-table-scroll'],
63
+ 'data-table-scroll': '',
64
+ tabIndex: 0,
65
+ },
66
+ children: [{
67
+ type: 'element',
68
+ tagName: table.tagName,
69
+ properties: { ...(table.properties ?? {}) },
70
+ children: [...(table.children ?? [])],
71
+ }],
72
+ }],
73
+ });
74
+ },
75
+ },
76
+ });
@@ -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;