@janga/norna 0.7.24 → 0.7.25

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 (43) hide show
  1. package/README.md +2 -2
  2. package/astro.config.mjs +2 -0
  3. package/package.json +14 -2
  4. package/schemas/category.schema.json +2 -2
  5. package/schemas/config.schema.json +56 -21
  6. package/schemas/content-frontmatter.schema.json +7 -7
  7. package/schemas/page-theme.schema.json +26 -26
  8. package/schemas/sitewide-content.schema.json +18 -18
  9. package/schemas/theme.schema.json +241 -241
  10. package/scripts/check-config.mjs +5 -1
  11. package/scripts/dev-local.mjs +106 -11
  12. package/scripts/init-site.mjs +2 -0
  13. package/scripts/lib/code-fence-metadata.mjs +11 -6
  14. package/scripts/lib/edit-source-link.mjs +53 -0
  15. package/scripts/lib/navigation-model.mjs +1 -1
  16. package/scripts/lib/norna-markdown-blocks.mjs +91 -0
  17. package/scripts/lib/project-config.mjs +42 -12
  18. package/scripts/lib/schema-definitions.mjs +10 -2
  19. package/scripts/lib/schema-editor-metadata.mjs +20 -3
  20. package/scripts/lib/schema-value-definitions.mjs +3 -0
  21. package/scripts/lib/site-paths.mjs +14 -2
  22. package/scripts/lib/table-render-plugin.mjs +33 -0
  23. package/src/components/CardList.astro +1 -0
  24. package/src/components/CodeBlockCopyScript.astro +2 -1
  25. package/src/components/ImageCarousel.astro +4 -1
  26. package/src/components/ImageStack.astro +34 -9
  27. package/src/components/ImageStackEnhancement.astro +331 -0
  28. package/src/components/PageContentsNavigation.astro +2 -3
  29. package/src/components/SectionNavigationScript.astro +45 -7
  30. package/src/components/SiteNavigation.astro +73 -49
  31. package/src/components/SitePage.astro +38 -23
  32. package/src/components/SiteTreeNavigation.astro +3 -0
  33. package/src/components/TableOverflowScript.astro +251 -0
  34. package/src/components/TreeNavigationScript.astro +78 -22
  35. package/src/layouts/BaseLayout.astro +31 -5
  36. package/src/lib/generatedImages.ts +18 -0
  37. package/src/lib/sectionContent.ts +4 -27
  38. package/src/lib/sitePages.ts +6 -3
  39. package/src/styles/content.css +298 -23
  40. package/src/styles/media.css +187 -3
  41. package/src/styles/navigation.css +168 -3
  42. package/src/styles/page-layout.css +48 -16
  43. package/src/styles/responsive.css +67 -20
