@janga/norna 0.7.27 → 0.7.29

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 (136) hide show
  1. package/README.md +41 -29
  2. package/astro.config.mjs +33 -3
  3. package/bin/norna-cli.mjs +5 -3
  4. package/package.json +18 -12
  5. package/schemas/card-list.schema.json +1 -1
  6. package/schemas/config.schema.json +81 -66
  7. package/schemas/content-frontmatter.schema.json +23 -7
  8. package/schemas/manifest.json +49 -3
  9. package/schemas/page-theme.schema.json +1585 -41
  10. package/schemas/root-theme.schema.json +1767 -0
  11. package/schemas/sitewide-content.schema.json +18 -18
  12. package/schemas/theme.schema.json +243 -243
  13. package/scripts/add-site-node.mjs +31 -250
  14. package/scripts/build-site.mjs +11 -6
  15. package/scripts/check-config.mjs +7 -23
  16. package/scripts/dev-local.mjs +51 -17
  17. package/scripts/export-theme-preset.mjs +2 -2
  18. package/scripts/generate-images.mjs +89 -70
  19. package/scripts/generate-schemas.mjs +9 -6
  20. package/scripts/generate-search-index.mjs +3 -2
  21. package/scripts/lib/attachment-render.mjs +23 -0
  22. package/scripts/lib/category-destinations.mjs +13 -12
  23. package/scripts/lib/dev-server-identity.mjs +30 -0
  24. package/scripts/lib/documentation-links.mjs +1 -1
  25. package/scripts/lib/documentation-routes.json +70 -62
  26. package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
  27. package/scripts/lib/editor-attachments.mjs +69 -0
  28. package/scripts/lib/editor-file-policy.mjs +20 -0
  29. package/scripts/lib/editor-language-service.mjs +74 -73
  30. package/scripts/lib/editor-page-addresses.mjs +57 -0
  31. package/scripts/lib/editor-page-placement.mjs +102 -0
  32. package/scripts/lib/editor-resource-actions.mjs +216 -0
  33. package/scripts/lib/editor-site-editing-tree.mjs +181 -0
  34. package/scripts/lib/editor-site-files.mjs +154 -0
  35. package/scripts/lib/editor-site-links.mjs +81 -0
  36. package/scripts/lib/editor-site-preview.mjs +149 -0
  37. package/scripts/lib/editor-site-tree.mjs +158 -0
  38. package/scripts/lib/editor-source-files.mjs +87 -0
  39. package/scripts/lib/editor-theme-help.mjs +43 -0
  40. package/scripts/lib/locale-registry.mjs +1 -1
  41. package/scripts/lib/locales/en.mjs +1 -1
  42. package/scripts/lib/logo-assets.mjs +2 -1
  43. package/scripts/lib/markdown-images.mjs +59 -0
  44. package/scripts/lib/markdown-links.mjs +29 -9
  45. package/scripts/lib/navigation-model.mjs +2 -44
  46. package/scripts/lib/navigation-review.mjs +14 -50
  47. package/scripts/lib/norna-markdown-blocks.mjs +1 -58
  48. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  49. package/scripts/lib/page-aliases.mjs +8 -1
  50. package/scripts/lib/page-attachments.mjs +59 -0
  51. package/scripts/lib/page-markdown.mjs +11 -7
  52. package/scripts/lib/page-model.mjs +19 -36
  53. package/scripts/lib/page-move-apply.mjs +119 -0
  54. package/scripts/lib/page-move-plan.mjs +64 -78
  55. package/scripts/lib/page-order-source.mjs +22 -0
  56. package/scripts/lib/page-order.mjs +45 -0
  57. package/scripts/lib/page-placement-cli.mjs +50 -0
  58. package/scripts/lib/presentation.mjs +3 -3
  59. package/scripts/lib/project-config.mjs +10 -8
  60. package/scripts/lib/publication-paths.mjs +72 -0
  61. package/scripts/lib/schema-definitions.mjs +15 -38
  62. package/scripts/lib/schema-editor-metadata.mjs +16 -60
  63. package/scripts/lib/schema-value-definitions.mjs +1 -1
  64. package/scripts/lib/site-content.mjs +8 -25
  65. package/scripts/lib/site-conventions.mjs +19 -1
  66. package/scripts/lib/site-link-graph.mjs +88 -18
  67. package/scripts/lib/site-navigation-tree.mjs +2 -7
  68. package/scripts/lib/site-node-create.mjs +182 -0
  69. package/scripts/lib/site-paths.mjs +24 -12
  70. package/scripts/lib/site-source-format.mjs +12 -0
  71. package/scripts/lib/site-structure.mjs +86 -95
  72. package/scripts/lib/site-upgrade.mjs +32 -0
  73. package/scripts/lib/social-image-assets.mjs +2 -1
  74. package/scripts/lib/source-files.mjs +34 -0
  75. package/scripts/lib/structured-blocks.mjs +3 -1
  76. package/scripts/lib/theme-config.mjs +26 -68
  77. package/scripts/lib/theme-packages.mjs +47 -0
  78. package/scripts/lib/theme-presets.mjs +20 -53
  79. package/scripts/lib/typography.mjs +2 -2
  80. package/scripts/lib/yaml-config.mjs +1 -1
  81. package/scripts/lib/yaml-source-edit.mjs +94 -0
  82. package/scripts/move-site-page.mjs +46 -254
  83. package/scripts/show-typography.mjs +32 -17
  84. package/scripts/sync-content-sections.mjs +17 -17
  85. package/scripts/sync-site-public.mjs +44 -66
  86. package/scripts/upgrade-site.mjs +17 -0
  87. package/src/components/AreaNavigationMenu.astro +24 -0
  88. package/src/components/AreaNavigationPanel.astro +55 -0
  89. package/src/components/AreaNavigationSwitcher.astro +30 -0
  90. package/src/components/ContentBlocks.astro +2 -1
  91. package/src/components/ImageStack.astro +7 -5
  92. package/src/components/ImageStackEnhancement.astro +0 -7
  93. package/src/components/NavigationContinuity.astro +26 -0
  94. package/src/components/NavigationPageTree.astro +13 -4
  95. package/src/components/NavigationState.astro +11 -0
  96. package/src/components/NavigationTreeControls.astro +3 -2
  97. package/src/components/SearchPage.astro +11 -2
  98. package/src/components/SectionNavigationScript.astro +106 -26
  99. package/src/components/SiteNavigation.astro +45 -20
  100. package/src/components/SitePage.astro +38 -61
  101. package/src/components/SiteSection.astro +1 -0
  102. package/src/components/SiteTreeNavigation.astro +38 -18
  103. package/src/components/TopPageMenu.astro +4 -3
  104. package/src/components/TreeNavigationScript.astro +50 -57
  105. package/src/content.config.ts +11 -12
  106. package/src/layouts/BaseLayout.astro +15 -39
  107. package/src/lib/areaNavigation.ts +42 -0
  108. package/src/lib/imagePresentation.ts +3 -0
  109. package/src/lib/markdownImages.ts +101 -0
  110. package/src/lib/navigationContinuity.ts +212 -0
  111. package/src/lib/navigationFollowing.ts +28 -18
  112. package/src/lib/navigationState.mjs +88 -0
  113. package/src/lib/navigationStateScript.mjs +76 -0
  114. package/src/lib/navigationVisibility.ts +9 -0
  115. package/src/lib/pageThemes.ts +5 -42
  116. package/src/lib/sectionContent.ts +67 -21
  117. package/src/lib/siteNavigation.ts +6 -0
  118. package/src/lib/sitePages.ts +13 -36
  119. package/src/lib/sitePublicAssets.ts +2 -1
  120. package/src/pages/404.astro +1 -1
  121. package/src/pages/[...slug].astro +7 -16
  122. package/src/pages/index.astro +1 -3
  123. package/src/styles/area-navigation.css +408 -0
  124. package/src/styles/content.css +8 -29
  125. package/src/styles/media.css +24 -0
  126. package/src/styles/navigation-continuity.css +163 -0
  127. package/src/styles/page-layout.css +57 -68
  128. package/src/styles/responsive.css +0 -4
  129. package/starters/basic/README.md +9 -8
  130. package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
  131. package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
  132. package/schemas/category.schema.json +0 -20
  133. package/src/components/PageContentsNavigation.astro +0 -20
  134. /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
  135. /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
  136. /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
