@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
@@ -1,8 +1,10 @@
1
1
  import { markdownToHtml } from 'satteri';
2
+ import { groupRenderedTabs } from '../../scripts/lib/content-tabs.mjs';
2
3
  import projectConfig from '../../scripts/lib/project-config.mjs';
3
4
  import {
4
5
  formatHeadingIdentifierIssue,
5
6
  } from '../../scripts/lib/heading-ids.mjs';
7
+ import { splitNornaRenderedBlocks } from '../../scripts/lib/norna-markdown-blocks.mjs';
6
8
  import { applyBasePathToHtml } from './basePath';
7
9
  import type { SitePage } from './sitePages';
8
10
 
@@ -46,15 +48,9 @@ type PageListItem = {
46
48
  pathname: string;
47
49
  description?: string;
48
50
  };
49
- type InlineNote = {
50
- markdown: string;
51
- number: number;
52
- id: string;
53
- referenceId: string;
54
- html?: string;
55
- };
56
- type SectionContentBlock =
51
+ export type SectionContentBlock =
57
52
  | { type: 'html'; html: string }
53
+ | { type: 'tabs'; index: number; panels: Array<{ label: string; contentBlocks: SectionContentBlock[] }> }
58
54
  | { type: 'image-stack'; images: ManagedImage[] }
59
55
  | { type: 'image-carousel'; images: ManagedImage[] }
60
56
  | { type: 'card-list'; layout: CardListLayout; flow: CardListFlow; size: CardListSize; width?: CardListWidth; cards: CardListItem[] }
@@ -77,6 +73,11 @@ export type HeadingNavigation = SectionNavigation & {
77
73
  parentId: string | null;
78
74
  };
79
75
 
76
+ export const flattenContentBlocks = (blocks: SectionContentBlock[]): SectionContentBlock[] =>
77
+ blocks.flatMap((block) => block.type === 'tabs'
78
+ ? [block, ...block.panels.flatMap((panel) => flattenContentBlocks(panel.contentBlocks))]
79
+ : [block]);
80
+
80
81
  const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
81
82
  const imageProvenanceCommentRegex = /<!--\s*norna-image-provenance:[\s\S]*?-->/gi;
82
83
 
@@ -88,43 +89,6 @@ const prepareContentHtml = (html: string) =>
88
89
  stripImageProvenanceComments(html),
89
90
  );
90
91
 