@@ -76,7 +76,11 @@ try {
76
76
  console.log('Config check passed.');
77
77
  console.log(`Site URL: ${projectConfig.site.url}`);
78
78
  console.log(`Base path: ${projectConfig.site.basePath}`);
79
- console.log(`Edit links: ${projectConfig.editLink?.baseUrl ?? '(disabled)'}`);
79
+ const editLinkDestinations = [
80
+ projectConfig.editLink?.localEditor ? `local ${projectConfig.editLink.localEditor}` : null,
81
+ projectConfig.editLink?.baseUrl ? `remote ${projectConfig.editLink.baseUrl}` : null,
82
+ ].filter(Boolean);
83
+ console.log(`Edit links: ${editLinkDestinations.join(', ') || '(disabled)'}`);
80
84
  console.log(`Theme preset: ${themeConfig.preset ?? '(none)'}`);
81
85
  console.log(`Page width: ${projectConfig.layout.pageWidth}`);
82
86
  console.log(`Gutter: desktop ${projectConfig.layout.gutter.desktop}, mobile ${projectConfig.layout.gutter.mobile}`);
@@ -5,7 +5,12 @@ import { networkInterfaces } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { getAstroArgs, runAstroInherit } from './lib/astro-command.mjs';
8
- import { astroCacheDir, engineRoot, siteProjectRoot } from './lib/site-paths.mjs';
8
+ import {
9
+ astroCacheDir,
10
+ engineRoot,
11
+ siteProjectRoot,
12
+ siteStateDir,
13
+ } from './lib/site-paths.mjs';
9
14
 
10
15
  const execFileAsync = promisify(execFile);
11
16
 
@@ -27,7 +32,10 @@ const probeUrls = [
27
32
  `http://localhost:${port}${projectConfig.site.basePath}`,
28
33
  ];
29
34
  const skipOpen = process.env.NORNA_NO_OPEN === '1';
30
- const statePath = path.join(astroCacheDir, 'dev-local.json');
35
+ const stateDirectory = path.join(siteStateDir, 'dev');
36
+ const statePath = path.join(stateDirectory, 'state.json');
37
+ const legacyStatePath = path.join(astroCacheDir, 'dev-local.json');
38
+ const astroStatePath = path.join(astroCacheDir, 'dev.json');
31
39
  const logPath = path.join(astroCacheDir, 'dev.log');
32
40
  const args = process.argv.slice(2);
33
41
  const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
@@ -51,9 +59,9 @@ const generateImages = async () => execFileAsync(process.execPath, [path.join(en
51
59
  maxBuffer: 1024 * 1024 * 10,
52
60
  });
53
61
 
54
- const readState = async () => {
62
+ const readJsonFile = async (filePath) => {
55
63
  try {
56
- return JSON.parse(await readFile(statePath, 'utf8'));
64
+ return JSON.parse(await readFile(filePath, 'utf8'));
57
65
  } catch (error) {
58
66
  if (error.code === 'ENOENT') {
59
67
  return null;
@@ -63,15 +71,34 @@ const readState = async () => {
63
71
  }
64
72
  };
65
73
 
74
+ const readState = async () => (
75
+ await readJsonFile(statePath)
76
+ ?? await readJsonFile(legacyStatePath)
77
+ );
78
+
66
79
  const getLanUrls = () => Object.values(networkInterfaces())
67
80
  .flat()
68
81
  .filter((network) => network?.family === 'IPv4' && !network.internal)
69
82
  .map((network) => `http://${network.address}:${port}${projectConfig.site.basePath}`);
70
83
 
71
84
  const writeState = async (host) => {
72
- await mkdir(path.dirname(statePath), { recursive: true });
85
+ const astroState = await readJsonFile(astroStatePath);
86
+ let pid = astroState?.port === port && isProcessAlive(astroState.pid)
87
+ ? astroState.pid
88
+ : null;
89
+ if (!Number.isInteger(pid)) {
90
+ const listeningPids = await getPortPids({ required: false });
91
+ pid = listeningPids?.length === 1 ? listeningPids[0] : null;
92
+ }
93
+
94
+ if (!Number.isInteger(pid)) {
95
+ throw new Error(`The dev server started at ${localUrl}, but Norna could not record its process. Stop the process on port ${port} before starting it again.`);
96
+ }
97
+
98
+ await mkdir(stateDirectory, { recursive: true });
73
99
  await writeFile(statePath, `${JSON.stringify({
74
100
  port,
101
+ pid,
75
102
  host,
76
103
  mode: host === '0.0.0.0' ? 'lan' : 'local',
77
104
  url: localUrl,
@@ -81,6 +108,8 @@ const writeState = async (host) => {
81
108
 
82
109
  const removeState = async () => {
83
110
  await rm(statePath, { force: true });
111
+ await rm(legacyStatePath, { force: true });
112
+ await rm(stateDirectory, { recursive: true, force: true });
84
113
  };
85
114
 
86
115
  const readDevLog = async () => {
@@ -161,6 +190,16 @@ const sleep = (milliseconds) => new Promise((resolve) => {
161
190
  setTimeout(resolve, milliseconds);
162
191
  });
163
192
 
193
+ const isProcessAlive = (pid) => {
194
+ if (!Number.isInteger(pid)) return false;
195
+ try {
196
+ process.kill(pid, 0);
197
+ return true;
198
+ } catch (error) {
199
+ return error.code === 'EPERM';
200
+ }
201
+ };
202
+
164
203
  const isServerReachable = async () => {
165
204
  for (const probeUrl of probeUrls) {
166
205
  try {
@@ -213,7 +252,7 @@ const parseWindowsPortPids = (stdout) => [...new Set(
213
252
  .filter(Number.isInteger),
214
253
  )];
215
254
 
216
- const getPortPids = async () => {
255
+ const getPortPids = async ({ required = true } = {}) => {
217
256
  try {
218
257
  if (process.platform === 'win32') {
219
258
  const { stdout } = await execFileAsync('powershell.exe', [
@@ -234,6 +273,7 @@ const getPortPids = async () => {
234
273
  } catch (error) {
235
274
  if (error.code === 1) return [];
236
275
  if (error.code === 'ENOENT') {
276
+ if (!required) return null;
237
277
  const commandName = process.platform === 'win32' ? 'PowerShell' : 'lsof';
238
278
  throw new Error(`Cannot use --kill because ${commandName} is not available. Stop the process on port ${port} manually, then rerun the command.`);
239
279
  }
@@ -273,6 +313,31 @@ const terminatePortProcesses = async (host) => {
273
313
  }
274
314
  };
275
315
 
316
+ const terminateTrackedProcess = async (state, host) => {
317
+ if (state?.port !== port || !Number.isInteger(state.pid)) return false;
318
+ if (!isProcessAlive(state.pid)) return false;
319
+
320
+ const listeningPids = await getPortPids({ required: false });
321
+ if (listeningPids && !listeningPids.includes(state.pid)) return false;
322
+ if (!listeningPids && !(await isServerReachable())) return false;
323
+
324
+ try {
325
+ process.kill(state.pid, 'SIGTERM');
326
+ } catch (error) {
327
+ if (error.code !== 'ESRCH') throw error;
328
+ }
329
+
330
+ if (await waitForPortToBeFree(host)) return true;
331
+
332
+ try {
333
+ process.kill(state.pid, 'SIGKILL');
334
+ } catch (error) {
335
+ if (error.code !== 'ESRCH') throw error;
336
+ }
337
+
338
+ return waitForPortToBeFree(host);
339
+ };
340
+
276
341
  const openBrowser = async () => {
277
342
  if (skipOpen) {
278
343
  console.log(`Browser open skipped. Open ${localUrl}`);
@@ -294,10 +359,27 @@ const openBrowser = async () => {
294
359
 
295
360
  const stopServer = async ({ quiet = false } = {}) => {
296
361
  const state = await readState();
297
- const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
298
- const stopped = `${stdout}\n${stderr}`.includes('Stopped dev server');
299
- if (stopped) await waitForPortToBeFree(state?.host ?? localHost);
300
- await removeState();
362
+ let astroError;
363
+ let astroOutput = '';
364
+ try {
365
+ const { stdout = '', stderr = '' } = await runAstro(['dev', 'stop']);
366
+ astroOutput = `${stdout}\n${stderr}`;
367
+ } catch (error) {
368
+ astroError = error;
369
+ }
370
+
371
+ let stopped = astroOutput.includes('Stopped dev server');
372
+ if (stopped) {
373
+ await waitForPortToBeFree(state?.host ?? localHost);
374
+ } else {
375
+ stopped = await terminateTrackedProcess(state, state?.host ?? localHost);
376
+ }
377
+
378
+ if (stopped || !(await isServerReachable())) {
379
+ await removeState();
380
+ }
381
+
382
+ if (astroError && !stopped) throw astroError;
301
383
 
302
384
  if (!quiet) {
303
385
  console.log(stopped
@@ -370,7 +452,20 @@ const showStatus = async () => {
370
452
  }
371
453
 
372
454
  if (reachable) {
373
- console.log(`A server is responding at ${localUrl}, but Astro does not track it for this site.`);
455
+ const listeningPids = await getPortPids({ required: false });
456
+ const stateOwnsPort = state?.port === port
457
+ && Number.isInteger(state.pid)
458
+ && (listeningPids ? listeningPids.includes(state.pid) : isProcessAlive(state.pid));
459
+ if (stateOwnsPort) {
460
+ console.log(`dev:${state.mode ?? 'local'} is running at ${localUrl}. Norna retained its process record after Astro cleared its cache.`);
461
+ if (state.host === '0.0.0.0') {
462
+ const lanUrls = getLanUrls();
463
+ if (lanUrls.length > 0) console.log(`On this local network: ${lanUrls.join(', ')}`);
464
+ }
465
+ return;
466
+ }
467
+
468
+ console.log(`A server is responding at ${localUrl}, but neither Astro nor Norna tracks it for this site.`);
374
469
  return;
375
470
  }
376
471
 
@@ -18,6 +18,7 @@ dist/
18
18
  # generated site state
19
19
  **/.norna/public/
20
20
  **/.norna/.astro/
21
+ **/.norna/dev/
21
22
 
22
23
  # legacy Astro cache location
23
24
  .astro/
@@ -40,6 +41,7 @@ pnpm-debug.log*
40
41
  `;
41
42
  const siteGitignore = `.norna/public/
42
43
  .norna/.astro/
44
+ .norna/dev/
43
45
  `;
44
46
 
45
47
  const usage = `
@@ -210,12 +210,17 @@ export const nornaCodeFenceTransformer = {
210
210
  tagName: 'figure',
211
211
  properties: { className: ['norna-code-example'] },
212
212
  children: [
213
- {
214
- type: 'element',
215
- tagName: 'figcaption',
216
- properties: { className: ['norna-code-title'] },
217
- children: [{ type: 'text', value: metadata.title }],
218
- },
213
+ {
214
+ type: 'element',
215
+ tagName: 'figcaption',
216
+ properties: { className: ['norna-code-title'] },
217
+ children: [{
218
+ type: 'element',
219
+ tagName: 'span',
220
+ properties: { className: ['norna-code-title-text'] },
221
+ children: [{ type: 'text', value: metadata.title }],
222
+ }],
223
+ },
219
224
  node,
220
225
  ],
221
226
  };
@@ -1,3 +1,8 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+
4
+ export const localEditorNames = Object.freeze(['vscode']);
5
+
1
6
  const assertHttpUrl = (value, label) => {
2
7
  if (typeof value !== 'string' || value.trim() === '') {
3
8
  throw new Error(`${label} must be a non-empty absolute URL.`);
@@ -47,3 +52,51 @@ export const getEditSourceUrl = ({ baseUrl, sourcePath }) => {
47
52
  const encodedPath = segments.map((segment) => encodeURIComponent(segment)).join('/');
48
53
  return new URL(encodedPath, normalizedBaseUrl).href;
49
54
  };
55
+
56
+ export const isLoopbackHostname = (hostname) => {
57
+ const normalizedHostname = String(hostname ?? '')
58
+ .trim()
59
+ .toLowerCase()
60
+ .replace(/^\[|\]$/g, '')
61
+ .replace(/\.$/, '');
62
+
63
+ return normalizedHostname === 'localhost'
64
+ || normalizedHostname.endsWith('.localhost')
65
+ || normalizedHostname === '::1'
66
+ || normalizedHostname.startsWith('::ffff:127.')
67
+ || /^127(?:\.\d{1,3}){3}$/.test(normalizedHostname);
68
+ };
69
+
70
+ export const getLocalEditorSourceUrl = ({ editor, sourcePath }) => {
71
+ if (!localEditorNames.includes(editor)) {
72
+ throw new Error(`Unknown local editor "${editor}". Use one of: ${localEditorNames.join(', ')}.`);
73
+ }
74
+ if (typeof sourcePath !== 'string' || !path.isAbsolute(sourcePath)) {
75
+ throw new Error('Local editor source path must be absolute.');
76
+ }
77
+
78
+ const fileUrl = pathToFileURL(path.normalize(sourcePath));
79
+ return `vscode://file${fileUrl.pathname}`;
80
+ };
81
+
82
+ export const resolveEditSourceTarget = ({
83
+ baseUrl,
84
+ development = false,
85
+ hostname,
86
+ localEditor,
87
+ sourceLabel,
88
+ sourcePath,
89
+ }) => {
90
+ if (development && localEditor && isLoopbackHostname(hostname)) {
91
+ return Object.freeze({
92
+ href: getLocalEditorSourceUrl({ editor: localEditor, sourcePath }),
93
+ kind: 'local',
94
+ });
95
+ }
96
+ if (!baseUrl) return null;
97
+
98
+ return Object.freeze({
99
+ href: getEditSourceUrl({ baseUrl, sourcePath: sourceLabel }),
100
+ kind: 'remote',
101
+ });
102
+ };
@@ -75,7 +75,7 @@ export const resolvePageContentsPlacement = ({
75
75
  Math.max(maximum, getNodeDepth(node))
76
76
  ), 0);
77
77
  const hasPageContents = headingCount >= 2;
78
- const placement = navigationMode !== 'tree' || !hasPageContents
78
+ const placement = currentPage?.isHome || navigationMode !== 'tree' || !hasPageContents
79
79
  ? 'none'
80
80
  : activeBranchDepth <= 2
81
81
  ? 'page-tree'
@@ -607,6 +607,97 @@ export const extractNornaMarkdownBlockDiagnostics = (markdown, options = {}) =>
607
607
  return { blocks, errors };
608
608
  };
609
609
 
610
+ const stripRenderedTags = (value) => value.replace(/<[^>]*>/g, '');
611
+ const decodeRenderedEntities = (value) => value
612
+ .replace(/&amp;/g, '&')
613
+ .replace(/&lt;/g, '<')
614
+ .replace(/&gt;/g, '>')
615
+ .replace(/&quot;/g, '"')
616
+ .replace(/&#39;/g, "'");
617
+ const normalizeRenderedBlockSource = (value) => value.replace(/\r\n?/g, '\n').trim();
618
+
619
+ /**
620
+ * Splits rendered HTML at Norna markers. During an Astro content hot reload,
621
+ * custom code nodes can occasionally arrive as highlighted code instead of
622
+ * their marker; match that output against the already validated source block.
623
+ *
624
+ * @template {{ type: string, source?: string }} Block
625
+ * @param {string} html
626
+ * @param {Block[]} blocks
627
+ * @returns {Array<{ type: 'html', html: string } | Block>}
628
+ */
629
+ export const splitNornaRenderedBlocks = (html, blocks) => {
630
+ const markerRegex = /<norna-block\s+data-index="(\d+)"\s*><\/norna-block>/g;
631
+ const markerMatches = [...html.matchAll(markerRegex)];
632
+ let replacements;
633
+
634
+ if (markerMatches.length > 0) {
635
+ const seen = new Set();
636
+ replacements = markerMatches.map((match) => {
637
+ const blockIndex = Number.parseInt(match[1] ?? '', 10);
638
+ if (!blocks[blockIndex]) {
639
+ throw new Error(`Rendered Norna block ${blockIndex + 1} has no matching parsed block.`);
640
+ }
641
+ if (seen.has(blockIndex)) {
642
+ throw new Error(`Rendered Norna block ${blockIndex + 1} appears more than once.`);
643
+ }
644
+ seen.add(blockIndex);
645
+ return { match, blockIndex };
646
+ });
647
+
648
+ if (seen.size !== blocks.length) {
649
+ throw new Error(`Rendered Markdown contains ${seen.size} Norna block markers, but ${blocks.length} blocks were parsed.`);
650
+ }
651
+ } else {
652
+ const codeRegex = /<pre\b[^>]*>\s*<code\b[^>]*>([\s\S]*?)<\/code>\s*<\/pre>/g;
653
+ const candidates = [...html.matchAll(codeRegex)].map((match) => ({
654
+ match,
655
+ source: normalizeRenderedBlockSource(decodeRenderedEntities(stripRenderedTags(match[1] ?? ''))),
656
+ }));
657
+ const solutions = [];
658
+ const findSolutions = (blockIndex, candidateIndex, selected) => {
659
+ if (solutions.length > 1) return;
660
+ if (blockIndex === blocks.length) {
661
+ solutions.push([...selected]);
662
+ return;
663
+ }
664
+
665
+ const source = normalizeRenderedBlockSource(blocks[blockIndex]?.source ?? '');
666
+ for (let index = candidateIndex; index < candidates.length; index += 1) {
667
+ if (candidates[index].source !== source) continue;
668
+ selected.push(index);
669
+ findSolutions(blockIndex + 1, index + 1, selected);
670
+ selected.pop();
671
+ }
672
+ };
673
+ findSolutions(0, 0, []);
674
+
675
+ if (solutions.length === 0) {
676
+ throw new Error(`Rendered Markdown contains 0 Norna block markers, but ${blocks.length} blocks were parsed.`);
677
+ }
678
+ if (solutions.length > 1) {
679
+ throw new Error('Rendered Markdown contains ambiguous plain-code matches for Norna blocks. Restart the local preview to rebuild the Markdown content cache.');
680
+ }
681
+
682
+ replacements = solutions[0].map((candidateIndex, blockIndex) => ({
683
+ match: candidates[candidateIndex].match,
684
+ blockIndex,
685
+ }));
686
+ }
687
+
688
+ const result = [];
689
+ let cursor = 0;
690
+ for (const { match, blockIndex } of replacements) {
691
+ const start = match.index ?? 0;
692
+ if (start > cursor) result.push({ type: 'html', html: html.slice(cursor, start) });
693
+ result.push(blocks[blockIndex]);
694
+ cursor = start + match[0].length;
695
+ }
696
+ if (cursor < html.length) result.push({ type: 'html', html: html.slice(cursor) });
697
+
698
+ return result.filter((block) => block.type !== 'html' || block.html.trim());
699
+ };
700
+
610
701
  const maskFencedCodeBlocks = (markdown) => {
611
702
  const lines = normalizeLines(markdown);
612
703
  const maskedLines = [];
@@ -13,7 +13,7 @@ import {
13
13
  siteConfigPath,
14
14
  siteThemeLabel,
15
15
  } from './site-paths.mjs';
16
- import { normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
16
+ import { localEditorNames, normalizeEditLinkBaseUrl } from './edit-source-link.mjs';
17
17
  import { readThemeConfig } from './theme-config.mjs';
18
18
  import { resolveThemeConfig } from './theme-presets.mjs';
19
19
  import { parseYamlConfig } from './yaml-config.mjs';
@@ -213,6 +213,7 @@ const localeLabels = Object.freeze({
213
213
  calloutNote: 'Note',
214
214
  calloutTip: 'Tip',
215
215
  calloutWarning: 'Warning',
216
+ closeNavigation: 'Close navigation',
216
217
  editSource: 'Edit this page',
217
218
  displaySettings: 'Display',
218
219
  focusReading: 'Focus reading',
@@ -227,6 +228,11 @@ const localeLabels = Object.freeze({
227
228
  built: 'Built',
228
229
  images: 'Images',
229
230
  imageCarousel: 'image carousel',
231
+ imageInspection: 'Image inspection',
232
+ imageInspectionActualSize: 'Show actual size',
233
+ imageInspectionClose: 'Close image inspection',
234
+ imageInspectionFit: 'Fit image to window',
235
+ inspectImage: 'Inspect image: {description}',
230
236
  navigationCollapsedAll: 'All navigation items collapsed.',
231
237
  navigationCollapseAll: 'Collapse all',
232
238
  navigationControls: 'Navigation tree controls',
@@ -242,6 +248,7 @@ const localeLabels = Object.freeze({
242
248
  nextImage: 'Next image',
243
249
  nextPage: 'Next page',
244
250
  note: 'Note',
251
+ openInVsCode: 'Open in VS Code',
245
252
  notFound: 'Page not found',
246
253
  notFoundText: 'The requested page does not exist or may have moved.',
247
254
  pageMoved: 'Page moved',
@@ -260,6 +267,10 @@ const localeLabels = Object.freeze({
260
267
  siteBanners: 'Site notices',
261
268
  siteNavigation: 'Pages',
262
269
  skipToContent: 'Skip to content',
270
+ tableColumns: 'Table columns',
271
+ tableNextColumns: 'Show next columns',
272
+ tableOverflowDescription: 'More table columns are available horizontally.',
273
+ tablePreviousColumns: 'Show previous columns',
263
274
  }),
264
275
  sv: Object.freeze({
265
276
  breadcrumb: 'Brödsmulor',
@@ -276,6 +287,7 @@ const localeLabels = Object.freeze({
276
287
  calloutNote: 'Notera',
277
288
  calloutTip: 'Tips',
278
289
  calloutWarning: 'Varning',
290
+ closeNavigation: 'Stäng navigationen',
279
291
  editSource: 'Redigera den här sidan',
280
292
  displaySettings: 'Visning',
281
293
  focusReading: 'Fokuserad läsning',
@@ -290,6 +302,11 @@ const localeLabels = Object.freeze({
290
302
  built: 'Byggd',
291
303
  images: 'Bilder',
292
304
  imageCarousel: 'bildkarusell',
305
+ imageInspection: 'Bildgranskning',
306
+ imageInspectionActualSize: 'Visa faktisk storlek',
307
+ imageInspectionClose: 'Stäng bildgranskning',
308
+ imageInspectionFit: 'Anpassa bilden till fönstret',
309
+ inspectImage: 'Granska bild: {description}',
293
310
  navigationCollapsedAll: 'Alla navigationsposter har fällts ihop.',
294
311
  navigationCollapseAll: 'Fäll ihop alla',
295
312
  navigationControls: 'Kontroller för navigationsträdet',
@@ -305,6 +322,7 @@ const localeLabels = Object.freeze({
305
322
  nextImage: 'Nästa bild',
306
323
  nextPage: 'Nästa sida',
307
324
  note: 'Not',
325
+ openInVsCode: 'Öppna i VS Code',
308
326
  notFound: 'Sidan hittades inte',
309
327
  notFoundText: 'Den begärda sidan finns inte eller kan ha flyttats.',
310
328
  pageMoved: 'Sidan har flyttats',
@@ -323,6 +341,10 @@ const localeLabels = Object.freeze({
323
341
  siteBanners: 'Meddelanden',
324
342
  siteNavigation: 'Sidor',
325
343
  skipToContent: 'Hoppa till innehållet',
344
+ tableColumns: 'Tabellkolumner',
345
+ tableNextColumns: 'Visa nästa kolumner',
346
+ tableOverflowDescription: 'Fler tabellkolumner är tillgängliga i sidled.',
347
+ tablePreviousColumns: 'Visa föregående kolumner',
326
348
  }),
327
349
  });
328
350
 
@@ -541,22 +563,30 @@ export const resolveEditLinkConfig = (config, sourceLabel = siteConfigLabel) =>
541
563
  if (config.editLink === undefined) return null;
542
564
 
543
565
  const rawEditLink = assertObject(config.editLink, 'editLink', sourceLabel);
544
- if (!Object.hasOwn(rawEditLink, 'baseUrl')) {
545
- throw new Error(`editLink.baseUrl is required when editLink is configured in ${sourceLabel}.`);
546
- }
547
- const unknownKeys = Object.keys(rawEditLink).filter((key) => key !== 'baseUrl');
566
+ const unknownKeys = Object.keys(rawEditLink).filter((key) => !['baseUrl', 'localEditor'].includes(key));
548
567
  if (unknownKeys.length > 0) {
549
568
  throw new Error(`editLink.${unknownKeys[0]} is not a valid setting in ${sourceLabel}.`);
550
569
  }
570
+ if (!Object.hasOwn(rawEditLink, 'baseUrl') && !Object.hasOwn(rawEditLink, 'localEditor')) {
571
+ throw new Error(`editLink must specify baseUrl, localEditor, or both in ${sourceLabel}.`);
572
+ }
551
573
 
552
- try {
553
- return Object.freeze({
554
- baseUrl: normalizeEditLinkBaseUrl(rawEditLink.baseUrl, 'editLink.baseUrl'),
555
- });
556
- } catch (error) {
557
- const message = error instanceof Error ? error.message : String(error);
558
- throw new Error(`${message.replace(/\.$/, '')} in ${sourceLabel}.`);
574
+ let baseUrl = null;
575
+ if (Object.hasOwn(rawEditLink, 'baseUrl')) {
576
+ try {
577
+ baseUrl = normalizeEditLinkBaseUrl(rawEditLink.baseUrl, 'editLink.baseUrl');
578
+ } catch (error) {
579
+ const message = error instanceof Error ? error.message : String(error);
580
+ throw new Error(`${message.replace(/\.$/, '')} in ${sourceLabel}.`);
581
+ }
559
582
  }
583
+
584
+ return Object.freeze({
585
+ baseUrl,
586
+ localEditor: Object.hasOwn(rawEditLink, 'localEditor')
587
+ ? readEnum(rawEditLink, 'localEditor', 'editLink', localEditorNames, undefined, sourceLabel)
588
+ : null,
589
+ });
560
590
  };
561
591
 
562
592
  export const projectConfig = Object.freeze({
@@ -1,4 +1,5 @@
1
1
  import { z } from 'astro/zod';
2
+ import { localEditorNames } from './edit-source-link.mjs';
2
3
  import { imagePresentationNames } from './image-presentation.mjs';
3
4
  import { navigationModeNames } from './navigation-model.mjs';
4
5
  import { presentationPaletteNames } from './presentation-palette-metadata.mjs';
@@ -51,8 +52,15 @@ const editLink = z.object({
51
52
  return false;
52
53
  }
53
54
  }, 'Use an absolute http or https URL without credentials, a query string, or a fragment.')
54
- .describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.'),
55
- }).strict().describe('Optional base URL for links from rendered pages to their Markdown source files.');
55
+ .describe('Absolute edit URL prefix containing the repository, branch, and any repository subdirectory.')
56
+ .optional(),
57
+ localEditor: z.enum(localEditorNames)
58
+ .optional()
59
+ .describe('Editor used by source links in a development preview opened through a loopback address.'),
60
+ }).strict().refine(
61
+ (value) => value.baseUrl !== undefined || value.localEditor !== undefined,
62
+ 'Specify baseUrl, localEditor, or both.',
63
+ ).describe('Optional local and remote links from rendered pages to their Markdown source files.');
56
64
  const createLineHeight = (minimum, role) => z.number()
57
65
  .min(minimum, `Use a unitless ${role} line height of at least ${minimum}.`)
58
66
  .max(3, `Use a unitless ${role} line height of at most 3.`)
@@ -160,15 +160,32 @@ const addConfigHelp = (jsonSchema) => {
160
160
  documentationLink('Language reference', 'configuration.md', 'language'),
161
161
  ], ['en', 'sv', 'en-GB', 'sv-SE']);
162
162
  addHelp(jsonSchema, 'editLink', [
163
- yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
164
- 'Adds a localized **Edit this page** link from each rendered page to its `content.md` source file. The base URL identifies the repository, branch, and any repository subdirectory; Norna appends the page source path.',
163
+ yamlExample('editLink:\n localEditor: vscode\n baseUrl: https://github.com/owner/repository/edit/main/'),
164
+ 'Links each rendered page to its `content.md` source. A loopback development preview uses the explicitly selected local editor; published output and LAN previews use the optional remote base URL. Specify either destination or both.',
165
165
  documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
166
166
  ]);
167
+ const editLink = schemaProperty(jsonSchema, 'editLink');
168
+ editLink.anyOf = [{ required: ['localEditor'] }, { required: ['baseUrl'] }];
169
+ addSnippets(jsonSchema, 'editLink', [schemaSnippet({
170
+ label: 'Local and remote source links',
171
+ body: {
172
+ localEditor: 'vscode',
173
+ baseUrl: 'https://github.com/${1:owner}/${2:repository}/edit/${3:main}/',
174
+ },
175
+ description: 'Open page source in VS Code during same-computer development and on the remote source host elsewhere.',
176
+ file: 'configuration.md',
177
+ anchor: 'edit-link',
178
+ })]);
167
179
  addHelp(jsonSchema, 'editLink.baseUrl', [
168
180
  yamlExample('editLink:\n baseUrl: https://github.com/owner/repository/edit/main/'),
169
- 'Absolute edit URL prefix. Include any repository subdirectory and branch in this URL. Norna adds the project-relative path to each page\'s `content.md` file.',
181
+ 'Optional absolute remote edit URL prefix. Include any repository subdirectory and branch. Norna adds the project-relative path to each page\'s `content.md` file. This destination is used by published output and LAN previews.',
170
182
  documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
171
183
  ], ['https://github.com/owner/repository/edit/main/']);
184
+ addHelp(jsonSchema, 'editLink.localEditor', [
185
+ yamlExample('editLink:\n localEditor: vscode'),
186
+ 'Optional editor for a development preview opened through localhost or another loopback address. `vscode` opens the absolute page source path through VS Code\'s registered URL handler. The setting is never emitted as a local path in published output.',
187
+ documentationLink('Edit-link reference', 'configuration.md', 'edit-link'),
188
+ ], ['vscode']);
172
189
  addHelp(jsonSchema, 'navigation', [
173
190
  yamlExample('navigation:\n mode: automatic'),
174
191
  'Sets the site-wide navigation policy. `automatic` uses sections for one page, top navigation for a flat multi-page site, and a stable left rail throughout a hierarchical site.',
@@ -32,6 +32,9 @@ export const schemaValueDefinitions = Object.freeze([
32
32
  en: option('English', 'Use Norna\'s built-in English interface text.'),
33
33
  sv: option('Swedish', 'Use Norna\'s built-in Swedish interface text.'),
34
34
  }),
35
+ definition(['vscode'], {
36
+ vscode: option('Visual Studio Code', 'Open the current page source in the locally installed Visual Studio Code application.'),
37
+ }),
35
38
  definition(['left', 'center', 'right'], {
36
39
  left: option('Left', 'Align text with the left edge of its text area.'),
37
40
  center: option('Center', 'Center text within its text area.'),
@@ -5,6 +5,7 @@ import { homePageDirectory } from './site-conventions.mjs';
5
5
 
6
6
  const siteDirectoryEnvName = 'NORNA_SITE_DIR';
7
7
  const invocationRootEnvName = 'NORNA_INVOCATION_ROOT';
8
+ const stateDirectoryEnvName = 'NORNA_INTERNAL_STATE_DIR';
8
9
  const defaultSiteDirectory = 'site';
9
10
  export { homePageDirectory };
10
11
 
@@ -16,12 +17,19 @@ export const siteDirectoryEnv = siteDirectoryEnvName;
16
17
 
17
18
  const normalizeSiteDirectory = (value) => String(value ?? '').trim();
18
19
  const normalizeInvocationRoot = (value) => String(value ?? '').trim();
20
+ const normalizeStateDirectory = (value) => String(value ?? '').trim();
19
21
 
20
22
  const configuredInvocationRoot = normalizeInvocationRoot(process.env[invocationRootEnvName]);
21
23
  export const invocationRoot = configuredInvocationRoot
22
24
  ? path.resolve(configuredInvocationRoot)
23
25
  : process.cwd();
24
26
 
27
+ const hasStateDirectoryEnv = Object.hasOwn(process.env, stateDirectoryEnvName);
28
+ const configuredStateDirectory = normalizeStateDirectory(process.env[stateDirectoryEnvName]);
29
+ if (hasStateDirectoryEnv && !configuredStateDirectory) {
30
+ throw new Error(`${stateDirectoryEnvName} must not be empty.`);
31
+ }
32
+
25
33
  const hasSiteFilesInDirectory = (siteDir) => {
26
34
  return (
27
35
  existsSync(path.join(siteDir, 'config.yaml'))
@@ -126,9 +134,13 @@ export const siteHomePageDir = path.join(sitePagesDir, homePageDirectory);
126
134
  export const siteContentPath = path.join(siteHomePageDir, 'content.md');
127
135
  export const siteImagesDir = path.join(siteHomePageDir, 'images');
128
136
  export const sitePublicDir = path.join(siteDir, 'public');
129
- export const siteStateDir = path.join(siteDir, '.norna');
137
+ export const siteStateDir = configuredStateDirectory
138
+ ? path.resolve(configuredStateDirectory)
139
+ : path.join(siteDir, '.norna');
130
140
  export const astroPublicDir = path.join(siteStateDir, 'public');
131
- export const astroDistDir = path.join(siteProjectRoot, 'dist');
141
+ export const astroDistDir = configuredStateDirectory
142
+ ? path.join(siteStateDir, 'dist')
143
+ : path.join(siteProjectRoot, 'dist');
132
144
  export const astroRootDir = siteStateDir;
133
145
  export const astroCacheDir = path.join(astroRootDir, '.astro');
134
146
  export const generatedImagesDir = path.join(astroPublicDir, 'images', 'generated');