@@ -0,0 +1,154 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { markdownToMdast } from 'satteri';
4
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
5
+ import { getSiteStructure } from './site-structure.mjs';
6
+ import { getEditorIncomingLinks } from './editor-site-links.mjs';
7
+ import { editorFileRemovalPolicy } from './editor-file-policy.mjs';
8
+ import { getEditorThemeRemovalHelp } from './editor-theme-help.mjs';
9
+
10
+ export const siteFileOperationsApiVersion = 1;
11
+ export const siteRemovalApiVersion = 1;
12
+ const imageName = /^[a-z0-9][a-z0-9.-]*\.(jpe?g|png|svg)$/i;
13
+ const stat = (filename) => lstat(filename).catch((error) => {
14
+ if (error.code === 'ENOENT') return null;
15
+ throw error;
16
+ });
17
+
18
+ export const checkedPath = async (siteRoot, filename) => {
19
+ const root = path.resolve(siteRoot);
20
+ const target = path.resolve(filename);
21
+ const relative = path.relative(root, target);
22
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
23
+ throw new Error('Choose a file in the selected site.');
24
+ }
25
+ let current = root;
26
+ for (const segment of ['', ...relative.split(path.sep).filter(Boolean)]) {
27
+ current = path.join(current, segment);
28
+ if ((await stat(current))?.isSymbolicLink()) throw new Error('Symbolic links cannot be changed through Site Tree. Use the real source directory.');
29
+ }
30
+ return target;
31
+ };
32
+
33
+ const pageTarget = async ({ siteRoot, sourcePath }) => {
34
+ const filename = await checkedPath(siteRoot, sourcePath);
35
+ const structure = await getSiteStructure({ siteRoot, tolerant: true });
36
+ const page = structure.nodes.find((node) => (node.contentPath ?? node.categoryPath) === filename);
37
+ if (!page) throw new Error('The selected page is no longer in this site. Refresh Site Tree and select it again.');
38
+ return { page, structure, directory: path.dirname(filename), sourcePath: filename };
39
+ };
40
+
41
+ export const snapshot = async (filename) => {
42
+ const result = [];
43
+ const visit = async (current) => {
44
+ const information = await stat(current);
45
+ if (!information) throw new Error('The selected file no longer exists. Refresh Site Tree.');
46
+ if (information.isSymbolicLink()) throw new Error('This selection contains a symbolic link. Remove it through the ordinary Explorer instead.');
47
+ if (!information.isFile() && !information.isDirectory()) throw new Error('This selection contains an unsupported filesystem entry.');
48
+ result.push([path.relative(filename, current), information.isDirectory() ? 'directory' : 'file', information.size, information.mtimeMs, information.ctimeMs, information.ino]);
49
+ if (information.isDirectory()) for (const entry of (await readdir(current)).sort()) await visit(path.join(current, entry));
50
+ };
51
+ await visit(filename);
52
+ return result;
53
+ };
54
+
55
+ export const planEditorImageCopy = async ({ siteRoot, sourcePath, imagePath, filename, replace = false }) => {
56
+ const target = await pageTarget({ siteRoot, sourcePath });
57
+ if (target.page.kind !== 'page') throw new Error('Choose a content page for this image.');
58
+ if (!imageName.test(filename)) throw new Error('Use a filename starting with a letter or number, followed by letters, numbers, dots or hyphens, ending in .jpg, .jpeg, .png or .svg.');
59
+ const source = path.resolve(imagePath);
60
+ const information = await stat(source);
61
+ if (!information?.isFile() || information.isSymbolicLink()) throw new Error('Choose a regular image file to copy.');
62
+ if (!/\.(jpe?g|png|svg)$/i.test(source) || path.extname(source).toLowerCase() !== path.extname(filename).toLowerCase()) {
63
+ throw new Error('Keep the source image extension. This action does not convert image formats.');
64
+ }
65
+ const destination = await checkedPath(siteRoot, path.join(target.directory, 'images', filename));
66
+ if (source === destination) throw new Error('This image already belongs to the selected page. Use Insert Image instead.');
67
+ const existing = await stat(destination);
68
+ if (replace ? !existing?.isFile() : existing !== null) {
69
+ throw new Error(replace ? 'The image to replace no longer exists.' : 'An image already has this name on the page. Choose another filename or use Replace Image.');
70
+ }
71
+ return { source, destination, sourcePath: target.sourcePath, filename,
72
+ fingerprint: JSON.stringify([await snapshot(source), existing ? await snapshot(destination) : null]) };
73
+ };
74
+
75
+ export const getEditorImageUsage = async ({ siteRoot, sourcePath, imagePath, sources = new Map() }) => {
76
+ const { page, structure, directory } = await pageTarget({ siteRoot, sourcePath });
77
+ const target = await checkedPath(siteRoot, imagePath);
78
+ const targetStat = await stat(target);
79
+ if (page.kind !== 'page' || path.dirname(target) !== path.join(directory, 'images') || !imageName.test(path.basename(target)) || !targetStat?.isFile()) {
80
+ throw new Error('Choose an image directly inside this page’s images folder.');
81
+ }
82
+ const references = [];
83
+ const incomplete = structure.problems.map((problem) => problem.message);
84
+ for (const node of structure.nodes.filter((node) => node.contentPath)) {
85
+ if (path.dirname(node.contentPath) !== directory) continue;
86
+ try {
87
+ const source = sources.has(node.contentPath) ? sources.get(node.contentPath) : await readFile(node.contentPath, 'utf8');
88
+ const document = await parsePageMarkdownSource(source, { label: node.contentPath });
89
+ if (document.diagnostics.some(({ severity }) => severity === 'error')) incomplete.push(node.contentPath);
90
+ for (const reference of document.managedImages.filter((reference) => reference.image.toLowerCase() === path.basename(target).toLowerCase())) {
91
+ const localPath = path.join(path.dirname(node.contentPath), 'images', reference.image);
92
+ const local = await stat(localPath);
93
+ if (!local || local.dev !== targetStat.dev || local.ino !== targetStat.ino) continue;
94
+ references.push({ sourcePath: node.contentPath, line: reference.line, unresolved: !local });
95
+ }
96
+ } catch (error) { incomplete.push(`${node.contentPath}: ${error.message}`); }
97
+ }
98
+ return { references, incomplete };
99
+ };
100
+
101
+ export const planEditorRemoval = async ({ siteRoot, sourcePath, imagePath, filePath, sources }) => {
102
+ siteRoot = path.resolve(siteRoot);
103
+ const siteFile = filePath && !imagePath && path.resolve(sourcePath) === siteRoot;
104
+ const target = siteFile ? { sourcePath: await checkedPath(siteRoot, sourcePath) } : await pageTarget({ siteRoot, sourcePath });
105
+ if (!imagePath && !filePath && target.page.isHome) throw new Error('The homepage is required and cannot be removed.');
106
+ const filename = imagePath || filePath ? await checkedPath(siteRoot, imagePath ?? filePath) : target.directory;
107
+ const policy = filePath ? editorFileRemovalPolicy({ siteRoot: path.resolve(siteRoot), sourcePath: target.sourcePath, filePath: filename }) : null;
108
+ if (filePath && (!policy || !(await stat(filename))?.isFile())) throw new Error('This file cannot be removed separately. Required page files belong to their page; required site settings must remain.');
109
+ const usage = imagePath ? await getEditorImageUsage({ siteRoot, sourcePath, imagePath, sources })
110
+ : !filePath || ['public', 'attachment'].includes(policy.kind) ? await getEditorIncomingLinks({ siteRoot, sourcePath, sources,
111
+ descendants: !filePath, excludeBranch: !filePath, filePath }) : null;
112
+ const files = await snapshot(filename);
113
+ const effect = policy?.kind === 'theme'
114
+ ? `${policy.effect}\n\n${await getEditorThemeRemovalHelp({ siteRoot, filename, sources })}` : policy?.effect;
115
+ return { target: filename, recursive: !imagePath && !filePath, effect,
116
+ files: files.filter(([, kind]) => kind === 'file').map(([relative]) => relative || path.basename(filename)),
117
+ pages: imagePath || filePath ? 0 : target.structure.nodes.filter((node) => {
118
+ const relative = path.relative(filename, node.contentPath ?? node.categoryPath);
119
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
120
+ }).length,
121
+ usage, fingerprint: JSON.stringify([files, usage, effect]) };
122
+ };
123
+
124
+ export const createEditorImageAppend = async ({ source, filename, alt, caption = '' }) => {
125
+ if (typeof alt !== 'string' || !alt.trim()) throw new Error('Describe the image before inserting it.');
126
+ return createEditorImageBatchAppend({ source, items: [{ filename, alt, caption }] });
127
+ };
128
+
129
+ export const createEditorImageBatchAppend = async ({ source, items }) => {
130
+ if (!Array.isArray(items) || !items.length) throw new Error('Choose at least one image to insert.');
131
+ for (const item of items) {
132
+ if (!imageName.test(item.filename)) throw new Error('Choose a managed image filename.');
133
+ if (item.alt !== undefined && typeof item.alt !== 'string') throw new Error('Alternative text must be text.');
134
+ if (item.caption !== undefined && typeof item.caption !== 'string') throw new Error('Caption must be text.');
135
+ }
136
+ const document = await parsePageMarkdownSource(source, { label: 'content.md' });
137
+ if (document.diagnostics.some(({ severity }) => severity === 'error')) {
138
+ throw new Error('Repair this page’s content errors before appending an image block. The imported file remains available.');
139
+ }
140
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
141
+ const block = ['```image-stack', 'items:', ...items.flatMap(({ filename, alt, caption }) => [
142
+ ` - image: ${filename}`,
143
+ ...(alt !== undefined ? [` alt: ${JSON.stringify(alt)}`] : []),
144
+ ...(caption ? [` caption: ${JSON.stringify(caption)}`] : []),
145
+ ]), '```', ''].join(eol);
146
+ const text = `${source.endsWith(eol + eol) ? '' : source.endsWith(eol) ? eol : eol + eol}${block}`;
147
+ const result = await parsePageMarkdownSource(source + text, { label: 'content.md' });
148
+ const appended = markdownToMdast(source + text, { features: { gfm: true, frontmatter: true } }).children.at(-1);
149
+ if (appended?.type !== 'code' || appended.lang !== 'image-stack' || appended.position.start.offset < source.length
150
+ || result.managedImages.length !== document.managedImages.length + items.length || result.diagnostics.some(({ severity }) => severity === 'error')) {
151
+ throw new Error('Close the open Markdown block or comment before appending an image. The imported file remains available.');
152
+ }
153
+ return { start: source.length, end: source.length, text };
154
+ };
@@ -0,0 +1,81 @@
1
+ import { getSiteAttachments } from './page-attachments.mjs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getGeneratedSiteRoutes } from './page-aliases.mjs';
5
+ import { parsePageMarkdownSource } from './page-markdown.mjs';
6
+ import { configSchema } from './schema-definitions.mjs';
7
+ import { parseContentFrontmatter, splitSiteFile } from './site-content.mjs';
8
+ import { createSiteLinkGraph, getSitePublicFiles } from './site-link-graph.mjs';
9
+ import { getSiteStructure } from './site-structure.mjs';
10
+ import { parseYamlConfig } from './yaml-config.mjs';
11
+
12
+ export const isInside = (root, filename) => {
13
+ const relative = path.relative(root, filename);
14
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
15
+ };
16
+
17
+ // The engine remains the only URL resolver. Editor reads overlay unsaved
18
+ // buffers and retain useful results when another page needs repair.
19
+ export const readEditorLinkState = async ({ siteRoot, sources = new Map() }) => {
20
+ const readSource = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
21
+ const structure = await getSiteStructure({ siteRoot, tolerant: true, readSource });
22
+ const incomplete = structure.problems.map(({ message }) => message);
23
+ let settings;
24
+ const settingsPath = path.join(siteRoot, 'site-config/settings.yaml');
25
+ try { settings = parseYamlConfig(await readSource(settingsPath), settingsPath, { schema: configSchema }); }
26
+ catch (error) { incomplete.push(error.message); }
27
+ const pageDocuments = await Promise.all(structure.contentFiles.map(async (contentFile) => {
28
+ let source = '';
29
+ let data = {};
30
+ try {
31
+ source = await readSource(contentFile.contentPath);
32
+ data = parseContentFrontmatter(splitSiteFile(source, contentFile.contentLabel).frontmatterBody, contentFile.contentLabel);
33
+ } catch (error) { incomplete.push(error.message); }
34
+ let document;
35
+ try {
36
+ document = await parsePageMarkdownSource(source, { label: contentFile.contentLabel });
37
+ for (const diagnostic of document.diagnostics.filter(({ severity }) => severity === 'error')) {
38
+ incomplete.push(`${contentFile.contentLabel}:${diagnostic.line}: ${diagnostic.message}`);
39
+ }
40
+ } catch (error) {
41
+ incomplete.push(error.message);
42
+ document = await parsePageMarkdownSource('# Unavailable page\n', { label: contentFile.contentLabel });
43
+ }
44
+ return { contentFile, data, document };
45
+ }));
46
+ let publicFiles = [];
47
+ try { publicFiles = await getSitePublicFiles(path.join(siteRoot, 'public'), settings); }
48
+ catch (error) { incomplete.push(error.message); }
49
+ const generatedRoutes = getGeneratedSiteRoutes({ searchEnabled: settings?.search });
50
+ const graph = createSiteLinkGraph({ siteUrl: settings?.url, siteStructure: structure, pageDocuments, publicFiles, generatedRoutes, attachments: await getSiteAttachments(structure) });
51
+ // Missing destinations do not make reference discovery incomplete. Ambiguous
52
+ // ownership of an address does, and must never be presented as a clean check.
53
+ incomplete.push(...graph.diagnostics.filter((entry) => (!entry.reference || entry.code === 'ambiguous-attachment') && entry.severity === 'error').map(({ message }) => message));
54
+ return { structure, graph, publicFiles, generatedRoutes, settings, incomplete: [...new Set(incomplete)].sort() };
55
+ };
56
+
57
+ export const summarizeReference = (reference) => ({
58
+ sourcePath: reference.sourceContentFile.contentPath,
59
+ title: reference.sourcePage.title,
60
+ line: reference.line,
61
+ column: reference.column ?? 1,
62
+ target: reference.target.kind === 'internal' ? reference.resolution?.pathname ?? reference.target.pathname : '',
63
+ text: reference.sourcePage.document.fullSource.split(/\r?\n/)[reference.line - 1]?.trim() ?? '',
64
+ });
65
+
66
+ export const getEditorIncomingLinks = async ({ siteRoot, sourcePath, sources, descendants = false, excludeBranch = false, filePath, alias }) => {
67
+ const state = await readEditorLinkState({ siteRoot, sources });
68
+ const node = state.structure.nodes.find((entry) => (entry.contentPath ?? entry.categoryPath) === sourcePath);
69
+ if (!node && !(filePath && path.resolve(sourcePath) === path.resolve(siteRoot))) throw new Error('This page is no longer in the selected site. Refresh Site Tree.');
70
+ const directory = node ? path.dirname(sourcePath) : siteRoot;
71
+ const targetPaths = new Set(state.structure.nodes.filter((entry) => entry === node || (descendants && isInside(directory, entry.nodeDir)))
72
+ .map((entry) => entry.isHome ? '/' : `/${entry.pagePath}/`));
73
+ const references = state.graph.references.filter((reference) => {
74
+ if (excludeBranch && isInside(directory, reference.sourceContentFile.contentPath)) return false;
75
+ if (alias) return reference.target.pageLookupPathname === alias;
76
+ if (filePath) return ['public-file', 'attachment'].includes(reference.resolution?.kind) && reference.resolution.file.filePath === filePath;
77
+ if (reference.resolution?.kind === 'attachment' && targetPaths.has(reference.resolution.file.ownerPathname)) return true;
78
+ return targetPaths.has(reference.resolution?.pathname ?? reference.target.pageLookupPathname);
79
+ }).map(summarizeReference);
80
+ return { references, incomplete: state.incomplete };
81
+ };
@@ -0,0 +1,149 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readFile, realpath } from 'node:fs/promises';
4
+ import net from 'node:net';
5
+ import path from 'node:path';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { verifyDevServer } from './dev-server-identity.mjs';
8
+ import { getEditorPageAddresses } from './editor-page-addresses.mjs';
9
+
10
+ export const sitePreviewApiVersion = 1;
11
+ const engineRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
12
+ const queues = new Map();
13
+ const serialize = (key, action) => {
14
+ const task = (queues.get(key) ?? Promise.resolve()).catch(() => {}).then(action);
15
+ queues.set(key, task);
16
+ void task.finally(() => { if (queues.get(key) === task) queues.delete(key); }).catch(() => {});
17
+ return task;
18
+ };
19
+ const readState = async root => {
20
+ try { return JSON.parse(await readFile(path.join(root, '.norna/dev/state.json'), 'utf8')); }
21
+ catch (error) { if (error.code === 'ENOENT') return null; throw new Error('The preview server record is unreadable. Inspect Show Preview Log before restarting.', { cause: error }); }
22
+ };
23
+ const registryEntry = async siteRoot => {
24
+ let registry;
25
+ try { registry = await import(pathToFileURL(path.join(engineRoot, 'scripts/review-environment-registry.mjs')).href); }
26
+ catch (error) { if (error.code === 'ERR_MODULE_NOT_FOUND') return null; throw error; }
27
+ for (const [target, definition] of Object.entries(registry.reviewEnvironments)) {
28
+ const root = await realpath(path.join(engineRoot, definition.siteDirectory)).catch(() => null);
29
+ if (root === siteRoot) return { target, ...definition };
30
+ }
31
+ return null;
32
+ };
33
+ export const getEditorPreviewConfiguration = async ({ siteRoot, environment = process.env }) => {
34
+ const root = await realpath(siteRoot);
35
+ const registered = await registryEntry(root);
36
+ const value = registered ? String(registered.port) : environment.NORNA_DEV_PORT ?? '4321';
37
+ const port = /^\d+$/.test(value) ? Number(value) : NaN;
38
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('NORNA_DEV_PORT must be an integer from 1 through 65535. Correct the VS Code launch environment and restart VS Code.');
39
+ return { siteRoot: root, port, registered, source: registered ? `review registry (${registered.target})` : environment.NORNA_DEV_PORT === undefined ? 'default' : 'NORNA_DEV_PORT' };
40
+ };
41
+ export const getEditorPreviewStatus = async ({ siteRoot }) => {
42
+ const root = await realpath(siteRoot);
43
+ const state = await readState(root);
44
+ return { state, verified: await verifyDevServer(state, root) };
45
+ };
46
+ export const getEditorPreviewLog = async ({ siteRoot }) => {
47
+ const root = await realpath(siteRoot);
48
+ const files = ['dev/preparation.log', '.astro/dev.log', 'dev/state.json'];
49
+ const logs = await Promise.all(files.map(async name => {
50
+ try { return `${name}\n${(await readFile(path.join(root, '.norna', name), 'utf8')).slice(-64000)}`; }
51
+ catch (error) { if (error.code === 'ENOENT') return ''; throw error; }
52
+ }));
53
+ return logs.filter(Boolean).join('\n\n') || 'No preview log exists for this site yet.';
54
+ };
55
+ const runManager = (config, operation, { token, environment, onOutput = () => {} } = {}) => new Promise((resolve, reject) => {
56
+ const args = config.registered
57
+ ? [path.join(engineRoot, 'scripts/review-environments.mjs'), operation === 'stop' ? 'stop' : 'start', config.registered.target]
58
+ : [path.join(engineRoot, 'bin/norna.mjs'), '--site-dir', config.siteRoot, operation === 'stop' ? 'dev:stop' : 'dev:local'];
59
+ const env = { ...process.env, ...environment, NORNA_DEV_PORT: String(config.port), NORNA_SITE_DIR: config.siteRoot,
60
+ NORNA_NO_OPEN: '1', NORNA_DEV_NO_RESTART: '1', ELECTRON_RUN_AS_NODE: '1' };
61
+ // The extension may itself have been launched while another site was selected.
62
+ delete env.NORNA_INTERNAL_STATE_DIR;
63
+ delete env.NORNA_INVOCATION_ROOT;
64
+ delete env.NORNA_DEV_EXPECT_TOKEN;
65
+ delete env.NORNA_DEV_START_TOKEN;
66
+ if (operation === 'stop') env.NORNA_DEV_EXPECT_TOKEN = token;
67
+ else env.NORNA_DEV_START_TOKEN = token;
68
+ const child = spawn(process.execPath, args, { cwd: engineRoot, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
69
+ let output = '';
70
+ const capture = chunk => { output = (output + chunk).slice(-64000); onOutput(String(chunk)); };
71
+ child.stdout.on('data', capture); child.stderr.on('data', capture);
72
+ child.once('error', reject);
73
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Preview ${operation} failed. ${output.replace(/\n\s+at [\s\S]*$/, '').trim().slice(-2500) || 'Show Preview Log for details.'}`)));
74
+ });
75
+ const portOccupied = (port, host) => new Promise((resolve, reject) => {
76
+ const socket = net.connect({ port, host });
77
+ socket.once('connect', () => { socket.destroy(); resolve(true); });
78
+ socket.once('error', error => { socket.destroy(); ['ECONNREFUSED', 'EADDRNOTAVAIL', 'ENETUNREACH', 'EHOSTUNREACH'].includes(error.code) ? resolve(false) : reject(error); });
79
+ socket.setTimeout(1000, () => { socket.destroy(); resolve(true); });
80
+ });
81
+ const cancelled = signal => { if (signal?.aborted) throw new Error('Preview cancelled.'); };
82
+ const stopVerified = async (config, state, options) => {
83
+ if (!(await verifyDevServer(state, config.siteRoot))) throw new Error('Cannot verify this site’s preview server. No process was stopped. Use Show Preview Log to inspect its record.');
84
+ if (config.registered && config.registered.port !== state.port) throw new Error('The registered port differs from the tracked server. Restore its registry port before stopping through the registered review command.');
85
+ await runManager({ ...config, port: state.port }, 'stop', { ...options, token: state.token });
86
+ };
87
+ export const stopEditorSitePreview = async (options) => {
88
+ const root = await realpath(options.siteRoot);
89
+ return serialize(root, async () => {
90
+ const state = await readState(root);
91
+ if (!state) return { stopped: false };
92
+ await stopVerified({ siteRoot: root, registered: await registryEntry(root) }, state, options);
93
+ return { stopped: true };
94
+ });
95
+ };
96
+ export const startEditorSitePreview = async (options) => {
97
+ const config = await getEditorPreviewConfiguration(options);
98
+ return serialize(config.siteRoot, async () => {
99
+ cancelled(options.signal);
100
+ // Read saved sources now, after any explicit Save Site action.
101
+ const addresses = await getEditorPageAddresses({ siteRoot: config.siteRoot, sourcePath: options.sourcePath ?? path.join(config.siteRoot, 'root/content.md') });
102
+ if (!addresses.webAddress || addresses.incomplete.length) throw new Error(`Repair the site before previewing it. ${addresses.incomplete.join('\n')}`);
103
+ let state = await readState(config.siteRoot);
104
+ let created = false;
105
+ if (await verifyDevServer(state, config.siteRoot)) {
106
+ if (state.port !== config.port) throw new Error(`This site is already running on port ${state.port}, but ${config.source} selects ${config.port}. Use Stop Preview Server before starting on the new port.`);
107
+ if (state.mode !== 'local') throw new Error('This site is running in LAN mode. Stop that server explicitly before starting a local preview.');
108
+ } else {
109
+ if (state) throw new Error('The tracked preview server cannot be verified. It may be stopped or belong to an older engine. Inspect Show Preview Log and stop/restart it through the site’s dev commands; preview did not stop any process.');
110
+ if (await portOccupied(config.port, '127.0.0.1') || await portOccupied(config.port, '::1')) throw new Error(`Port ${config.port} is used by another or unverified server. Stop it yourself or configure NORNA_DEV_PORT in the VS Code launch environment. No port fallback was attempted.`);
111
+ cancelled(options.signal);
112
+ const token = randomUUID();
113
+ try {
114
+ await runManager(config, 'start', { ...options, token });
115
+ state = await readState(config.siteRoot);
116
+ if (state?.token !== token || !(await verifyDevServer(state, config.siteRoot))) throw new Error('The new preview server did not provide a matching site identity. Inspect Show Preview Log.');
117
+ created = true;
118
+ } catch (error) {
119
+ const failed = await readState(config.siteRoot);
120
+ if (failed?.token === token && await verifyDevServer(failed, config.siteRoot)) {
121
+ try { await stopVerified(config, failed, options); } catch (cleanup) { throw new Error(`${error.message}\nCleanup failed: ${cleanup.message}`); }
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+ const url = new URL(addresses.webAddress);
127
+ url.protocol = 'http:'; url.hostname = '127.0.0.1'; url.port = String(state.port);
128
+ try {
129
+ cancelled(options.signal);
130
+ const timeout = AbortSignal.timeout(30_000);
131
+ const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout;
132
+ const response = await fetch(url, { signal, redirect: 'manual' });
133
+ await response.arrayBuffer();
134
+ if (!response.ok) throw new Error(`The preview page returned HTTP ${response.status}: ${url.href}. Repair the page and use Show Preview Log for details.`);
135
+ cancelled(options.signal);
136
+ if (!(await verifyDevServer(state, config.siteRoot))) throw new Error('The preview server identity changed while loading the page. No browser was opened.');
137
+ } catch (failure) {
138
+ const message = options.signal?.aborted ? 'Preview cancelled.'
139
+ : failure.name === 'TimeoutError' ? `Timed out loading the preview page ${url.href}. Use Show Preview Log.`
140
+ : failure.message;
141
+ if (created) {
142
+ try { await stopVerified(config, state, options); }
143
+ catch (cleanup) { throw new Error(`${message}\nCleanup failed: ${cleanup.message} Use Stop Preview Server after inspecting the log.`); }
144
+ }
145
+ throw new Error(message, { cause: failure });
146
+ }
147
+ return { url: url.href, reused: !created, siteRoot: config.siteRoot, port: state.port };
148
+ });
149
+ };
@@ -0,0 +1,158 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getMarkdownHeadings, slugifyAsciiIdentifier } from './heading-ids.mjs';
4
+ import { parsePageDirectoryPath } from './page-model.mjs';
5
+ import { splitPageMarkdownSource } from './page-markdown.mjs';
6
+ import { siteSchema } from './schema-definitions.mjs';
7
+ import { getSiteSourcePaths, homePageDirectory } from './site-conventions.mjs';
8
+ import { createSiteNode, escapeMarkdownHeading, planSiteNodeCreation } from './site-node-create.mjs';
9
+ import { getSiteStructure } from './site-structure.mjs';
10
+ import { parseYamlConfig } from './yaml-config.mjs';
11
+ import { changeYamlField } from './yaml-source-edit.mjs';
12
+ import { readEditorLinkState } from './editor-site-links.mjs';
13
+ import { readSiteEditingTree } from './editor-site-editing-tree.mjs';
14
+
15
+ // Optional capability: older engines keep IntelliSense without exposing writes
16
+ // through a site-tree API whose contract they do not implement.
17
+ export const siteTreeApiVersion = 1;
18
+ export const siteFileTreeApiVersion = 1;
19
+ export const sitePageFormApiVersion = 1;
20
+ export const sitePageOrderApiVersion = 1;
21
+ export { siteTreeEditingApiVersion, getEditorSourceFileChoices, planEditorSourceFileCreation, createEditorSourceFile } from './editor-source-files.mjs';
22
+ export { createSiteNode, planSiteNodeCreation, slugifyAsciiIdentifier };
23
+ export { siteFileOperationsApiVersion, siteRemovalApiVersion, planEditorImageCopy, planEditorRemoval, getEditorImageUsage, createEditorImageAppend, createEditorImageBatchAppend } from './editor-site-files.mjs';
24
+ export { getEditorIncomingLinks } from './editor-site-links.mjs';
25
+ export { siteAddressApiVersion, sitePageAddressOptionsApiVersion, getEditorPageAddresses, planEditorPageAddress, applyEditorPageAddress } from './editor-page-addresses.mjs';
26
+ export { sitePagePlacementApiVersion, sitePagePlacementOptionsApiVersion, planEditorPagePlacement, applyEditorPagePlacement } from './editor-page-placement.mjs';
27
+
28
+ const sourceParts = (source, kind) => {
29
+ const split = splitPageMarkdownSource(source);
30
+ if (split.frontmatterUnclosed) throw new Error('Close the YAML frontmatter with --- before editing page information.');
31
+ const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
32
+ const bodyLine = split.lineOffset;
33
+ return {
34
+ yaml: bodyLine ? lines.slice(1, bodyLine - 1).join('') : '',
35
+ offset: bodyLine ? lines[0].length : 0,
36
+ body: split.body,
37
+ bodyLine,
38
+ };
39
+ };
40
+
41
+ const parseInformation = async ({ source, kind, isHome, sourcePath }) => {
42
+ const parts = sourceParts(source, kind);
43
+ const data = parseYamlConfig(parts.yaml, sourcePath, { schema: siteSchema });
44
+ const { headings } = await getMarkdownHeadings(parts.body);
45
+ const titles = headings.filter(({ depth }) => depth === 1);
46
+ if (titles.length !== 1) throw new Error('Keep exactly one Markdown H1 title in this page before editing its information.');
47
+ if (isHome && data.navigation?.listed === false) throw new Error('Home must remain listed. Remove navigation.listed: false.');
48
+ return {
49
+ title: titles[0].title,
50
+ description: data.page?.description ?? '',
51
+ listed: data.navigation?.listed !== false,
52
+ aliases: data.page?.aliases ?? [],
53
+ listChildren: data.page?.listChildren === true,
54
+ titleLine: parts.bodyLine + titles[0].line - 1,
55
+ titleLineCount: titles[0].source.split('\n').length,
56
+ };
57
+ };
58
+
59
+ export const getSiteNodeInformation = async (input) => {
60
+ try {
61
+ return { ...await parseInformation(input), problem: null };
62
+ } catch (error) {
63
+ return { title: input.fallbackTitle ?? path.basename(path.dirname(input.sourcePath)),
64
+ description: '', listed: true, aliases: [], problem: error.message };
65
+ }
66
+ };
67
+
68
+ export const readSiteTree = async ({ siteRoot, sources = new Map(), cache = new Map() }) => {
69
+ const readSource = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
70
+ const structure = await getSiteStructure({ siteRoot, tolerant: true, readSource });
71
+ const nodes = [];
72
+ const used = new Set();
73
+ for (const node of structure.nodes) {
74
+ const sourcePath = node.contentPath ?? node.categoryPath;
75
+ used.add(sourcePath);
76
+ let information;
77
+ try {
78
+ const source = await readSource(sourcePath);
79
+ const previous = cache.get(sourcePath);
80
+ information = previous?.source === source ? previous.information : await getSiteNodeInformation({
81
+ kind: node.kind, isHome: node.isHome, source, sourcePath, fallbackTitle: node.label ?? node.pageId,
82
+ });
83
+ cache.set(sourcePath, { source, information });
84
+ } catch (error) {
85
+ information = { title: node.label ?? node.pageId, listed: true, aliases: [], problem: error.message };
86
+ }
87
+ nodes.push({ ...node, ...information, sourcePath, url: node.isHome ? '/' : `/${node.pagePath}/`,
88
+ problem: information.problem ?? node.problems?.[0] ?? null });
89
+ }
90
+ for (const filename of cache.keys()) if (!used.has(filename)) cache.delete(filename);
91
+ return { nodes, problems: [...structure.problems, ...structure.warnings.map(warning => ({ ...warning, severity: 'warning' }))] };
92
+ };
93
+
94
+ // One physical projection preserves resources and repairable entries even when
95
+ // the logical structure cannot be read. The logical page API remains separate.
96
+ export const readSiteFileTree = async (options) => {
97
+ const siteRoot = path.resolve(options.siteRoot);
98
+ const snapshot = await readSiteTree({ ...options, siteRoot }).catch((error) => ({
99
+ nodes: [], problems: [{ path: siteRoot, message: error.message }],
100
+ }));
101
+ return readSiteEditingTree({ ...options, siteRoot, snapshot, getInformation: getSiteNodeInformation });
102
+ };
103
+
104
+ export const editSiteNodeInformation = async ({ siteRoot, sourcePath, source, field, value, sources = new Map() }) => {
105
+ const relative = path.relative(getSiteSourcePaths(siteRoot).pages, sourcePath).split(path.sep).join('/');
106
+ const filename = path.posix.basename(relative);
107
+ if (filename !== 'content.md') throw new Error('Choose a page content.md source file.');
108
+ const pageDirectory = path.resolve(sourcePath) === getSiteSourcePaths(path.resolve(siteRoot)).content
109
+ ? homePageDirectory : path.posix.dirname(relative);
110
+ parsePageDirectoryPath(pageDirectory);
111
+ const kind = 'page';
112
+ const isHome = pageDirectory === homePageDirectory;
113
+ const info = await parseInformation({ source, kind, isHome, sourcePath });
114
+ const allowed = ['title', 'description', 'listed', 'aliases', 'listChildren'];
115
+ if (!allowed.includes(field)) throw new Error('This information field is read-only.');
116
+ if (field === 'aliases') {
117
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new Error('Enter additional addresses as site-relative paths, such as /old-guide/.');
118
+ } else if (field === 'listed' || field === 'listChildren') {
119
+ if (typeof value !== 'boolean') throw new Error(field === 'listed' ? 'Choose whether this page is listed in navigation.' : 'Choose whether to append the list of child pages.');
120
+ if (field === 'listed' && isHome && !value) throw new Error('Home must remain listed.');
121
+ } else {
122
+ if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new Error('Use a single line of text.');
123
+ value = value.trim();
124
+ if (field === 'title' && !value) throw new Error('Enter a title.');
125
+ }
126
+ if (info[field] === value) return [];
127
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
128
+ if (field === 'title') {
129
+ const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
130
+ const start = lines.slice(0, info.titleLine).join('').length;
131
+ const original = lines.slice(info.titleLine, info.titleLine + info.titleLineCount).join('');
132
+ return [{ start, end: start + original.replace(/\r?\n$/, '').length, text: `# ${escapeMarkdownHeading(value)}` }];
133
+ }
134
+ const parts = sourceParts(source, kind);
135
+ const keys = field === 'listed' ? ['navigation', 'listed'] : ['page', field === 'aliases' ? 'aliases' : field === 'listChildren' ? 'listChildren' : 'description'];
136
+ const yaml = changeYamlField(parts.yaml, keys, (field === 'description' && !value) || (field === 'aliases' && !value.length) || (field === 'listChildren' && !value) ? undefined : value, eol);
137
+ const next = parts.bodyLine ? !yaml.trim() ? source.slice(parts.offset + parts.yaml.length).replace(/^---[ \t]*(?:\r?\n|$)/, '')
138
+ : source.slice(0, parts.offset) + yaml + source.slice(parts.offset + parts.yaml.length)
139
+ : `---${eol}${yaml}---${eol}${eol}${source}`;
140
+ // Validate the complete result before offering it to the editor.
141
+ await parseInformation({ source: next, kind, isHome, sourcePath });
142
+ if (field === 'aliases') {
143
+ const state = await readEditorLinkState({ siteRoot, sources: new Map([...sources, [sourcePath, next]]) });
144
+ if (state.incomplete.length) throw new Error(`Additional addresses could not be checked. ${state.incomplete.join('\n')}`);
145
+ }
146
+ let start = 0;
147
+ while (start < source.length && start < next.length && source[start] === next[start]) start++;
148
+ let end = source.length;
149
+ let nextEnd = next.length;
150
+ while (end > start && nextEnd > start && source[end - 1] === next[nextEnd - 1]) { end--; nextEnd--; }
151
+ return [{ start, end, text: next.slice(start, nextEnd) }];
152
+ };
153
+
154
+ export { siteResourceActionsApiVersion, getEditorResourceReferences, planEditorResourceRename, planEditorPublicCreation, planEditorFolderRemoval, getEditorResourceAddress } from './editor-resource-actions.mjs';
155
+
156
+ export { siteAttachmentsApiVersion, planEditorAttachmentCopy, createEditorAttachmentInsertion } from './editor-attachments.mjs';
157
+
158
+ export { sitePreviewApiVersion, getEditorPreviewConfiguration, getEditorPreviewStatus, getEditorPreviewLog, startEditorSitePreview, stopEditorSitePreview } from './editor-site-preview.mjs';
@@ -0,0 +1,87 @@
1
+ import { getSiteSourcePaths } from './site-conventions.mjs';
2
+ import { lstat, mkdir, open, rmdir, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getSourcePageLocation, getSourceFileDefinition, sourceFileDefinitions } from './source-files.mjs';
5
+ import { parseYamlConfig } from './yaml-config.mjs';
6
+ import { checkedPath } from './editor-site-files.mjs';
7
+ import { escapeMarkdownHeading } from './site-node-create.mjs';
8
+
9
+ export const siteTreeEditingApiVersion = 1;
10
+ const stat = (filename) => lstat(filename).catch((error) => {
11
+ if (error.code === 'ENOENT') return null;
12
+ throw error;
13
+ });
14
+
15
+ export const editorPageLocation = getSourcePageLocation;
16
+ export const editorSourceDefinition = getSourceFileDefinition;
17
+
18
+ export const getEditorSourceFileChoices = async ({ siteRoot, directory }) => {
19
+ siteRoot = path.resolve(siteRoot);
20
+ directory = await checkedPath(siteRoot, directory);
21
+ const location = editorPageLocation(siteRoot, directory);
22
+ const directoryInfo = await stat(directory);
23
+ if ((!location && directory !== siteRoot) || (!directoryInfo?.isDirectory() && !(location?.isHome && !directoryInfo))) {
24
+ throw new Error('Select an existing page directory at a permitted location in this site.');
25
+ }
26
+ const content = await stat(path.join(directory, 'content.md'));
27
+ const names = location ? sourceFileDefinitions.filter((entry) => entry.location === 'page' && (entry.input === 'title' ? !content : content?.isFile() || location.isHome)).map((entry) => entry.name) : [];
28
+ if (directory === siteRoot || location?.isHome) {
29
+ names.push(...sourceFileDefinitions.filter((entry) => entry.location === 'site-config').map((entry) => `site-config/${entry.name}`));
30
+ }
31
+ if (directory === siteRoot) names.push('root/content.md', 'root/tree-theme.yaml');
32
+ const choices = [];
33
+ for (const name of names) {
34
+ const filename = path.join(name.startsWith('site-config/') || name.startsWith('root/') ? siteRoot : directory, name);
35
+ if (await stat(filename)) continue;
36
+ // A file or symlink occupying site-config must be repaired, not replaced.
37
+ const parent = await stat(path.dirname(filename));
38
+ if (parent && (!parent.isDirectory() || parent.isSymbolicLink())) continue;
39
+ const definition = editorSourceDefinition(siteRoot, filename);
40
+ choices.push({ filename, name, description: definition.description, input: definition.input,
41
+ required: Boolean(definition.required), effect: definition.description });
42
+ }
43
+ return choices;
44
+ };
45
+
46
+ export const planEditorSourceFileCreation = async ({ siteRoot, directory, filename, value }) => {
47
+ siteRoot = path.resolve(siteRoot);
48
+ const choices = await getEditorSourceFileChoices({ siteRoot, directory });
49
+ const choice = choices.find((entry) => entry.filename === filename);
50
+ if (!choice) throw new Error('This file already exists or is not allowed here. Refresh Site Tree and review Add again.');
51
+ await checkedPath(siteRoot, filename);
52
+ const definition = editorSourceDefinition(siteRoot, filename);
53
+ let text = definition.text;
54
+ if (definition.input) {
55
+ if (typeof value !== 'string' || !value.trim() || /[\r\n]/.test(value)) throw new Error('Enter a non-empty, single line of text.');
56
+ text = definition.input === 'title' ? `# ${escapeMarkdownHeading(value.trim())}\n`
57
+ : `${definition.schemaKind === 'config' ? 'sourceFormat: 2\n' : ''}${definition.input}: ${JSON.stringify(value.trim())}\n`;
58
+ }
59
+ if (definition.schema) parseYamlConfig(text, filename, { schema: definition.schema });
60
+ const identity = async (location) => { const entry = await stat(location); return entry ? [entry.dev, entry.ino] : null; };
61
+ return { siteRoot, directory: path.resolve(directory), filename, value, text,
62
+ fingerprint: JSON.stringify([await identity(siteRoot), await identity(directory), filename, text]) };
63
+ };
64
+
65
+ export const createEditorSourceFile = async (plan) => {
66
+ const current = await planEditorSourceFileCreation(plan);
67
+ if (plan.fingerprint !== current.fingerprint) throw new Error('The selected directory changed. Review Add again.');
68
+ const parent = path.dirname(current.filename);
69
+ let createdDirectory = false;
70
+ let handle;
71
+ try {
72
+ if (!await stat(parent)) { await mkdir(parent); createdDirectory = true; }
73
+ await checkedPath(current.siteRoot, current.filename);
74
+ handle = await open(current.filename, 'wx');
75
+ await handle.writeFile(current.text, 'utf8');
76
+ } catch (error) {
77
+ if (handle) {
78
+ const owned = await handle.stat();
79
+ await handle.close(); handle = null;
80
+ const existing = await stat(current.filename);
81
+ if (existing?.ino === owned.ino && existing?.dev === owned.dev) await unlink(current.filename);
82
+ }
83
+ if (createdDirectory) await rmdir(parent).catch(() => {});
84
+ throw error;
85
+ } finally { await handle?.close(); }
86
+ return { sourcePath: current.filename };
87
+ };