@janga/norna 0.7.28 → 0.7.30
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 +8 -6
- package/astro.config.mjs +14 -2
- package/package.json +13 -9
- package/schemas/config.schema.json +80 -65
- package/schemas/content-frontmatter.schema.json +15 -8
- package/schemas/manifest.json +1 -1
- package/schemas/page-theme.schema.json +237 -237
- package/schemas/root-theme.schema.json +237 -237
- package/schemas/sitewide-content.schema.json +18 -18
- package/schemas/theme.schema.json +237 -237
- package/scripts/add-site-node.mjs +29 -100
- package/scripts/dev-local.mjs +34 -7
- package/scripts/generate-images.mjs +97 -127
- package/scripts/generate-search-index.mjs +3 -2
- package/scripts/lib/attachment-render.mjs +23 -0
- package/scripts/lib/dev-server-identity.mjs +30 -0
- package/scripts/lib/documentation-routes.json +63 -62
- package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
- package/scripts/lib/editor-attachments.mjs +69 -0
- package/scripts/lib/editor-file-policy.mjs +2 -0
- package/scripts/lib/editor-language-service.mjs +21 -18
- package/scripts/lib/editor-page-addresses.mjs +5 -3
- package/scripts/lib/editor-page-placement.mjs +28 -98
- package/scripts/lib/editor-resource-actions.mjs +216 -0
- package/scripts/lib/editor-site-editing-tree.mjs +44 -9
- package/scripts/lib/editor-site-files.mjs +3 -2
- package/scripts/lib/editor-site-links.mjs +6 -4
- package/scripts/lib/editor-site-preview.mjs +149 -0
- package/scripts/lib/editor-site-tree.mjs +10 -3
- package/scripts/lib/editor-source-files.mjs +7 -1
- package/scripts/lib/image-dimensions.mjs +3 -72
- 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/norna-markdown-blocks.mjs +0 -57
- package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
- package/scripts/lib/page-aliases.mjs +5 -0
- package/scripts/lib/page-attachments.mjs +59 -0
- package/scripts/lib/page-markdown.mjs +11 -7
- package/scripts/lib/page-model.mjs +8 -22
- package/scripts/lib/page-move-apply.mjs +6 -4
- package/scripts/lib/page-move-plan.mjs +37 -19
- 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/project-config.mjs +3 -0
- package/scripts/lib/publication-paths.mjs +72 -0
- package/scripts/lib/schema-definitions.mjs +4 -0
- package/scripts/lib/schema-editor-metadata.mjs +4 -1
- package/scripts/lib/site-content.mjs +1 -1
- package/scripts/lib/site-link-graph.mjs +88 -18
- package/scripts/lib/site-node-create.mjs +47 -34
- package/scripts/lib/site-paths.mjs +2 -2
- package/scripts/lib/site-source-format.mjs +12 -0
- package/scripts/lib/site-structure.mjs +14 -13
- package/scripts/lib/site-upgrade.mjs +2 -0
- package/scripts/lib/social-image-assets.mjs +2 -1
- package/scripts/lib/source-files.mjs +1 -1
- package/scripts/lib/structured-blocks.mjs +3 -1
- package/scripts/move-site-page.mjs +46 -133
- package/scripts/sync-content-sections.mjs +8 -10
- package/scripts/sync-site-public.mjs +44 -66
- package/src/components/ContentBlocks.astro +2 -1
- package/src/components/ImageStack.astro +7 -5
- package/src/components/SearchPage.astro +2 -1
- package/src/components/SitePage.astro +1 -0
- package/src/components/SiteSection.astro +1 -0
- package/src/content.config.ts +1 -4
- package/src/lib/imagePresentation.ts +3 -0
- package/src/lib/markdownImages.ts +101 -0
- package/src/lib/sectionContent.ts +64 -19
- package/src/lib/sitePages.ts +4 -30
- package/src/lib/sitePublicAssets.ts +2 -1
- package/src/pages/[...slug].astro +2 -1
- package/src/styles/area-navigation.css +14 -5
- package/src/styles/content.css +6 -0
- package/src/styles/media.css +24 -0
- package/starters/basic/.github/workflows/deploy.yml +3 -6
- package/starters/basic/README.md +2 -0
- package/starters/basic/site/site-config/settings.yaml +3 -0
|
@@ -1,135 +1,48 @@
|
|
|
1
|
-
import { applyPageMovePlan } from './lib/page-move-apply.mjs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from './lib/page-
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const usage = `
|
|
20
|
-
Usage:
|
|
21
|
-
norna page:move <old-url> <new-url> [--order <NNN>] [--no-aliases] [--write]
|
|
22
|
-
|
|
23
|
-
Examples:
|
|
24
|
-
norna page:move /guides/install/ /guides/installation/
|
|
25
|
-
norna page:move /guides/install/ /reference/install/ --write
|
|
26
|
-
|
|
27
|
-
The command is a dry run unless --write is present. If the old page has
|
|
28
|
-
already been moved by hand, the same command reconciles its links and aliases.
|
|
29
|
-
`.trim();
|
|
30
|
-
|
|
31
|
-
const parseOrder = (value) => {
|
|
32
|
-
if (!/^\d{1,3}$/.test(value ?? '')) {
|
|
33
|
-
throw new Error(`Invalid --order "${value ?? ''}". Use an integer from 1 to 999.\n${usage}`);
|
|
34
|
-
}
|
|
35
|
-
const order = Number.parseInt(value, 10);
|
|
36
|
-
if (order < 1 || order > 999) {
|
|
37
|
-
throw new Error(`Invalid --order "${value}". Use an integer from 1 to 999.\n${usage}`);
|
|
38
|
-
}
|
|
39
|
-
return order;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const parseArgs = (rawArgs) => {
|
|
43
|
-
const options = {
|
|
44
|
-
help: false,
|
|
45
|
-
order: null,
|
|
46
|
-
preserveAliases: true,
|
|
47
|
-
urls: [],
|
|
48
|
-
write: false,
|
|
49
|
-
};
|
|
50
|
-
const seen = new Set();
|
|
51
|
-
|
|
52
|
-
for (let index = 0; index < rawArgs.length; index += 1) {
|
|
53
|
-
const arg = rawArgs[index];
|
|
54
|
-
if (arg === '-h' || arg === '--help') {
|
|
55
|
-
options.help = true;
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
if (arg === '--write') {
|
|
59
|
-
if (seen.has('write')) throw new Error(`--write may be specified only once.\n${usage}`);
|
|
60
|
-
seen.add('write');
|
|
61
|
-
options.write = true;
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
if (arg === '--no-aliases') {
|
|
65
|
-
if (seen.has('aliases')) throw new Error(`--no-aliases may be specified only once.\n${usage}`);
|
|
66
|
-
seen.add('aliases');
|
|
67
|
-
options.preserveAliases = false;
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
if (arg === '--order' || arg.startsWith('--order=')) {
|
|
71
|
-
if (seen.has('order')) throw new Error(`--order may be specified only once.\n${usage}`);
|
|
72
|
-
seen.add('order');
|
|
73
|
-
const inlineValue = arg.startsWith('--order=') ? arg.slice('--order='.length) : null;
|
|
74
|
-
const value = inlineValue ?? rawArgs[index + 1];
|
|
75
|
-
if (inlineValue === null) index += 1;
|
|
76
|
-
options.order = parseOrder(value);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}\n${usage}`);
|
|
80
|
-
options.urls.push(arg);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (!options.help && options.urls.length !== 2) {
|
|
84
|
-
throw new Error(`page:move requires an old URL and a new URL.\n${usage}`);
|
|
85
|
-
}
|
|
86
|
-
return options;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const toDisplayPath = (filePath) => {
|
|
90
|
-
const relativePath = path.relative(siteProjectRoot, filePath);
|
|
91
|
-
return (relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
|
|
92
|
-
? relativePath
|
|
93
|
-
: filePath).split(path.sep).join('/');
|
|
94
|
-
};
|
|
95
|
-
|
|
96
|
-
const main = async () => {
|
|
97
|
-
const options = parseArgs(process.argv.slice(2));
|
|
98
|
-
if (options.help) {
|
|
99
|
-
console.log(usage);
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const siteStructure = await getSiteStructure();
|
|
104
|
-
const [graph, publicFiles] = await Promise.all([
|
|
105
|
-
getSiteLinkGraph({ siteStructure }),
|
|
106
|
-
getSitePublicFiles(),
|
|
107
|
-
]);
|
|
108
|
-
const plan = await createPageMovePlan({
|
|
109
|
-
from: options.urls[0],
|
|
110
|
-
graph,
|
|
111
|
-
order: options.order,
|
|
112
|
-
preserveAliases: options.preserveAliases,
|
|
113
|
-
publicFiles,
|
|
114
|
-
sitePagesDir,
|
|
115
|
-
sitePagesLabel,
|
|
116
|
-
siteStructure,
|
|
117
|
-
to: options.urls[1],
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
console.log(formatPageMovePlan(plan, toDisplayPath).join('\n'));
|
|
121
|
-
if (!options.write) {
|
|
122
|
-
console.log('\nDry run only. Run the same command with --write to apply this plan.');
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
await applyPageMovePlan(plan, { siteRoot: siteDir });
|
|
127
|
-
console.log(`\nPage move completed: ${plan.from} -> ${plan.to}`);
|
|
128
|
-
};
|
|
129
|
-
|
|
2
|
+
import { applyPageMovePlan } from './lib/page-move-apply.mjs';
|
|
3
|
+
import { createPageMovePlan, formatPageMovePlan } from './lib/page-move-plan.mjs';
|
|
4
|
+
import { planEditorPagePlacement, applyEditorPagePlacement } from './lib/editor-page-placement.mjs';
|
|
5
|
+
import { parsePlacementArguments, confirmCliRenumbering, describeRenumbering } from './lib/page-placement-cli.mjs';
|
|
6
|
+
import { readEditorLinkState } from './lib/editor-site-links.mjs';
|
|
7
|
+
import { sitePagesDir, sitePagesLabel, siteProjectRoot, siteDir } from './lib/site-paths.mjs';
|
|
8
|
+
|
|
9
|
+
const usage = `Usage:
|
|
10
|
+
norna page:move <old-url> <new-url> [--order <integer>]
|
|
11
|
+
norna page:move <page-url> --before <target-url> | --after <target-url>
|
|
12
|
+
norna page:move <page-url> --parent <parent-url> [--first | --last]
|
|
13
|
+
|
|
14
|
+
Options: --no-aliases, --write, --dry-run, --allow-renumber
|
|
15
|
+
Dry run unless --write is present. The URL-to-URL form also reconciles a page
|
|
16
|
+
already moved by hand. Renumbering other pages requires confirmation.`;
|
|
17
|
+
const display = filename => path.relative(siteProjectRoot, filename) || filename;
|
|
130
18
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
19
|
+
const options = parsePlacementArguments(process.argv.slice(2));
|
|
20
|
+
if (options.help) { console.log(usage); process.exit(0); }
|
|
21
|
+
if (options.slug !== null) throw new Error('Use the new URL to change a slug with page:move.');
|
|
22
|
+
const relative = options.placement !== null;
|
|
23
|
+
if (options.positionals.length !== (relative ? 1 : 2)) throw new Error(usage);
|
|
24
|
+
if (relative && options.order !== null) throw new Error('Do not combine --order with a placement selector.');
|
|
25
|
+
const state = await readEditorLinkState({ siteRoot: siteDir });
|
|
26
|
+
if (state.incomplete.length) throw new Error(state.incomplete.join('\n'));
|
|
27
|
+
let plan;
|
|
28
|
+
if (relative) {
|
|
29
|
+
const find = url => state.structure.nodes.find(node => (node.isHome ? '/' : `/${node.pagePath}/`) === url);
|
|
30
|
+
const source = find(options.positionals[0]), target = find(options.target);
|
|
31
|
+
if (!source || !target) throw new Error('Choose existing source and target page URLs.');
|
|
32
|
+
plan = await planEditorPagePlacement({ siteRoot: siteDir, sourcePath: source.contentPath, targetPath: target.contentPath,
|
|
33
|
+
placement: options.placement, preserveAliases: options.preserveAliases });
|
|
34
|
+
console.log(`Page placement: ${plan.sourceUrl} ${options.placement} ${options.target}\nOrder: ${plan.sourceOrder}`);
|
|
35
|
+
if (plan.movePreview) console.log(formatPageMovePlan(plan.movePreview, display).join('\n'));
|
|
36
|
+
else if (plan.orderChanges.length) console.log(describeRenumbering(plan.orderChanges, display));
|
|
37
|
+
} else {
|
|
38
|
+
plan = await createPageMovePlan({ from: options.positionals[0], to: options.positionals[1], order: options.order,
|
|
39
|
+
preserveAliases: options.preserveAliases, graph: state.graph, publicFiles: state.publicFiles,
|
|
40
|
+
siteStructure: state.structure, sitePagesDir, sitePagesLabel, generatedRoutes: state.generatedRoutes });
|
|
41
|
+
console.log(formatPageMovePlan(plan, display).join('\n'));
|
|
42
|
+
}
|
|
43
|
+
if (!options.write) { console.log('\nDry run only. Run with --write to apply this plan.'); process.exit(0); }
|
|
44
|
+
if (!await confirmCliRenumbering(plan.orderChanges, options)) { console.log('Cancelled. No files changed.'); process.exit(0); }
|
|
45
|
+
if (relative) await applyEditorPagePlacement(plan, { allowRenumber: true });
|
|
46
|
+
else await applyPageMovePlan(plan, { siteRoot: siteDir, generatedRoutes: state.generatedRoutes, allowRenumber: true });
|
|
47
|
+
console.log(`Page move completed: ${plan.sourceUrl ?? plan.from} -> ${plan.destinationUrl ?? plan.to}. ${plan.orderChanges.length} existing sibling page(s) renumbered.`);
|
|
48
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getSiteAttachments } from './lib/page-attachments.mjs';
|
|
1
2
|
import { createInterface } from 'node:readline/promises';
|
|
2
3
|
import { stdin as input, stdout as output } from 'node:process';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -272,7 +273,7 @@ for (const contentFile of contentFiles) {
|
|
|
272
273
|
prelude,
|
|
273
274
|
regions: sections,
|
|
274
275
|
} = page;
|
|
275
|
-
for (const issue of page.diagnostics.filter((candidate) =>
|
|
276
|
+
for (const issue of page.diagnostics.filter((candidate) => ['heading-inside-details', 'invalid-markdown-image'].includes(candidate.code))) {
|
|
276
277
|
addContentIssue(contentFile, issue);
|
|
277
278
|
}
|
|
278
279
|
for (const issue of page.noteDiagnostics) {
|
|
@@ -468,12 +469,17 @@ for (const context of contentFileContexts) {
|
|
|
468
469
|
}
|
|
469
470
|
|
|
470
471
|
const siteLinkGraph = createSiteLinkGraph({
|
|
472
|
+
siteUrl: projectConfig.site.url,
|
|
473
|
+
attachments: await getSiteAttachments(siteStructure),
|
|
471
474
|
pageDocuments: contentFileContexts.map(({ contentFile, frontmatterData, page }) => ({
|
|
472
475
|
contentFile,
|
|
473
476
|
data: frontmatterData,
|
|
474
477
|
document: page,
|
|
475
478
|
})),
|
|
476
|
-
publicFiles: await getSitePublicFiles()
|
|
479
|
+
publicFiles: await getSitePublicFiles(undefined, projectConfig).catch(error => {
|
|
480
|
+
addIssue({ severity: 'error', message: error.message, fix: 'Use the supported public-file locations and verificationFiles settings.' });
|
|
481
|
+
return [];
|
|
482
|
+
}),
|
|
477
483
|
siteStructure,
|
|
478
484
|
generatedRoutes: getGeneratedSiteRoutes({ searchEnabled: projectConfig.search.enabled }),
|
|
479
485
|
});
|
|
@@ -531,14 +537,6 @@ for (const context of contentFileContexts) {
|
|
|
531
537
|
for (const section of sections) {
|
|
532
538
|
if (!validSections.has(section)) continue;
|
|
533
539
|
|
|
534
|
-
for (const reference of section.markdownImages) {
|
|
535
|
-
addSectionIssue(contentFile, section, {
|
|
536
|
-
severity: 'warning',
|
|
537
|
-
message: `Markdown image "${reference.target}" references a local image that is not managed by Norna.`,
|
|
538
|
-
fix: 'Use an image-stack, image-carousel, or card-list block for site images that should be validated, processed and synced.',
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
|
|
542
540
|
const { blocks, errors: blockErrors } = blockResultsBySection.get(section) ?? { blocks: [], errors: [] };
|
|
543
541
|
for (const error of blockErrors) {
|
|
544
542
|
addSectionIssue(contentFile, section, {
|
|
@@ -5,84 +5,62 @@ import { createSitemapXml, sitemapFilename } from './lib/sitemap.mjs';
|
|
|
5
5
|
import {
|
|
6
6
|
astroPublicDir,
|
|
7
7
|
astroPublicLabel,
|
|
8
|
-
sitePublicDir,
|
|
9
8
|
sitePublicLabel,
|
|
10
9
|
} from './lib/site-paths.mjs';
|
|
11
10
|
import { getSiteStructure } from './lib/site-structure.mjs';
|
|
12
11
|
import { getSiteLinkGraph } from './lib/site-link-graph.mjs';
|
|
13
12
|
import { assertCategoryDestinationModel } from './lib/category-destinations.mjs';
|
|
14
13
|
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
const main = async () => {
|
|
15
|
+
const readDirectory = async (directory) => {
|
|
16
|
+
try {
|
|
17
|
+
return await readdir(directory, { withFileTypes: true });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (error?.code === 'ENOENT') {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
try {
|
|
22
|
-
return await readdir(directory, { withFileTypes: true });
|
|
23
|
-
} catch (error) {
|
|
24
|
-
if (error?.code === 'ENOENT') {
|
|
25
|
-
return [];
|
|
23
|
+
throw error;
|
|
26
24
|
}
|
|
25
|
+
};
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
{
|
|
39
|
-
filename: '404.html',
|
|
40
|
-
explanation: 'Norna generates 404.html as the site\'s localized missing-page response.',
|
|
41
|
-
},
|
|
42
|
-
...(projectConfig.search.enabled ? [{
|
|
43
|
-
filename: 'pagefind',
|
|
44
|
-
explanation: 'Norna generates pagefind/ from the rendered editorial content when search is enabled.',
|
|
45
|
-
}] : []),
|
|
46
|
-
];
|
|
47
|
-
for (const generatedFile of generatedPublicFiles) {
|
|
48
|
-
const conflict = sourceEntries.find(({ name }) => name.toLowerCase() === generatedFile.filename);
|
|
49
|
-
if (!conflict) continue;
|
|
50
|
-
|
|
51
|
-
throw new Error([
|
|
52
|
-
`${sitePublicLabel}/${conflict.name} conflicts with Norna's generated ${generatedFile.filename}.`,
|
|
53
|
-
`Remove that source file. ${generatedFile.explanation}`,
|
|
54
|
-
].join('\n'));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const siteStructure = await getSiteStructure();
|
|
58
|
-
const { categoryModel } = await getSiteLinkGraph({ siteStructure });
|
|
59
|
-
const { destinations: categoryDestinations } = assertCategoryDestinationModel(categoryModel);
|
|
60
|
-
const sitemapXml = createSitemapXml({
|
|
61
|
-
siteStructure,
|
|
62
|
-
siteUrl: projectConfig.site.url,
|
|
63
|
-
categoryDestinations,
|
|
64
|
-
});
|
|
27
|
+
const siteStructure = await getSiteStructure();
|
|
28
|
+
const { categoryModel, attachments, diagnostics, publicFilesByPathname } = await getSiteLinkGraph({ siteStructure });
|
|
29
|
+
const outputErrors = diagnostics.filter(issue => !issue.reference && issue.severity === 'error');
|
|
30
|
+
if (outputErrors.length) throw new Error(outputErrors.map(issue => issue.message).join('\n'));
|
|
31
|
+
const { destinations: categoryDestinations } = assertCategoryDestinationModel(categoryModel);
|
|
32
|
+
const sitemapXml = createSitemapXml({
|
|
33
|
+
siteStructure,
|
|
34
|
+
siteUrl: projectConfig.site.url,
|
|
35
|
+
categoryDestinations,
|
|
36
|
+
});
|
|
65
37
|
|
|
66
|
-
await mkdir(astroPublicDir, { recursive: true });
|
|
38
|
+
await mkdir(astroPublicDir, { recursive: true });
|
|
67
39
|
|
|
68
|
-
for (const entry of await readDirectory(astroPublicDir)) {
|
|
69
|
-
|
|
70
|
-
|
|
40
|
+
for (const entry of await readDirectory(astroPublicDir)) {
|
|
41
|
+
if (entry.name !== '_norna') await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const resources = path.join(astroPublicDir, '_norna');
|
|
44
|
+
for (const entry of await readDirectory(resources)) {
|
|
45
|
+
if (entry.name === 'images' || entry.name === 'search' && projectConfig.search.enabled) continue;
|
|
46
|
+
await rm(path.join(resources, entry.name), { force: true, recursive: true });
|
|
47
|
+
}
|
|
48
|
+
for (const file of new Set(publicFilesByPathname.values())) {
|
|
49
|
+
const destination = path.join(astroPublicDir, file.pathname.slice(1));
|
|
50
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
51
|
+
await cp(file.filePath, destination, { force: false, errorOnExist: true });
|
|
71
52
|
}
|
|
72
53
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
path.join(astroPublicDir, entry.name),
|
|
80
|
-
{ force: true, recursive: true },
|
|
81
|
-
);
|
|
82
|
-
}
|
|
54
|
+
for (const file of attachments.files) {
|
|
55
|
+
const destination = path.join(astroPublicDir, file.pathname.slice(1));
|
|
56
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
57
|
+
await cp(file.filePath, destination, { force: false, errorOnExist: true });
|
|
58
|
+
}
|
|
59
|
+
await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
|
|
83
60
|
|
|
84
|
-
|
|
61
|
+
console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
|
|
62
|
+
const pageCount = siteStructure.contentFiles.length + categoryDestinations.filter(({ kind }) => kind === 'listing').length;
|
|
63
|
+
console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${pageCount} public page${pageCount === 1 ? '' : 's'}.`);
|
|
64
|
+
};
|
|
85
65
|
|
|
86
|
-
|
|
87
|
-
const pageCount = siteStructure.contentFiles.length + categoryDestinations.filter(({ kind }) => kind === 'listing').length;
|
|
88
|
-
console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${pageCount} public page${pageCount === 1 ? '' : 's'}.`);
|
|
66
|
+
await main().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
@@ -4,6 +4,7 @@ import ImageCarousel from './ImageCarousel.astro';
|
|
|
4
4
|
import ImageStack from './ImageStack.astro';
|
|
5
5
|
import PageList from './PageList.astro';
|
|
6
6
|
import type { SectionContentBlock } from '../lib/sectionContent';
|
|
7
|
+
import { renderNestedMarkdownImages } from '../lib/markdownImages';
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
blocks: SectionContentBlock[];
|
|
@@ -20,7 +21,7 @@ const firstMediaIndex = blocks.findIndex((block) => block.type === 'image-stack'
|
|
|
20
21
|
---
|
|
21
22
|
|
|
22
23
|
{blocks.map((block, index) => {
|
|
23
|
-
if (block.type === 'html') return <div class="section-markdown" set:html={block.html} />;
|
|
24
|
+
if (block.type === 'html') return <div class="section-markdown" set:html={renderNestedMarkdownImages(block.html, title, images.maxAvailableWidthPercent)} />;
|
|
24
25
|
if (block.type === 'tabs') return (
|
|
25
26
|
<div class="content-tabs content-block-note-lane-boundary" data-content-tabs>
|
|
26
27
|
<div class="content-tabs-controls" data-tabs-controls hidden></div>
|
|
@@ -43,10 +43,11 @@ const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
|
|
|
43
43
|
<div class="managed-image-frame">
|
|
44
44
|
<a
|
|
45
45
|
class="managed-image-inspection-link"
|
|
46
|
-
href={inspection.href}
|
|
47
|
-
|
|
46
|
+
href={image.href ?? inspection.href}
|
|
47
|
+
title={image.linkTitle}
|
|
48
|
+
aria-label={image.href === undefined ? inspectionLabel : undefined}
|
|
48
49
|
aria-describedby={captionId}
|
|
49
|
-
data-image-inspection-trigger
|
|
50
|
+
data-image-inspection-trigger={image.href === undefined ? '' : undefined}
|
|
50
51
|
data-image-intrinsic-width={inspection.width}
|
|
51
52
|
data-image-intrinsic-height={inspection.height}
|
|
52
53
|
data-image-inspection-scalable={inspection.scalable ? 'true' : undefined}
|
|
@@ -56,12 +57,13 @@ const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
|
|
|
56
57
|
{...getImageAttributes(image.src ?? image.image, managedImageSizes)}
|
|
57
58
|
class:list={['managed-image', getImageClass(image)]}
|
|
58
59
|
alt={image.alt ?? ''}
|
|
60
|
+
title={image.title}
|
|
59
61
|
aria-describedby={captionId}
|
|
60
62
|
{...getImageLoadingProps(isPriorityItem)}
|
|
61
63
|
/>
|
|
62
|
-
<span class="managed-image-inspection-affordance" aria-hidden="true">
|
|
64
|
+
{image.href === undefined && <span class="managed-image-inspection-affordance" aria-hidden="true">
|
|
63
65
|
<Maximize2 size={18} stroke-width={2} />
|
|
64
|
-
</span>
|
|
66
|
+
</span>}
|
|
65
67
|
</a>
|
|
66
68
|
</div>
|
|
67
69
|
{image.caption && (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { searchOutputDirectory } from '../../scripts/lib/publication-paths.mjs';
|
|
2
3
|
import { getCollection } from 'astro:content';
|
|
3
4
|
import { ArrowLeft } from '@lucide/astro';
|
|
4
5
|
import { resolveNavigationModel } from '../../scripts/lib/navigation-model.mjs';
|
|
@@ -84,7 +85,7 @@ const hasNavigationRailFrame = navigationModel.requestedMode === 'tree'
|
|
|
84
85
|
navigationModel.requestedMode === 'automatic'
|
|
85
86
|
&& (navigationModel.hasNestedPages || navigationModel.hasCategories)
|
|
86
87
|
);
|
|
87
|
-
const bundlePath = withBasePath(projectConfig.site.basePath,
|
|
88
|
+
const bundlePath = withBasePath(projectConfig.site.basePath, `/${searchOutputDirectory}/`);
|
|
88
89
|
const pagefindTranslationOption = projectConfig.locale.pagefindTranslations
|
|
89
90
|
? `,\n\t\t\ttranslations: ${JSON.stringify(projectConfig.locale.pagefindTranslations)}`
|
|
90
91
|
: '';
|
|
@@ -115,6 +115,7 @@ const hasTables = allContentBlocks.some((block) => (
|
|
|
115
115
|
));
|
|
116
116
|
const hasImageStack = allContentBlocks.some((block) => (
|
|
117
117
|
block.type === 'image-stack'
|
|
118
|
+
|| (block.type === 'html' && block.html.includes('data-norna-managed-image='))
|
|
118
119
|
));
|
|
119
120
|
const hasPrioritizableImage = (section: ResolvedSection) => section.contentBlocks.some((block) => (
|
|
120
121
|
block.type === 'image-stack'
|
|
@@ -109,6 +109,7 @@ const bodyWidth = {
|
|
|
109
109
|
};
|
|
110
110
|
const hasMedia = flattenContentBlocks(section.contentBlocks).some((block) => (
|
|
111
111
|
block.type === 'image-stack'
|
|
112
|
+
|| (block.type === 'html' && block.html.includes('data-norna-managed-image='))
|
|
112
113
|
|| block.type === 'image-carousel'
|
|
113
114
|
|| (block.type === 'card-list' && block.cards.some((card) => card.image))
|
|
114
115
|
));
|
package/src/content.config.ts
CHANGED
|
@@ -29,10 +29,7 @@ const site = defineCollection({
|
|
|
29
29
|
generateId: ({ entry }) => {
|
|
30
30
|
if (entry === 'content.md') return encodePageEntryId(siteDirLabel, homePageDirectory);
|
|
31
31
|
const pageEntryDirectory = entry.split('/').slice(1, -1).join('/');
|
|
32
|
-
const pageDirectory = pageEntryDirectory
|
|
33
|
-
.split('/')
|
|
34
|
-
.filter((segment) => segment !== 'pages')
|
|
35
|
-
.join('/pages/');
|
|
32
|
+
const pageDirectory = pageEntryDirectory;
|
|
36
33
|
return encodePageEntryId(siteDirLabel, parsePageDirectoryPath(pageDirectory, `page directory pages/${pageEntryDirectory}`).pageDirectory);
|
|
37
34
|
},
|
|
38
35
|
}),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { parseFragment, serializeOuter, type DefaultTreeAdapterMap } from 'parse5';
|
|
2
|
+
import { getPageImageSourceKey } from '../../scripts/lib/site-conventions.mjs';
|
|
3
|
+
import { getGeneratedImage, getImageAttributes, getImageInspectionAttributes } from './generatedImages';
|
|
4
|
+
import { getImageClass, getImageLoadingProps, getManagedImageSizes } from './imagePresentation';
|
|
5
|
+
import projectConfig from '../../scripts/lib/project-config.mjs';
|
|
6
|
+
|
|
7
|
+
type Node = DefaultTreeAdapterMap['node'];
|
|
8
|
+
type Element = DefaultTreeAdapterMap['element'];
|
|
9
|
+
const isElement = (node: Node): node is Element => 'tagName' in node;
|
|
10
|
+
const attribute = (node: Element, name: string) => node.attrs.find((attr) => attr.name === name)?.value;
|
|
11
|
+
const meaningfulChildren = (node: Element) => node.childNodes.filter((child) => child.nodeName !== '#text' || ('value' in child && child.value.trim()));
|
|
12
|
+
const soleImage = (node: Element): { image: Element; href?: string; linkTitle?: string } | null => {
|
|
13
|
+
const children = meaningfulChildren(node);
|
|
14
|
+
if (children.length !== 1 || !isElement(children[0])) return null;
|
|
15
|
+
const child = children[0];
|
|
16
|
+
if (child.tagName === 'img' && attribute(child, 'data-norna-managed-image')) return { image: child };
|
|
17
|
+
if (child.tagName === 'a') {
|
|
18
|
+
const nested = soleImage(child);
|
|
19
|
+
if (nested) return { image: nested.image, href: attribute(child, 'href'), linkTitle: attribute(child, 'title') };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Promote only top-level, single-image paragraphs to the shared Astro stack.
|
|
25
|
+
// Nested images stay in their original list, quote, table, details or text.
|
|
26
|
+
export const splitMarkdownImages = (html: string, pageDirectory: string) => {
|
|
27
|
+
if (!html.includes('data-norna-managed-image=')) return [{ type: 'html' as const, html }];
|
|
28
|
+
const fragment = parseFragment(html);
|
|
29
|
+
const result: Array<{ type: 'html'; html: string } | { type: 'image-stack'; images: Array<{ image: string; src: string; alt: string; title?: string; href?: string; linkTitle?: string }> }> = [];
|
|
30
|
+
let pending = '';
|
|
31
|
+
const flush = () => { if (pending.trim()) result.push({ type: 'html', html: pending }); pending = ''; };
|
|
32
|
+
const markSource = (node: Node) => {
|
|
33
|
+
if (!isElement(node)) return;
|
|
34
|
+
const marker = node.attrs.find((attr) => attr.name === 'data-norna-managed-image');
|
|
35
|
+
if (marker) marker.value = getPageImageSourceKey(pageDirectory, marker.value);
|
|
36
|
+
for (const child of node.childNodes) markSource(child);
|
|
37
|
+
};
|
|
38
|
+
for (const node of fragment.childNodes) {
|
|
39
|
+
const standalone = isElement(node) && node.tagName === 'p' ? soleImage(node) : null;
|
|
40
|
+
if (standalone) {
|
|
41
|
+
flush();
|
|
42
|
+
const name = attribute(standalone.image, 'data-norna-managed-image')!;
|
|
43
|
+
result.push({ type: 'image-stack', images: [{ image: name, src: getPageImageSourceKey(pageDirectory, name), alt: attribute(standalone.image, 'alt') ?? '', title: attribute(standalone.image, 'title'), href: standalone.href, linkTitle: standalone.linkTitle }] });
|
|
44
|
+
} else {
|
|
45
|
+
markSource(node);
|
|
46
|
+
pending += serializeOuter(node);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
flush();
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const escapeAttribute = (value: unknown) => String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
54
|
+
const attributes = (values: Record<string, unknown>) => Object.entries(values).filter(([, value]) => value !== undefined && value !== null).map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`).join('');
|
|
55
|
+
|
|
56
|
+
export const renderNestedMarkdownImages = (html: string, title: string, width: { desktop: number; mobile: number }) => {
|
|
57
|
+
if (!html.includes('data-norna-managed-image=')) return html;
|
|
58
|
+
const fragment = parseFragment(html, { sourceCodeLocationInfo: true });
|
|
59
|
+
const edits: Array<{ start: number; end: number; html: string }> = [];
|
|
60
|
+
const renderImage = (node: Element, standalone = false, href?: string, linkTitle?: string) => {
|
|
61
|
+
const src = attribute(node, 'data-norna-managed-image')!;
|
|
62
|
+
if (!getGeneratedImage(src)) throw new Error(`Managed Markdown image is missing from the generated manifest: ${src}. Run Norna's image preparation after correcting the source file.`);
|
|
63
|
+
const alt = attribute(node, 'alt') ?? '';
|
|
64
|
+
const imageClass = standalone ? ['managed-image', getImageClass({ image: src })].filter(Boolean).join(' ') : 'managed-markdown-inline';
|
|
65
|
+
const image = `<img${attributes({ ...getImageAttributes(src, getManagedImageSizes(width)), class: imageClass, alt, title: attribute(node, 'title'), ...getImageLoadingProps(false) })}>`;
|
|
66
|
+
if (!standalone) return image;
|
|
67
|
+
const inspection = getImageInspectionAttributes(src);
|
|
68
|
+
const link = attributes({
|
|
69
|
+
class: 'managed-image-inspection-link', href: href ?? inspection.href, title: linkTitle,
|
|
70
|
+
'aria-label': href === undefined ? projectConfig.locale.labels.inspectImage.replace('{description}', alt || title) : undefined,
|
|
71
|
+
'data-image-inspection-trigger': href === undefined ? '' : undefined,
|
|
72
|
+
'data-image-intrinsic-width': inspection.width, 'data-image-intrinsic-height': inspection.height,
|
|
73
|
+
'data-image-inspection-scalable': inspection.scalable ? 'true' : undefined,
|
|
74
|
+
style: inspection.width && inspection.height ? `--managed-image-intrinsic-width: ${inspection.width}px; --managed-image-aspect-ratio: ${inspection.width / inspection.height}` : undefined,
|
|
75
|
+
});
|
|
76
|
+
// The same Lucide Maximize2 affordance used by ImageStack.astro; this
|
|
77
|
+
// fragment stays inside the authored Markdown container.
|
|
78
|
+
const affordance = href === undefined ? '<span class="managed-image-inspection-affordance" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="m21 3-7 7"/><path d="m3 21 7-7"/><path d="M9 21H3v-6"/></svg></span>' : '';
|
|
79
|
+
return `<div class="image-stack managed-markdown-standalone content-block-note-lane-boundary"><figure class="managed-image-item"><div class="managed-image-frame"><a${link}>${image}${affordance}</a></div></figure></div>`;
|
|
80
|
+
};
|
|
81
|
+
const visit = (node: Node) => {
|
|
82
|
+
if (!isElement(node)) return;
|
|
83
|
+
const location = node.sourceCodeLocation;
|
|
84
|
+
if (location && (node.tagName === 'p' || node.tagName === 'li')) {
|
|
85
|
+
const standalone = soleImage(node);
|
|
86
|
+
if (standalone) {
|
|
87
|
+
const start = node.tagName === 'li' ? location.startTag!.endOffset : location.startOffset;
|
|
88
|
+
const end = node.tagName === 'li' ? (location.endTag?.startOffset ?? location.endOffset) : location.endOffset;
|
|
89
|
+
edits.push({ start, end, html: renderImage(standalone.image, true, standalone.href, standalone.linkTitle) });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (location && node.tagName === 'img' && attribute(node, 'data-norna-managed-image')) {
|
|
94
|
+
edits.push({ start: location.startOffset, end: location.endOffset, html: renderImage(node) });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const child of node.childNodes) visit(child);
|
|
98
|
+
};
|
|
99
|
+
for (const node of fragment.childNodes) visit(node);
|
|
100
|
+
return edits.sort((a, b) => b.start - a.start).reduce((result, edit) => result.slice(0, edit.start) + edit.html + result.slice(edit.end), html);
|
|
101
|
+
};
|