@janga/norna 0.7.25 → 0.7.27

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 (137) hide show
  1. package/README.md +18 -10
  2. package/astro.config.mjs +26 -12
  3. package/bin/norna-cli.mjs +14 -2
  4. package/package.json +14 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +4 -4
  7. package/schemas/config.schema.json +306 -32
  8. package/schemas/content-frontmatter.schema.json +8 -8
  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/add-site-node.mjs +1 -1
  16. package/scripts/dev-local.mjs +24 -2
  17. package/scripts/generate-schemas.mjs +15 -3
  18. package/scripts/lib/category-destinations.mjs +48 -0
  19. package/scripts/lib/code-fence-metadata.mjs +14 -37
  20. package/scripts/lib/content-tabs.mjs +155 -0
  21. package/scripts/lib/details-headings.mjs +58 -0
  22. package/scripts/lib/documentation-links.mjs +33 -2
  23. package/scripts/lib/documentation-routes.json +222 -0
  24. package/scripts/lib/docusaurus-migration-audit.mjs +603 -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/norna-markdown-blocks.mjs +32 -447
  78. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  79. package/scripts/lib/page-aliases.mjs +12 -1
  80. package/scripts/lib/page-markdown.mjs +63 -16
  81. package/scripts/lib/page-model.mjs +22 -0
  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 +2 -173
  87. package/scripts/lib/schema-definitions.mjs +6 -13
  88. package/scripts/lib/schema-editor-metadata.mjs +23 -44
  89. package/scripts/lib/schema-value-definitions.mjs +4 -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/sitemap.mjs +5 -3
  95. package/scripts/lib/structured-blocks.mjs +108 -0
  96. package/scripts/lib/table-render-plugin.mjs +46 -3
  97. package/scripts/lib/table-row-headers.mjs +218 -0
  98. package/scripts/lib/theme-presets.mjs +3 -10
  99. package/scripts/lib/yaml-config.mjs +2 -6
  100. package/scripts/migrate-check.mjs +19 -0
  101. package/scripts/sync-content-sections.mjs +39 -10
  102. package/scripts/sync-site-public.mjs +7 -1
  103. package/src/components/CodeBlockCopyScript.astro +47 -5
  104. package/src/components/ContentBlocks.astro +39 -0
  105. package/src/components/ContentTabsScript.astro +60 -0
  106. package/src/components/DisplaySettings.astro +17 -19
  107. package/src/components/NavigationPageTree.astro +1 -1
  108. package/src/components/NoteNavigationScript.astro +4 -0
  109. package/src/components/PageAliasRedirect.astro +49 -7
  110. package/src/components/SearchNavigationScript.astro +4 -0
  111. package/src/components/SearchPage.astro +43 -2
  112. package/src/components/SectionNavigationScript.astro +43 -11
  113. package/src/components/SiteBreadcrumbs.astro +1 -1
  114. package/src/components/SiteNavigation.astro +29 -20
  115. package/src/components/SitePage.astro +40 -15
  116. package/src/components/SiteSection.astro +5 -81
  117. package/src/components/SiteTreeNavigation.astro +2 -2
  118. package/src/components/TableOverflowScript.astro +386 -58
  119. package/src/components/TopPageMenu.astro +47 -0
  120. package/src/components/TreeNavigationScript.astro +10 -3
  121. package/src/content.config.ts +5 -8
  122. package/src/layouts/BaseLayout.astro +8 -12
  123. package/src/lib/navigationFollowing.ts +142 -0
  124. package/src/lib/noteNavigation.ts +50 -0
  125. package/src/lib/searchNavigation.ts +107 -0
  126. package/src/lib/sectionContent.ts +20 -90
  127. package/src/lib/siteNavigation.ts +0 -5
  128. package/src/lib/sitePages.ts +17 -20
  129. package/src/lib/table-sort-tooltip.ts +109 -0
  130. package/src/pages/[...slug].astro +16 -6
  131. package/src/styles/content.css +397 -134
  132. package/src/styles/foundations.css +9 -0
  133. package/src/styles/media.css +5 -0
  134. package/src/styles/navigation.css +63 -74
  135. package/src/styles/page-layout.css +7 -0
  136. package/src/styles/responsive.css +2 -7
  137. package/starters/basic/README.md +1 -1
