@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.
Files changed (80) hide show
  1. package/README.md +8 -6
  2. package/astro.config.mjs +14 -2
  3. package/package.json +13 -9
  4. package/schemas/config.schema.json +80 -65
  5. package/schemas/content-frontmatter.schema.json +15 -8
  6. package/schemas/manifest.json +1 -1
  7. package/schemas/page-theme.schema.json +237 -237
  8. package/schemas/root-theme.schema.json +237 -237
  9. package/schemas/sitewide-content.schema.json +18 -18
  10. package/schemas/theme.schema.json +237 -237
  11. package/scripts/add-site-node.mjs +29 -100
  12. package/scripts/dev-local.mjs +34 -7
  13. package/scripts/generate-images.mjs +97 -127
  14. package/scripts/generate-search-index.mjs +3 -2
  15. package/scripts/lib/attachment-render.mjs +23 -0
  16. package/scripts/lib/dev-server-identity.mjs +30 -0
  17. package/scripts/lib/documentation-routes.json +63 -62
  18. package/scripts/lib/docusaurus-migration-audit.mjs +9 -9
  19. package/scripts/lib/editor-attachments.mjs +69 -0
  20. package/scripts/lib/editor-file-policy.mjs +2 -0
  21. package/scripts/lib/editor-language-service.mjs +21 -18
  22. package/scripts/lib/editor-page-addresses.mjs +5 -3
  23. package/scripts/lib/editor-page-placement.mjs +28 -98
  24. package/scripts/lib/editor-resource-actions.mjs +216 -0
  25. package/scripts/lib/editor-site-editing-tree.mjs +44 -9
  26. package/scripts/lib/editor-site-files.mjs +3 -2
  27. package/scripts/lib/editor-site-links.mjs +6 -4
  28. package/scripts/lib/editor-site-preview.mjs +149 -0
  29. package/scripts/lib/editor-site-tree.mjs +10 -3
  30. package/scripts/lib/editor-source-files.mjs +7 -1
  31. package/scripts/lib/image-dimensions.mjs +3 -72
  32. package/scripts/lib/logo-assets.mjs +2 -1
  33. package/scripts/lib/markdown-images.mjs +59 -0
  34. package/scripts/lib/markdown-links.mjs +29 -9
  35. package/scripts/lib/norna-markdown-blocks.mjs +0 -57
  36. package/scripts/lib/norna-markdown-render-plugin.mjs +29 -0
  37. package/scripts/lib/page-aliases.mjs +5 -0
  38. package/scripts/lib/page-attachments.mjs +59 -0
  39. package/scripts/lib/page-markdown.mjs +11 -7
  40. package/scripts/lib/page-model.mjs +8 -22
  41. package/scripts/lib/page-move-apply.mjs +6 -4
  42. package/scripts/lib/page-move-plan.mjs +37 -19
  43. package/scripts/lib/page-order-source.mjs +22 -0
  44. package/scripts/lib/page-order.mjs +45 -0
  45. package/scripts/lib/page-placement-cli.mjs +50 -0
  46. package/scripts/lib/project-config.mjs +3 -0
  47. package/scripts/lib/publication-paths.mjs +72 -0
  48. package/scripts/lib/schema-definitions.mjs +4 -0
  49. package/scripts/lib/schema-editor-metadata.mjs +4 -1
  50. package/scripts/lib/site-content.mjs +1 -1
  51. package/scripts/lib/site-link-graph.mjs +88 -18
  52. package/scripts/lib/site-node-create.mjs +47 -34
  53. package/scripts/lib/site-paths.mjs +2 -2
  54. package/scripts/lib/site-source-format.mjs +12 -0
  55. package/scripts/lib/site-structure.mjs +14 -13
  56. package/scripts/lib/site-upgrade.mjs +2 -0
  57. package/scripts/lib/social-image-assets.mjs +2 -1
  58. package/scripts/lib/source-files.mjs +1 -1
  59. package/scripts/lib/structured-blocks.mjs +3 -1
  60. package/scripts/move-site-page.mjs +46 -133
  61. package/scripts/sync-content-sections.mjs +8 -10
  62. package/scripts/sync-site-public.mjs +44 -66
  63. package/src/components/ContentBlocks.astro +2 -1
  64. package/src/components/ImageStack.astro +7 -5
  65. package/src/components/SearchPage.astro +2 -1
  66. package/src/components/SitePage.astro +1 -0
  67. package/src/components/SiteSection.astro +1 -0
  68. package/src/content.config.ts +1 -4
  69. package/src/lib/imagePresentation.ts +3 -0
  70. package/src/lib/markdownImages.ts +101 -0
  71. package/src/lib/sectionContent.ts +64 -19
  72. package/src/lib/sitePages.ts +4 -30
  73. package/src/lib/sitePublicAssets.ts +2 -1
  74. package/src/pages/[...slug].astro +2 -1
  75. package/src/styles/area-navigation.css +14 -5
  76. package/src/styles/content.css +6 -0
  77. package/src/styles/media.css +24 -0
  78. package/starters/basic/.github/workflows/deploy.yml +3 -6
  79. package/starters/basic/README.md +2 -0
  80. 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
- createPageMovePlan,
5
- formatPageMovePlan,
6
- } from './lib/page-move-plan.mjs';
7
- import {
8
- getSiteLinkGraph,
9
- getSitePublicFiles,
10
- } from './lib/site-link-graph.mjs';
11
- import {
12
- sitePagesDir,
13
- sitePagesLabel,
14
- siteProjectRoot,
15
- siteDir,
16
- } from './lib/site-paths.mjs';
17
- import { getSiteStructure } from './lib/site-structure.mjs';
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
- await main();
132
- } catch (error) {
133
- console.error(error instanceof Error ? error.message : String(error));
134
- process.exit(1);
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) => candidate.code === 'heading-inside-details')) {
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 keepAstroPublicEntries = new Set([
16
- 'images',
17
- ...(projectConfig.search.enabled ? ['pagefind'] : []),
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
- const readDirectory = async (directory) => {
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
- throw error;
29
- }
30
- };
31
-
32
- const sourceEntries = await readDirectory(sitePublicDir);
33
- const generatedPublicFiles = [
34
- {
35
- filename: sitemapFilename,
36
- explanation: `Norna generates ${sitemapFilename} from the public page tree and the URL in site/site-config/settings.yaml.`,
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
- if (keepAstroPublicEntries.has(entry.name)) {
70
- continue;
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
- await rm(path.join(astroPublicDir, entry.name), { force: true, recursive: true });
74
- }
75
-
76
- for (const entry of sourceEntries) {
77
- await cp(
78
- path.join(sitePublicDir, entry.name),
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
- await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
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
- console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
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
- aria-label={inspectionLabel}
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, '/pagefind/');
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
  ));
@@ -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
  }),
@@ -5,6 +5,9 @@ export type ManagedImage = {
5
5
  src?: string;
6
6
  alt?: string;
7
7
  caption?: string;
8
+ title?: string;
9
+ href?: string;
10
+ linkTitle?: string;
8
11
  };
9
12
 
10
13
  type GeneratedImageWithDimensions = NonNullable<ReturnType<typeof getGeneratedImage>> & {
@@ -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('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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
+ };