@janga/norna 0.7.27 → 0.7.28
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 +37 -28
- package/astro.config.mjs +19 -1
- package/bin/norna-cli.mjs +5 -3
- package/package.json +9 -5
- package/schemas/card-list.schema.json +1 -1
- package/schemas/config.schema.json +66 -66
- package/schemas/content-frontmatter.schema.json +16 -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 +13 -161
- package/scripts/build-site.mjs +11 -6
- package/scripts/check-config.mjs +7 -23
- package/scripts/dev-local.mjs +17 -10
- package/scripts/export-theme-preset.mjs +2 -2
- package/scripts/generate-images.mjs +7 -6
- package/scripts/generate-schemas.mjs +9 -6
- package/scripts/lib/category-destinations.mjs +13 -12
- package/scripts/lib/documentation-links.mjs +1 -1
- package/scripts/lib/documentation-routes.json +67 -60
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -6
- package/scripts/lib/editor-file-policy.mjs +18 -0
- package/scripts/lib/editor-language-service.mjs +53 -55
- package/scripts/lib/editor-page-addresses.mjs +55 -0
- package/scripts/lib/editor-page-placement.mjs +172 -0
- package/scripts/lib/editor-site-editing-tree.mjs +146 -0
- package/scripts/lib/editor-site-files.mjs +153 -0
- package/scripts/lib/editor-site-links.mjs +79 -0
- package/scripts/lib/editor-site-tree.mjs +151 -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/navigation-model.mjs +2 -44
- package/scripts/lib/navigation-review.mjs +14 -50
- package/scripts/lib/norna-markdown-blocks.mjs +1 -1
- package/scripts/lib/page-aliases.mjs +3 -1
- package/scripts/lib/page-model.mjs +17 -20
- package/scripts/lib/page-move-apply.mjs +117 -0
- package/scripts/lib/page-move-plan.mjs +27 -59
- package/scripts/lib/presentation.mjs +3 -3
- package/scripts/lib/project-config.mjs +7 -8
- package/scripts/lib/schema-definitions.mjs +11 -38
- package/scripts/lib/schema-editor-metadata.mjs +12 -59
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/site-content.mjs +7 -24
- package/scripts/lib/site-conventions.mjs +19 -1
- package/scripts/lib/site-navigation-tree.mjs +2 -7
- package/scripts/lib/site-node-create.mjs +169 -0
- package/scripts/lib/site-paths.mjs +22 -10
- package/scripts/lib/site-structure.mjs +72 -82
- package/scripts/lib/site-upgrade.mjs +30 -0
- package/scripts/lib/source-files.mjs +34 -0
- 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 +3 -124
- package/scripts/show-typography.mjs +32 -17
- package/scripts/sync-content-sections.mjs +9 -7
- package/scripts/sync-site-public.mjs +1 -1
- 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/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 +9 -1
- package/src/components/SectionNavigationScript.astro +106 -26
- package/src/components/SiteNavigation.astro +45 -20
- package/src/components/SitePage.astro +37 -61
- 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 +10 -8
- package/src/layouts/BaseLayout.astro +15 -39
- package/src/lib/areaNavigation.ts +42 -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 +3 -2
- package/src/lib/siteNavigation.ts +6 -0
- package/src/lib/sitePages.ts +9 -6
- package/src/pages/404.astro +1 -1
- package/src/pages/[...slug].astro +5 -15
- package/src/pages/index.astro +1 -3
- package/src/styles/area-navigation.css +399 -0
- package/src/styles/content.css +2 -29
- 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/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/{config.yaml → site-config/settings.yaml} +0 -0
- /package/starters/basic/site/{sitewide-content.yaml → site-config/shared-content.yaml} +0 -0
|
@@ -0,0 +1,151 @@
|
|
|
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 { siteTreeEditingApiVersion, getEditorSourceFileChoices, planEditorSourceFileCreation, createEditorSourceFile } from './editor-source-files.mjs';
|
|
21
|
+
export { createSiteNode, planSiteNodeCreation, slugifyAsciiIdentifier };
|
|
22
|
+
export { siteFileOperationsApiVersion, siteRemovalApiVersion, planEditorImageCopy, planEditorRemoval, getEditorImageUsage, createEditorImageAppend, createEditorImageBatchAppend } from './editor-site-files.mjs';
|
|
23
|
+
export { getEditorIncomingLinks } from './editor-site-links.mjs';
|
|
24
|
+
export { siteAddressApiVersion, getEditorPageAddresses, planEditorPageAddress, applyEditorPageAddress } from './editor-page-addresses.mjs';
|
|
25
|
+
export { sitePagePlacementApiVersion, planEditorPagePlacement, applyEditorPagePlacement } from './editor-page-placement.mjs';
|
|
26
|
+
|
|
27
|
+
const sourceParts = (source, kind) => {
|
|
28
|
+
const split = splitPageMarkdownSource(source);
|
|
29
|
+
if (split.frontmatterUnclosed) throw new Error('Close the YAML frontmatter with --- before editing page information.');
|
|
30
|
+
const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
31
|
+
const bodyLine = split.lineOffset;
|
|
32
|
+
return {
|
|
33
|
+
yaml: bodyLine ? lines.slice(1, bodyLine - 1).join('') : '',
|
|
34
|
+
offset: bodyLine ? lines[0].length : 0,
|
|
35
|
+
body: split.body,
|
|
36
|
+
bodyLine,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const parseInformation = async ({ source, kind, isHome, sourcePath }) => {
|
|
41
|
+
const parts = sourceParts(source, kind);
|
|
42
|
+
const data = parseYamlConfig(parts.yaml, sourcePath, { schema: siteSchema });
|
|
43
|
+
const { headings } = await getMarkdownHeadings(parts.body);
|
|
44
|
+
const titles = headings.filter(({ depth }) => depth === 1);
|
|
45
|
+
if (titles.length !== 1) throw new Error('Keep exactly one Markdown H1 title in this page before editing its information.');
|
|
46
|
+
if (isHome && data.navigation?.listed === false) throw new Error('Home must remain listed. Remove navigation.listed: false.');
|
|
47
|
+
return {
|
|
48
|
+
title: titles[0].title,
|
|
49
|
+
description: data.page?.description ?? '',
|
|
50
|
+
listed: data.navigation?.listed !== false,
|
|
51
|
+
aliases: data.page?.aliases ?? [],
|
|
52
|
+
listChildren: data.page?.listChildren === true,
|
|
53
|
+
titleLine: parts.bodyLine + titles[0].line - 1,
|
|
54
|
+
titleLineCount: titles[0].source.split('\n').length,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const getSiteNodeInformation = async (input) => {
|
|
59
|
+
try {
|
|
60
|
+
return { ...await parseInformation(input), problem: null };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { title: input.fallbackTitle ?? path.basename(path.dirname(input.sourcePath)),
|
|
63
|
+
description: '', listed: true, aliases: [], problem: error.message };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const readSiteTree = async ({ siteRoot, sources = new Map(), cache = new Map() }) => {
|
|
68
|
+
const readSource = (filename) => sources.has(filename) ? sources.get(filename) : readFile(filename, 'utf8');
|
|
69
|
+
const structure = await getSiteStructure({ siteRoot, tolerant: true, readSource });
|
|
70
|
+
const nodes = [];
|
|
71
|
+
const used = new Set();
|
|
72
|
+
for (const node of structure.nodes) {
|
|
73
|
+
const sourcePath = node.contentPath ?? node.categoryPath;
|
|
74
|
+
used.add(sourcePath);
|
|
75
|
+
let information;
|
|
76
|
+
try {
|
|
77
|
+
const source = await readSource(sourcePath);
|
|
78
|
+
const previous = cache.get(sourcePath);
|
|
79
|
+
information = previous?.source === source ? previous.information : await getSiteNodeInformation({
|
|
80
|
+
kind: node.kind, isHome: node.isHome, source, sourcePath, fallbackTitle: node.label ?? node.pageId,
|
|
81
|
+
});
|
|
82
|
+
cache.set(sourcePath, { source, information });
|
|
83
|
+
} catch (error) {
|
|
84
|
+
information = { title: node.label ?? node.pageId, listed: true, aliases: [], problem: error.message };
|
|
85
|
+
}
|
|
86
|
+
nodes.push({ ...node, ...information, sourcePath, url: node.isHome ? '/' : `/${node.pagePath}/`,
|
|
87
|
+
problem: information.problem ?? node.problems?.[0] ?? null });
|
|
88
|
+
}
|
|
89
|
+
for (const filename of cache.keys()) if (!used.has(filename)) cache.delete(filename);
|
|
90
|
+
return { nodes, problems: structure.problems };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// One physical projection preserves resources and repairable entries even when
|
|
94
|
+
// the logical structure cannot be read. The logical page API remains separate.
|
|
95
|
+
export const readSiteFileTree = async (options) => {
|
|
96
|
+
const siteRoot = path.resolve(options.siteRoot);
|
|
97
|
+
const snapshot = await readSiteTree({ ...options, siteRoot }).catch((error) => ({
|
|
98
|
+
nodes: [], problems: [{ path: siteRoot, message: error.message }],
|
|
99
|
+
}));
|
|
100
|
+
return readSiteEditingTree({ ...options, siteRoot, snapshot, getInformation: getSiteNodeInformation });
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const editSiteNodeInformation = async ({ siteRoot, sourcePath, source, field, value, sources = new Map() }) => {
|
|
104
|
+
const relative = path.relative(getSiteSourcePaths(siteRoot).pages, sourcePath).split(path.sep).join('/');
|
|
105
|
+
const filename = path.posix.basename(relative);
|
|
106
|
+
if (filename !== 'content.md') throw new Error('Choose a page content.md source file.');
|
|
107
|
+
const pageDirectory = path.resolve(sourcePath) === getSiteSourcePaths(path.resolve(siteRoot)).content
|
|
108
|
+
? homePageDirectory : path.posix.dirname(relative);
|
|
109
|
+
parsePageDirectoryPath(pageDirectory);
|
|
110
|
+
const kind = 'page';
|
|
111
|
+
const isHome = pageDirectory === homePageDirectory;
|
|
112
|
+
const info = await parseInformation({ source, kind, isHome, sourcePath });
|
|
113
|
+
const allowed = ['title', 'description', 'listed', 'aliases', 'listChildren'];
|
|
114
|
+
if (!allowed.includes(field)) throw new Error('This information field is read-only.');
|
|
115
|
+
if (field === 'aliases') {
|
|
116
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) throw new Error('Enter additional addresses as site-relative paths, such as /old-guide/.');
|
|
117
|
+
} else if (field === 'listed' || field === 'listChildren') {
|
|
118
|
+
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.');
|
|
119
|
+
if (field === 'listed' && isHome && !value) throw new Error('Home must remain listed.');
|
|
120
|
+
} else {
|
|
121
|
+
if (typeof value !== 'string' || /[\r\n]/.test(value)) throw new Error('Use a single line of text.');
|
|
122
|
+
value = value.trim();
|
|
123
|
+
if (field === 'title' && !value) throw new Error('Enter a title.');
|
|
124
|
+
}
|
|
125
|
+
if (info[field] === value) return [];
|
|
126
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
127
|
+
if (field === 'title') {
|
|
128
|
+
const lines = source.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
129
|
+
const start = lines.slice(0, info.titleLine).join('').length;
|
|
130
|
+
const original = lines.slice(info.titleLine, info.titleLine + info.titleLineCount).join('');
|
|
131
|
+
return [{ start, end: start + original.replace(/\r?\n$/, '').length, text: `# ${escapeMarkdownHeading(value)}` }];
|
|
132
|
+
}
|
|
133
|
+
const parts = sourceParts(source, kind);
|
|
134
|
+
const keys = field === 'listed' ? ['navigation', 'listed'] : ['page', field === 'aliases' ? 'aliases' : field === 'listChildren' ? 'listChildren' : 'description'];
|
|
135
|
+
const yaml = changeYamlField(parts.yaml, keys, (field === 'description' && !value) || (field === 'aliases' && !value.length) || (field === 'listChildren' && !value) ? undefined : value, eol);
|
|
136
|
+
const next = parts.bodyLine ? !yaml.trim() ? source.slice(parts.offset + parts.yaml.length).replace(/^---[ \t]*(?:\r?\n|$)/, '')
|
|
137
|
+
: source.slice(0, parts.offset) + yaml + source.slice(parts.offset + parts.yaml.length)
|
|
138
|
+
: `---${eol}${yaml}---${eol}${eol}${source}`;
|
|
139
|
+
// Validate the complete result before offering it to the editor.
|
|
140
|
+
await parseInformation({ source: next, kind, isHome, sourcePath });
|
|
141
|
+
if (field === 'aliases') {
|
|
142
|
+
const state = await readEditorLinkState({ siteRoot, sources: new Map([...sources, [sourcePath, next]]) });
|
|
143
|
+
if (state.incomplete.length) throw new Error(`Additional addresses could not be checked. ${state.incomplete.join('\n')}`);
|
|
144
|
+
}
|
|
145
|
+
let start = 0;
|
|
146
|
+
while (start < source.length && start < next.length && source[start] === next[start]) start++;
|
|
147
|
+
let end = source.length;
|
|
148
|
+
let nextEnd = next.length;
|
|
149
|
+
while (end > start && nextEnd > start && source[end - 1] === next[nextEnd - 1]) { end--; nextEnd--; }
|
|
150
|
+
return [{ start, end, text: next.slice(start, nextEnd) }];
|
|
151
|
+
};
|
|
@@ -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.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
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { getSourcePageLocation } from './source-files.mjs';
|
|
3
|
+
import { selectPageTheme } from './theme-packages.mjs';
|
|
4
|
+
|
|
5
|
+
const presetHelp = (siteRoot, selection) => {
|
|
6
|
+
const origin = selection.sourceFiles.findLast((file) => file.config.preset !== undefined);
|
|
7
|
+
return `Preset: ${selection.selected.config.preset}\nPreset source: ${path.relative(siteRoot, origin.path)}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// Cache only within one tree read: dirty buffers and external changes must be
|
|
11
|
+
// reflected on the next refresh. The engine resolver owns all inheritance rules.
|
|
12
|
+
export const createEditorThemeHelp = ({ siteRoot, sources }) => {
|
|
13
|
+
const selections = new Map();
|
|
14
|
+
return async (directory, filename) => {
|
|
15
|
+
const includePageTheme = !filename || path.basename(filename) === 'page-theme.yaml';
|
|
16
|
+
const key = JSON.stringify([directory, includePageTheme]);
|
|
17
|
+
if (!selections.has(key)) selections.set(key, selectPageTheme({ siteRoot, sources,
|
|
18
|
+
pageDirectory: getSourcePageLocation(siteRoot, directory).pageDirectory, includePageTheme })
|
|
19
|
+
.catch(() => null));
|
|
20
|
+
const selection = await selections.get(key);
|
|
21
|
+
if (!selection) return 'Preset unavailable. Check the theme diagnostics.';
|
|
22
|
+
const file = filename && selection.sourceFiles.find((entry) => entry.path === filename);
|
|
23
|
+
const mode = !file ? '' : file.config.preset === undefined
|
|
24
|
+
? 'Modifies inherited settings. Add preset to replace the base.'
|
|
25
|
+
: getSourcePageLocation(siteRoot, directory).isHome && !includePageTheme
|
|
26
|
+
? 'Required base for the site. Its preset cannot be omitted.'
|
|
27
|
+
: 'Replaces inherited settings, even with the same preset name. Remove preset to resume inheritance.';
|
|
28
|
+
return [presetHelp(siteRoot, selection), mode].filter(Boolean).join('\n');
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const getEditorThemeRemovalHelp = async ({ siteRoot, filename, sources }) => {
|
|
33
|
+
const afterRemoval = new Map(sources);
|
|
34
|
+
afterRemoval.set(filename, null);
|
|
35
|
+
try {
|
|
36
|
+
const selection = await selectPageTheme({ siteRoot, sources: afterRemoval,
|
|
37
|
+
pageDirectory: getSourcePageLocation(siteRoot, path.dirname(filename)).pageDirectory,
|
|
38
|
+
includePageTheme: false });
|
|
39
|
+
return `After removal, the inherited tree theme is ${path.relative(siteRoot, selection.treeTheme.path)}.\n${presetHelp(siteRoot, selection)}`;
|
|
40
|
+
} catch {
|
|
41
|
+
return 'The inherited preset cannot be determined until the remaining theme errors are repaired.';
|
|
42
|
+
}
|
|
43
|
+
};
|
|
@@ -562,7 +562,7 @@ const normalizeLanguageTag = (value) => {
|
|
|
562
562
|
|
|
563
563
|
const formatSupportedLanguages = () => supportedLocaleTags.join(', ');
|
|
564
564
|
|
|
565
|
-
export const resolveLocale = (configuredLanguage = 'en', sourceLabel = 'site/config.yaml') => {
|
|
565
|
+
export const resolveLocale = (configuredLanguage = 'en', sourceLabel = 'site/site-config/settings.yaml') => {
|
|
566
566
|
let parsed;
|
|
567
567
|
try {
|
|
568
568
|
parsed = normalizeLanguageTag(configuredLanguage);
|
|
@@ -61,7 +61,7 @@ export default Object.freeze({
|
|
|
61
61
|
pageSections: 'Sections',
|
|
62
62
|
previousImage: 'Previous image',
|
|
63
63
|
previousPage: 'Previous page',
|
|
64
|
-
returnHome: '
|
|
64
|
+
returnHome: 'Home',
|
|
65
65
|
searchReturn: 'Back to {page}',
|
|
66
66
|
search: 'Search',
|
|
67
67
|
searchDescription: 'Search the published content on this site.',
|
|
@@ -17,24 +17,11 @@ const getListedNodes = (nodes) => nodes.filter((node) => node.isHome || node.lis
|
|
|
17
17
|
|
|
18
18
|
const getNodeDepth = (node) => node.depth ?? 1;
|
|
19
19
|
|
|
20
|
-
const getTopLevelPagePath = (pagePath) => pagePath?.split('/')[0] ?? '';
|
|
21
|
-
|
|
22
|
-
const getActiveBranchNodes = (nodes, currentPage) => {
|
|
23
|
-
if (!currentPage) return [];
|
|
24
|
-
if (currentPage.isHome) return nodes.filter((node) => node.isHome);
|
|
25
|
-
|
|
26
|
-
const rootPath = getTopLevelPagePath(currentPage.pagePath);
|
|
27
|
-
return nodes.filter((node) => (
|
|
28
|
-
node.pagePath === rootPath
|
|
29
|
-
|| node.pagePath?.startsWith(`${rootPath}/`)
|
|
30
|
-
));
|
|
31
|
-
};
|
|
32
|
-
|
|
33
20
|
export const getAutomaticNavigationMode = (nodes) => {
|
|
34
21
|
const listedNodes = getListedNodes(nodes);
|
|
35
22
|
if (listedNodes.length <= 1) return 'sections';
|
|
36
23
|
|
|
37
|
-
return listedNodes.some((node) =>
|
|
24
|
+
return listedNodes.some((node) => getNodeDepth(node) > 1)
|
|
38
25
|
? 'tree'
|
|
39
26
|
: 'top';
|
|
40
27
|
};
|
|
@@ -42,10 +29,6 @@ export const getAutomaticNavigationMode = (nodes) => {
|
|
|
42
29
|
export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
|
|
43
30
|
const requestedMode = assertNavigationMode(mode);
|
|
44
31
|
const listedNodes = getListedNodes(nodes);
|
|
45
|
-
const hasCategories = listedNodes.some((node) => node.kind === 'category');
|
|
46
|
-
if (hasCategories && requestedMode !== 'automatic' && requestedMode !== 'tree') {
|
|
47
|
-
throw new Error(`Navigation categories require tree navigation. Remove navigation.mode: ${requestedMode}, or set navigation.mode: tree.`);
|
|
48
|
-
}
|
|
49
32
|
const maximumDepth = listedNodes.reduce((maximum, node) => (
|
|
50
33
|
Math.max(maximum, getNodeDepth(node))
|
|
51
34
|
), 1);
|
|
@@ -57,33 +40,8 @@ export const resolveNavigationModel = ({ mode = 'automatic', nodes }) => {
|
|
|
57
40
|
: requestedMode,
|
|
58
41
|
requestedMode,
|
|
59
42
|
listedNodeCount: listedNodes.length,
|
|
60
|
-
hasCategories,
|
|
43
|
+
hasCategories: false,
|
|
61
44
|
hasNestedPages,
|
|
62
45
|
maximumDepth,
|
|
63
46
|
});
|
|
64
47
|
};
|
|
65
|
-
|
|
66
|
-
export const resolvePageContentsPlacement = ({
|
|
67
|
-
navigationMode,
|
|
68
|
-
nodes,
|
|
69
|
-
currentPage,
|
|
70
|
-
headingCount,
|
|
71
|
-
}) => {
|
|
72
|
-
const listedNodes = getListedNodes(nodes);
|
|
73
|
-
const activeBranchNodes = getActiveBranchNodes(listedNodes, currentPage);
|
|
74
|
-
const activeBranchDepth = activeBranchNodes.reduce((maximum, node) => (
|
|
75
|
-
Math.max(maximum, getNodeDepth(node))
|
|
76
|
-
), 0);
|
|
77
|
-
const hasPageContents = headingCount >= 2;
|
|
78
|
-
const placement = currentPage?.isHome || navigationMode !== 'tree' || !hasPageContents
|
|
79
|
-
? 'none'
|
|
80
|
-
: activeBranchDepth <= 2
|
|
81
|
-
? 'page-tree'
|
|
82
|
-
: 'contents-rail';
|
|
83
|
-
|
|
84
|
-
return Object.freeze({
|
|
85
|
-
activeBranchDepth,
|
|
86
|
-
hasPageContents,
|
|
87
|
-
placement,
|
|
88
|
-
});
|
|
89
|
-
};
|
|
@@ -26,12 +26,11 @@ const getTreeMetrics = (root) => {
|
|
|
26
26
|
const nodes = flattenSiteNavigationTree([root]);
|
|
27
27
|
const rootDepth = root.node.depth;
|
|
28
28
|
return {
|
|
29
|
-
categoryCount: nodes.filter(({ node }) => node.kind === 'category').length,
|
|
30
29
|
maximumLevels: nodes.reduce((maximum, { node }) => (
|
|
31
30
|
Math.max(maximum, node.depth - rootDepth + 1)
|
|
32
31
|
), 1),
|
|
33
32
|
nodeCount: nodes.length,
|
|
34
|
-
pageCount: nodes.
|
|
33
|
+
pageCount: nodes.length,
|
|
35
34
|
};
|
|
36
35
|
};
|
|
37
36
|
|
|
@@ -58,7 +57,7 @@ const getSiblingGroups = (roots) => {
|
|
|
58
57
|
return groups;
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
const
|
|
60
|
+
const toGraphFinding = (diagnostic) => ({
|
|
62
61
|
code: diagnostic.code,
|
|
63
62
|
...(diagnostic.fix ? { fix: diagnostic.fix } : {}),
|
|
64
63
|
...(diagnostic.reference?.line ? { line: diagnostic.reference.line } : {}),
|
|
@@ -85,10 +84,8 @@ const getNavigationEntries = (siteStructure, linkGraph) => {
|
|
|
85
84
|
]));
|
|
86
85
|
|
|
87
86
|
return siteStructure.nodes.map((siteNode) => {
|
|
88
|
-
const page = siteNode.
|
|
89
|
-
|
|
90
|
-
: null;
|
|
91
|
-
if (siteNode.kind === 'page' && !page) {
|
|
87
|
+
const page = pagesByDirectory.get(siteNode.pageDirectory);
|
|
88
|
+
if (!page) {
|
|
92
89
|
throw new Error(`Navigation review could not find parsed content for ${siteNode.contentLabel}.`);
|
|
93
90
|
}
|
|
94
91
|
|
|
@@ -99,8 +96,8 @@ const getNavigationEntries = (siteStructure, linkGraph) => {
|
|
|
99
96
|
navigation: {
|
|
100
97
|
listed: siteNode.isHome || (page?.navigation.listed ?? true),
|
|
101
98
|
},
|
|
102
|
-
pathname:
|
|
103
|
-
title:
|
|
99
|
+
pathname: page.pathname,
|
|
100
|
+
title: page.title,
|
|
104
101
|
},
|
|
105
102
|
page,
|
|
106
103
|
sections: [],
|
|
@@ -121,7 +118,7 @@ const getNavigationModes = ({ entries, listedEntries, requestedNavigationMode })
|
|
|
121
118
|
const modes = new Map();
|
|
122
119
|
const seenErrors = new Set();
|
|
123
120
|
|
|
124
|
-
for (const entry of entries
|
|
121
|
+
for (const entry of entries) {
|
|
125
122
|
try {
|
|
126
123
|
const model = resolveNavigationModel({
|
|
127
124
|
currentPage: {
|
|
@@ -173,9 +170,7 @@ export const createNavigationReview = ({
|
|
|
173
170
|
requestedNavigationMode,
|
|
174
171
|
});
|
|
175
172
|
|
|
176
|
-
const pages = completeEntries
|
|
177
|
-
.filter(({ node }) => node.kind === 'page')
|
|
178
|
-
.map(({ headings, node, page }) => {
|
|
173
|
+
const pages = completeEntries.map(({ headings, node, page }) => {
|
|
179
174
|
const incomingPageLinkCount = resolvedPageReferences.filter(({ resolution }) => (
|
|
180
175
|
resolution.page.pathname === page.pathname
|
|
181
176
|
)).length;
|
|
@@ -200,21 +195,6 @@ export const createNavigationReview = ({
|
|
|
200
195
|
};
|
|
201
196
|
});
|
|
202
197
|
|
|
203
|
-
const categories = completeEntries
|
|
204
|
-
.filter(({ node }) => node.kind === 'category')
|
|
205
|
-
.map(({ children, node }) => ({
|
|
206
|
-
childCount: children.length,
|
|
207
|
-
depth: node.depth,
|
|
208
|
-
listed: listedPaths.has(node.pagePath),
|
|
209
|
-
listedChildCount: listedPaths.has(node.pagePath)
|
|
210
|
-
? (listedEntries.find(({ node: listedNode }) => listedNode.pagePath === node.pagePath)?.children.length ?? 0)
|
|
211
|
-
: 0,
|
|
212
|
-
parentPath: node.parentPagePath === null ? null : `/${node.parentPagePath}/`,
|
|
213
|
-
path: logicalPathname(node),
|
|
214
|
-
source: node.categorySourceLabel,
|
|
215
|
-
title: node.title,
|
|
216
|
-
}));
|
|
217
|
-
|
|
218
198
|
const branches = listedTree.map((root) => {
|
|
219
199
|
const metrics = getTreeMetrics(root);
|
|
220
200
|
const branchPaths = new Set(flattenSiteNavigationTree([root]).map(({ node }) => node.pagePath));
|
|
@@ -235,7 +215,8 @@ export const createNavigationReview = ({
|
|
|
235
215
|
const siblingGroups = getSiblingGroups(listedTree);
|
|
236
216
|
const widestSiblingCount = siblingGroups.reduce((maximum, group) => Math.max(maximum, group.count), 0);
|
|
237
217
|
const observations = [
|
|
238
|
-
...siteStructure.warnings.map(toStructureObservation),
|
|
218
|
+
...siteStructure.warnings.filter(({ code }) => code !== 'empty-category').map(toStructureObservation),
|
|
219
|
+
...linkGraph.diagnostics.filter(({ severity }) => severity === 'warning').map(toGraphFinding),
|
|
239
220
|
];
|
|
240
221
|
const unlistedPages = pages.filter(({ listed }) => !listed);
|
|
241
222
|
if (unlistedPages.length > 0) {
|
|
@@ -246,13 +227,6 @@ export const createNavigationReview = ({
|
|
|
246
227
|
}
|
|
247
228
|
|
|
248
229
|
const recommendations = [];
|
|
249
|
-
for (const category of categories.filter(({ listed, listedChildCount }) => listed && listedChildCount === 1)) {
|
|
250
|
-
recommendations.push({
|
|
251
|
-
code: 'single-child-category',
|
|
252
|
-
message: `${category.title} (${category.path}) has one listed child. Keep the category when its label adds useful orientation; otherwise consider moving the child to the category's parent.`,
|
|
253
|
-
path: category.path,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
230
|
for (const branch of branches.filter(({ maximumLevels }) => maximumLevels >= thresholds.deepBranchLevels)) {
|
|
257
231
|
recommendations.push({
|
|
258
232
|
code: 'deep-branch',
|
|
@@ -279,7 +253,7 @@ export const createNavigationReview = ({
|
|
|
279
253
|
|
|
280
254
|
return {
|
|
281
255
|
command: 'navigation:review',
|
|
282
|
-
schemaVersion:
|
|
256
|
+
schemaVersion: 2,
|
|
283
257
|
thresholds: {
|
|
284
258
|
deepBranchLevels: thresholds.deepBranchLevels,
|
|
285
259
|
sectionCount: thresholds.sectionCount,
|
|
@@ -287,10 +261,8 @@ export const createNavigationReview = ({
|
|
|
287
261
|
},
|
|
288
262
|
site: {
|
|
289
263
|
branchCount: branches.length,
|
|
290
|
-
categoryCount: categories.length,
|
|
291
264
|
effectiveNavigationModes,
|
|
292
265
|
internalReferenceCount: linkGraph.references.length,
|
|
293
|
-
listedCategoryCount: categories.filter(({ listed }) => listed).length,
|
|
294
266
|
listedPageCount: pages.filter(({ listed }) => listed).length,
|
|
295
267
|
maximumDepth: listedEntries.reduce((maximum, { node }) => Math.max(maximum, node.depth), 0),
|
|
296
268
|
pageCount: pages.length,
|
|
@@ -301,9 +273,8 @@ export const createNavigationReview = ({
|
|
|
301
273
|
branches,
|
|
302
274
|
siblingGroups,
|
|
303
275
|
pages,
|
|
304
|
-
categories,
|
|
305
276
|
errors: [
|
|
306
|
-
...linkGraph.diagnostics.map(
|
|
277
|
+
...linkGraph.diagnostics.filter(({ severity }) => severity === 'error').map(toGraphFinding),
|
|
307
278
|
...navigationErrors,
|
|
308
279
|
],
|
|
309
280
|
observations,
|
|
@@ -330,13 +301,13 @@ export const formatNavigationReviewText = (review) => {
|
|
|
330
301
|
'',
|
|
331
302
|
'Site',
|
|
332
303
|
`- Navigation: ${review.site.requestedNavigationMode} configured; ${review.site.effectiveNavigationModes.join(', ') || 'unresolved'} effective`,
|
|
333
|
-
`- Content: ${plural(review.site.pageCount, 'page')} (${review.site.listedPageCount} listed)
|
|
304
|
+
`- Content: ${plural(review.site.pageCount, 'page')} (${review.site.listedPageCount} listed)`,
|
|
334
305
|
`- Structure: ${plural(review.site.branchCount, 'top-level branch', 'top-level branches')}, ${review.site.maximumDepth} listed ${review.site.maximumDepth === 1 ? 'level' : 'levels'}, widest sibling group ${review.site.widestSiblingCount}`,
|
|
335
306
|
`- Links: ${plural(review.site.internalReferenceCount, 'internal reference')}, ${review.site.resolvedPageLinkCount} resolved to pages`,
|
|
336
307
|
'',
|
|
337
308
|
'Branches',
|
|
338
309
|
...review.branches.map((branch) => (
|
|
339
|
-
`- ${branch.title} (${branch.path}; ${branch.kind}): ${plural(branch.pageCount, 'page')}, ${plural(branch.
|
|
310
|
+
`- ${branch.title} (${branch.path}; ${branch.kind}): ${plural(branch.pageCount, 'page')}, ${plural(branch.maximumLevels, 'visible level')}; navigation ${branch.navigationModes.join(', ') || 'unresolved'}`
|
|
340
311
|
)),
|
|
341
312
|
'',
|
|
342
313
|
'Pages',
|
|
@@ -344,13 +315,6 @@ export const formatNavigationReviewText = (review) => {
|
|
|
344
315
|
`- ${page.title} (${page.pathname}; ${page.listed ? 'listed' : 'not listed'}): H2 ${page.h2Count}, H3 ${page.h3Count}; page links ${page.outgoingPageLinkCount} out / ${page.incomingPageLinkCount} in; navigation ${page.navigationMode ?? 'unresolved'}`
|
|
345
316
|
)),
|
|
346
317
|
'',
|
|
347
|
-
'Categories',
|
|
348
|
-
...(review.categories.length === 0
|
|
349
|
-
? ['- None.']
|
|
350
|
-
: review.categories.map((category) => (
|
|
351
|
-
`- ${category.title} (${category.path}; ${category.listed ? 'listed' : 'not listed'}): ${category.listedChildCount} listed of ${plural(category.childCount, 'direct child', 'direct children')}`
|
|
352
|
-
))),
|
|
353
|
-
'',
|
|
354
318
|
...formatFindingSection('Errors', review.errors),
|
|
355
319
|
'',
|
|
356
320
|
...formatFindingSection('Observations', review.observations),
|
|
@@ -56,7 +56,7 @@ export const nornaMarkdownBlockDefinitions = Object.freeze({
|
|
|
56
56
|
xl: value('Extra large', 'Use the largest card and image size.'),
|
|
57
57
|
}),
|
|
58
58
|
}),
|
|
59
|
-
width: field('Override the
|
|
59
|
+
width: field('Override the shared theme\'s maximum width for this card list.', {
|
|
60
60
|
values: Object.freeze({
|
|
61
61
|
text: value('Text', 'Match the active body-text width, including a reader-selected reading width.'),
|
|
62
62
|
narrow: value('Narrow', 'Limit the complete card list to at most 48rem.'),
|
|
@@ -154,7 +154,9 @@ export const createPageAliasModel = ({
|
|
|
154
154
|
contentFile,
|
|
155
155
|
fix: conflict.kind === 'page-alias'
|
|
156
156
|
? 'Keep the old URL on exactly one current page.'
|
|
157
|
-
:
|
|
157
|
+
: conflict.kind === 'page'
|
|
158
|
+
? `Rename the page at ${conflict.label}, or remove ${pathname} from the previous addresses in ${contentLabel}. Removing it may break old links.`
|
|
159
|
+
: 'Remove or change the alias. A public URL can identify only one site resource.',
|
|
158
160
|
message: `Page alias "${pathname}" in ${contentLabel} conflicts with ${describeIdentity(conflict)}.`,
|
|
159
161
|
severity: 'error',
|
|
160
162
|
});
|
|
@@ -12,8 +12,8 @@ export const parsePageDirectory = (pageDirectory, label = 'page directory') => {
|
|
|
12
12
|
`Invalid ${label} "${pageDirectory}". Page directories must use the form NNN-page-id, for example 010-getting-started. Page ids may contain only lowercase letters, numbers, and single hyphens.`,
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
|
-
if (match[1] === '000'
|
|
16
|
-
throw new Error(`Invalid ${label} "${pageDirectory}". The
|
|
15
|
+
if (match[1] === '000') {
|
|
16
|
+
throw new Error(`Invalid ${label} "${pageDirectory}". Child page orders must be 001–999. The homepage is root/content.md in the site container.`);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
return {
|
|
@@ -27,6 +27,13 @@ const normalizePageDirectoryPath = (pageDirectoryPath) => pageDirectoryPath.repl
|
|
|
27
27
|
|
|
28
28
|
export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page directory path') => {
|
|
29
29
|
const normalizedPath = normalizePageDirectoryPath(pageDirectoryPath);
|
|
30
|
+
if (normalizedPath === homePageDirectory) {
|
|
31
|
+
return {
|
|
32
|
+
pageDirectory: homePageDirectory, pageDirectories: [], pageId: 'home',
|
|
33
|
+
pageIds: [], pageOrder: 0, pageOrders: [], pagePath: '',
|
|
34
|
+
parentPagePath: null, depth: 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
30
37
|
const segments = normalizedPath.split('/').filter(Boolean);
|
|
31
38
|
|
|
32
39
|
if (!normalizedPath || normalizedPath.startsWith('/') || normalizedPath.endsWith('/') || segments.length % 2 === 0) {
|
|
@@ -52,29 +59,18 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
52
59
|
const pageIds = pageDirectories.map(({ pageId }) => pageId);
|
|
53
60
|
const pageOrders = pageDirectories.map(({ pageOrder }) => pageOrder);
|
|
54
61
|
const currentPage = pageDirectories.at(-1);
|
|
55
|
-
const homeIndex = pageDirectories.findIndex(({ pageDirectory }) => pageDirectory === homePageDirectory);
|
|
56
|
-
if (homeIndex > 0) {
|
|
57
|
-
throw new Error(`Invalid ${label} "${pageDirectoryPath}". ${homePageDirectory} is allowed only as a top-level page.`);
|
|
58
|
-
}
|
|
59
|
-
if (homeIndex === 0 && pageDirectories.length > 1) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
`Invalid ${label} "${pageDirectoryPath}". ${homePageDirectory} is the homepage and cannot contain child pages. Place the page beside it under site/pages/, or below another non-home page.`,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const isHome = homeIndex === 0;
|
|
65
|
-
const logicalPageIds = isHome ? [] : pageIds;
|
|
66
|
-
const logicalPageOrders = isHome ? [] : pageOrders;
|
|
67
62
|
|
|
68
63
|
return {
|
|
69
64
|
pageDirectory: normalizedPath,
|
|
70
65
|
pageDirectories: pageDirectories.map(({ pageDirectory }) => pageDirectory),
|
|
71
66
|
pageId: currentPage.pageId,
|
|
72
|
-
pageIds
|
|
67
|
+
pageIds,
|
|
73
68
|
pageOrder: currentPage.pageOrder,
|
|
74
|
-
pageOrders
|
|
75
|
-
pagePath:
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
pageOrders,
|
|
70
|
+
pagePath: pageIds.join('/'),
|
|
71
|
+
// Navigation projects the root's children as top-level entries.
|
|
72
|
+
parentPagePath: pageIds.length > 1
|
|
73
|
+
? pageIds.slice(0, -1).join('/')
|
|
78
74
|
: null,
|
|
79
75
|
depth: pageDirectories.length,
|
|
80
76
|
};
|
|
@@ -82,10 +78,11 @@ export const parsePageDirectoryPath = (pageDirectoryPath, label = 'page director
|
|
|
82
78
|
|
|
83
79
|
export const encodePageDirectoryPath = (pageDirectoryPath) => {
|
|
84
80
|
const { pageDirectories } = parsePageDirectoryPath(pageDirectoryPath);
|
|
85
|
-
return pageDirectories.join(pageEntryPathSeparator);
|
|
81
|
+
return pageDirectories.length ? pageDirectories.join(pageEntryPathSeparator) : 'root';
|
|
86
82
|
};
|
|
87
83
|
|
|
88
84
|
export const decodePageDirectoryPath = (pageEntryPath, label = 'page entry path') => {
|
|
85
|
+
if (pageEntryPath === 'root') return homePageDirectory;
|
|
89
86
|
const pageDirectories = pageEntryPath.split(pageEntryPathSeparator);
|
|
90
87
|
if (pageDirectories.some((pageDirectory) => !pageDirectory)) {
|
|
91
88
|
throw new Error(`Invalid ${label} "${pageEntryPath}".`);
|