@janga/norna 0.7.24 → 0.7.26

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 (142) hide show
  1. package/README.md +5 -4
  2. package/astro.config.mjs +27 -11
  3. package/bin/norna-cli.mjs +12 -0
  4. package/package.json +26 -5
  5. package/schemas/card-list.schema.json +121 -0
  6. package/schemas/category.schema.json +2 -2
  7. package/schemas/config.schema.json +342 -33
  8. package/schemas/content-frontmatter.schema.json +7 -7
  9. package/schemas/image-carousel.schema.json +43 -0
  10. package/schemas/image-stack.schema.json +43 -0
  11. package/schemas/manifest.json +6 -1
  12. package/schemas/page-theme.schema.json +26 -26
  13. package/schemas/sitewide-content.schema.json +18 -18
  14. package/schemas/theme.schema.json +237 -262
  15. package/scripts/check-config.mjs +5 -1
  16. package/scripts/dev-local.mjs +130 -13
  17. package/scripts/generate-schemas.mjs +15 -3
  18. package/scripts/init-site.mjs +2 -0
  19. package/scripts/lib/category-destinations.mjs +48 -0
  20. package/scripts/lib/code-fence-metadata.mjs +25 -43
  21. package/scripts/lib/content-tabs.mjs +155 -0
  22. package/scripts/lib/details-headings.mjs +58 -0
  23. package/scripts/lib/docusaurus-migration-audit.mjs +603 -0
  24. package/scripts/lib/edit-source-link.mjs +53 -0
  25. package/scripts/lib/editor-language-service.mjs +238 -116
  26. package/scripts/lib/locale-registry.mjs +597 -0
  27. package/scripts/lib/locales/az-Latn.mjs +78 -0
  28. package/scripts/lib/locales/be.mjs +78 -0
  29. package/scripts/lib/locales/bg.mjs +78 -0
  30. package/scripts/lib/locales/bs.mjs +78 -0
  31. package/scripts/lib/locales/ca.mjs +78 -0
  32. package/scripts/lib/locales/cnr.mjs +78 -0
  33. package/scripts/lib/locales/cs.mjs +78 -0
  34. package/scripts/lib/locales/da.mjs +78 -0
  35. package/scripts/lib/locales/de.mjs +78 -0
  36. package/scripts/lib/locales/el.mjs +78 -0
  37. package/scripts/lib/locales/en.mjs +78 -0
  38. package/scripts/lib/locales/es.mjs +78 -0
  39. package/scripts/lib/locales/et.mjs +78 -0
  40. package/scripts/lib/locales/fi.mjs +78 -0
  41. package/scripts/lib/locales/fil.mjs +78 -0
  42. package/scripts/lib/locales/fr.mjs +78 -0
  43. package/scripts/lib/locales/ga.mjs +78 -0
  44. package/scripts/lib/locales/ha.mjs +78 -0
  45. package/scripts/lib/locales/hr.mjs +78 -0
  46. package/scripts/lib/locales/hu.mjs +78 -0
  47. package/scripts/lib/locales/id.mjs +78 -0
  48. package/scripts/lib/locales/is.mjs +78 -0
  49. package/scripts/lib/locales/it.mjs +78 -0
  50. package/scripts/lib/locales/jv.mjs +78 -0
  51. package/scripts/lib/locales/lb.mjs +78 -0
  52. package/scripts/lib/locales/lt.mjs +78 -0
  53. package/scripts/lib/locales/lv.mjs +78 -0
  54. package/scripts/lib/locales/mk.mjs +78 -0
  55. package/scripts/lib/locales/mt.mjs +78 -0
  56. package/scripts/lib/locales/nb.mjs +78 -0
  57. package/scripts/lib/locales/nl.mjs +78 -0
  58. package/scripts/lib/locales/nn.mjs +78 -0
  59. package/scripts/lib/locales/pcm.mjs +78 -0
  60. package/scripts/lib/locales/pl.mjs +78 -0
  61. package/scripts/lib/locales/pt.mjs +78 -0
  62. package/scripts/lib/locales/ro.mjs +78 -0
  63. package/scripts/lib/locales/ru.mjs +78 -0
  64. package/scripts/lib/locales/sk.mjs +78 -0
  65. package/scripts/lib/locales/sl.mjs +78 -0
  66. package/scripts/lib/locales/sq.mjs +78 -0
  67. package/scripts/lib/locales/sr-Cyrl.mjs +78 -0
  68. package/scripts/lib/locales/sr-Latn.mjs +78 -0
  69. package/scripts/lib/locales/sv.mjs +78 -0
  70. package/scripts/lib/locales/sw.mjs +78 -0
  71. package/scripts/lib/locales/tr.mjs +78 -0
  72. package/scripts/lib/locales/uk.mjs +78 -0
  73. package/scripts/lib/locales/vi.mjs +78 -0
  74. package/scripts/lib/markdown-links.mjs +4 -25
  75. package/scripts/lib/markdown-notes-render-plugin.mjs +75 -0
  76. package/scripts/lib/markdown-notes.mjs +165 -0
  77. package/scripts/lib/navigation-model.mjs +1 -1
  78. package/scripts/lib/norna-markdown-blocks.mjs +123 -447
  79. package/scripts/lib/norna-markdown-render-plugin.mjs +4 -0
  80. package/scripts/lib/page-aliases.mjs +12 -1
  81. package/scripts/lib/page-markdown.mjs +63 -16
  82. package/scripts/lib/page-move-plan.mjs +22 -5
  83. package/scripts/lib/pagefind-translations.mjs +620 -0
  84. package/scripts/lib/presentation-contract.mjs +6 -1
  85. package/scripts/lib/presentation.mjs +29 -30
  86. package/scripts/lib/project-config.mjs +22 -163
  87. package/scripts/lib/schema-definitions.mjs +13 -12
  88. package/scripts/lib/schema-editor-metadata.mjs +42 -46
  89. package/scripts/lib/schema-value-definitions.mjs +7 -8
  90. package/scripts/lib/semantic-callouts.mjs +4 -2
  91. package/scripts/lib/site-content.mjs +4 -2
  92. package/scripts/lib/site-link-graph.mjs +16 -6
  93. package/scripts/lib/site-navigation-tree.mjs +0 -11
  94. package/scripts/lib/site-paths.mjs +14 -2
  95. package/scripts/lib/sitemap.mjs +5 -3
  96. package/scripts/lib/structured-blocks.mjs +108 -0
  97. package/scripts/lib/table-render-plugin.mjs +76 -0
  98. package/scripts/lib/table-row-headers.mjs +218 -0
  99. package/scripts/lib/theme-presets.mjs +3 -10
  100. package/scripts/lib/yaml-config.mjs +2 -6
  101. package/scripts/migrate-check.mjs +19 -0
  102. package/scripts/sync-content-sections.mjs +39 -10
  103. package/scripts/sync-site-public.mjs +7 -1
  104. package/src/components/CardList.astro +1 -0
  105. package/src/components/CodeBlockCopyScript.astro +49 -6
  106. package/src/components/ContentBlocks.astro +39 -0
  107. package/src/components/ContentTabsScript.astro +60 -0
  108. package/src/components/DisplaySettings.astro +17 -19
  109. package/src/components/ImageCarousel.astro +4 -1
  110. package/src/components/ImageStack.astro +34 -9
  111. package/src/components/ImageStackEnhancement.astro +331 -0
  112. package/src/components/NavigationPageTree.astro +1 -1
  113. package/src/components/NoteNavigationScript.astro +4 -0
  114. package/src/components/PageAliasRedirect.astro +5 -4
  115. package/src/components/PageContentsNavigation.astro +2 -3
  116. package/src/components/SearchNavigationScript.astro +4 -0
  117. package/src/components/SearchPage.astro +43 -2
  118. package/src/components/SectionNavigationScript.astro +86 -16
  119. package/src/components/SiteBreadcrumbs.astro +1 -1
  120. package/src/components/SiteNavigation.astro +98 -65
  121. package/src/components/SitePage.astro +73 -33
  122. package/src/components/SiteSection.astro +5 -81
  123. package/src/components/SiteTreeNavigation.astro +5 -2
  124. package/src/components/TableOverflowScript.astro +579 -0
  125. package/src/components/TopPageMenu.astro +47 -0
  126. package/src/components/TreeNavigationScript.astro +86 -23
  127. package/src/layouts/BaseLayout.astro +34 -12
  128. package/src/lib/generatedImages.ts +18 -0
  129. package/src/lib/navigationFollowing.ts +142 -0
  130. package/src/lib/noteNavigation.ts +50 -0
  131. package/src/lib/searchNavigation.ts +107 -0
  132. package/src/lib/sectionContent.ts +23 -116
  133. package/src/lib/siteNavigation.ts +0 -5
  134. package/src/lib/sitePages.ts +18 -4
  135. package/src/lib/table-sort-tooltip.ts +109 -0
  136. package/src/pages/[...slug].astro +16 -6
  137. package/src/styles/content.css +563 -26
  138. package/src/styles/foundations.css +9 -0
  139. package/src/styles/media.css +192 -3
  140. package/src/styles/navigation.css +214 -60
  141. package/src/styles/page-layout.css +55 -16
  142. package/src/styles/responsive.css +62 -20
