@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
@@ -0,0 +1,603 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { load as loadYaml } from 'js-yaml';
7
+ import { markdownToMdast } from 'satteri';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const repoRoot = path.resolve(import.meta.dirname, '..', '..');
11
+ const packageJsonPath = path.join(repoRoot, 'package.json');
12
+ const supportedCallouts = new Set(['info', 'note', 'tip', 'important', 'warning', 'caution', 'danger']);
13
+ const ignoredDirectoryNames = new Set(['.git', 'node_modules', 'build', '.docusaurus', 'dist', 'cache']);
14
+ const sourceFilePattern = /\.(?:md|mdx)$/i;
15
+ const navigationFilePattern = /(?:^|\/)(?:docusaurus\.config\.[^/]+|sidebars\.[^/]+|versions\.json)$/i;
16
+ const pageClassificationOrder = ['unresolved', 'model-gap', 'feature-gap', 'assist', 'out-of-scope', 'rewrite', 'copy'];
17
+
18
+ const toPosixPath = (value) => value.split(path.sep).join('/');
19
+ const isDirectory = async (directory) => access(directory).then(() => true, () => false);
20
+ const isWithin = (parent, child) => {
21
+ const relative = path.relative(parent, child);
22
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
23
+ };
24
+
25
+ const normalizeRepositoryUrl = (value) => {
26
+ if (!value) return null;
27
+ const source = value.trim().replace(/\.git$/, '');
28
+ if (source.startsWith('git@github.com:')) return 'https://github.com/' + source.slice('git@github.com:'.length);
29
+ if (source.startsWith('ssh://git@github.com/')) return 'https://github.com/' + source.slice('ssh://git@github.com/'.length);
30
+ if (/^https?:\/\/github\.com\//i.test(source)) return source.replace(/\/$/, '');
31
+ return null;
32
+ };
33
+
34
+ const readGitValue = async (sourceRoot, args) => {
35
+ try {
36
+ const result = await execFileAsync('git', ['-C', sourceRoot, ...args], { maxBuffer: 1024 * 1024 });
37
+ return result.stdout.trim() || null;
38
+ } catch (error) {
39
+ if (error.status === 128 || error.code === 'ENOENT') return null;
40
+ throw error;
41
+ }
42
+ };
43
+
44
+ const getGitMetadata = async (sourceRoot) => {
45
+ const [revision, repository, commitDate] = await Promise.all([
46
+ readGitValue(sourceRoot, ['rev-parse', 'HEAD']),
47
+ readGitValue(sourceRoot, ['config', '--get', 'remote.origin.url']),
48
+ readGitValue(sourceRoot, ['show', '-s', '--format=%cI', 'HEAD']),
49
+ ]);
50
+ return { commitDate, repository: normalizeRepositoryUrl(repository), revision };
51
+ };
52
+
53
+ const collectFiles = async (directory, predicate = () => true) => {
54
+ const entries = (await readdir(directory, { withFileTypes: true }))
55
+ .sort((left, right) => left.name.localeCompare(right.name, 'en'));
56
+ const files = [];
57
+ for (const entry of entries) {
58
+ if (entry.isDirectory() && ignoredDirectoryNames.has(entry.name)) continue;
59
+ const entryPath = path.join(directory, entry.name);
60
+ if (entry.isDirectory()) files.push(...await collectFiles(entryPath, predicate));
61
+ else if (predicate(entryPath, entry.name)) files.push(entryPath);
62
+ }
63
+ return files;
64
+ };
65
+
66
+ const discoverDocsRoots = async (sourceRoot) => {
67
+ const candidates = [
68
+ ['website/docs', 'current'],
69
+ ['docs', 'current'],
70
+ ['website/versioned_docs', 'versioned'],
71
+ ['versioned_docs', 'versioned'],
72
+ ];
73
+ const roots = [];
74
+ for (const [relativePath, kind] of candidates) {
75
+ const directory = path.join(sourceRoot, relativePath);
76
+ if (await isDirectory(directory) && !roots.some((root) => root.directory === directory)) {
77
+ roots.push({ directory, kind, relativePath });
78
+ }
79
+ }
80
+ return roots;
81
+ };
82
+
83
+ const getVersionFromPath = (relativePath, root) => root.kind === 'versioned'
84
+ ? relativePath.split('/').find((segment) => segment.startsWith('version-')) ?? 'versioned'
85
+ : 'current';
86
+
87
+ const parseFrontmatter = (source) => {
88
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
89
+ if (!match) return { data: {}, present: false };
90
+ try {
91
+ const data = loadYaml(match[1]) ?? {};
92
+ return {
93
+ data: data && typeof data === 'object' && !Array.isArray(data) ? data : {},
94
+ present: true,
95
+ };
96
+ } catch (error) {
97
+ return {
98
+ data: {},
99
+ error: error instanceof Error ? error.message : String(error),
100
+ present: true,
101
+ };
102
+ }
103
+ };
104
+
105
+ const nodeRange = (node) => ({
106
+ line: node.position?.start.line ?? 1,
107
+ start: node.position?.start.offset ?? 0,
108
+ end: node.position?.end.offset ?? node.position?.start.offset ?? 0,
109
+ });
110
+
111
+ const visit = (node, callback) => {
112
+ if (!node || typeof node !== 'object') return;
113
+ callback(node);
114
+ for (const child of node.children ?? []) visit(child, callback);
115
+ };
116
+
117
+ const isInRange = (offset, ranges) => ranges.some((range) => offset >= range.start && offset < range.end);
118
+ const findingPriority = (classification) => pageClassificationOrder.indexOf(classification);
119
+ const getPageClassification = (findings) => findings.length === 0
120
+ ? 'copy'
121
+ : findings.reduce((best, finding) => findingPriority(finding.classification) < findingPriority(best)
122
+ ? finding.classification
123
+ : best, 'copy');
124
+
125
+ const createFindingCollector = (relativePath) => {
126
+ const findings = [];
127
+ const seen = new Set();
128
+ return {
129
+ add({ code, classification, line = 1, message, action }) {
130
+ const key = code + ':' + line;
131
+ if (seen.has(key)) return;
132
+ seen.add(key);
133
+ findings.push({ action, classification, code, line, message, sourceFile: relativePath });
134
+ },
135
+ list() {
136
+ return findings.sort((left, right) => left.line - right.line || left.code.localeCompare(right.code));
137
+ },
138
+ };
139
+ };
140
+
141
+ const inspectHtmlNode = (node, collector) => {
142
+ const value = String(node.value ?? '');
143
+ const line = nodeRange(node).line;
144
+ const knownComponents = [
145
+ ['Tabs', 'content-tabs', 'assist', 'Convert the alternatives to Norna content tabs and keep headings outside the tab group.'],
146
+ ['TabItem', 'content-tabs', 'assist', 'Convert each alternative to a labelled Norna tab and review the grouping.'],
147
+ ['DocCardList', 'generated-child-list', 'assist', 'Review whether the source collection should become a Norna page-list block.'],
148
+ ['Details', 'native-details', 'rewrite', 'Convert the component to native HTML details with a concise summary.'],
149
+ ['Admonition', 'semantic-callout', 'assist', 'Map the meaning to a closed Norna callout type and review the title.'],
150
+ ['BrowserWindow', 'presentation-wrapper', 'assist', 'Remove the source-only wrapper and retain its readable content.'],
151
+ ['ThemedImage', 'themed-image', 'assist', 'Choose one managed Norna image or provide explicit image alternatives.'],
152
+ ];
153
+ for (const [component, code, classification, action] of knownComponents) {
154
+ if (new RegExp('<' + component + '\\b|</' + component + '>').test(value)) {
155
+ collector.add({
156
+ action,
157
+ classification,
158
+ code,
159
+ line,
160
+ message: 'The source uses the Docusaurus ' + component + ' component.',
161
+ });
162
+ }
163
+ }
164
+ if (/<[A-Z][A-Za-z0-9]*(?:\s|>|\/)/.test(value)
165
+ && !knownComponents.some(([component]) => value.includes('<' + component))) {
166
+ collector.add({
167
+ action: 'Replace the component with supported Markdown or a reviewed static fallback.',
168
+ classification: 'feature-gap',
169
+ code: 'unsupported-mdx-component',
170
+ line,
171
+ message: 'The source uses an MDX component without a known Norna equivalent.',
172
+ });
173
+ }
174
+ };
175
+
176
+ const inspectFrontmatter = (data, collector) => {
177
+ const directReview = new Set(['slug', 'sidebar_label', 'sidebar_position', 'pagination_next', 'pagination_prev']);
178
+ const presentationGap = new Set(['hide_title', 'hide_table_of_contents', 'toc_min_heading_level', 'toc_max_heading_level', 'image', 'keywords']);
179
+ for (const key of Object.keys(data)) {
180
+ if (['title', 'description'].includes(key)) continue;
181
+ const normalizedKey = key.replaceAll('_', '-');
182
+ if (directReview.has(key)) {
183
+ collector.add({
184
+ action: 'Review the field and express its meaning through Norna page structure or supported metadata.',
185
+ classification: 'assist',
186
+ code: 'frontmatter-' + normalizedKey,
187
+ message: 'Docusaurus frontmatter field "' + key + '" has no guaranteed one-to-one Norna mapping.',
188
+ });
189
+ } else if (presentationGap.has(key)) {
190
+ collector.add({
191
+ action: 'Keep the information explicitly in the report and choose a supported Norna presentation.',
192
+ classification: 'feature-gap',
193
+ code: 'frontmatter-' + normalizedKey,
194
+ message: 'Docusaurus frontmatter field "' + key + '" controls source-specific presentation or metadata.',
195
+ });
196
+ } else {
197
+ collector.add({
198
+ action: 'Review the field instead of copying it into Norna configuration automatically.',
199
+ classification: 'assist',
200
+ code: 'frontmatter-' + normalizedKey,
201
+ message: 'Docusaurus frontmatter field "' + key + '" needs semantic review.',
202
+ });
203
+ }
204
+ }
205
+ };
206
+
207
+ const inspectSourceLines = (source, codeRanges, collector) => {
208
+ let offset = 0;
209
+ for (const [index, line] of source.split('\n').entries()) {
210
+ const lineNumber = index + 1;
211
+ if (!isInRange(offset, codeRanges)) {
212
+ const callout = line.match(/^\s*:::\s*([a-z][a-z0-9-]*)(?:\[([^\]]*)\])?/i);
213
+ if (callout) {
214
+ const type = callout[1].toLowerCase();
215
+ const supported = supportedCallouts.has(type);
216
+ collector.add({
217
+ action: supported
218
+ ? 'Convert the meaning to Norna GitHub-style semantic callout syntax.'
219
+ : 'Map the meaning to a supported Norna callout or retain a readable Markdown fallback.',
220
+ classification: supported ? 'rewrite' : 'assist',
221
+ code: supported ? 'semantic-callout' : 'custom-container',
222
+ line: lineNumber,
223
+ message: supported
224
+ ? 'The source uses the Docusaurus ' + type + ' admonition.'
225
+ : 'The source uses a custom Docusaurus container named ' + type + '.',
226
+ });
227
+ }
228
+ if (/^\s*(?:import|export)\s+/.test(line)) {
229
+ collector.add({
230
+ action: 'Do not execute it; remove the statement and review the content it provided.',
231
+ classification: 'out-of-scope',
232
+ code: 'mdx-module-statement',
233
+ line: lineNumber,
234
+ message: 'The source contains an MDX module statement.',
235
+ });
236
+ }
237
+ if (/\{\s*[A-Za-z_$][\w$]*(?:\.|\s*[+\-*/])/u.test(line)) {
238
+ collector.add({
239
+ action: 'Replace it with explicit Markdown content or a reviewed static fallback.',
240
+ classification: 'feature-gap',
241
+ code: 'mdx-expression',
242
+ line: lineNumber,
243
+ message: 'The source contains an MDX expression that requires JavaScript evaluation.',
244
+ });
245
+ }
246
+ }
247
+ offset += line.length + 1;
248
+ }
249
+ };
250
+
251
+ const inspectCodeNodes = (tree, collector) => {
252
+ visit(tree, (node) => {
253
+ if (node.type !== 'code' || !node.meta) return;
254
+ const metadata = String(node.meta);
255
+ const line = nodeRange(node).line;
256
+ if (/\b(?:live|playground|noInline|showLineNumbers)\b/i.test(metadata)) {
257
+ collector.add({
258
+ action: 'Retain the code as a static Norna example and document the lost interaction.',
259
+ classification: 'feature-gap',
260
+ code: 'interactive-code-block',
261
+ line,
262
+ message: 'The code block requests Docusaurus-specific interactive or display behavior.',
263
+ });
264
+ } else if (/title\s*=|\{\d/.test(metadata)) {
265
+ collector.add({
266
+ action: 'Normalize the title and highlighted-line metadata to Norna code-fence syntax.',
267
+ classification: 'rewrite',
268
+ code: 'rich-code-metadata',
269
+ line,
270
+ message: 'The code block uses metadata that has a supported Norna presentation equivalent.',
271
+ });
272
+ } else {
273
+ collector.add({
274
+ action: 'Review the metadata and retain the code in a supported static form.',
275
+ classification: 'assist',
276
+ code: 'unsupported-code-metadata',
277
+ line,
278
+ message: 'The code block uses Docusaurus-specific metadata.',
279
+ });
280
+ }
281
+ });
282
+ };
283
+
284
+ const inspectPage = async ({ absolutePath, relativePath, root }) => {
285
+ const source = await readFile(absolutePath, 'utf8');
286
+ const frontmatter = parseFrontmatter(source);
287
+ const collector = createFindingCollector(relativePath);
288
+ if (frontmatter.error) {
289
+ collector.add({
290
+ action: 'Review the frontmatter manually before migration.',
291
+ classification: 'unresolved',
292
+ code: 'invalid-frontmatter',
293
+ message: 'Frontmatter could not be parsed without executing the source project.',
294
+ });
295
+ }
296
+ let tree;
297
+ try {
298
+ tree = await markdownToMdast(source, { features: { frontmatter: true, gfm: true } });
299
+ } catch {
300
+ collector.add({
301
+ action: 'Review the source syntax and choose a supported Norna fallback.',
302
+ classification: 'unresolved',
303
+ code: 'unparseable-markdown',
304
+ message: 'The source document could not be parsed as Markdown without running Docusaurus or MDX.',
305
+ });
306
+ return createPageResult({ frontmatter, relativePath, root, findings: collector.list() });
307
+ }
308
+ const headings = [];
309
+ const codeRanges = [];
310
+ visit(tree, (node) => {
311
+ if (node.type === 'heading') headings.push(node);
312
+ if (node.type === 'code') codeRanges.push(nodeRange(node));
313
+ if (node.type === 'image') {
314
+ collector.add({
315
+ action: 'Move the asset beside the Norna page and convert the reference to image-stack or image-carousel syntax.',
316
+ classification: 'rewrite',
317
+ code: 'managed-image-rewrite',
318
+ line: nodeRange(node).line,
319
+ message: 'A Markdown image needs conversion to a Norna-managed image block.',
320
+ });
321
+ }
322
+ if (node.type === 'html') inspectHtmlNode(node, collector);
323
+ });
324
+ const pageHeadings = headings.filter((heading) => heading.depth === 1);
325
+ if (pageHeadings.length === 0 && typeof frontmatter.data.title === 'string' && frontmatter.data.title.trim()) {
326
+ collector.add({
327
+ action: "Create Norna's single Markdown H1 from the frontmatter title.",
328
+ classification: 'rewrite',
329
+ code: 'frontmatter-page-title',
330
+ message: 'The page title is supplied by Docusaurus frontmatter rather than an H1.',
331
+ });
332
+ } else if (pageHeadings.length === 0) {
333
+ collector.add({
334
+ action: 'Choose the page title before writing the Norna page.',
335
+ classification: 'unresolved',
336
+ code: 'missing-page-title',
337
+ message: 'No H1 or frontmatter title identifies the source page.',
338
+ });
339
+ } else if (pageHeadings.length > 1) {
340
+ collector.add({
341
+ action: 'Keep one H1 and turn the remaining title into an appropriate section heading.',
342
+ classification: 'assist',
343
+ code: 'multiple-page-titles',
344
+ line: nodeRange(pageHeadings[1]).line,
345
+ message: 'The source contains more than one H1, while Norna requires one page title.',
346
+ });
347
+ }
348
+ inspectFrontmatter(frontmatter.data, collector);
349
+ inspectSourceLines(source, codeRanges, collector);
350
+ inspectCodeNodes(tree, collector);
351
+ return createPageResult({
352
+ frontmatter,
353
+ headings,
354
+ relativePath,
355
+ root,
356
+ findings: collector.list(),
357
+ });
358
+ };
359
+
360
+ const createPageResult = ({ frontmatter, headings = [], relativePath, root, findings }) => {
361
+ const title = typeof frontmatter.data.title === 'string' && frontmatter.data.title.trim()
362
+ ? frontmatter.data.title.trim()
363
+ : headings.find((heading) => heading.depth === 1)?.children?.map((child) => child.value ?? '').join('').trim()
364
+ || path.basename(relativePath).replace(/\.(?:md|mdx)$/i, '');
365
+ return {
366
+ classification: getPageClassification(findings),
367
+ documentationVersion: getVersionFromPath(relativePath, root),
368
+ findings,
369
+ relativePath,
370
+ title,
371
+ };
372
+ };
373
+
374
+ const classifyCounts = (items, selector) => Object.fromEntries(pageClassificationOrder.map((classification) => [
375
+ classification,
376
+ items.filter((item) => selector(item) === classification).length,
377
+ ]));
378
+
379
+ const problemKey = (finding) => [
380
+ finding.classification,
381
+ finding.code,
382
+ finding.message,
383
+ finding.action,
384
+ ].join('\u0000');
385
+
386
+ const aggregateProblemTypes = (pages) => {
387
+ const grouped = new Map();
388
+ for (const page of pages) {
389
+ for (const finding of page.findings) {
390
+ const key = problemKey(finding);
391
+ const group = grouped.get(key) ?? {
392
+ action: finding.action,
393
+ classification: finding.classification,
394
+ code: finding.code,
395
+ fingerprint: createHash('sha256').update(key).digest('hex'),
396
+ message: finding.message,
397
+ occurrences: [],
398
+ };
399
+ group.occurrences.push({
400
+ line: finding.line,
401
+ pageTitle: page.title,
402
+ sourceFile: finding.sourceFile,
403
+ });
404
+ grouped.set(key, group);
405
+ }
406
+ }
407
+ return [...grouped.values()]
408
+ .map((group) => {
409
+ const ranked = [...group.occurrences].sort((left, right) => {
410
+ const leftRank = createHash('sha256')
411
+ .update(group.fingerprint + '\u0000' + left.sourceFile + '\u0000' + left.line)
412
+ .digest('hex');
413
+ const rightRank = createHash('sha256')
414
+ .update(group.fingerprint + '\u0000' + right.sourceFile + '\u0000' + right.line)
415
+ .digest('hex');
416
+ return leftRank.localeCompare(rightRank);
417
+ });
418
+ const sample = ranked.slice(0, 20).sort((left, right) => (
419
+ left.sourceFile.localeCompare(right.sourceFile, 'en') || left.line - right.line
420
+ ));
421
+ return {
422
+ action: group.action,
423
+ classification: group.classification,
424
+ code: group.code,
425
+ fingerprint: group.fingerprint,
426
+ id: 'DM-' + group.fingerprint.slice(0, 12).toUpperCase(),
427
+ message: group.message,
428
+ occurrences: group.occurrences.length,
429
+ sample,
430
+ };
431
+ })
432
+ .sort((left, right) => right.occurrences - left.occurrences || left.id.localeCompare(right.id));
433
+ };
434
+
435
+ export const parseMigrationCheckArgs = (args, cwd = process.cwd()) => {
436
+ let sourceSystem = null;
437
+ let sourceRoot = null;
438
+ let reportDir = null;
439
+ let help = false;
440
+ for (let index = 0; index < args.length; index += 1) {
441
+ const arg = args[index];
442
+ if (arg === '--help' || arg === '-h') {
443
+ help = true;
444
+ continue;
445
+ }
446
+ const readOption = (name) => {
447
+ const value = arg === name
448
+ ? args[++index]
449
+ : arg.startsWith(name + '=') ? arg.slice(name.length + 1) : null;
450
+ if (!value || value.startsWith('-')) throw new Error(name + ' requires a value.');
451
+ return value;
452
+ };
453
+ if (arg === '--source' || arg.startsWith('--source=')) sourceSystem = readOption('--source');
454
+ else if (arg === '--report-dir' || arg.startsWith('--report-dir=')) reportDir = readOption('--report-dir');
455
+ else if (arg.startsWith('-')) throw new Error('Unknown migrate:check option "' + arg + '".');
456
+ else if (sourceRoot) throw new Error('migrate:check accepts exactly one Docusaurus project root.');
457
+ else sourceRoot = arg;
458
+ }
459
+ if (help) return { help: true };
460
+ if (sourceSystem !== 'docusaurus') throw new Error('migrate:check currently supports only --source docusaurus.');
461
+ if (!sourceRoot) throw new Error('migrate:check requires a Docusaurus project root.');
462
+ if (!reportDir) throw new Error('migrate:check requires --report-dir <empty-directory>.');
463
+ return {
464
+ reportDir: path.resolve(cwd, reportDir),
465
+ sourceRoot: path.resolve(cwd, sourceRoot),
466
+ sourceSystem,
467
+ };
468
+ };
469
+
470
+ export const migrationCheckUsage = 'Usage: norna migrate:check --source docusaurus <project-root> --report-dir <empty-directory>';
471
+
472
+ export const auditDocusaurusProject = async ({ sourceRoot, analysisTime = new Date().toISOString() }) => {
473
+ if (!(await isDirectory(sourceRoot))) throw new Error('Docusaurus project root does not exist: ' + sourceRoot);
474
+ const [docsRoots, git, packageSource] = await Promise.all([
475
+ discoverDocsRoots(sourceRoot),
476
+ getGitMetadata(sourceRoot),
477
+ readFile(packageJsonPath, 'utf8').then(JSON.parse),
478
+ ]);
479
+ if (docsRoots.length === 0) throw new Error('No Docusaurus docs directory found below ' + sourceRoot + '. Expected docs/ or website/docs/.');
480
+ const pages = [];
481
+ for (const root of docsRoots) {
482
+ const files = await collectFiles(root.directory, (absolutePath) => sourceFilePattern.test(absolutePath));
483
+ for (const absolutePath of files) {
484
+ const relativePath = toPosixPath(path.relative(sourceRoot, absolutePath));
485
+ pages.push(await inspectPage({ absolutePath, relativePath, root }));
486
+ }
487
+ }
488
+ pages.sort((left, right) => left.relativePath.localeCompare(right.relativePath, 'en'));
489
+ const allFiles = await collectFiles(sourceRoot);
490
+ const navigationFiles = allFiles
491
+ .filter((absolutePath) => navigationFilePattern.test(toPosixPath(path.relative(sourceRoot, absolutePath))))
492
+ .map((absolutePath) => toPosixPath(path.relative(sourceRoot, absolutePath)))
493
+ .sort((left, right) => left.localeCompare(right, 'en'));
494
+ const findings = pages.flatMap((page) => page.findings);
495
+ const problemTypes = aggregateProblemTypes(pages);
496
+ return {
497
+ analysis: { adapterVersion: 1, analyzedAt: analysisTime, nornaVersion: packageSource.version },
498
+ navigation: { files: navigationFiles, status: 'discovered-not-executed' },
499
+ pages,
500
+ problemTypes,
501
+ source: {
502
+ commitDate: git.commitDate,
503
+ documentationRoots: docsRoots.map(({ kind, relativePath }) => ({ kind, path: relativePath })),
504
+ repository: git.repository,
505
+ revision: git.revision,
506
+ system: 'docusaurus',
507
+ },
508
+ summary: {
509
+ findings: findings.length,
510
+ findingsByClassification: classifyCounts(findings, (finding) => finding.classification),
511
+ pages: pages.length,
512
+ pagesByClassification: classifyCounts(pages, (page) => page.classification),
513
+ pagesWithFindings: pages.filter((page) => page.findings.length > 0).length,
514
+ problemTypes: problemTypes.length,
515
+ },
516
+ };
517
+ };
518
+
519
+ const codeMark = String.fromCharCode(96);
520
+ const problemPageId = (problem) => problem.id.toLowerCase();
521
+ const problemDescription = (problem) => String(problem.message)
522
+ .replace(/[\r\n:|]/g, ' ')
523
+ .replace(/\.+$/, '')
524
+ .trim();
525
+ const sourceLink = (report, occurrence) => report.source.repository && report.source.revision
526
+ ? report.source.repository + '/blob/' + report.source.revision + '/' + occurrence.sourceFile + '#L' + occurrence.line
527
+ : null;
528
+
529
+ const renderReportHome = (report) => {
530
+ const sourceLabel = report.source.repository ? '[' + report.source.repository + '](' + report.source.repository + ')' : 'No public repository was detected.';
531
+ const revision = report.source.revision ? '\n\nSource revision: ' + codeMark + report.source.revision + codeMark + '.' : '';
532
+ const problemRows = report.problemTypes.map((problem) => (
533
+ '| [' + problem.id + '](problems/' + problemPageId(problem) + '/) | ' + problemDescription(problem) + ' | ' + problem.occurrences + ' | ' + problem.classification + ' |'
534
+ )).join('\n');
535
+ return '---\npage:\n description: Read-only Docusaurus migration audit generated by Norna.\n---\n\n'
536
+ + '# Docusaurus migration audit\n\n'
537
+ + 'This report inspects Docusaurus source without installing dependencies or executing MDX, React components, plugins, configuration, or the source website. It reports only pages and constructs that need a migration decision.\n\n'
538
+ + '## Summary\n\n| Measure | Value |\n| --- | ---: |\n'
539
+ + '| Source pages | ' + report.summary.pages + ' |\n'
540
+ + '| Pages requiring attention | ' + report.summary.pagesWithFindings + ' |\n'
541
+ + '| Findings | ' + report.summary.findings + ' |\n'
542
+ + '| Problem types | ' + report.summary.problemTypes + ' |\n'
543
+ + '| Adapter version | ' + report.analysis.adapterVersion + ' |\n'
544
+ + '| Norna version | ' + report.analysis.nornaVersion + ' |\n\n'
545
+ + '## Source and provenance\n\nSource system: **Docusaurus**.\n\nRepository: ' + sourceLabel + '.' + revision + '\n\n'
546
+ + 'The report was generated at ' + report.analysis.analyzedAt + '. A public documentation URL is not guessed; each finding links to the exact Git revision when one is available.\n\n'
547
+ + 'Documentation roots: ' + report.source.documentationRoots.map((root) => codeMark + root.path + codeMark + ' (' + root.kind + ')').join(', ') + '.\n\n'
548
+ + 'Navigation files were discovered but not executed: ' + (report.navigation.files.length ? report.navigation.files.map((file) => codeMark + file + codeMark).join(', ') : 'none detected') + '.\n\n'
549
+ + '## Classification\n\n'
550
+ + '- **copy**: ordinary Markdown or GFM can be used without syntactic conversion.\n'
551
+ + '- **rewrite**: a deterministic Norna conversion is available.\n'
552
+ + '- **assist**: a useful conversion is possible but needs review.\n'
553
+ + '- **model-gap**: the source uses a data model Norna does not have.\n'
554
+ + '- **feature-gap**: the concept fits Norna but has no corresponding capability.\n'
555
+ + '- **unresolved**: the audit cannot determine a safe conversion.\n'
556
+ + '- **out-of-scope**: source build or application code, not page content.\n\n'
557
+ + '## Problem types\n\nThe report groups repeated findings by a stable problem ID. Each problem page contains at most 20 deterministic representative occurrences; the JSON report retains every occurrence.\n\n'
558
+ + '| Problem ID | Description | Occurrences | Classification |\n| --- | --- | ---: | --- |\n'
559
+ + (problemRows || '| None | No findings | 0 | copy |') + '\n';
560
+ };
561
+
562
+ const renderProblemPage = (report, problem) => {
563
+ const rows = problem.sample.map((occurrence) => {
564
+ const link = sourceLink(report, occurrence);
565
+ return '| ' + occurrence.pageTitle.replaceAll('|', '\\|') + ' | ' + codeMark + occurrence.sourceFile + codeMark + ' | ' + occurrence.line + ' | ' + (link ? '[Open source](' + link + ')' : 'Unavailable') + ' |';
566
+ }).join('\n');
567
+ return '---\npage:\n description: ' + problemDescription(problem) + '.\n---\n\n'
568
+ + '# ' + problem.id + '\n\n'
569
+ + problem.message + '\n\n'
570
+ + 'Classification: **' + problem.classification + '**. Occurrences: **' + problem.occurrences + '**.\n\n'
571
+ + 'Problem fingerprint: ' + codeMark + problem.fingerprint + codeMark + '.\n\n'
572
+ + '## Recommended follow-up\n\n' + problem.action + '\n\n'
573
+ + '## Representative occurrences\n\n'
574
+ + '| Page | Source file | Line | Source |\n| --- | --- | ---: | --- |\n'
575
+ + (rows || '| None | None | 0 | Unavailable |') + '\n';
576
+ };
577
+
578
+ export const writeMigrationReport = async (report, reportDir, { sourceRoot = null } = {}) => {
579
+ const resolvedReportDir = path.resolve(reportDir);
580
+ if (sourceRoot && (isWithin(path.resolve(sourceRoot), resolvedReportDir) || isWithin(resolvedReportDir, path.resolve(sourceRoot)))) {
581
+ throw new Error('The migration report directory must be separate from the Docusaurus project root.');
582
+ }
583
+ if (await isDirectory(resolvedReportDir)) {
584
+ const entries = await readdir(resolvedReportDir);
585
+ if (entries.length > 0) throw new Error('Migration report directory must be empty: ' + resolvedReportDir);
586
+ } else await mkdir(resolvedReportDir, { recursive: true });
587
+ await mkdir(path.join(resolvedReportDir, 'pages', '000-home'), { recursive: true });
588
+ await writeFile(path.join(resolvedReportDir, 'config.yaml'), 'url: https://example.invalid/\n');
589
+ await writeFile(path.join(resolvedReportDir, 'theme.yaml'), 'preset: documentation\n');
590
+ await writeFile(path.join(resolvedReportDir, 'pages', '000-home', 'content.md'), renderReportHome(report));
591
+ if (report.problemTypes.length > 0) {
592
+ const categoryDir = path.join(resolvedReportDir, 'pages', '010-problems');
593
+ await mkdir(path.join(categoryDir, 'pages'), { recursive: true });
594
+ await writeFile(path.join(categoryDir, 'category.yaml'), 'label: Problems\n');
595
+ for (const [index, problem] of report.problemTypes.entries()) {
596
+ const pageDir = path.join(categoryDir, 'pages', String((index + 1) * 10).padStart(3, '0') + '-' + problemPageId(problem));
597
+ await mkdir(pageDir, { recursive: true });
598
+ await writeFile(path.join(pageDir, 'content.md'), renderProblemPage(report, problem));
599
+ }
600
+ }
601
+ await writeFile(path.join(resolvedReportDir, 'migration-report.json'), JSON.stringify(report, null, 2) + '\n');
602
+ return resolvedReportDir;
603
+ };
@@ -1,3 +1,8 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export const localEditorNames = Object.freeze(['vscode']);
5
+
1
6
  const assertHttpUrl = (value, label) => {
2
7
  if (typeof value !== 'string' || value.trim() === '') {
3
8
  throw new Error(`${label} must be a non-empty absolute URL.`);
@@ -47,3 +52,51 @@ export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
47
52
  const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
48
53
  return new URL(encodedPath, normalizedBaseUrl).href;
49
54
  };
55
+
56
+ export const isLoopbackHostname = (hostname) => {
57
+ const normalizedHostname = String(hostname ?? '')
58
+ .trim()
59
+ .toLowerCase()
60
+ .replace(/^\[|\]$/g, '')
61
+ .replace(/\.$/, '');
62
+
63
+ return normalizedHostname === 'localhost'
64
+ || normalizedHostname.endsWith('.localhost')
65
+ || normalizedHostname === '::1'
66
+ || normalizedHostname.startsWith('::ffff:127.')
67
+ || /^127(?:\.\d{1,3}){3}$/.test(normalizedHostname);
68
+ };
69
+
70
+ export const getLocalEditorSourceUrl = ({ editor, sourcePath }) => {
71
+ if (!localEditorNames.includes(editor)) {
72
+ throw new Error(`Unknown local editor "${editor}". Use one of: ${localEditorNames.join(', ')}.`);
73
+ }
74
+ if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) {
75
+ throw new Error('Local editor source path must be absolute.');
76
+ }
77
+
78
+ const fileUrl = pathToFileURL(path.normalize(sourcePath));
79
+ return `vscode://file${fileUrl.pathname}`;
80
+ };
81
+
82
+ export const resolveEditSourceTarget = ({
83
+ baseUrl,
84
+ development = false,
85
+ hostname,
86
+ localEditor,
87
+ sourceLabel,
88
+ sourcePath,
89
+ }) => {
90
+ if (development && localEditor && isLoopbackHostname(hostname)) {
91
+ return Object.freeze({
92
+ href: getLocalEditorSourceUrl({ editor: localEditor, sourcePath }),
93
+ kind: 'local',
94
+ });
95
+ }
96
+ if (!baseUrl) return null;
97
+
98
+ return Object.freeze({
99
+ href: getEditSourceUrl({ baseUrl, sourcePath: sourceLabel }),
100
+ kind: 'remote',
101
+ });
102
+ };