@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.
- package/README.md +41 -29
- package/astro.config.mjs +33 -3
- package/bin/norna-cli.mjs +5 -3
- package/package.json +18 -12
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +81 -66
- package/schemas/content-frontmatter.schema.json +23 -7
- package/schemas/manifest.json +49 -3
- package/schemas/page-theme.schema.json +1585 -41
- package/schemas/root-theme.schema.json +1767 -0
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +243 -243
- package/scripts/add-site-node.mjs +31 -250
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +51 -17
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +89 -70
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +70 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +15 -12
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +20 -0
- package/scripts/lib/editor-language-service.mjs +74 -73
- package/scripts/lib/editor-page-addresses.mjs +57 -0
- package/scripts/lib/editor-page-placement.mjs +102 -0
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +181 -0
- package/scripts/lib/editor-site-files.mjs +154 -0
- package/scripts/lib/editor-site-links.mjs +81 -0
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +158 -0
- package/scripts/lib/editor-source-files.mjs +87 -0
- package/scripts/lib/editor-theme-help.mjs +43 -0
- package/scripts/lib/locale-registry.mjs +1 -1
- package/scripts/lib/locales/en.mjs +1 -1
- package/scripts/lib/logo-assets.mjs +2 -1
- package/scripts/lib/markdown-images.mjs +59 -0
- package/scripts/lib/markdown-links.mjs +29 -9
- package/scripts/lib/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -58
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +8 -1
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +19 -36
- package/scripts/lib/page-move-apply.mjs +119 -0
- package/scripts/lib/page-move-plan.mjs +64 -78
- package/scripts/lib/page-order-source.mjs +22 -0
- package/scripts/lib/page-order.mjs +45 -0
- package/scripts/lib/page-placement-cli.mjs +50 -0
- package/scripts/lib/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +10 -8
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +15 -38
- package/scripts/lib/schema-editor-metadata.mjs +16 -60
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +8 -25
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +182 -0
- package/scripts/lib/site-paths.mjs +24 -12
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +86 -95
- package/scripts/lib/site-upgrade.mjs +32 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +34 -0
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/lib/theme-config.mjs +26 -68
- package/scripts/lib/theme-packages.mjs +47 -0
- package/scripts/lib/theme-presets.mjs +20 -53
- package/scripts/lib/typography.mjs +2 -2
- package/scripts/lib/yaml-config.mjs +1 -1
- package/scripts/lib/yaml-source-edit.mjs +94 -0
- package/scripts/move-site-page.mjs +46 -254
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +17 -17
- package/scripts/sync-site-public.mjs +44 -66
- package/scripts/upgrade-site.mjs +17 -0
- package/src/components/AreaNavigationMenu.astro +24 -0
- package/src/components/AreaNavigationPanel.astro +55 -0
- package/src/components/AreaNavigationSwitcher.astro +30 -0
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/ImageStackEnhancement.astro +0 -7
- package/src/components/NavigationContinuity.astro +26 -0
- package/src/components/NavigationPageTree.astro +13 -4
- package/src/components/NavigationState.astro +11 -0
- package/src/components/NavigationTreeControls.astro +3 -2
- package/src/components/SearchPage.astro +11 -2
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +38 -61
- package/src/components/SiteSection.astro +1 -0
- package/src/components/SiteTreeNavigation.astro +38 -18
- package/src/components/TopPageMenu.astro +4 -3
- package/src/components/TreeNavigationScript.astro +50 -57
- package/src/content.config.ts +11 -12
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -0
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/navigationContinuity.ts +212 -0
- package/src/lib/navigationFollowing.ts +28 -18
- package/src/lib/navigationState.mjs +88 -0
- package/src/lib/navigationStateScript.mjs +76 -0
- package/src/lib/navigationVisibility.ts +9 -0
- package/src/lib/pageThemes.ts +5 -42
- package/src/lib/sectionContent.ts +67 -21
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +13 -36
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +7 -16
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +408 -0
- package/src/styles/content.css +8 -29
- package/src/styles/media.css +24 -0
- package/src/styles/navigation-continuity.css +163 -0
- package/src/styles/page-layout.css +57 -68
- package/src/styles/responsive.css +0 -4
- package/starters/basic/README.md +9 -8
- package/starters/basic/site/{pages/000-home → root}/content.md +2 -2
- package/starters/basic/site/{config.yaml → site-config/settings.yaml} +1 -0
- package/schemas/category.schema.json +0 -20
- package/src/components/PageContentsNavigation.astro +0 -20
- /package/starters/basic/site/{pages/000-home → root}/images/.gitkeep +0 -0
- /package/starters/basic/site/{theme.yaml → root/tree-theme.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { publicFilePublication } from './publication-paths.mjs';
|
|
2
|
+
import { getSiteAttachments, resolveAttachment, parseLocalAttachmentTarget, attachmentOutputIssues } from './page-attachments.mjs';
|
|
3
|
+
import { readdir, readFile, lstat } from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import {
|
|
4
6
|
createPageAliasModel,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
toPosixPath,
|
|
12
14
|
} from './site-content.mjs';
|
|
13
15
|
import {
|
|
16
|
+
siteDir as defaultSiteDir,
|
|
14
17
|
sitePagesLabel,
|
|
15
18
|
sitePublicDir,
|
|
16
19
|
sitePublicLabel,
|
|
@@ -18,11 +21,15 @@ import {
|
|
|
18
21
|
import { getSiteNodePathname } from './site-page-urls.mjs';
|
|
19
22
|
import { getSiteStructure } from './site-structure.mjs';
|
|
20
23
|
import { createCategoryDestinationModel } from './category-destinations.mjs';
|
|
24
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
25
|
+
import { configSchema } from './schema-definitions.mjs';
|
|
21
26
|
|
|
22
27
|
const internalUrlOrigin = 'https://norna.invalid';
|
|
23
28
|
const externalSchemePattern = /^[a-z][a-z0-9+.-]*:/i;
|
|
24
29
|
|
|
25
|
-
const readPublicFiles = async (directory, relativeDirectory = '') => {
|
|
30
|
+
const readPublicFiles = async (directory, settings = {}, relativeDirectory = '') => {
|
|
31
|
+
const info = await lstat(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error; });
|
|
32
|
+
if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new Error(`${directory} must be a regular public directory.`);
|
|
26
33
|
const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
|
|
27
34
|
if (error?.code === 'ENOENT') return [];
|
|
28
35
|
throw error;
|
|
@@ -30,19 +37,22 @@ const readPublicFiles = async (directory, relativeDirectory = '') => {
|
|
|
30
37
|
const files = [];
|
|
31
38
|
|
|
32
39
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
40
|
+
if (entry.name === '.DS_Store' || entry.name.startsWith('._')) continue;
|
|
33
41
|
const relativePath = relativeDirectory
|
|
34
42
|
? path.join(relativeDirectory, entry.name)
|
|
35
43
|
: entry.name;
|
|
36
44
|
const entryPath = path.join(directory, entry.name);
|
|
37
45
|
if (entry.isDirectory()) {
|
|
38
|
-
|
|
46
|
+
publicFilePublication(toPosixPath(relativePath), settings, { directory: true });
|
|
47
|
+
files.push(...await readPublicFiles(entryPath, settings, relativePath));
|
|
39
48
|
} else if (entry.isFile()) {
|
|
40
49
|
files.push({
|
|
41
50
|
filePath: entryPath,
|
|
42
|
-
label:
|
|
43
|
-
|
|
51
|
+
label: entryPath,
|
|
52
|
+
relativePath: toPosixPath(relativePath),
|
|
53
|
+
...publicFilePublication(toPosixPath(relativePath), settings),
|
|
44
54
|
});
|
|
45
|
-
}
|
|
55
|
+
} else throw new Error(`${entryPath} must be a regular public file. Symbolic links are not supported.`);
|
|
46
56
|
}
|
|
47
57
|
|
|
48
58
|
return files;
|
|
@@ -68,32 +78,47 @@ const getPageLookupPathname = (pathname) => {
|
|
|
68
78
|
|
|
69
79
|
const isExternalTarget = (target) => target.startsWith('//') || externalSchemePattern.test(target);
|
|
70
80
|
|
|
71
|
-
export const resolveInternalTarget = (target, sourcePathname) => {
|
|
72
|
-
|
|
81
|
+
export const resolveInternalTarget = (target, sourcePathname, siteUrl) => {
|
|
82
|
+
const absolute = isExternalTarget(target);
|
|
83
|
+
if (absolute && (!siteUrl || !/^(?:https?:)?\/\//i.test(target))) return { kind: 'external' };
|
|
73
84
|
|
|
74
85
|
let url;
|
|
86
|
+
let siteBase;
|
|
75
87
|
try {
|
|
76
|
-
|
|
88
|
+
siteBase = absolute ? new URL(siteUrl) : null;
|
|
89
|
+
url = new URL(target, siteBase ?? `${internalUrlOrigin}${sourcePathname}`);
|
|
77
90
|
} catch {
|
|
91
|
+
if (absolute) return { kind: 'external' };
|
|
78
92
|
return {
|
|
79
93
|
kind: 'invalid',
|
|
80
94
|
reason: 'The target is not a valid relative or site-relative URL.',
|
|
81
95
|
};
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
if (url.origin !== internalUrlOrigin) return { kind: 'external' };
|
|
98
|
+
if (url.origin !== (siteBase?.origin ?? internalUrlOrigin) || url.username || url.password) return { kind: 'external' };
|
|
85
99
|
|
|
86
100
|
try {
|
|
87
|
-
|
|
101
|
+
let pathname = decodePathname(url.pathname);
|
|
102
|
+
let absolutePrefix;
|
|
103
|
+
if (siteBase) {
|
|
104
|
+
const encodedBase = siteBase.pathname.replace(/\/?$/, '/');
|
|
105
|
+
const base = decodePathname(encodedBase);
|
|
106
|
+
if (pathname === base.slice(0, -1)) pathname = '/';
|
|
107
|
+
else if (pathname.startsWith(base)) pathname = '/' + pathname.slice(base.length);
|
|
108
|
+
else return { kind: 'external' };
|
|
109
|
+
absolutePrefix = (target.startsWith('//') ? `//${url.host}` : url.origin) + encodedBase.slice(0, -1);
|
|
110
|
+
}
|
|
88
111
|
const fragment = url.hash.length > 1 ? decodeFragment(url.hash.slice(1)) : '';
|
|
89
112
|
return {
|
|
90
113
|
kind: 'internal',
|
|
114
|
+
...(absolutePrefix ? { absolutePrefix } : {}),
|
|
91
115
|
fragment,
|
|
92
116
|
pageLookupPathname: getPageLookupPathname(pathname),
|
|
93
117
|
pathname,
|
|
94
118
|
query: url.search,
|
|
95
119
|
};
|
|
96
120
|
} catch (error) {
|
|
121
|
+
if (absolute) return { kind: 'external' };
|
|
97
122
|
return {
|
|
98
123
|
kind: 'invalid',
|
|
99
124
|
reason: error instanceof Error ? error.message : String(error),
|
|
@@ -146,9 +171,11 @@ const looksLikePublicFile = (pathname) => {
|
|
|
146
171
|
};
|
|
147
172
|
|
|
148
173
|
export const createSiteLinkGraph = ({
|
|
174
|
+
siteUrl,
|
|
149
175
|
siteStructure,
|
|
150
176
|
pageDocuments,
|
|
151
177
|
publicFiles = [],
|
|
178
|
+
attachments = { files: [], diagnostics: [] },
|
|
152
179
|
generatedRoutes,
|
|
153
180
|
}) => {
|
|
154
181
|
const activeGeneratedRoutes = generatedRoutes ?? generatedSiteRoutes;
|
|
@@ -193,15 +220,28 @@ export const createSiteLinkGraph = ({
|
|
|
193
220
|
|
|
194
221
|
const references = [];
|
|
195
222
|
const referencesByTarget = new Map();
|
|
196
|
-
const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics
|
|
223
|
+
const diagnostics = [...aliasModel.diagnostics, ...categoryModel.diagnostics, ...attachments.diagnostics,
|
|
224
|
+
...attachmentOutputIssues({ files: attachments.files, pages, publicFiles, generatedRoutes: activeGeneratedRoutes })];
|
|
197
225
|
|
|
198
226
|
for (const page of pages) {
|
|
199
227
|
for (const sourceReference of page.document.links) {
|
|
200
|
-
const target = resolveInternalTarget(sourceReference.target, page.pathname);
|
|
228
|
+
const target = resolveInternalTarget(sourceReference.target, page.pathname, siteUrl);
|
|
201
229
|
if (target.kind === 'external') continue;
|
|
230
|
+
const attachment = target.kind === 'internal'
|
|
231
|
+
? resolveAttachment(sourceReference.target, page.pathname, attachments.files, target.pathname) : null;
|
|
232
|
+
// One origin/base path may also host independently built sites. Only
|
|
233
|
+
// adopt absolute URLs whose destination this source tree actually owns.
|
|
234
|
+
if (target.absolutePrefix && !attachment
|
|
235
|
+
&& !pagesByPathname.has(target.pageLookupPathname)
|
|
236
|
+
&& !aliasModel.aliasesByPathname.has(target.pageLookupPathname)
|
|
237
|
+
&& !publicFilesByPathname.has(target.pathname)
|
|
238
|
+
&& !categoriesByPathname.has(target.pageLookupPathname)
|
|
239
|
+
&& !generatedRoutesByPathname.has(target.pathname)
|
|
240
|
+
&& !generatedRoutesByPathname.has(target.pageLookupPathname)) continue;
|
|
202
241
|
|
|
203
242
|
const reference = {
|
|
204
243
|
...sourceReference,
|
|
244
|
+
sourceTarget: sourceReference.target,
|
|
205
245
|
sourceContentFile: page.contentFile,
|
|
206
246
|
sourcePage: page,
|
|
207
247
|
target,
|
|
@@ -222,6 +262,23 @@ export const createSiteLinkGraph = ({
|
|
|
222
262
|
if (!referencesByTarget.has(targetKey)) referencesByTarget.set(targetKey, []);
|
|
223
263
|
referencesByTarget.get(targetKey).push(reference);
|
|
224
264
|
|
|
265
|
+
const localAttachment = parseLocalAttachmentTarget(sourceReference.target);
|
|
266
|
+
if (attachment) {
|
|
267
|
+
const ambiguous = localAttachment?.prefix === '' && (publicFilesByPathname.has(target.pathname) || pagesByPathname.has(target.pageLookupPathname) || aliasModel.aliasesByPathname.has(target.pageLookupPathname));
|
|
268
|
+
if (ambiguous) {
|
|
269
|
+
reference.resolution = { kind: 'ambiguous-attachment' };
|
|
270
|
+
diagnostics.push(createIssue(reference, 'ambiguous-attachment', `Link "${sourceReference.target}" could identify a page/public file or a local attachment.`, `Use ${attachment.pathname} for the attachment or an explicit site-relative page/public link.`));
|
|
271
|
+
} else reference.resolution = { kind: 'attachment', file: attachment, pathname: attachment.pathname };
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (localAttachment?.prefix) {
|
|
276
|
+
const expectedPath = path.join(path.dirname(page.contentFile.contentPath), 'downloads', localAttachment.filename);
|
|
277
|
+
reference.resolution = { kind: 'missing-attachment', pathname: target.pathname };
|
|
278
|
+
diagnostics.push(createIssue(reference, 'missing-attachment', `Attachment "${sourceReference.target}" was not found at ${toPosixPath(expectedPath)}.`, 'Add the file to this page’s downloads/ folder or correct the link.'));
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
225
282
|
const targetPage = target.pageLookupPathname
|
|
226
283
|
? pagesByPathname.get(target.pageLookupPathname)
|
|
227
284
|
: null;
|
|
@@ -304,13 +361,19 @@ export const createSiteLinkGraph = ({
|
|
|
304
361
|
continue;
|
|
305
362
|
}
|
|
306
363
|
|
|
364
|
+
if (target.pathname.startsWith('/_norna/downloads/') && !target.pathname.endsWith('/')) {
|
|
365
|
+
reference.resolution = { kind: 'missing-attachment', pathname: target.pathname };
|
|
366
|
+
diagnostics.push(createIssue(reference, 'missing-attachment', `Attachment "${sourceReference.target}" does not exist.`, 'Add the file to its owning page’s downloads/ or correct the link.'));
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
307
370
|
if (looksLikePublicFile(target.pathname)) {
|
|
308
371
|
reference.resolution = { kind: 'missing-public-file', pathname: target.pathname };
|
|
309
372
|
diagnostics.push(createIssue(
|
|
310
373
|
reference,
|
|
311
374
|
'missing-public-file',
|
|
312
|
-
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to public file "${target.pathname}", but no matching file exists under ${sitePublicLabel}/.`,
|
|
313
|
-
|
|
375
|
+
`Internal link "${sourceReference.target}" on line ${sourceReference.line} points to public file "${target.pathname}", but no matching file exists under ${sitePublicLabel}/ or the owning page’s downloads/.`,
|
|
376
|
+
'Use the published address of an allowed public image or technical file, or link to a page-owned attachment.',
|
|
314
377
|
));
|
|
315
378
|
continue;
|
|
316
379
|
}
|
|
@@ -327,6 +390,8 @@ export const createSiteLinkGraph = ({
|
|
|
327
390
|
}
|
|
328
391
|
|
|
329
392
|
return {
|
|
393
|
+
siteUrl,
|
|
394
|
+
attachments,
|
|
330
395
|
categoryModel,
|
|
331
396
|
aliasModel,
|
|
332
397
|
aliases: aliasModel.aliases,
|
|
@@ -342,7 +407,10 @@ export const createSiteLinkGraph = ({
|
|
|
342
407
|
};
|
|
343
408
|
|
|
344
409
|
export const getSiteLinkGraph = async (options = {}) => {
|
|
345
|
-
const
|
|
410
|
+
const siteRoot = options.siteRoot ?? defaultSiteDir;
|
|
411
|
+
const siteStructure = options.siteStructure ?? await getSiteStructure({ siteRoot });
|
|
412
|
+
const settingsPath = path.join(siteRoot, 'site-config/settings.yaml');
|
|
413
|
+
const settings = parseYamlConfig(await readFile(settingsPath, 'utf8'), settingsPath, { schema: configSchema });
|
|
346
414
|
const [pageDocuments, publicFiles] = await Promise.all([
|
|
347
415
|
Promise.all(siteStructure.contentFiles.map(async (contentFile) => {
|
|
348
416
|
const source = await readFile(contentFile.contentPath, 'utf8');
|
|
@@ -353,15 +421,17 @@ export const getSiteLinkGraph = async (options = {}) => {
|
|
|
353
421
|
document: await parsePageMarkdownSource(source, { label: contentFile.contentLabel }),
|
|
354
422
|
};
|
|
355
423
|
})),
|
|
356
|
-
readPublicFiles(options.publicDir ??
|
|
424
|
+
readPublicFiles(options.publicDir ?? path.join(siteRoot, 'public'), settings),
|
|
357
425
|
]);
|
|
358
426
|
|
|
359
427
|
return createSiteLinkGraph({
|
|
428
|
+
siteUrl: settings.url,
|
|
360
429
|
pageDocuments,
|
|
361
430
|
publicFiles,
|
|
362
431
|
siteStructure,
|
|
432
|
+
attachments: await getSiteAttachments(siteStructure),
|
|
363
433
|
generatedRoutes: options.generatedRoutes,
|
|
364
434
|
});
|
|
365
435
|
};
|
|
366
436
|
|
|
367
|
-
export const getSitePublicFiles = async (publicDir = sitePublicDir) => readPublicFiles(publicDir);
|
|
437
|
+
export const getSitePublicFiles = async (publicDir = sitePublicDir, settings = {}) => readPublicFiles(publicDir, settings);
|
|
@@ -30,7 +30,6 @@ export const getListedSiteNavigationTree = (entries) => {
|
|
|
30
30
|
const filterListed = (nodes) => nodes.flatMap((node) => {
|
|
31
31
|
if (!node.node.isHome && !node.node.navigation.listed) return [];
|
|
32
32
|
const children = filterListed(node.children);
|
|
33
|
-
if (node.node.kind === 'category' && children.length === 0) return [];
|
|
34
33
|
return [{ ...node, children }];
|
|
35
34
|
});
|
|
36
35
|
|
|
@@ -48,9 +47,7 @@ export const getSequentialPageNavigation = (nodes, currentPagePath) => {
|
|
|
48
47
|
));
|
|
49
48
|
if (!activeRoot) return { previous: null, next: null };
|
|
50
49
|
|
|
51
|
-
const pages = flattenSiteNavigationTree([activeRoot])
|
|
52
|
-
.filter(({ node }) => node.kind === 'page')
|
|
53
|
-
.map(({ node }) => node);
|
|
50
|
+
const pages = flattenSiteNavigationTree([activeRoot]).map(({ node }) => node);
|
|
54
51
|
const currentIndex = pages.findIndex(({ pagePath }) => pagePath === currentPagePath);
|
|
55
52
|
if (currentIndex < 0) return { previous: null, next: null };
|
|
56
53
|
|
|
@@ -68,7 +65,5 @@ export const getDirectChildPages = (nodes, currentPagePath) => {
|
|
|
68
65
|
? nodes.filter(({ node }) => !node.isHome)
|
|
69
66
|
: current.children;
|
|
70
67
|
|
|
71
|
-
return children
|
|
72
|
-
.filter(({ node }) => node.kind === 'page')
|
|
73
|
-
.map(({ node }) => node);
|
|
68
|
+
return children.map(({ node }) => node);
|
|
74
69
|
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { comparePageOrder, parsePageOrder, planPageOrder, assertRenumberingAllowed } from './page-order.mjs';
|
|
2
|
+
import { pageOrderFileChanges } from './page-order-source.mjs';
|
|
3
|
+
import { writeFileAtomically } from './page-move-apply.mjs';
|
|
4
|
+
import { getSiteSourcePaths } from './site-conventions.mjs';
|
|
5
|
+
import { mkdir, realpath, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { dump as dumpYaml } from 'js-yaml';
|
|
8
|
+
import { slugifyAsciiIdentifier } from './heading-ids.mjs';
|
|
9
|
+
import { pageDirectoryPattern } from './page-model.mjs';
|
|
10
|
+
import { getSiteStructure } from './site-structure.mjs';
|
|
11
|
+
import { siteSchema } from './schema-definitions.mjs';
|
|
12
|
+
import { readEditorLinkState } from './editor-site-links.mjs';
|
|
13
|
+
import { createPageAliasModel, assertPageAliasModel } from './page-aliases.mjs';
|
|
14
|
+
import { parseYamlConfig } from './yaml-config.mjs';
|
|
15
|
+
import { publicationIssues } from './publication-paths.mjs';
|
|
16
|
+
|
|
17
|
+
const normalizeParentPath = (value) => {
|
|
18
|
+
if (value === '/') return '';
|
|
19
|
+
if (value.includes('\\') || value.includes('?') || value.includes('#')) {
|
|
20
|
+
throw new Error(`Invalid --parent "${value}". Use a logical path such as /guides/installation/.`);
|
|
21
|
+
}
|
|
22
|
+
const normalized = value.replace(/^\/+|\/+$/g, '');
|
|
23
|
+
if (!normalized || normalized.split('/').some((segment) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segment))) {
|
|
24
|
+
throw new Error(`Invalid --parent "${value}". Use / for the top level or a logical path such as /guides/installation/.`);
|
|
25
|
+
}
|
|
26
|
+
return normalized;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const escapeMarkdownHeading = (value) => value.replace(/([\\`*_[\]{}<>#])/g, '\\$1');
|
|
30
|
+
|
|
31
|
+
const canonicalDirectory = async (directory) => realpath(directory).catch((error) => {
|
|
32
|
+
if (error?.code === 'ENOENT') return path.resolve(directory);
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const resolveParent = async ({ nodes, parent, siteRoot, invocationDirectory }) => {
|
|
37
|
+
const sitePagesDir = getSiteSourcePaths(siteRoot).pages;
|
|
38
|
+
if (parent !== null) {
|
|
39
|
+
const parentPath = normalizeParentPath(parent);
|
|
40
|
+
if (!parentPath) {
|
|
41
|
+
return { collectionDir: sitePagesDir, node: null, pagePath: '' };
|
|
42
|
+
}
|
|
43
|
+
const node = nodes.find((candidate) => candidate.pagePath === parentPath);
|
|
44
|
+
if (!node) {
|
|
45
|
+
throw new Error(`Cannot find parent "${parent}". Use an existing page path, or / for the top level.`);
|
|
46
|
+
}
|
|
47
|
+
return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const currentDirectory = await canonicalDirectory(invocationDirectory);
|
|
51
|
+
if (currentDirectory === await canonicalDirectory(sitePagesDir) || currentDirectory === await canonicalDirectory(siteRoot)) {
|
|
52
|
+
return { collectionDir: sitePagesDir, node: null, pagePath: '' };
|
|
53
|
+
}
|
|
54
|
+
let node = null;
|
|
55
|
+
for (const candidate of nodes) {
|
|
56
|
+
if (await canonicalDirectory(candidate.nodeDir) === currentDirectory) {
|
|
57
|
+
node = candidate;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!node) {
|
|
62
|
+
throw new Error([
|
|
63
|
+
'Cannot infer where to add the node from the current directory.',
|
|
64
|
+
`Run the command from ${sitePagesDir} for a top-level node, from an existing page directory for a child, or pass --parent.`,
|
|
65
|
+
].join('\n'));
|
|
66
|
+
}
|
|
67
|
+
return { collectionDir: path.join(node.nodeDir, 'pages'), node, pagePath: node.pagePath };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Planning never writes. Callers must present this same destination before applying.
|
|
71
|
+
export const planSiteNodeCreation = async ({
|
|
72
|
+
siteRoot, kind, title, slug = null, order = null, parentPath = '/',
|
|
73
|
+
invocationDirectory = siteRoot, metadata, sources = new Map(), placement = null, targetPath = null,
|
|
74
|
+
}) => {
|
|
75
|
+
const request = { siteRoot, kind, title, slug, order, parentPath, invocationDirectory, metadata, placement, targetPath };
|
|
76
|
+
if (placement !== null && !['before', 'after', 'first', 'last'].includes(placement)) throw new Error('Choose before, after, first or last.');
|
|
77
|
+
if (placement !== null && order !== null) throw new Error('Do not combine --order with a placement selector.');
|
|
78
|
+
if (kind !== 'page') throw new Error('Choose a page. Navigation categories are no longer supported; use page.listChildren: true for an overview.');
|
|
79
|
+
siteRoot = path.resolve(siteRoot);
|
|
80
|
+
title = String(title ?? '').trim();
|
|
81
|
+
if (!title || /[\r\n]/.test(title)) throw new Error('A one-line page title is required.');
|
|
82
|
+
const generatedSlug = slug === null;
|
|
83
|
+
slug ??= slugifyAsciiIdentifier(title);
|
|
84
|
+
if (!slug || !pageDirectoryPattern.test(slug)) {
|
|
85
|
+
throw new Error(`Invalid ${generatedSlug ? 'generated ' : ''}slug "${slug}". Use lowercase ASCII letters, numbers, and single hyphens; pass --slug when automatic transliteration is unsuitable.`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const structure = await getSiteStructure({ siteRoot });
|
|
89
|
+
let relativeTarget = null;
|
|
90
|
+
if (placement === 'before' || placement === 'after') {
|
|
91
|
+
relativeTarget = structure.nodes.find(node => node.contentPath === targetPath || `/${node.pagePath}/` === targetPath);
|
|
92
|
+
if (!relativeTarget || relativeTarget.isHome) throw new Error('Choose an existing sibling page for before/after placement.');
|
|
93
|
+
parentPath = relativeTarget.parentPagePath ? `/${relativeTarget.parentPagePath}/` : '/';
|
|
94
|
+
}
|
|
95
|
+
const parent = await resolveParent({ nodes: structure.nodes, parent: parentPath, siteRoot, invocationDirectory });
|
|
96
|
+
const siblings = structure.nodes.filter((node) => !node.isHome && node.parentPagePath === (parent.pagePath || null)).sort(comparePageOrder);
|
|
97
|
+
if (siblings.some((node) => node.pageId === slug)) {
|
|
98
|
+
throw new Error(`Cannot create "${slug}" below ${parent.pagePath ? `/${parent.pagePath}/` : '/'}. A sibling with that slug already exists.`);
|
|
99
|
+
}
|
|
100
|
+
const dirty = [...sources.keys()].filter(filename => filename === path.join(siteRoot, 'site-config/settings.yaml') || siblings.some(node => node.contentPath === filename));
|
|
101
|
+
if (dirty.length) throw new Error(`Save or undo these unsaved files before choosing page placement:\n${dirty.join('\n')}`);
|
|
102
|
+
const index = placement === 'first' ? 0 : relativeTarget ? siblings.indexOf(relativeTarget) + (placement === 'after' ? 1 : 0) : siblings.length;
|
|
103
|
+
const insertion = order === null ? planPageOrder(siblings, index) : { order: parsePageOrder(Number(order), '--order'), orderChanges: [] };
|
|
104
|
+
order = insertion.order;
|
|
105
|
+
const orderChanges = await pageOrderFileChanges(insertion.orderChanges);
|
|
106
|
+
const directoryName = slug;
|
|
107
|
+
const destination = path.join(parent.collectionDir, directoryName);
|
|
108
|
+
const pagePath = [parent.pagePath, slug].filter(Boolean).join('/');
|
|
109
|
+
const state = await readEditorLinkState({ siteRoot, sources });
|
|
110
|
+
if (state.incomplete.length) throw new Error(`Page addresses could not be checked. ${state.incomplete.join('\n')}`);
|
|
111
|
+
const address = `/${pagePath}/`;
|
|
112
|
+
const owner = state.graph.aliasModel.identitiesByPathname.get(address);
|
|
113
|
+
if (owner?.kind === 'page-alias') {
|
|
114
|
+
const page = owner.source.page;
|
|
115
|
+
throw new Error(`${address} is a previous address for “${page.title}”, now at ${owner.source.targetPathname}. Choose another URL segment, or remove this previous address from “${page.title}” through Addresses and links… → Additional addresses….`);
|
|
116
|
+
}
|
|
117
|
+
if (owner) throw new Error(`Address ${address} is already in use by ${owner.label}. Choose another URL segment.`);
|
|
118
|
+
if (metadata !== undefined) {
|
|
119
|
+
metadata = parseYamlConfig(dumpYaml(metadata), 'Page information', { schema: siteSchema });
|
|
120
|
+
assertPageAliasModel(createPageAliasModel({ pages: [...state.graph.pages,
|
|
121
|
+
{ pathname: `/${pagePath}/`, contentLabel: destination, aliases: metadata.page?.aliases ?? [] }],
|
|
122
|
+
categories: [], publicFiles: state.publicFiles, generatedRoutes: state.generatedRoutes }));
|
|
123
|
+
}
|
|
124
|
+
const publicationErrors = publicationIssues({ pages: [...state.graph.pages,
|
|
125
|
+
{ pathname: address, contentLabel: destination, aliases: metadata?.page?.aliases ?? [] }],
|
|
126
|
+
publicFiles: state.publicFiles, attachments: state.graph.attachments.files, generatedRoutes: state.generatedRoutes });
|
|
127
|
+
if (publicationErrors.length) throw new Error(publicationErrors.map(({ message }) => message).join('\n'));
|
|
128
|
+
metadata = { ...metadata, navigation: { ...metadata?.navigation, order } };
|
|
129
|
+
const fingerprint = JSON.stringify([destination, title, metadata, order, orderChanges, state.settings,
|
|
130
|
+
state.graph.pages.map(page => [page.contentFile.contentPath, page.document.fullSource]), state.publicFiles.map(file => file.filePath)]);
|
|
131
|
+
return { request, fingerprint, orderChanges, siteRoot, kind, title, slug, order, parentPath: parent.pagePath ? `/${parent.pagePath}/` : '/',
|
|
132
|
+
collectionDir: parent.collectionDir, destination, pagePath, url: `/${pagePath}/`, ...(metadata === undefined ? {} : { metadata }) };
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const createSiteNode = async (plan, { sources = new Map(), allowRenumber = false, writeSource = writeFileAtomically } = {}) => {
|
|
136
|
+
// Revalidate after the author has inspected the preview. Never change its placement.
|
|
137
|
+
const current = await planSiteNodeCreation({ ...(plan.request ?? plan), sources });
|
|
138
|
+
if (current.destination !== plan.destination || current.fingerprint !== plan.fingerprint) throw new Error('The site changed. Preview the new location before creating the node.');
|
|
139
|
+
assertRenumberingAllowed(current.orderChanges, allowRenumber);
|
|
140
|
+
const written = [];
|
|
141
|
+
let createdCollection = false;
|
|
142
|
+
try {
|
|
143
|
+
const info = await stat(current.collectionDir);
|
|
144
|
+
if (!info.isDirectory()) throw new Error(`${current.collectionDir} exists but is not a directory.`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error.code !== 'ENOENT') throw error;
|
|
147
|
+
await mkdir(current.collectionDir);
|
|
148
|
+
createdCollection = true;
|
|
149
|
+
}
|
|
150
|
+
const created = [];
|
|
151
|
+
try {
|
|
152
|
+
// Exclusive reservation also protects an empty destination created after planning.
|
|
153
|
+
await mkdir(current.destination);
|
|
154
|
+
created.push({ file: current.destination, directory: true });
|
|
155
|
+
const filename = path.join(current.destination, 'content.md');
|
|
156
|
+
const source = `${current.metadata && Object.keys(current.metadata).length ? `---\n${dumpYaml(current.metadata, { lineWidth: -1, noRefs: true })}---\n\n` : ''}# ${escapeMarkdownHeading(current.title)}\n\n## Introduction\n\nStart writing here.\n`;
|
|
157
|
+
await writeFile(filename, source, { flag: 'wx' });
|
|
158
|
+
created.push({ file: filename });
|
|
159
|
+
const childDirectory = path.join(current.destination, 'images');
|
|
160
|
+
await mkdir(childDirectory);
|
|
161
|
+
created.push({ file: childDirectory, directory: true });
|
|
162
|
+
for (const change of current.orderChanges) {
|
|
163
|
+
await writeSource(change.originalPath, change.updatedSource, change.mode);
|
|
164
|
+
written.push(change);
|
|
165
|
+
}
|
|
166
|
+
return { ...current, sourcePath: filename, renumberedPages: current.orderChanges.length };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const restoreErrors = [];
|
|
169
|
+
for (const change of written.reverse()) {
|
|
170
|
+
try { await writeFileAtomically(change.originalPath, change.originalSource, change.mode); }
|
|
171
|
+
catch (failure) { restoreErrors.push(`${change.originalPath}: ${failure.message}`); }
|
|
172
|
+
}
|
|
173
|
+
// Remove only entries this operation created; never recursively remove an
|
|
174
|
+
// unexpected file written concurrently by another process.
|
|
175
|
+
for (const entry of created.reverse()) {
|
|
176
|
+
await (entry.directory ? rmdir(entry.file) : unlink(entry.file)).catch(() => {});
|
|
177
|
+
}
|
|
178
|
+
if (createdCollection) await rmdir(current.collectionDir).catch(() => {});
|
|
179
|
+
if (restoreErrors.length) throw new Error(`${error.message}\nRestoration was incomplete:\n${restoreErrors.join('\n')}`);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { homePageDirectory } from './site-conventions.mjs';
|
|
4
|
+
import { getSiteSourcePaths, homePageDirectory, legacyHomePageDirectory } from './site-conventions.mjs';
|
|
5
5
|
|
|
6
6
|
const siteDirectoryEnvName = 'NORNA_SITE_DIR';
|
|
7
7
|
const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
|
|
@@ -32,9 +32,10 @@ if (hasStateDirectoryEnv && !configuredStateDirectory) {
|
|
|
32
32
|
|
|
33
33
|
const hasSiteFilesInDirectory = (siteDir) => {
|
|
34
34
|
return (
|
|
35
|
-
existsSync(path.join(siteDir, 'config.yaml'))
|
|
35
|
+
(existsSync(path.join(siteDir, 'site-config', 'settings.yaml')) || existsSync(path.join(siteDir, 'config.yaml')))
|
|
36
36
|
&& (
|
|
37
|
-
existsSync(path.join(siteDir, 'pages',
|
|
37
|
+
existsSync(path.join(siteDir, 'pages', legacyHomePageDirectory, 'content.md'))
|
|
38
|
+
|| existsSync(getSiteSourcePaths(siteDir).content)
|
|
38
39
|
|| existsSync(path.join(siteDir, 'content.md'))
|
|
39
40
|
)
|
|
40
41
|
);
|
|
@@ -63,6 +64,16 @@ const findSiteProjectRoot = (startDirectory, siteDirectory) => {
|
|
|
63
64
|
}
|
|
64
65
|
};
|
|
65
66
|
|
|
67
|
+
const findContainingSite = (startDirectory) => {
|
|
68
|
+
let current = path.resolve(startDirectory);
|
|
69
|
+
while (true) {
|
|
70
|
+
if (hasSiteFilesInDirectory(current)) return current;
|
|
71
|
+
const parent = path.dirname(current);
|
|
72
|
+
if (parent === current) return null;
|
|
73
|
+
current = parent;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
66
77
|
const hasSiteDirectoryEnv = Object.hasOwn(process.env, siteDirectoryEnvName);
|
|
67
78
|
const configuredSiteDirectory = normalizeSiteDirectory(process.env[siteDirectoryEnvName]);
|
|
68
79
|
const hasConfiguredSiteDirectory = hasSiteDirectoryEnv && configuredSiteDirectory !== '';
|
|
@@ -84,8 +95,9 @@ const resolveSitePaths = () => {
|
|
|
84
95
|
};
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
const containingSite = !hasConfiguredSiteDirectory && findContainingSite(invocationRoot);
|
|
99
|
+
if (containingSite) {
|
|
100
|
+
const siteDir = containingSite;
|
|
89
101
|
const siteProjectRoot = path.dirname(siteDir);
|
|
90
102
|
|
|
91
103
|
return {
|
|
@@ -126,11 +138,11 @@ const getPathLabel = (filePath) => {
|
|
|
126
138
|
};
|
|
127
139
|
|
|
128
140
|
export const siteDir = resolvedSitePaths.siteDir;
|
|
129
|
-
export const siteConfigPath = path.join(siteDir, 'config.yaml');
|
|
130
|
-
export const siteThemePath =
|
|
131
|
-
export const sitewideContentPath = path.join(siteDir, '
|
|
132
|
-
export const sitePagesDir =
|
|
133
|
-
export const siteHomePageDir =
|
|
141
|
+
export const siteConfigPath = path.join(siteDir, 'site-config', 'settings.yaml');
|
|
142
|
+
export const siteThemePath = getSiteSourcePaths(siteDir).theme;
|
|
143
|
+
export const sitewideContentPath = path.join(siteDir, 'site-config', 'shared-content.yaml');
|
|
144
|
+
export const sitePagesDir = getSiteSourcePaths(siteDir).pages;
|
|
145
|
+
export const siteHomePageDir = getSiteSourcePaths(siteDir).root;
|
|
134
146
|
export const siteContentPath = path.join(siteHomePageDir, 'content.md');
|
|
135
147
|
export const siteImagesDir = path.join(siteHomePageDir, 'images');
|
|
136
148
|
export const sitePublicDir = path.join(siteDir, 'public');
|
|
@@ -143,8 +155,8 @@ export const astroDistDir = configuredStateDirectory
|
|
|
143
155
|
: path.join(siteProjectRoot, 'dist');
|
|
144
156
|
export const astroRootDir = siteStateDir;
|
|
145
157
|
export const astroCacheDir = path.join(astroRootDir, '.astro');
|
|
146
|
-
export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');
|
|
147
|
-
export const originalImagesDir = path.join(astroPublicDir, 'images', 'original');
|
|
158
|
+
export const generatedImagesDir = path.join(astroPublicDir, '_norna', 'images', 'generated');
|
|
159
|
+
export const originalImagesDir = path.join(astroPublicDir, '_norna', 'images', 'original');
|
|
148
160
|
export const generatedImagesManifestPath = path.join(siteStateDir, 'generated-images.json');
|
|
149
161
|
|
|
150
162
|
export const engineRootLabel = getPathLabel(engineRoot);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { load } from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
export const assertSiteSourceFormat = async (siteRoot, readSource = filename => readFile(filename, 'utf8')) => {
|
|
6
|
+
const filename = path.join(siteRoot, 'site-config/settings.yaml');
|
|
7
|
+
const source = await readSource(filename);
|
|
8
|
+
let settings;
|
|
9
|
+
try { settings = load(source); }
|
|
10
|
+
catch (error) { throw new Error(`${filename} contains invalid YAML: ${error.reason ?? error.message}`); }
|
|
11
|
+
if (settings?.sourceFormat !== 2) throw new Error(`${filename} needs sourceFormat: 2 after converting the site: move numeric page-directory prefixes into navigation.order frontmatter and remove those prefixes. Keep this site on its previous engine until converted. No files changed.`);
|
|
12
|
+
};
|