@@ -0,0 +1,19 @@
1
+ import {
2
+ auditDocusaurusProject,
3
+ migrationCheckUsage,
4
+ parseMigrationCheckArgs,
5
+ writeMigrationReport,
6
+ } from './lib/docusaurus-migration-audit.mjs';
7
+
8
+ const parsed = parseMigrationCheckArgs(process.argv.slice(2));
9
+ if (parsed.help) {
10
+ console.log(migrationCheckUsage);
11
+ process.exit(0);
12
+ }
13
+
14
+ const report = await auditDocusaurusProject({ sourceRoot: parsed.sourceRoot });
15
+ await writeMigrationReport(report, parsed.reportDir, { sourceRoot: parsed.sourceRoot });
16
+ console.log('Docusaurus migration audit written to ' + parsed.reportDir);
17
+ console.log('Source pages: ' + report.summary.pages);
18
+ console.log('Pages requiring attention: ' + report.summary.pagesWithFindings);
19
+ console.log('Findings: ' + report.summary.findings);
@@ -233,6 +233,7 @@ try {
233
233
  const siteStructure = await getSiteStructure();
234
234
  const contentFiles = siteStructure.contentFiles;
235
235
  for (const warning of siteStructure.warnings) {
236
+ if (warning.code === 'empty-category') continue;
236
237
  addIssue({
237
238
  severity: 'warning',
238
239
  message: warning.message,
@@ -272,6 +273,14 @@ for (const contentFile of contentFiles) {
272
273
  prelude,
273
274
  regions: sections,
274
275
  } = page;
276
+ for (const issue of page.diagnostics.filter((candidate) => candidate.code === 'heading-inside-details')) {
277
+ addContentIssue(contentFile, issue);
278
+ }
279
+ for (const issue of page.noteDiagnostics) {
280
+ const section = sections.find((candidate) => issue.offset >= candidate.startOffset && issue.offset < candidate.endOffset);
281
+ if (section) addSectionIssue(contentFile, section, issue);
282
+ else addContentIssue(contentFile, issue);
283
+ }
275
284
  const invalidHeadingLines = new Set();
276
285
  for (const issue of headingIdentifierIssues) {
277
286
  invalidHeadingLines.add(issue.heading.line);
@@ -362,14 +371,19 @@ for (const contentFile of contentFiles) {
362
371
  const blockResults = { blocks: section.blocks, errors: section.blockErrors };
363
372
  blockResultsBySection.set(section, blockResults);
364
373
 
365
- for (const error of section.noteErrors) {
374
+ for (const error of section.tabErrors) {
375
+ addSectionIssue(contentFile, section, { severity: 'error', message: error.message });
376
+ }
377
+
378
+ for (const error of section.calloutErrors) {
366
379
  addSectionIssue(contentFile, section, {
367
- severity: 'error',
380
+ severity: error.severity ?? 'error',
368
381
  message: error.message,
382
+ fix: error.fix,
369
383
  });
370
384
  }
371
385
 
372
- for (const error of section.calloutErrors) {
386
+ for (const error of section.codeFenceErrors) {
373
387
  addSectionIssue(contentFile, section, {
374
388
  severity: 'error',
375
389
  message: error.message,
@@ -377,7 +391,7 @@ for (const contentFile of contentFiles) {
377
391
  });
378
392
  }
379
393
 
380
- for (const error of section.codeFenceErrors) {
394
+ for (const error of section.tableErrors) {
381
395
  addSectionIssue(contentFile, section, {
382
396
  severity: 'error',
383
397
  message: error.message,
@@ -421,17 +435,32 @@ const listedNavigationTree = getListedSiteNavigationTree(siteStructure.nodes.map
421
435
 
422
436
  for (const context of contentFileContexts) {
423
437
  const childPages = getDirectChildPages(listedNavigationTree, context.contentFile.pagePath);
424
- if (childPages.length > 0) continue;
438
+ const warnedChildren = new Set();
425
439
 
426
440
  for (const section of context.sections) {
427
441
  if (!context.validSections.has(section)) continue;
428
442
  for (const block of section.blocks) {
429
443
  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
- });
444
+ if (childPages.length === 0) {
445
+ addSectionIssue(context.contentFile, section, {
446
+ severity: 'error',
447
+ message: `page-list on line ${block.line} has no listed direct child pages to display. Navigation categories are not pages.`,
448
+ fix: 'Add a listed direct child page or remove the block.',
449
+ });
450
+ continue;
451
+ }
452
+ for (const child of childPages) {
453
+ const childContext = contentContextByPageDirectory.get(child.pageDirectory);
454
+ if (!childContext || warnedChildren.has(child.pageDirectory)) continue;
455
+ const description = childContext.frontmatterData.page?.description;
456
+ if (typeof description === 'string' && description.trim()) continue;
457
+ warnedChildren.add(child.pageDirectory);
458
+ addSectionIssue(context.contentFile, section, {
459
+ severity: 'warning',
460
+ message: `page-list on line ${block.line} includes ${childContext.contentFile.contentLabel} without a non-empty page.description.`,
461
+ fix: 'Add page.description to that child file\'s frontmatter. Explain what the reader will find or when to choose this page, rather than repeating its title.',
462
+ });
463
+ }
435
464
  }
436
465
  }
437
466
  }
@@ -9,6 +9,8 @@ import {
9
9
  sitePublicLabel,
10
10
  } from './lib/site-paths.mjs';
11
11
  import { getSiteStructure } from './lib/site-structure.mjs';
12
+ import { getSiteLinkGraph } from './lib/site-link-graph.mjs';
13
+ import { assertCategoryDestinationModel } from './lib/category-destinations.mjs';
12
14
 
13
15
  const keepAstroPublicEntries = new Set([
14
16
  'images',
@@ -53,9 +55,12 @@ for (const generatedFile of generatedPublicFiles) {
53
55
  }
54
56
 
55
57
  const siteStructure = await getSiteStructure();
58
+ const { categoryModel } = await getSiteLinkGraph({ siteStructure });
59
+ const { destinations: categoryDestinations } = assertCategoryDestinationModel(categoryModel);
56
60
  const sitemapXml = createSitemapXml({
57
61
  siteStructure,
58
62
  siteUrl: projectConfig.site.url,
63
+ categoryDestinations,
59
64
  });
60
65
 
61
66
  await mkdir(astroPublicDir, { recursive: true });
@@ -79,4 +84,5 @@ for (const entry of sourceEntries) {
79
84
  await writeFile(path.join(astroPublicDir, sitemapFilename), sitemapXml);
80
85
 
81
86
  console.log(`Synced ${sitePublicLabel}/ to ${astroPublicLabel}/.`);
82
- console.log(`Generated ${astroPublicLabel}/${sitemapFilename} for ${siteStructure.contentFiles.length} public page${siteStructure.contentFiles.length === 1 ? '' : 's'}.`);
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'}.`);
@@ -32,6 +32,7 @@ const cardImageSizes = [
32
32
  const displayLink = (link: string) => withBasePath(projectConfig.site.basePath, link);
33
33
  const cardListClasses = [
34
34
  'card-list',
35
+ 'content-block-note-lane-boundary',
35
36
  `card-list-layout-${layout}`,
36
37
  `card-list-flow-${flow}`,
37
38
  `card-list-size-${size}`,
@@ -14,7 +14,7 @@ const labels = {
14
14
  <Copy aria-hidden="true" data-code-copy-icon="copy" size={18} stroke-width={2} />
15
15
  <Check aria-hidden="true" data-code-copy-icon="copied" hidden size={18} stroke-width={2} />
16
16
  <X aria-hidden="true" data-code-copy-icon="failed" hidden size={18} stroke-width={2} />
17
- <span class="visually-hidden" aria-live="polite" data-code-copy-status></span>
17
+ <span class="code-block-copy-status" aria-live="polite" data-code-copy-status></span>
18
18
  </button>
19
19
  </template>
20
20
 
@@ -24,6 +24,39 @@ const labels = {
24
24
  if (!(template instanceof HTMLTemplateElement)) return;
25
25
 
26
26
  const resetTimers = new WeakMap();
27
+ const layoutUpdaters = [];
28
+ const prepareLayout = (wrapper, pre) => {
29
+ const topLevel = wrapper.parentElement?.classList.contains('section-markdown');
30
+ let scheduled = false;
31
+ const update = () => {
32
+ scheduled = false;
33
+ if (topLevel) {
34
+ const layout = ['prose', 'end', 'canvas'].find((candidate) => {
35
+ wrapper.dataset.codeLayout = candidate;
36
+ return pre.scrollWidth <= pre.clientWidth + 1;
37
+ }) ?? 'canvas';
38
+ wrapper.dataset.codeLayout = layout;
39
+ }
40
+ const overflow = pre.scrollWidth > pre.clientWidth + 1;
41
+ wrapper.dataset.codeOverflow = String(overflow);
42
+ if (overflow) pre.setAttribute('tabindex', '0');
43
+ else pre.removeAttribute('tabindex');
44
+ };
45
+ const schedule = () => {
46
+ if (scheduled) return;
47
+ scheduled = true;
48
+ requestAnimationFrame(update);
49
+ };
50
+ layoutUpdaters.push(schedule);
51
+ if ('ResizeObserver' in window) {
52
+ const observer = new ResizeObserver(schedule);
53
+ observer.observe(wrapper.parentElement);
54
+ const body = wrapper.closest('.section-body');
55
+ if (body) observer.observe(body);
56
+ }
57
+ window.addEventListener('resize', schedule, { passive: true });
58
+ update();
59
+ };
27
60
  const copyText = async (text) => {
28
61
  if (navigator.clipboard?.writeText) {
29
62
  try {
@@ -45,7 +78,7 @@ const labels = {
45
78
 
46
79
  const showState = (button, state) => {
47
80
  button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
48
- icon.hidden = icon.getAttribute('data-code-copy-icon') !== state;
81
+ icon.toggleAttribute('hidden', icon.getAttribute('data-code-copy-icon') !== state);
49
82
  });
50
83
  const status = button.querySelector('[data-code-copy-status]');
51
84
  if (status) status.textContent = state === 'copied' ? labels.copied : labels.failed;
@@ -54,11 +87,11 @@ const labels = {
54
87
  clearTimeout(resetTimers.get(button));
55
88
  resetTimers.set(button, setTimeout(() => {
56
89
  button.querySelectorAll('[data-code-copy-icon]').forEach((icon) => {
57
- icon.hidden = icon.getAttribute('data-code-copy-icon') !== 'copy';
90
+ icon.toggleAttribute('hidden', icon.getAttribute('data-code-copy-icon') !== 'copy');
58
91
  });
59
- if (status) status.textContent = '';
92
+ // Keep the live announcement available after the visual feedback ends.
60
93
  delete button.dataset.copyState;
61
- }, 2000));
94
+ }, state === 'copied' ? 1000 : 2000));
62
95
  };
63
96
 
64
97
  document.querySelectorAll('#main-content .section-markdown pre > code').forEach((code) => {
@@ -77,10 +110,14 @@ const labels = {
77
110
 
78
111
  const button = template.content.firstElementChild?.cloneNode(true);
79
112
  if (!(button instanceof HTMLButtonElement)) return;
80
- wrapper.append(button);
113
+ const title = codeExample?.querySelector(':scope > .norna-code-title');
114
+ (title ?? wrapper).append(button);
115
+ prepareLayout(wrapper, pre);
81
116
  button.addEventListener('click', async () => {
82
117
  if (button.dataset.copyBusy === 'true') return;
83
118
  button.dataset.copyBusy = 'true';
119
+ const status = button.querySelector('[data-code-copy-status]');
120
+ if (status) status.textContent = '';
84
121
  try {
85
122
  await copyText(code.textContent ?? '');
86
123
  showState(button, 'copied');
@@ -91,6 +128,12 @@ const labels = {
91
128
  }
92
129
  });
93
130
  });
131
+ const preferences = new MutationObserver(() => layoutUpdaters.forEach((update) => update()));
132
+ preferences.observe(document.documentElement, {
133
+ attributes: true,
134
+ attributeFilter: ['data-reading-width', 'data-focus-reading', 'dir'],
135
+ });
136
+ document.fonts?.ready.then(() => layoutUpdaters.forEach((update) => update()));
94
137
 
95
138
  template.remove();
96
139
  })();
@@ -0,0 +1,39 @@
1
+ ---
2
+ import CardList from './CardList.astro';
3
+ import ImageCarousel from './ImageCarousel.astro';
4
+ import ImageStack from './ImageStack.astro';
5
+ import PageList from './PageList.astro';
6
+ import type { SectionContentBlock } from '../lib/sectionContent';
7
+
8
+ interface Props {
9
+ blocks: SectionContentBlock[];
10
+ title: string;
11
+ images: {
12
+ maxAvailableWidthPercent: { desktop: number; mobile: number };
13
+ maxAvailableHeightPercent?: { desktop: number; mobile: number };
14
+ };
15
+ cardWidth: 'text' | 'narrow' | 'normal' | 'wide';
16
+ prioritizeFirstImage?: boolean;
17
+ }
18
+ const { blocks, title, images, cardWidth, prioritizeFirstImage = false } = Astro.props;
19
+ const firstMediaIndex = blocks.findIndex((block) => block.type === 'image-stack' || block.type === 'image-carousel');
20
+ ---
21
+
22
+ {blocks.map((block, index) => {
23
+ if (block.type === 'html') return <div class="section-markdown" set:html={block.html} />;
24
+ if (block.type === 'tabs') return (
25
+ <div class="content-tabs content-block-note-lane-boundary" data-content-tabs>
26
+ <div class="content-tabs-controls" data-tabs-controls hidden></div>
27
+ {block.panels.map((panel, panelIndex) => (
28
+ <div class="content-tab-panel" data-tab-panel role="group" aria-labelledby={`norna-tabs-label-${block.index}-${panelIndex}`}>
29
+ <div class="content-tab-label" data-tab-label id={`norna-tabs-label-${block.index}-${panelIndex}`}>{panel.label}</div>
30
+ <Astro.self blocks={panel.contentBlocks} title={title} images={images} cardWidth={cardWidth} />
31
+ </div>
32
+ ))}
33
+ </div>
34
+ );
35
+ if (block.type === 'page-list') return <PageList pages={block.pages} />;
36
+ if (block.type === 'card-list') return <CardList cards={block.cards} layout={block.layout} flow={block.flow} size={block.size} width={block.width ?? cardWidth} />;
37
+ if (block.type === 'image-carousel') return <ImageCarousel title={title} images={block.images} maxAvailableWidthPercent={images.maxAvailableWidthPercent} maxAvailableHeightPercent={images.maxAvailableHeightPercent} priorityFirstImage={prioritizeFirstImage && index === firstMediaIndex} />;
38
+ return <ImageStack title={title} images={block.images} maxAvailableWidthPercent={images.maxAvailableWidthPercent} priorityFirstImage={prioritizeFirstImage && index === firstMediaIndex} />;
39
+ })}
@@ -0,0 +1,60 @@
1
+ <script>
2
+ document.querySelectorAll<HTMLElement>('[data-content-tabs]').forEach((group, groupIndex) => {
3
+ const controls = group.querySelector<HTMLElement>('[data-tabs-controls]')!;
4
+ const panels = Array.from(group.querySelectorAll<HTMLElement>(':scope > [data-tab-panel]'));
5
+ const labels = panels.map((panel) => panel.querySelector<HTMLElement>('[data-tab-label]')!);
6
+ const buttons = panels.map((panel, index) => {
7
+ const button = document.createElement('button');
8
+ const id = `norna-tabs-${groupIndex}-${index}`;
9
+ button.type = 'button';
10
+ button.id = `${id}-tab`;
11
+ button.textContent = labels[index].textContent;
12
+ button.setAttribute('role', 'tab');
13
+ button.setAttribute('aria-controls', id);
14
+ panel.id = id;
15
+ panel.setAttribute('role', 'tabpanel');
16
+ panel.setAttribute('aria-labelledby', button.id);
17
+ panel.tabIndex = 0;
18
+ controls.append(button);
19
+ return button;
20
+ });
21
+ const heading = group.closest('section')?.querySelector<HTMLElement>('h1, h2');
22
+ controls.setAttribute('role', 'tablist');
23
+ if (heading?.id) controls.setAttribute('aria-labelledby', heading.id);
24
+ const select = (selected: number, focus = false) => {
25
+ buttons.forEach((button, index) => {
26
+ button.setAttribute('aria-selected', String(index === selected));
27
+ button.tabIndex = index === selected ? 0 : -1;
28
+ panels[index].hidden = index !== selected;
29
+ });
30
+ if (focus) buttons[selected].focus({ preventScroll: true });
31
+ group.dispatchEvent(new CustomEvent('norna:tab-change', { bubbles: true }));
32
+ window.dispatchEvent(new Event('resize'));
33
+ };
34
+ buttons.forEach((button, index) => {
35
+ button.addEventListener('click', () => select(index));
36
+ button.addEventListener('keydown', (event) => {
37
+ let selected = index;
38
+ if (event.key === 'ArrowRight') selected = (index + 1) % buttons.length;
39
+ else if (event.key === 'ArrowLeft') selected = (index - 1 + buttons.length) % buttons.length;
40
+ else if (event.key === 'Home') selected = 0;
41
+ else if (event.key === 'End') selected = buttons.length - 1;
42
+ else return;
43
+ event.preventDefault();
44
+ select(selected, true);
45
+ });
46
+ });
47
+ controls.hidden = false;
48
+ group.dataset.tabsReady = 'true';
49
+ select(0);
50
+ const revealTarget = () => {
51
+ let id: string;
52
+ try { id = decodeURIComponent(location.hash.slice(1)); } catch { return; }
53
+ const target = document.getElementById(id);
54
+ const index = panels.findIndex((panel) => target && panel.contains(target));
55
+ if (index >= 0) { select(index); target?.scrollIntoView(); }
56
+ };
57
+ window.addEventListener('hashchange', revealTarget);
58
+ revealTarget();
59
+ });
60
+ </script>
@@ -49,25 +49,23 @@ const widthOptions = [
49
49
  <form class="display-settings-panel">
50
50
  <p class="display-settings-title">{labels.displaySettings}</p>
51
51
 
52
- {controls.appearance && (
53
- <fieldset>
54
- <legend>{labels.appearance}</legend>
55
- <div class="display-settings-segmented">
56
- {appearanceOptions.map((option) => (
57
- <label>
58
- <input
59
- type="radio"
60
- name="appearance"
61
- value={option.value}
62
- checked={defaults.appearance === option.value}
63
- data-reader-appearance
64
- />
65
- <span>{option.label}</span>
66
- </label>
67
- ))}
68
- </div>
69
- </fieldset>
70
- )}
52
+ <fieldset>
53
+ <legend>{labels.appearance}</legend>
54
+ <div class="display-settings-segmented">
55
+ {appearanceOptions.map((option) => (
56
+ <label>
57
+ <input
58
+ type="radio"
59
+ name="appearance"
60
+ value={option.value}
61
+ checked={defaults.appearance === option.value}
62
+ data-reader-appearance
63
+ />
64
+ <span>{option.label}</span>
65
+ </label>
66
+ ))}
67
+ </div>
68
+ </fieldset>
71
69
 
72
70
  {controls.readingWidth && (
73
71
  <fieldset>
@@ -45,7 +45,10 @@ const carouselStyle = carouselAspectRatio
45
45
  : undefined;
46
46
  ---
47
47
 
48
- <div class="managed-images" aria-label={`${projectConfig.locale.labels.images}: ${title}`}>
48
+ <div
49
+ class="managed-images content-block-note-lane-boundary"
50
+ aria-label={`${projectConfig.locale.labels.images}: ${title}`}
51
+ >
49
52
  <section
50
53
  class="managed-image-item image-carousel"
51
54
  data-carousel
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
- import { getImageAttributes } from '../lib/generatedImages';
3
+ import { Maximize2 } from '@lucide/astro';
4
+ import { getImageAttributes, getImageInspectionAttributes } from '../lib/generatedImages';
4
5
  import {
5
6
  getCaptionId,
6
7
  getManagedImageSizes,
@@ -23,21 +24,45 @@ const { title, images, maxAvailableWidthPercent, priorityFirstImage = false } =
23
24
  const managedImageSizes = getManagedImageSizes(maxAvailableWidthPercent);
24
25
  ---
25
26
 
26
- <div class="image-stack" aria-label={`${projectConfig.locale.labels.images}: ${title}`}>
27
+ <div
28
+ class="image-stack content-block-note-lane-boundary"
29
+ aria-label={`${projectConfig.locale.labels.images}: ${title}`}
30
+ >
27
31
  {images.map((image, itemIndex) => {
28
32
  const isPriorityItem = priorityFirstImage && itemIndex === 0;
29
33
  const captionId = image.caption ? getCaptionId(image, itemIndex) : undefined;
34
+ const inspection = getImageInspectionAttributes(image.src ?? image.image);
35
+ const inspectionDescription = image.alt || image.caption || `${title} ${itemIndex + 1}`;
36
+ const inspectionLabel = projectConfig.locale.labels.inspectImage.replace('{description}', inspectionDescription);
37
+ const inspectionStyle = inspection.width && inspection.height
38
+ ? `--managed-image-intrinsic-width: ${inspection.width}px; --managed-image-aspect-ratio: ${inspection.width / inspection.height}`
39
+ : undefined;
30
40
 
31
41
  return (
32
- <figure class="managed-image-item">
42
+ <figure class="managed-image-item" data-image-stack-figure>
33
43
  <div class="managed-image-frame">
34
- <img
35
- {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
36
- class:list={['managed-image', getImageClass(image)]}
37
- alt={image.alt ?? ''}
44
+ <a
45
+ class="managed-image-inspection-link"
46
+ href={inspection.href}
47
+ aria-label={inspectionLabel}
38
48
  aria-describedby={captionId}
39
- {...getImageLoadingProps(isPriorityItem)}
40
- />
49
+ data-image-inspection-trigger
50
+ data-image-intrinsic-width={inspection.width}
51
+ data-image-intrinsic-height={inspection.height}
52
+ data-image-inspection-scalable={inspection.scalable ? 'true' : undefined}
53
+ style={inspectionStyle}
54
+ >
55
+ <img
56
+ {...getImageAttributes(image.src ?? image.image, managedImageSizes)}
57
+ class:list={['managed-image', getImageClass(image)]}
58
+ alt={image.alt ?? ''}
59
+ aria-describedby={captionId}
60
+ {...getImageLoadingProps(isPriorityItem)}
61
+ />
62
+ <span class="managed-image-inspection-affordance" aria-hidden="true">
63
+ <Maximize2 size={18} stroke-width={2} />
64
+ </span>
65
+ </a>
41
66
  </div>
42
67
  {image.caption && (
43
68
  <figcaption class="image-meta" id={captionId}>