91
- const noteDeclarationParagraphRegex = /<p>\s*\{note:\s*[\s\S]*?\}\s*<\/p>/gi;
92
- const htmlCodeRegionRegex = /<pre\b[\s\S]*?<\/pre>|<code\b[\s\S]*?<\/code>/gi;
93
-
94
- const replaceHtmlOutsideCode = (html: string, transform: (value: string) => string) => {
95
- let result = '';
96
- let cursor = 0;
97
-
98
- for (const match of html.matchAll(htmlCodeRegionRegex)) {
99
- const start = match.index ?? 0;
100
- result += transform(html.slice(cursor, start));
101
- result += match[0];
102
- cursor = start + match[0].length;
103
- }
104
-
105
- return result + transform(html.slice(cursor));
106
- };
107
-
108
- const applyInlineNoteMarkup = (html: string, notes: InlineNote[]) => {
109
- let referenceIndex = 0;
110
- const withReferences = replaceHtmlOutsideCode(html, (value) => value.replace(/\{note-ref\}/g, () => {
111
- const note = notes[referenceIndex];
112
- if (!note) return '{note-ref}';
113
-
114
- referenceIndex += 1;
115
- return [
116
- '\u2060',
117
- `<sup class="section-note-ref"><a id="${note.referenceId}" href="#${note.id}" aria-label="Note ${note.number}" aria-describedby="${note.id}">${note.number}</a></sup>`,
118
- `<span class="section-note section-note-margin" id="${note.id}" aria-label="Note ${note.number}" role="note">`,
119
- `<a class="section-note-number" href="#${note.referenceId}" aria-label="Note ${note.number}">${note.number}</a>`,
120
- `<span class="section-note-content">${note.html ?? ''}</span>`,
121
- '</span>',
122
- ].join('');
123
- }));
124
-
125
- return withReferences.replace(noteDeclarationParagraphRegex, '');
126
- };
127
-
128
92
  const applyH3HeadingIds = (
129
93
  html: string,
130
94
  headings: Array<{ id: string | null }>,
@@ -149,27 +113,6 @@ const applyH3HeadingIds = (
149
113
 
150
114
  const getImageSourceKey = (page: SitePage, image: string) => `pages/${page.pageDirectory}/images/${image}`;
151
115
 
152
- const renderInlineNoteMarkdown = async (markdown: string) => {
153
- const result = await markdownToHtml(markdown, {
154
- features: {
155
- gfm: true,
156
- smartPunctuation: true,
157
- },
158
- });
159
-
160
- if (/<img\b/i.test(result.html)) {
161
- throw new Error('Inline notes cannot contain images. Use a Norna image block with a caption instead.');
162
- }
163
-
164
- const inlineHtml = result.html.trim();
165
- const paragraph = inlineHtml.match(/^<p>([\s\S]*)<\/p>$/i);
166
- if (!paragraph) {
167
- throw new Error('Inline notes support inline Markdown only. Move headings, lists, and separate paragraphs into the page content.');
168
- }
169
-
170
- return paragraph[1] ?? '';
171
- };
172
-
173
116
  const renderHeadingTitleHtml = async (headingSource: string) => {
174
117
  const headingMarkdown = headingSource
175
118
  .replace(/^#{1,6}[ \t]+/, '')
@@ -202,45 +145,15 @@ const splitRenderedRegions = (html: string, regionCount: number) => {
202
145
  });
203
146
  };
204
147
 
205
- const splitNornaBlockMarkers = (html: string, blocks: ParsedNornaBlock[]) => {
206
- const result: Array<{ type: 'html'; html: string } | ParsedNornaBlock> = [];
207
- const markerRegex = /<norna-block\s+data-index="(\d+)"\s*><\/norna-block>/g;
208
- const seen = new Set<number>();
209
- let cursor = 0;
210
-
211
- for (const match of html.matchAll(markerRegex)) {
212
- const start = match.index ?? 0;
213
- if (start > cursor) result.push({ type: 'html', html: html.slice(cursor, start) });
214
-
215
- const index = Number.parseInt(match[1] ?? '', 10);
216
- const block = blocks[index];
217
- if (!block) throw new Error(`Rendered Norna block ${index + 1} has no matching parsed block.`);
218
- seen.add(index);
219
- result.push(block);
220
- cursor = start + match[0].length;
221
- }
222
-
223
- if (cursor < html.length) result.push({ type: 'html', html: html.slice(cursor) });
224
- if (seen.size !== blocks.length) {
225
- throw new Error(`Rendered Markdown contains ${seen.size} Norna block markers, but ${blocks.length} blocks were parsed.`);
226
- }
227
-
228
- return result.filter((block) => block.type !== 'html' || block.html.trim());
229
- };
230
-
231
148
  const resolveContentBlocks = async (
232
149
  html: string,
233
150
  blocks: ParsedNornaBlock[],
234
151
  page: SitePage,
235
152
  childPages: SitePage[],
236
- inlineNotes: InlineNote[] = [],
237
153
  ) => {
238
- const renderedNotes = await Promise.all(inlineNotes.map(async (note) => ({
239
- ...note,
240
- html: await renderInlineNoteMarkdown(note.markdown),
241
- })));
242
- const renderedHtml = applyInlineNoteMarkup(html, renderedNotes);
243
- const splitBlocks = splitNornaBlockMarkers(renderedHtml, blocks);
154
+ const splitBlocks = splitNornaRenderedBlocks(html, blocks) as Array<
155
+ { type: 'html'; html: string } | ParsedNornaBlock
156
+ >;
244
157
  const resolvedBlocks: SectionContentBlock[] = [];
245
158
 
246
159
  for (const block of splitBlocks) {
@@ -296,6 +209,10 @@ export const getSectionsContent = async (
296
209
  childPages: SitePage[] = [],
297
210
  ) => {
298
211
  const pageDocument = page.markdownDocument;
212
+ const noteError = pageDocument.noteDiagnostics.find((issue) => issue.severity === 'error');
213
+ if (noteError) throw new Error(noteError.message);
214
+ const tabError = pageDocument.diagnostics.find((issue) => issue.code === 'invalid-content-tabs');
215
+ if (tabError) throw new Error(tabError.message);
299
216
  const sourceLabel = page.contentLabel;
300
217
  const headingIdentifierIssues = pageDocument.headingIssues;
301
218
  if (headingIdentifierIssues.length > 0) {
@@ -303,7 +220,6 @@ export const getSectionsContent = async (
303
220
  }
304
221
  const sections: ResolvedSection[] = [];
305
222
  const sectionIds = new Set<string>();
306
- let nextNoteNumber = 1;
307
223
  const pageHeadingCount = pageDocument.pageHeadings.length;
308
224
 
309
225
  if (pageHeadingCount !== 1 || pageDocument.regions[0]?.kind !== 'page-intro') {
@@ -333,29 +249,20 @@ export const getSectionsContent = async (
333
249
  if (bodySection.noteErrors.length > 0) {
334
250
  throw new Error(bodySection.noteErrors[0].message);
335
251
  }
336
- if (bodySection.calloutErrors.length > 0) {
337
- throw new Error(bodySection.calloutErrors[0].message);
252
+ const calloutErrors = bodySection.calloutErrors.filter((error) => error.severity !== 'warning');
253
+ if (calloutErrors.length > 0) {
254
+ throw new Error(calloutErrors[0].message);
338
255
  }
339
256
 
340
- const inlineNotes = bodySection.notes.map((note) => {
341
- const number = nextNoteNumber;
342
- nextNoteNumber += 1;
343
- const pageKey = page.pageId || 'home';
344
- const noteKey = `${pageKey}-${id ?? 'page-title'}-${number}`;
345
- return {
346
- ...note,
347
- number,
348
- id: `note-${noteKey}`,
349
- referenceId: `note-ref-${noteKey}`,
350
- };
351
- });
352
-
353
257
  sections.push({
354
258
  id,
355
259
  title,
356
260
  titleHtml: await renderHeadingTitleHtml(bodySection.heading.source),
357
261
  headingLevel,
358
- contentBlocks: await resolveContentBlocks(content, bodySection.blocks, page, childPages, inlineNotes),
262
+ contentBlocks: groupRenderedTabs(
263
+ await resolveContentBlocks(content, bodySection.blocks, page, childPages),
264
+ bodySection.tabGroups,
265
+ ),
359
266
  });
360
267
  }
361
268
 
@@ -3,7 +3,6 @@ import type { SiteNode, SitePage } from './sitePages';
3
3
  import {
4
4
  flattenSiteNavigationTree as flattenSiteNavigationTreeShared,
5
5
  getDirectChildPages as getDirectChildPagesShared,
6
- getFirstPageInNavigationNode as getFirstPageInNavigationNodeShared,
7
6
  getListedSiteNavigationTree as getListedSiteNavigationTreeShared,
8
7
  getSequentialPageNavigation as getSequentialPageNavigationShared,
9
8
  } from '../../scripts/lib/site-navigation-tree.mjs';
@@ -22,10 +21,6 @@ export const getListedSiteNavigationTree = (entries: SiteNavigationEntry[]) => (
22
21
  getListedSiteNavigationTreeShared(entries) as SiteNavigationNode[]
23
22
  );
24
23
 
25
- export const getFirstPageInNavigationNode = (node: SiteNavigationNode): SitePage | null => (
26
- getFirstPageInNavigationNodeShared(node) as SitePage | null
27
- );
28
-
29
24
  export const flattenSiteNavigationTree = (nodes: SiteNavigationNode[]): SiteNavigationNode[] => (
30
25
  flattenSiteNavigationTreeShared(nodes) as SiteNavigationNode[]
31
26
  );
@@ -4,6 +4,7 @@ import { decodePageDirectoryPath, parsePageDirectoryPath } from '../../scripts/l
4
4
  import { parsePageMarkdown } from '../../scripts/lib/page-markdown.mjs';
5
5
  import { readSiteFile } from '../../scripts/lib/site-content.mjs';
6
6
  import { homePageDirectory } from '../../scripts/lib/site-conventions.mjs';
7
+ import { assertCategoryDestinationModel, createCategoryDestinationModel } from '../../scripts/lib/category-destinations.mjs';
7
8
  import { getSiteStructure } from '../../scripts/lib/site-structure.mjs';
8
9
  import { sitePagesDir, sitePagesLabel } from '../../scripts/lib/site-paths.mjs';
9
10
 
@@ -20,6 +21,7 @@ type SiteNodeBase = {
20
21
  depth: number;
21
22
  pageDirectories: string[];
22
23
  pathSegment: string;
24
+ pathname: string;
23
25
  pageDirectory: string;
24
26
  pageId: string;
25
27
  pageIds: string[];
@@ -36,10 +38,12 @@ export type SitePage = SiteNodeBase & {
36
38
  markdown: string;
37
39
  markdownDocument: Awaited<ReturnType<typeof parsePageMarkdown>>;
38
40
  contentLabel: string;
41
+ contentPath: string;
39
42
  };
40
43
 
41
44
  export type SiteCategory = SiteNodeBase & {
42
45
  kind: 'category';
46
+ categorySourceLabel: string;
43
47
  };
44
48
 
45
49
  export type SiteNode = SitePage | SiteCategory;
@@ -86,20 +90,26 @@ const compareNumberPaths = (left: number[], right: number[]) => {
86
90
  const readPageMarkdownDocument = async (entry: SiteEntry) => {
87
91
  const pageDirectory = getPageDirectory(entry);
88
92
  const contentLabel = `${sitePagesLabel}/${pageDirectory}/content.md`;
89
- const { body } = await readSiteFile(path.join(sitePagesDir, pageDirectory, 'content.md'), contentLabel);
90
- const markdownDocument = await parsePageMarkdown(body, { label: contentLabel });
93
+ const contentPath = path.join(sitePagesDir, pageDirectory, 'content.md');
94
+ const { body, frontmatter } = await readSiteFile(contentPath, contentLabel);
95
+ const markdownDocument = await parsePageMarkdown(body, {
96
+ label: contentLabel,
97
+ lineOffset: frontmatter.split('\n').length - 1,
98
+ });
99
+ const detailsError = markdownDocument.diagnostics.find((issue) => issue.code === 'heading-inside-details');
100
+ if (detailsError) throw new Error(`${detailsError.message} ${detailsError.fix}`);
91
101
 
92
102
  if (markdownDocument.pageHeadings.length !== 1 || markdownDocument.regions[0]?.kind !== 'page-intro') {
93
103
  throw new Error(`Page entry "${entry.id}" must contain exactly one Markdown H1 page title.`);
94
104
  }
95
105
 
96
- return { body, contentLabel, markdownDocument };
106
+ return { body, contentLabel, contentPath, markdownDocument };
97
107
  };
98
108
 
99
109
  const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
100
110
  const isHome = isHomePageEntry(entry);
101
111
  const pageMetadata = getPageMetadata(entry);
102
- const { body, contentLabel, markdownDocument } = await readPageMarkdownDocument(entry);
112
+ const { body, contentLabel, contentPath, markdownDocument } = await readPageMarkdownDocument(entry);
103
113
  const pageDirectory = pageMetadata.pageDirectory;
104
114
  const pageId = pageMetadata.pageId;
105
115
  const pagePath = pageMetadata.pagePath;
@@ -126,6 +136,7 @@ const createSitePage = async (entry: SiteEntry): Promise<SitePage> => {
126
136
  pagePath,
127
137
  parentPagePath: isHome ? null : pageMetadata.parentPagePath,
128
138
  contentLabel,
139
+ contentPath,
129
140
  markdown: body,
130
141
  markdownDocument,
131
142
  title: markdownDocument.pageTitle.title,
@@ -142,6 +153,8 @@ const createSiteCategory = (category: Awaited<ReturnType<typeof getSiteStructure
142
153
  order: category.pageOrder,
143
154
  },
144
155
  pathSegment: category.pagePath,
156
+ pathname: getPagePathname(category.pagePath),
157
+ categorySourceLabel: category.categorySourceLabel,
145
158
  pageDirectory: category.pageDirectory,
146
159
  pageDirectories: category.pageDirectories,
147
160
  pageId: category.pageId,
@@ -223,6 +236,7 @@ export const getSiteModel = async (entries: SiteEntry[]) => {
223
236
  }
224
237
 
225
238
  return {
239
+ categoryDestinations: assertCategoryDestinationModel(createCategoryDestinationModel(nodes)),
226
240
  categories: nodes.filter((node): node is SiteCategory => node.kind === 'category'),
227
241
  nodes,
228
242
  pages,
@@ -0,0 +1,109 @@
1
+ export const createTableSortTooltip = () => {
2
+ const tooltip = document.createElement('div');
3
+ tooltip.id = 'norna-table-sort-tooltip';
4
+ tooltip.className = 'norna-table-sort-tooltip';
5
+ tooltip.setAttribute('role', 'tooltip');
6
+ tooltip.hidden = true;
7
+ document.body.append(tooltip);
8
+ let owner: HTMLButtonElement | null = null;
9
+ let openTimer: ReturnType<typeof setTimeout> | undefined;
10
+ let closeTimer: ReturnType<typeof setTimeout> | undefined;
11
+
12
+ const setDescription = (button: HTMLButtonElement, enabled: boolean) => {
13
+ const ids = new Set((button.getAttribute('aria-describedby') ?? '').split(/\s+/u).filter(Boolean));
14
+ if (enabled) ids.add(tooltip.id);
15
+ else ids.delete(tooltip.id);
16
+ if (ids.size) button.setAttribute('aria-describedby', [...ids].join(' '));
17
+ else button.removeAttribute('aria-describedby');
18
+ };
19
+ const hide = () => {
20
+ clearTimeout(openTimer);
21
+ clearTimeout(closeTimer);
22
+ if (owner) setDescription(owner, false);
23
+ owner = null;
24
+ tooltip.hidden = true;
25
+ };
26
+ const position = () => {
27
+ if (!owner || tooltip.hidden) return;
28
+ const bounds = owner.getBoundingClientRect();
29
+ const tip = tooltip.getBoundingClientRect();
30
+ const width = document.documentElement.clientWidth;
31
+ const height = window.innerHeight;
32
+ const left = Math.max(8, Math.min(width - tip.width - 8, bounds.left + (bounds.width - tip.width) / 2));
33
+ const below = bounds.bottom + 4;
34
+ const top = below + tip.height <= height - 8 ? below : Math.max(8, bounds.top - tip.height - 4);
35
+ tooltip.style.left = `${left}px`;
36
+ tooltip.style.top = `${top}px`;
37
+ };
38
+ const refresh = (button: HTMLButtonElement) => {
39
+ if (button !== owner || tooltip.hidden) return;
40
+ tooltip.textContent = button.dataset.tableSortAction ?? '';
41
+ position();
42
+ };
43
+ const show = (button: HTMLButtonElement, delay: number) => {
44
+ if (button === owner && !tooltip.hidden) {
45
+ clearTimeout(closeTimer);
46
+ return;
47
+ }
48
+ hide();
49
+ owner = button;
50
+ const open = () => {
51
+ if (!button.isConnected || button.closest('[inert]') || button.getAttribute('aria-hidden') === 'true') {
52
+ hide();
53
+ return;
54
+ }
55
+ tooltip.hidden = false;
56
+ setDescription(button, true);
57
+ refresh(button);
58
+ };
59
+ if (delay === 0) open();
60
+ else openTimer = setTimeout(open, delay);
61
+ };
62
+ const scheduleHide = () => {
63
+ clearTimeout(openTimer);
64
+ clearTimeout(closeTimer);
65
+ // Allow the pointer to cross the small gap into the tooltip.
66
+ closeTimer = setTimeout(() => {
67
+ if (!tooltip.matches(':hover') && !owner?.matches(':hover, :focus-visible')) hide();
68
+ }, 120);
69
+ };
70
+ tooltip.addEventListener('pointerenter', () => clearTimeout(closeTimer));
71
+ tooltip.addEventListener('pointerleave', scheduleHide);
72
+ document.addEventListener('keydown', (event) => {
73
+ if (event.key === 'Escape' && owner) {
74
+ hide();
75
+ event.preventDefault();
76
+ }
77
+ });
78
+ document.addEventListener('scroll', () => {
79
+ // Keyboard focus may scroll a clipped heading into view after opening its tip.
80
+ if (owner?.matches(':focus-visible')) {
81
+ const bounds = owner.getBoundingClientRect();
82
+ if (bounds.bottom > 0 && bounds.top < window.innerHeight) {
83
+ position();
84
+ return;
85
+ }
86
+ }
87
+ hide();
88
+ }, { capture: true, passive: true });
89
+ window.addEventListener('resize', hide, { passive: true });
90
+
91
+ return {
92
+ refresh,
93
+ attach(button: HTMLButtonElement) {
94
+ setDescription(button, false);
95
+ button.addEventListener('pointerenter', (event) => {
96
+ if (event.pointerType !== 'touch') show(button, 500);
97
+ });
98
+ button.addEventListener('pointerleave', () => {
99
+ if (button === owner) scheduleHide();
100
+ });
101
+ button.addEventListener('focus', () => {
102
+ if (button.matches(':focus-visible')) show(button, 0);
103
+ });
104
+ button.addEventListener('blur', () => {
105
+ if (button === owner) scheduleHide();
106
+ });
107
+ },
108
+ };
109
+ };
@@ -45,18 +45,28 @@ export async function getStaticPaths() {
45
45
  ? [{ params: { slug: 'search' }, props: { searchPage: true } }]
46
46
  : [];
47
47
 
48
- return [...pagePaths, ...aliasPaths, ...searchPath];
48
+ const categoryPaths = siteModel.categoryDestinations.destinations.map((destination) => ({
49
+ params: { slug: destination.category.pathSegment },
50
+ props: destination.kind === 'redirect' ? {
51
+ alias: {
52
+ targetPathname: destination.target.pathname,
53
+ targetTitle: destination.target.title,
54
+ categoryTitle: destination.category.title,
55
+ },
56
+ } : { categoryPath: destination.pathname },
57
+ }));
58
+ return [...pagePaths, ...aliasPaths, ...searchPath, ...categoryPaths];
49
59
  }
50
60
 
51
- const { alias, entry, searchPage } = Astro.props;
61
+ const { alias, entry, searchPage, categoryPath } = Astro.props;
52
62
 
53
- if (!searchPage && !alias && (!entry || isHomePageEntry(entry))) {
63
+ if (!searchPage && !alias && !categoryPath && (!entry || isHomePageEntry(entry))) {
54
64
  throw new Error('Page entry is missing.');
55
65
  }
56
66
 
57
67
  const currentPage = entry ? await getSitePage(entry) : null;
58
68
 
59
- if (!searchPage && !alias && !currentPage?.pathSegment) {
69
+ if (!searchPage && !alias && !categoryPath && !currentPage?.pathSegment) {
60
70
  throw new Error(`Page "${entry.id}" needs a page id.`);
61
71
  }
62
72
  ---
@@ -64,5 +74,5 @@ if (!searchPage && !alias && !currentPage?.pathSegment) {
64
74
  {searchPage
65
75
  ? <SearchPage />
66
76
  : alias
67
- ? <PageAliasRedirect targetPathname={alias.targetPathname} targetTitle={alias.targetTitle} />
68
- : <SitePage entry={entry} />}
77
+ ? <PageAliasRedirect targetPathname={alias.targetPathname} targetTitle={alias.targetTitle} categoryTitle={alias.categoryTitle} />
78
+ : <SitePage entry={entry} categoryPath={categoryPath} />}