@janga/norna 0.7.23 → 0.7.24
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 +14 -18
- package/astro.config.mjs +12 -0
- package/bin/norna-cli.mjs +6 -0
- package/package.json +13 -2
- package/schemas/category.schema.json +2 -2
- package/schemas/config.schema.json +45 -15
- package/schemas/content-frontmatter.schema.json +7 -7
- package/schemas/page-theme.schema.json +26 -26
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +246 -246
- package/scripts/build-site.mjs +1 -0
- package/scripts/check-config.mjs +12 -1
- package/scripts/generate-search-index.mjs +57 -0
- package/scripts/init-site.mjs +1 -0
- package/scripts/lib/code-fence-metadata.mjs +223 -0
- package/scripts/lib/edit-source-link.mjs +49 -0
- package/scripts/lib/editor-language-service.mjs +32 -12
- package/scripts/lib/image-presentation.mjs +4 -0
- package/scripts/lib/navigation-model.mjs +34 -13
- package/scripts/lib/navigation-review.mjs +396 -0
- package/scripts/lib/norna-markdown-blocks.mjs +73 -26
- package/scripts/lib/norna-markdown-render-plugin.mjs +64 -1
- package/scripts/lib/page-aliases.mjs +21 -1
- package/scripts/lib/page-markdown.mjs +32 -1
- package/scripts/lib/page-move-plan.mjs +659 -0
- package/scripts/lib/presentation-palette-metadata.mjs +1 -1
- package/scripts/lib/presentation.mjs +1 -10
- package/scripts/lib/project-config.mjs +111 -5
- package/scripts/lib/public-asset-conventions.mjs +36 -2
- package/scripts/lib/schema-definitions.mjs +22 -5
- package/scripts/lib/schema-editor-metadata.mjs +19 -4
- package/scripts/lib/schema-value-definitions.mjs +1 -1
- package/scripts/lib/semantic-callouts.mjs +128 -0
- package/scripts/lib/site-content.mjs +2 -1
- package/scripts/lib/site-link-graph.mjs +33 -2
- package/scripts/lib/site-navigation-tree.mjs +85 -0
- package/scripts/lib/social-image-assets.mjs +30 -0
- package/scripts/lib/theme-presets.mjs +2 -2
- package/scripts/lib/theme-profiles.mjs +0 -4
- package/scripts/move-site-page.mjs +256 -0
- package/scripts/review-navigation.mjs +32 -0
- package/scripts/sync-content-sections.mjs +67 -4
- package/scripts/sync-site-public.mjs +24 -5
- package/src/components/CodeBlockCopyScript.astro +9 -4
- package/src/components/EditSourceLink.astro +17 -0
- package/src/components/ImageCarousel.astro +5 -6
- package/src/components/NavigationPageTree.astro +66 -55
- package/src/components/NavigationTreeControls.astro +77 -0
- package/src/components/PageAliasRedirect.astro +1 -1
- package/src/components/PageList.astro +33 -0
- package/src/components/PageSequenceNavigation.astro +37 -0
- package/src/components/SearchPage.astro +151 -0
- package/src/components/SiteNavigation.astro +35 -8
- package/src/components/SitePage.astro +79 -26
- package/src/components/SiteSection.astro +11 -3
- package/src/components/SiteTreeNavigation.astro +8 -1
- package/src/components/TreeNavigationScript.astro +207 -26
- package/src/layouts/BaseLayout.astro +57 -2
- package/src/lib/sectionContent.ts +32 -3
- package/src/lib/siteNavigation.ts +31 -52
- package/src/lib/sitePublicAssets.ts +1 -0
- package/src/pages/404.astro +110 -0
- package/src/pages/[...slug].astro +15 -5
- package/src/styles/content.css +242 -18
- package/src/styles/media.css +0 -5
- package/src/styles/navigation.css +31 -0
- package/src/styles/page-layout.css +438 -89
- package/src/styles/responsive.css +77 -8
- package/starters/basic/README.md +6 -19
- package/starters/basic/package.json +1 -0
- package/starters/basic/site/pages/000-home/content.md +13 -50
|
@@ -2,6 +2,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {
|
|
4
4
|
createPageAliasModel,
|
|
5
|
+
generatedSiteRoutes,
|
|
5
6
|
} from './page-aliases.mjs';
|
|
6
7
|
import { parsePageMarkdownSource } from './page-markdown.mjs';
|
|
7
8
|
import {
|
|
@@ -117,6 +118,9 @@ const createPageRecord = ({ contentFile, data = {}, document }) => {
|
|
|
117
118
|
anchors,
|
|
118
119
|
contentFile,
|
|
119
120
|
document,
|
|
121
|
+
navigation: {
|
|
122
|
+
listed: data.navigation?.listed ?? true,
|
|
123
|
+
},
|
|
120
124
|
pathname,
|
|
121
125
|
title: document.pageTitle?.title ?? contentFile.pageId,
|
|
122
126
|
};
|
|
@@ -140,7 +144,13 @@ const looksLikePublicFile = (pathname) => {
|
|
|
140
144
|
return (pathname.split('/').at(-1) ?? '').includes('.');
|
|
141
145
|
};
|
|
142
146
|
|
|
143
|
-
export const createSiteLinkGraph = ({
|
|
147
|
+
export const createSiteLinkGraph = ({
|
|
148
|
+
siteStructure,
|
|
149
|
+
pageDocuments,
|
|
150
|
+
publicFiles = [],
|
|
151
|
+
generatedRoutes,
|
|
152
|
+
}) => {
|
|
153
|
+
const activeGeneratedRoutes = generatedRoutes ?? generatedSiteRoutes;
|
|
144
154
|
const documentsByDirectory = new Map(pageDocuments.map(({ contentFile, data, document }) => [
|
|
145
155
|
contentFile.pageDirectory,
|
|
146
156
|
{ contentFile, data, document },
|
|
@@ -167,7 +177,11 @@ export const createSiteLinkGraph = ({ siteStructure, pageDocuments, publicFiles
|
|
|
167
177
|
categories: siteStructure.categories,
|
|
168
178
|
pages,
|
|
169
179
|
publicFiles,
|
|
180
|
+
generatedRoutes: activeGeneratedRoutes,
|
|
170
181
|
});
|
|
182
|
+
const generatedRoutesByPathname = new Map(
|
|
183
|
+
activeGeneratedRoutes.map((route) => [route.pathname, route]),
|
|
184
|
+
);
|
|
171
185
|
|
|
172
186
|
const references = [];
|
|
173
187
|
const referencesByTarget = new Map();
|
|
@@ -249,6 +263,18 @@ export const createSiteLinkGraph = ({ siteStructure, pageDocuments, publicFiles
|
|
|
249
263
|
continue;
|
|
250
264
|
}
|
|
251
265
|
|
|
266
|
+
const generatedRoute = generatedRoutesByPathname.get(
|
|
267
|
+
target.pageLookupPathname ?? target.pathname,
|
|
268
|
+
);
|
|
269
|
+
if (generatedRoute && !target.fragment) {
|
|
270
|
+
reference.resolution = {
|
|
271
|
+
kind: 'generated-route',
|
|
272
|
+
pathname: generatedRoute.pathname,
|
|
273
|
+
route: generatedRoute,
|
|
274
|
+
};
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
|
|
252
278
|
const targetCategory = target.pageLookupPathname
|
|
253
279
|
? categoriesByPathname.get(target.pageLookupPathname)
|
|
254
280
|
: null;
|
|
@@ -320,7 +346,12 @@ export const getSiteLinkGraph = async (options = {}) => {
|
|
|
320
346
|
readPublicFiles(options.publicDir ?? sitePublicDir),
|
|
321
347
|
]);
|
|
322
348
|
|
|
323
|
-
return createSiteLinkGraph({
|
|
349
|
+
return createSiteLinkGraph({
|
|
350
|
+
pageDocuments,
|
|
351
|
+
publicFiles,
|
|
352
|
+
siteStructure,
|
|
353
|
+
generatedRoutes: options.generatedRoutes,
|
|
354
|
+
});
|
|
324
355
|
};
|
|
325
356
|
|
|
326
357
|
export const getSitePublicFiles = async (publicDir = sitePublicDir) => readPublicFiles(publicDir);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export const getSiteNavigationTree = (entries) => {
|
|
2
|
+
const nodes = new Map();
|
|
3
|
+
const roots = [];
|
|
4
|
+
|
|
5
|
+
for (const entry of entries) {
|
|
6
|
+
nodes.set(entry.node.pagePath, { ...entry, children: [] });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
for (const entry of entries) {
|
|
10
|
+
const node = nodes.get(entry.node.pagePath);
|
|
11
|
+
if (!node) continue;
|
|
12
|
+
|
|
13
|
+
if (entry.node.isHome || entry.node.parentPagePath === null) {
|
|
14
|
+
roots.push(node);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parent = nodes.get(entry.node.parentPagePath);
|
|
19
|
+
if (!parent) {
|
|
20
|
+
throw new Error(`Site node "${entry.node.pagePath}" has no parent node "${entry.node.parentPagePath}".`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
parent.children.push(node);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return roots;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const getListedSiteNavigationTree = (entries) => {
|
|
30
|
+
const filterListed = (nodes) => nodes.flatMap((node) => {
|
|
31
|
+
if (!node.node.isHome && !node.node.navigation.listed) return [];
|
|
32
|
+
const children = filterListed(node.children);
|
|
33
|
+
if (node.node.kind === 'category' && children.length === 0) return [];
|
|
34
|
+
return [{ ...node, children }];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return filterListed(getSiteNavigationTree(entries));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const getFirstPageInNavigationNode = (node) => {
|
|
41
|
+
if (node.node.kind === 'page') return node.node;
|
|
42
|
+
|
|
43
|
+
for (const child of node.children) {
|
|
44
|
+
const page = getFirstPageInNavigationNode(child);
|
|
45
|
+
if (page) return page;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const flattenSiteNavigationTree = (nodes) => nodes.flatMap((node) => [
|
|
52
|
+
node,
|
|
53
|
+
...flattenSiteNavigationTree(node.children),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export const getSequentialPageNavigation = (nodes, currentPagePath) => {
|
|
57
|
+
const activeRoot = nodes.find((root) => (
|
|
58
|
+
flattenSiteNavigationTree([root]).some(({ node }) => node.pagePath === currentPagePath)
|
|
59
|
+
));
|
|
60
|
+
if (!activeRoot) return { previous: null, next: null };
|
|
61
|
+
|
|
62
|
+
const pages = flattenSiteNavigationTree([activeRoot])
|
|
63
|
+
.filter(({ node }) => node.kind === 'page')
|
|
64
|
+
.map(({ node }) => node);
|
|
65
|
+
const currentIndex = pages.findIndex(({ pagePath }) => pagePath === currentPagePath);
|
|
66
|
+
if (currentIndex < 0) return { previous: null, next: null };
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
previous: pages[currentIndex - 1] ?? null,
|
|
70
|
+
next: pages[currentIndex + 1] ?? null,
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const getDirectChildPages = (nodes, currentPagePath) => {
|
|
75
|
+
const current = flattenSiteNavigationTree(nodes)
|
|
76
|
+
.find(({ node }) => node.pagePath === currentPagePath);
|
|
77
|
+
if (!current) return [];
|
|
78
|
+
const children = current.node.isHome
|
|
79
|
+
? nodes.filter(({ node }) => !node.isHome)
|
|
80
|
+
: current.children;
|
|
81
|
+
|
|
82
|
+
return children
|
|
83
|
+
.filter(({ node }) => node.kind === 'page')
|
|
84
|
+
.map(({ node }) => node);
|
|
85
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getPublicAssetInspection } from './logo-assets.mjs';
|
|
2
|
+
import {
|
|
3
|
+
socialImageAssetDefinitions,
|
|
4
|
+
socialImageAssetFilenames,
|
|
5
|
+
} from './public-asset-conventions.mjs';
|
|
6
|
+
import { sitePublicLabel } from './site-paths.mjs';
|
|
7
|
+
|
|
8
|
+
const definitionsByFilename = new Map(
|
|
9
|
+
socialImageAssetDefinitions.map((definition) => [definition.filename, definition]),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export const getSocialImageAssets = () => getPublicAssetInspection().socialImages
|
|
13
|
+
.map((filename) => ({
|
|
14
|
+
...definitionsByFilename.get(filename),
|
|
15
|
+
filename,
|
|
16
|
+
href: `/${filename}`,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
export const getSocialImageAsset = () => {
|
|
20
|
+
const assets = getSocialImageAssets();
|
|
21
|
+
|
|
22
|
+
if (assets.length > 1) {
|
|
23
|
+
throw new Error([
|
|
24
|
+
`Found multiple social sharing images in ${sitePublicLabel}. Keep exactly one of ${socialImageAssetFilenames.join(', ')}.`,
|
|
25
|
+
...assets.map(({ filename }) => `- ${sitePublicLabel}/${filename}`),
|
|
26
|
+
].join('\n'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return assets[0] ?? null;
|
|
30
|
+
};
|
|
@@ -4,7 +4,7 @@ import { resolveThemeProfileRecipe } from './theme-profiles.mjs';
|
|
|
4
4
|
|
|
5
5
|
export const themePresetRecipes = Object.freeze({
|
|
6
6
|
portfolio: Object.freeze({
|
|
7
|
-
color: 'near-monochrome-
|
|
7
|
+
color: 'near-monochrome-adaptive',
|
|
8
8
|
typography: 'restrained-sans',
|
|
9
9
|
rhythm: 'balanced',
|
|
10
10
|
geometry: 'image-led',
|
|
@@ -231,7 +231,7 @@ export const renderThemePresetReference = (presetName, sourceLabel = 'theme.yaml
|
|
|
231
231
|
'blocks:',
|
|
232
232
|
' cardList:',
|
|
233
233
|
' # Alternatives: text, narrow, normal, wide.',
|
|
234
|
-
' # A width written in a
|
|
234
|
+
' # A width written in a card-list block overrides this default.',
|
|
235
235
|
` width: ${blocks.cardList.width}`,
|
|
236
236
|
'',
|
|
237
237
|
'typography:',
|
|
@@ -2,10 +2,6 @@ import { freezeDeep, mergeDeep } from './object.mjs';
|
|
|
2
2
|
|
|
3
3
|
export const themeProfileDefinitions = freezeDeep({
|
|
4
4
|
color: {
|
|
5
|
-
'near-monochrome-dark': {
|
|
6
|
-
appearance: { default: 'dark' },
|
|
7
|
-
palette: 'near-monochrome',
|
|
8
|
-
},
|
|
9
5
|
'near-monochrome-adaptive': {
|
|
10
6
|
appearance: { default: 'system' },
|
|
11
7
|
palette: 'near-monochrome',
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
rmdir,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import {
|
|
13
|
+
createPageMovePlan,
|
|
14
|
+
formatPageMovePlan,
|
|
15
|
+
} from './lib/page-move-plan.mjs';
|
|
16
|
+
import {
|
|
17
|
+
getSiteLinkGraph,
|
|
18
|
+
getSitePublicFiles,
|
|
19
|
+
} from './lib/site-link-graph.mjs';
|
|
20
|
+
import {
|
|
21
|
+
sitePagesDir,
|
|
22
|
+
sitePagesLabel,
|
|
23
|
+
siteProjectRoot,
|
|
24
|
+
} from './lib/site-paths.mjs';
|
|
25
|
+
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
26
|
+
|
|
27
|
+
const usage = `
|
|
28
|
+
Usage:
|
|
29
|
+
norna page:move <old-url> <new-url> [--order <NNN>] [--no-aliases] [--write]
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
norna page:move /guides/install/ /guides/installation/
|
|
33
|
+
norna page:move /guides/install/ /reference/install/ --write
|
|
34
|
+
|
|
35
|
+
The command is a dry run unless --write is present. If the old page has
|
|
36
|
+
already been moved by hand, the same command reconciles its links and aliases.
|
|
37
|
+
`.trim();
|
|
38
|
+
|
|
39
|
+
const parseOrder = (value) => {
|
|
40
|
+
if (!/^\d{1,3}$/.test(value ?? '')) {
|
|
41
|
+
throw new Error(`Invalid --order "${value ?? ''}". Use an integer from 1 to 999.\n${usage}`);
|
|
42
|
+
}
|
|
43
|
+
const order = Number.parseInt(value, 10);
|
|
44
|
+
if (order < 1 || order > 999) {
|
|
45
|
+
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.\n${usage}`);
|
|
46
|
+
}
|
|
47
|
+
return order;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const parseArgs = (rawArgs) => {
|
|
51
|
+
const options = {
|
|
52
|
+
help: false,
|
|
53
|
+
order: null,
|
|
54
|
+
preserveAliases: true,
|
|
55
|
+
urls: [],
|
|
56
|
+
write: false,
|
|
57
|
+
};
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
|
|
60
|
+
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
61
|
+
const arg = rawArgs[index];
|
|
62
|
+
if (arg === '-h' || arg === '--help') {
|
|
63
|
+
options.help = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (arg === '--write') {
|
|
67
|
+
if (seen.has('write')) throw new Error(`--write may be specified only once.\n${usage}`);
|
|
68
|
+
seen.add('write');
|
|
69
|
+
options.write = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (arg === '--no-aliases') {
|
|
73
|
+
if (seen.has('aliases')) throw new Error(`--no-aliases may be specified only once.\n${usage}`);
|
|
74
|
+
seen.add('aliases');
|
|
75
|
+
options.preserveAliases = false;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === '--order' || arg.startsWith('--order=')) {
|
|
79
|
+
if (seen.has('order')) throw new Error(`--order may be specified only once.\n${usage}`);
|
|
80
|
+
seen.add('order');
|
|
81
|
+
const inlineValue = arg.startsWith('--order=') ? arg.slice('--order='.length) : null;
|
|
82
|
+
const value = inlineValue ?? rawArgs[index + 1];
|
|
83
|
+
if (inlineValue === null) index += 1;
|
|
84
|
+
options.order = parseOrder(value);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
88
|
+
options.urls.push(arg);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!options.help && options.urls.length !== 2) {
|
|
92
|
+
throw new Error(`page:move requires an old URL and a new URL.\n${usage}`);
|
|
93
|
+
}
|
|
94
|
+
return options;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const toDisplayPath = (filePath) => {
|
|
98
|
+
const relativePath = path.relative(siteProjectRoot, filePath);
|
|
99
|
+
return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
|
100
|
+
? relativePath
|
|
101
|
+
: filePath).split(path.sep).join('/');
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const fileExists = async (filePath) => access(filePath).then(() => true, () => false);
|
|
105
|
+
|
|
106
|
+
const writeFileAtomically = async (filePath, source, mode) => {
|
|
107
|
+
const temporaryPath = path.join(
|
|
108
|
+
path.dirname(filePath),
|
|
109
|
+
`.${path.basename(filePath)}.norna-${process.pid}-${randomUUID()}.tmp`,
|
|
110
|
+
);
|
|
111
|
+
try {
|
|
112
|
+
await writeFile(temporaryPath, source, { mode: mode & 0o777 });
|
|
113
|
+
await rename(temporaryPath, filePath);
|
|
114
|
+
} finally {
|
|
115
|
+
await rm(temporaryPath, { force: true });
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const assertSourcesUnchanged = async (plan) => {
|
|
120
|
+
for (const file of plan.sourceFiles) {
|
|
121
|
+
const currentSource = await readFile(file.contentPath, 'utf8');
|
|
122
|
+
if (currentSource !== file.source) {
|
|
123
|
+
throw new Error([
|
|
124
|
+
`Page move stopped because ${file.contentLabel} changed after the plan was created.`,
|
|
125
|
+
'Run the command again to build a fresh plan. No file was changed.',
|
|
126
|
+
].join('\n'));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const assertFinalSite = async () => {
|
|
132
|
+
const structure = await getSiteStructure();
|
|
133
|
+
const graph = await getSiteLinkGraph({ siteStructure: structure });
|
|
134
|
+
if (graph.diagnostics.length === 0) return;
|
|
135
|
+
throw new Error([
|
|
136
|
+
'Post-move link validation failed.',
|
|
137
|
+
...graph.diagnostics.flatMap((diagnostic) => [
|
|
138
|
+
`- ${diagnostic.message}`,
|
|
139
|
+
...(diagnostic.fix ? [` Fix: ${diagnostic.fix}`] : []),
|
|
140
|
+
]),
|
|
141
|
+
].join('\n'));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const applyPlan = async (plan) => {
|
|
145
|
+
await assertSourcesUnchanged(plan);
|
|
146
|
+
let collectionCreated = false;
|
|
147
|
+
let directoryMoved = false;
|
|
148
|
+
let fileWritten = false;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
if (plan.mode === 'move') {
|
|
152
|
+
if (!plan.destinationCollectionExists) {
|
|
153
|
+
await mkdir(plan.destinationCollectionDirectory);
|
|
154
|
+
collectionCreated = true;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await rename(plan.sourceDirectory, plan.destinationDirectory);
|
|
158
|
+
directoryMoved = true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error?.code === 'EXDEV') {
|
|
161
|
+
throw new Error('Page directories must stay on one filesystem so Norna can move the complete subtree atomically.');
|
|
162
|
+
}
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
for (const change of plan.fileChanges) {
|
|
168
|
+
await writeFileAtomically(change.finalPath, change.updatedSource, change.mode);
|
|
169
|
+
fileWritten = true;
|
|
170
|
+
}
|
|
171
|
+
await assertFinalSite();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const changesStarted = directoryMoved || fileWritten;
|
|
174
|
+
const rollbackErrors = [];
|
|
175
|
+
if (directoryMoved) {
|
|
176
|
+
try {
|
|
177
|
+
await rename(plan.destinationDirectory, plan.sourceDirectory);
|
|
178
|
+
directoryMoved = false;
|
|
179
|
+
} catch (rollbackError) {
|
|
180
|
+
rollbackErrors.push(`Could not restore the page directory: ${rollbackError.message}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const change of plan.fileChanges) {
|
|
185
|
+
try {
|
|
186
|
+
const restorePath = directoryMoved && change.finalPath !== change.originalPath
|
|
187
|
+
? change.finalPath
|
|
188
|
+
: change.originalPath;
|
|
189
|
+
await writeFileAtomically(restorePath, change.originalSource, change.mode);
|
|
190
|
+
} catch (rollbackError) {
|
|
191
|
+
rollbackErrors.push(`Could not restore ${change.contentLabel}: ${rollbackError.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (collectionCreated && !directoryMoved) {
|
|
196
|
+
try {
|
|
197
|
+
await rmdir(plan.destinationCollectionDirectory);
|
|
198
|
+
} catch (rollbackError) {
|
|
199
|
+
if (rollbackError?.code !== 'ENOENT' && rollbackError?.code !== 'ENOTEMPTY') {
|
|
200
|
+
rollbackErrors.push(`Could not remove the newly created pages directory: ${rollbackError.message}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new Error([
|
|
206
|
+
error instanceof Error ? error.message : String(error),
|
|
207
|
+
rollbackErrors.length === 0
|
|
208
|
+
? (changesStarted
|
|
209
|
+
? 'The original page directory and content files were restored.'
|
|
210
|
+
: 'No source file was changed.')
|
|
211
|
+
: 'Rollback was incomplete. Inspect these paths before running page:move again:',
|
|
212
|
+
...rollbackErrors.map((message) => `- ${message}`),
|
|
213
|
+
].join('\n'));
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const main = async () => {
|
|
218
|
+
const options = parseArgs(process.argv.slice(2));
|
|
219
|
+
if (options.help) {
|
|
220
|
+
console.log(usage);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const siteStructure = await getSiteStructure();
|
|
225
|
+
const [graph, publicFiles] = await Promise.all([
|
|
226
|
+
getSiteLinkGraph({ siteStructure }),
|
|
227
|
+
getSitePublicFiles(),
|
|
228
|
+
]);
|
|
229
|
+
const plan = await createPageMovePlan({
|
|
230
|
+
from: options.urls[0],
|
|
231
|
+
graph,
|
|
232
|
+
order: options.order,
|
|
233
|
+
preserveAliases: options.preserveAliases,
|
|
234
|
+
publicFiles,
|
|
235
|
+
sitePagesDir,
|
|
236
|
+
sitePagesLabel,
|
|
237
|
+
siteStructure,
|
|
238
|
+
to: options.urls[1],
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
console.log(formatPageMovePlan(plan, toDisplayPath).join('\n'));
|
|
242
|
+
if (!options.write) {
|
|
243
|
+
console.log('\nDry run only. Run the same command with --write to apply this plan.');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await applyPlan(plan);
|
|
248
|
+
console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
await main();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatNavigationReviewText,
|
|
3
|
+
getNavigationReview,
|
|
4
|
+
parseNavigationReviewArgs,
|
|
5
|
+
} from './lib/navigation-review.mjs';
|
|
6
|
+
|
|
7
|
+
const usage = `
|
|
8
|
+
Usage: norna navigation:review [--format text|json]
|
|
9
|
+
|
|
10
|
+
Read the selected site and report its page hierarchy, heading outlines,
|
|
11
|
+
internal page links, effective navigation modes, and structural review prompts.
|
|
12
|
+
No files are changed.
|
|
13
|
+
`.trim();
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const { format, help } = parseNavigationReviewArgs(process.argv.slice(2));
|
|
17
|
+
if (help) {
|
|
18
|
+
console.log(usage);
|
|
19
|
+
} else {
|
|
20
|
+
const { projectConfig } = await import('./lib/project-config.mjs');
|
|
21
|
+
const review = await getNavigationReview({
|
|
22
|
+
requestedNavigationMode: projectConfig.navigation.mode,
|
|
23
|
+
});
|
|
24
|
+
process.stdout.write(format === 'json'
|
|
25
|
+
? `${JSON.stringify(review, null, 2)}\n`
|
|
26
|
+
: formatNavigationReviewText(review));
|
|
27
|
+
if (review.errors.length > 0) process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
@@ -21,10 +21,16 @@ import {
|
|
|
21
21
|
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
22
22
|
import { readImageDimensions } from './lib/image-dimensions.mjs';
|
|
23
23
|
import { parsePageMarkdownSource } from './lib/page-markdown.mjs';
|
|
24
|
+
import {
|
|
25
|
+
getDirectChildPages,
|
|
26
|
+
getListedSiteNavigationTree,
|
|
27
|
+
} from './lib/site-navigation-tree.mjs';
|
|
24
28
|
import {
|
|
25
29
|
createSiteLinkGraph,
|
|
26
30
|
getSitePublicFiles,
|
|
27
31
|
} from './lib/site-link-graph.mjs';
|
|
32
|
+
import { getGeneratedSiteRoutes } from './lib/page-aliases.mjs';
|
|
33
|
+
import projectConfig from './lib/project-config.mjs';
|
|
28
34
|
import {
|
|
29
35
|
siteThemeLabel,
|
|
30
36
|
siteThemePath,
|
|
@@ -363,6 +369,22 @@ for (const contentFile of contentFiles) {
|
|
|
363
369
|
});
|
|
364
370
|
}
|
|
365
371
|
|
|
372
|
+
for (const error of section.calloutErrors) {
|
|
373
|
+
addSectionIssue(contentFile, section, {
|
|
374
|
+
severity: 'error',
|
|
375
|
+
message: error.message,
|
|
376
|
+
fix: error.fix,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
for (const error of section.codeFenceErrors) {
|
|
381
|
+
addSectionIssue(contentFile, section, {
|
|
382
|
+
severity: 'error',
|
|
383
|
+
message: error.message,
|
|
384
|
+
fix: error.fix,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
366
388
|
for (const reference of section.managedImages) {
|
|
367
389
|
managedImageReferences.push({ contentFile, section, reference });
|
|
368
390
|
}
|
|
@@ -379,6 +401,41 @@ for (const contentFile of contentFiles) {
|
|
|
379
401
|
});
|
|
380
402
|
}
|
|
381
403
|
|
|
404
|
+
const contentContextByPageDirectory = new Map(contentFileContexts.map((context) => [
|
|
405
|
+
context.contentFile.pageDirectory,
|
|
406
|
+
context,
|
|
407
|
+
]));
|
|
408
|
+
const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map((node) => {
|
|
409
|
+
const context = contentContextByPageDirectory.get(node.pageDirectory);
|
|
410
|
+
return {
|
|
411
|
+
node: {
|
|
412
|
+
...node,
|
|
413
|
+
navigation: {
|
|
414
|
+
listed: node.kind === 'category'
|
|
415
|
+
? true
|
|
416
|
+
: context?.frontmatterData.navigation?.listed ?? true,
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}));
|
|
421
|
+
|
|
422
|
+
for (const context of contentFileContexts) {
|
|
423
|
+
const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
|
|
424
|
+
if (childPages.length > 0) continue;
|
|
425
|
+
|
|
426
|
+
for (const section of context.sections) {
|
|
427
|
+
if (!context.validSections.has(section)) continue;
|
|
428
|
+
for (const block of section.blocks) {
|
|
429
|
+
if (block.type !== 'page-list') continue;
|
|
430
|
+
addSectionIssue(context.contentFile, section, {
|
|
431
|
+
severity: 'error',
|
|
432
|
+
message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
|
|
433
|
+
fix: 'Add a listed direct child page or remove the block.',
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
382
439
|
const siteLinkGraph = createSiteLinkGraph({
|
|
383
440
|
pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
|
|
384
441
|
contentFile,
|
|
@@ -387,9 +444,15 @@ const siteLinkGraph = createSiteLinkGraph({
|
|
|
387
444
|
})),
|
|
388
445
|
publicFiles: await getSitePublicFiles(),
|
|
389
446
|
siteStructure,
|
|
447
|
+
generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
|
|
390
448
|
});
|
|
391
449
|
for (const issue of siteLinkGraph.diagnostics) {
|
|
392
|
-
|
|
450
|
+
const issueContentFile = issue.reference?.sourceContentFile ?? issue.contentFile;
|
|
451
|
+
if (issueContentFile) {
|
|
452
|
+
addContentIssue(issueContentFile, issue);
|
|
453
|
+
} else {
|
|
454
|
+
addIssue(issue);
|
|
455
|
+
}
|
|
393
456
|
}
|
|
394
457
|
|
|
395
458
|
const imageSyncPlan = createImageSyncPlan({
|
|
@@ -441,7 +504,7 @@ for (const context of contentFileContexts) {
|
|
|
441
504
|
addSectionIssue(contentFile, section, {
|
|
442
505
|
severity: 'warning',
|
|
443
506
|
message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
|
|
444
|
-
fix: 'Use
|
|
507
|
+
fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
|
|
445
508
|
});
|
|
446
509
|
}
|
|
447
510
|
|
|
@@ -457,8 +520,8 @@ for (const context of contentFileContexts) {
|
|
|
457
520
|
if (block.type === 'image-carousel' && block.images.length < 2) {
|
|
458
521
|
addSectionIssue(contentFile, section, {
|
|
459
522
|
severity: 'error',
|
|
460
|
-
message: `
|
|
461
|
-
fix: 'Add another image entry, or use
|
|
523
|
+
message: `image-carousel on line ${block.line} contains ${block.images.length} image. An image carousel needs at least two images.`,
|
|
524
|
+
fix: 'Add another image entry, or use image-stack for a single image.',
|
|
462
525
|
});
|
|
463
526
|
}
|
|
464
527
|
}
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
} from './lib/site-paths.mjs';
|
|
11
11
|
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
12
12
|
|
|
13
|
-
const keepAstroPublicEntries = new Set([
|
|
13
|
+
const keepAstroPublicEntries = new Set([
|
|
14
|
+
'images',
|
|
15
|
+
...(projectConfig.search.enabled ? ['pagefind'] : []),
|
|
16
|
+
]);
|
|
14
17
|
|
|
15
18
|
const readDirectory = async (directory) => {
|
|
16
19
|
try {
|
|
@@ -25,11 +28,27 @@ const readDirectory = async (directory) => {
|
|
|
25
28
|
};
|
|
26
29
|
|
|
27
30
|
const sourceEntries = await readDirectory(sitePublicDir);
|
|
28
|
-
const
|
|
29
|
-
|
|
31
|
+
const generatedPublicFiles = [
|
|
32
|
+
{
|
|
33
|
+
filename: sitemapFilename,
|
|
34
|
+
explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/config.yaml.`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
filename: '404.html',
|
|
38
|
+
explanation: 'Norna generates 404.html as the site\'s localized missing-page response.',
|
|
39
|
+
},
|
|
40
|
+
...(projectConfig.search.enabled ? [{
|
|
41
|
+
filename: 'pagefind',
|
|
42
|
+
explanation: 'Norna generates pagefind/ from the rendered editorial content when search is enabled.',
|
|
43
|
+
}] : []),
|
|
44
|
+
];
|
|
45
|
+
for (const generatedFile of generatedPublicFiles) {
|
|
46
|
+
const conflict = sourceEntries.find(({ name }) => name.toLowerCase() === generatedFile.filename);
|
|
47
|
+
if (!conflict) continue;
|
|
48
|
+
|
|
30
49
|
throw new Error([
|
|
31
|
-
`${sitePublicLabel}/${
|
|
32
|
-
`Remove that source file.
|
|
50
|
+
`${sitePublicLabel}/${conflict.name} conflicts with Norna's generated ${generatedFile.filename}.`,
|
|
51
|
+
`Remove that source file. ${generatedFile.explanation}`,
|
|
33
52
|
].join('\n'));
|
|
34
53
|
}
|
|
35
54
|
|
|
@@ -65,10 +65,15 @@ const labels = {
|
|
|
65
65
|
const pre = code.parentElement;
|
|
66
66
|
if (!pre || pre.parentElement?.classList.contains('code-block')) return;
|
|
67
67
|
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
wrapper.
|
|
68
|
+
const codeExample = pre.parentElement?.classList.contains('norna-code-example')
|
|
69
|
+
? pre.parentElement
|
|
70
|
+
: null;
|
|
71
|
+
const wrapper = codeExample ?? document.createElement('div');
|
|
72
|
+
wrapper.classList.add('code-block');
|
|
73
|
+
if (!codeExample) {
|
|
74
|
+
pre.before(wrapper);
|
|
75
|
+
wrapper.append(pre);
|
|
76
|
+
}
|
|
72
77
|
|
|
73
78
|
const button = template.content.firstElementChild?.cloneNode(true);
|
|
74
79
|
if (!(button instanceof HTMLButtonElement)) return;
|