@@ -0,0 +1,165 @@
1
+ import { markdownToMdast } from 'satteri';
2
+ import { parseContentTabs } from './content-tabs.mjs';
3
+
4
+ const features = { gfm: true, frontmatter: true };
5
+ const walk = (node, visit, ancestors = []) => {
6
+ visit(node, ancestors);
7
+ for (const child of node.children ?? []) walk(child, visit, [...ancestors, node]);
8
+ };
9
+ const start = (node) => node.position?.start.offset ?? 0;
10
+ const end = (node) => node.position?.end.offset ?? 0;
11
+ const isMargin = (identifier) => identifier.startsWith('margin:');
12
+ const inlineTypes = new Set(['text', 'emphasis', 'strong', 'link', 'linkReference', 'inlineCode']);
13
+ const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
14
+
15
+ export const noteMarker = (index) => {
16
+ let result = '';
17
+ for (let value = index + 1; value > 0; value = Math.floor((value - 1) / 26)) {
18
+ result = String.fromCharCode(97 + (value - 1) % 26) + result;
19
+ }
20
+ return result;
21
+ };
22
+
23
+ // Satteri owns identity: its AST case-folds names (including ss/eszett), but
24
+ // does not NFC-normalize Unicode. Hex is injective and ':' cannot be a heading id.
25
+ export const noteTargetKey = (identifier) => Buffer.from(identifier, 'utf8').toString('hex');
26
+
27
+ const unescapedMatches = (source, pattern) => [...source.matchAll(pattern)].filter((match) => {
28
+ let slashes = 0;
29
+ for (let index = match.index - 1; index >= 0 && source[index] === '\\'; index -= 1) slashes += 1;
30
+ return slashes % 2 === 0;
31
+ });
32
+
33
+ export const extractInlineNoteDiagnostics = (markdown, options = {}) => {
34
+ const source = String(markdown).replace(/\r\n?/g, '\n');
35
+ const tabs = options.tabResult ?? parseContentTabs(source, options);
36
+ const parsedSource = tabs.maskedSource;
37
+ const tree = markdownToMdast(parsedSource, { features });
38
+ const diagnostics = [];
39
+ const definitions = new Map();
40
+ const candidates = new Set();
41
+ const htmlScopes = [];
42
+ const htmlStack = [];
43
+ const colonMarkers = [];
44
+ const lineAt = (offset) => (options.lineOffset ?? 0) + source.slice(0, offset).split('\n').length;
45
+ const issue = (code, message, node, severity = 'error') => {
46
+ const offset = typeof node === 'number' ? node : start(node);
47
+ const line = lineAt(offset);
48
+ diagnostics.push({ code, severity, offset, line, blockType: 'inline-note', message: `${options.label ?? 'Markdown'} line ${line}: ${message}` });
49
+ };
50
+ const checkName = (node) => {
51
+ if (!isMargin(node.identifier)) return;
52
+ if (!node.label.startsWith('margin:')) issue('invalid-note-prefix', 'Write the reserved sidenote prefix in lowercase: "[^margin:name]".', node);
53
+ if (node.identifier === 'margin:') issue('empty-note-name', 'Add a nonempty name after "margin:".', node);
54
+ };
55
+
56
+ walk(tree, (node, ancestors) => {
57
+ if (node.type === 'html') {
58
+ const value = node.value.replace(/<!--[\s\S]*?-->/g, (match) => ' '.repeat(match.length));
59
+ for (const match of value.matchAll(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi)) {
60
+ const tag = match[1].toLowerCase();
61
+ if (match[0].startsWith('</')) {
62
+ const index = htmlStack.findLastIndex((entry) => entry.tag === tag);
63
+ if (index >= 0) for (const entry of htmlStack.splice(index)) htmlScopes.push({ start: entry.start, end: start(node) + match.index + match[0].length });
64
+ } else if (!voidTags.has(tag) && !match[0].endsWith('/>')) htmlStack.push({ tag, start: start(node) + match.index });
65
+ }
66
+ }
67
+ if (node.type === 'text') {
68
+ const raw = parsedSource.slice(start(node), end(node));
69
+ for (const match of raw.matchAll(/^ {0,3}:{3,}(?:[ \t]+([^\n]+?))?[ \t]*$/gm)) {
70
+ colonMarkers.push({ start: start(node) + match.index, opening: Boolean(match[1]) });
71
+ }
72
+ for (const match of unescapedMatches(raw, /\[\^([^\]\r\n]+)\]/g)) candidates.add(match[1]);
73
+ for (const match of unescapedMatches(raw, /\{note(?:-ref\}|\s*:)/g)) {
74
+ issue('removed-positional-note', 'Positional notes are no longer supported. Use "[^margin:name]" with a matching top-level definition.', start(node) + match.index);
75
+ }
76
+ }
77
+ if (node.type !== 'footnoteDefinition') return;
78
+ checkName(node);
79
+ if (definitions.has(node.identifier)) issue('duplicate-note-definition', `Duplicate definition for "[^${node.label}]". Keep one definition per name.`, node);
80
+ else definitions.set(node.identifier, { node, ancestors, references: [] });
81
+ });
82
+ for (const entry of htmlStack) htmlScopes.push({ start: entry.start, end: source.length });
83
+ const colonStack = [];
84
+ for (const marker of colonMarkers) {
85
+ if (marker.opening) colonStack.push(marker.start);
86
+ else if (colonStack.length) htmlScopes.push({ start: colonStack.pop(), end: marker.start });
87
+ }
88
+ for (const offset of colonStack) htmlScopes.push({ start: offset, end: source.length });
89
+ const inContainer = (node) => htmlScopes.some((scope) => start(node) >= scope.start && start(node) < scope.end)
90
+ || tabs.groups.some((group) => start(node) >= group.start && start(node) < (group.end ?? source.length));
91
+
92
+ // Undefined references are text in MDAST. Supply temporary definitions and
93
+ // parse again so escaped text, code, and identifier matching still belong to
94
+ // Satteri, rather than treating every bracket-shaped string as a reference.
95
+ const referenceTree = candidates.size ? markdownToMdast(`${parsedSource}\n\n${[...candidates].map((name) => `[^${name}]: norna-note-probe`).join('\n')}`, { features }) : tree;
96
+ const references = [];
97
+ walk(referenceTree, (node, ancestors) => {
98
+ if (node.type !== 'footnoteReference' || start(node) >= source.length) return;
99
+ checkName(node);
100
+ const reference = { node, ancestors };
101
+ references.push(reference);
102
+ const definition = definitions.get(node.identifier);
103
+ if (!definition) issue('missing-note-definition', `Missing definition for "[^${node.label}]". Add its definition at the page's top level.`, node);
104
+ else definition.references.push(reference);
105
+ if (isMargin(node.identifier) && (inContainer(node) || ancestors[1]?.type !== 'paragraph'
106
+ || ancestors.some((ancestor) => !['root', 'paragraph', 'emphasis', 'strong', 'delete'].includes(ancestor.type)))) {
107
+ issue('invalid-sidenote-placement', 'Sidenote references belong in ordinary body paragraphs, outside headings, lists, tables, tabs, and other containers.', node);
108
+ }
109
+ });
110
+
111
+ for (const { node, ancestors, references: uses } of definitions.values()) {
112
+ if (ancestors.length !== 1 || inContainer(node)) issue('invalid-note-definition-placement', 'Note definitions belong at the page\'s top level, outside all containers.', node);
113
+ if (uses.length === 0) issue('unused-note-definition', `The definition "[^${node.label}]" is unused. Reference it or remove it.`, node, 'warning');
114
+ if (!isMargin(node.identifier)) continue;
115
+ for (const reference of uses.slice(1)) issue('reused-sidenote', `Sidenote "[^${node.label}]" may be referenced only once. Use an ordinary footnote for reusable material.`, reference.node);
116
+ let valid = node.children.length === 1 && node.children[0].type === 'paragraph';
117
+ for (const child of node.children[0]?.children ?? []) walk(child, (inline) => {
118
+ if (!inlineTypes.has(inline.type)) valid = false;
119
+ if (inline.type === 'text' && /^\s*:{3,}/m.test(inline.value)) valid = false;
120
+ });
121
+ // The augmented tree exposes unresolved nested note references as well.
122
+ if (references.some((reference) => start(reference.node) >= start(node) && start(reference.node) < end(node))) valid = false;
123
+ if (!valid) issue('invalid-sidenote-body', 'A sidenote contains one paragraph with only text, emphasis, strong emphasis, links, and inline code. Move blocks, images, and note references into the page content.', node);
124
+ }
125
+
126
+ const notes = [];
127
+ for (const reference of references) {
128
+ const definition = definitions.get(reference.node.identifier);
129
+ if (!definition || !isMargin(reference.node.identifier) || definition.references[0] !== reference) continue;
130
+ const node = definition.node;
131
+ const paragraph = node.children[0];
132
+ const key = noteTargetKey(node.identifier);
133
+ notes.push({
134
+ identifier: node.identifier,
135
+ marker: noteMarker(notes.length),
136
+ id: `norna-note:margin:${key}`,
137
+ referenceId: `norna-note:ref:${key}`,
138
+ referenceOffset: start(reference.node),
139
+ referenceLine: lineAt(start(reference.node)),
140
+ noteLine: lineAt(start(node)),
141
+ markdown: paragraph ? source.slice(start(paragraph), end(paragraph)).replace(/\n {1,4}/g, '\n') : '',
142
+ });
143
+ }
144
+ return { notes, references: references.map(({ node }) => node), definitions: [...definitions.values()].map(({ node }) => node), diagnostics,
145
+ errors: diagnostics.filter((item) => item.severity === 'error'), warnings: diagnostics.filter((item) => item.severity === 'warning') };
146
+ };
147
+
148
+ export const getStructuredSidenoteDiagnostics = (blocks, { label = 'Markdown', regionOffset = 0 } = {}) => {
149
+ const diagnostics = [];
150
+ for (const block of blocks) for (const item of block.images ?? block.cards ?? []) {
151
+ for (const field of ['caption', 'alt', 'title', 'text', 'badge-text']) {
152
+ const value = item[field];
153
+ if (typeof value !== 'string' || !value.includes('[^')) continue;
154
+ const parsed = extractInlineNoteDiagnostics(value);
155
+ if (!parsed.references.some((reference) => isMargin(reference.identifier))) continue;
156
+ const line = item.line ?? block.line;
157
+ diagnostics.push({
158
+ code: 'invalid-sidenote-placement', severity: 'error', line,
159
+ offset: regionOffset + block.startOffset, blockType: 'inline-note',
160
+ message: `${label} line ${line}: Sidenote references are not allowed in the "${field}" field of ${block.type}. Put the reference in an ordinary body paragraph.`,
161
+ });
162
+ }
163
+ }
164
+ return diagnostics;
165
+ };