@janga/norna 0.7.0 → 0.7.2

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 (63) hide show
  1. package/README.md +18 -4
  2. package/astro.config.mjs +2 -0
  3. package/bin/norna-cli.mjs +170 -0
  4. package/bin/norna.mjs +149 -150
  5. package/docs/README.md +36 -16
  6. package/docs/commands.md +18 -5
  7. package/docs/configuration.md +37 -2
  8. package/docs/content.md +98 -257
  9. package/docs/{command-organization.md → design/command-organization.md} +9 -5
  10. package/docs/{site-examples-structure-note.md → design/site-examples-structure.md} +17 -22
  11. package/docs/engine-development.md +33 -5
  12. package/docs/getting-started.md +40 -4
  13. package/docs/local-development.md +13 -0
  14. package/docs/publishing.md +23 -0
  15. package/docs/routes.md +90 -0
  16. package/docs/site-structure.md +15 -8
  17. package/docs/theme.md +150 -0
  18. package/docs/typography.md +125 -0
  19. package/examples/dog-gallery/site/.norna/generated-images.json +226 -0
  20. package/examples/dog-gallery/site/config.mjs +97 -0
  21. package/examples/dog-gallery/site/content.md +146 -0
  22. package/examples/dog-gallery/site/images/black-dogs/black-puppy-meadow.png +0 -0
  23. package/examples/dog-gallery/site/images/black-dogs/photo-of-a-black-dog.jpg +0 -0
  24. package/examples/dog-gallery/site/images/brown-dogs/brown-dog.jpg +0 -0
  25. package/examples/dog-gallery/site/images/brown-dogs/dog-accompanies-master.jpg +0 -0
  26. package/examples/dog-gallery/site/images/golden-dogs/golden-retriever.jpg +0 -0
  27. package/examples/dog-gallery/site/images/golden-dogs/toller-puppy.jpg +0 -0
  28. package/examples/dog-gallery/site/images/white-dogs/white-cute-dog.jpg +0 -0
  29. package/examples/dog-gallery/site/images/white-dogs/white-puppy-garden.png +0 -0
  30. package/examples/dog-gallery/site/public/favicon.svg +7 -0
  31. package/examples/dog-gallery/site/public/robots.txt +2 -0
  32. package/examples/dog-gallery/site/routes/dog-care/route-content.md +35 -0
  33. package/examples/dog-gallery/site/theme.md +55 -0
  34. package/fixtures/basic/site/config.mjs +1 -0
  35. package/package.json +8 -7
  36. package/scripts/check-config.mjs +1 -0
  37. package/scripts/dev-local.mjs +64 -15
  38. package/scripts/init-site.mjs +1 -1
  39. package/scripts/lib/project-config.mjs +22 -0
  40. package/scripts/lib/site-content.mjs +2 -1
  41. package/scripts/lib/site-paths.mjs +18 -3
  42. package/scripts/lib/typography.mjs +5 -5
  43. package/scripts/show-typography.mjs +252 -29
  44. package/scripts/test-cli-discovery.mjs +124 -0
  45. package/scripts/test-engine-commands.mjs +18 -4
  46. package/scripts/test-navigation.mjs +10 -6
  47. package/scripts/test-package-check.mjs +32 -4
  48. package/src/components/SiteNavigation.astro +7 -5
  49. package/src/components/SitePage.astro +3 -0
  50. package/src/components/SiteSection.astro +24 -24
  51. package/src/content.config.ts +4 -0
  52. package/src/layouts/BaseLayout.astro +2 -1
  53. package/src/lib/basePath.ts +21 -0
  54. package/src/lib/generatedImages.ts +8 -4
  55. package/src/lib/sectionContent.ts +6 -1
  56. package/src/lib/sitePublicAssets.ts +8 -1
  57. package/src/styles/global.css +8 -8
  58. package/starters/basic/.github/workflows/deploy.yml +3 -3
  59. package/starters/basic/README.md +21 -0
  60. package/starters/basic/package.json +1 -1
  61. package/starters/basic/site/config.mjs +1 -0
  62. package/starters/basic/site/content.md +5 -3
  63. package/starters/basic/site/theme.md +4 -0
@@ -5,18 +5,26 @@ 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 projectConfig from './lib/project-config.mjs';
8
9
  import { astroCacheDir, engineRoot, siteProjectRoot } from './lib/site-paths.mjs';
9
10
 
10
11
  const execFileAsync = promisify(execFile);
11
12
 
12
13
  const port = 4321;
13
- const localHost = 'localhost';
14
- const localUrl = `http://${localHost}:${port}/`;
15
- const probeUrls = [localUrl, `http://127.0.0.1:${port}/`, `http://[::1]:${port}/`];
14
+ const localHost = '127.0.0.1';
15
+ const localUrl = `http://${localHost}:${port}${projectConfig.site.basePath}`;
16
+ const probeUrls = [
17
+ localUrl,
18
+ `http://localhost:${port}${projectConfig.site.basePath}`,
19
+ ];
16
20
  const skipOpen = process.env.WALDE_NO_OPEN === '1';
17
21
  const statePath = path.join(astroCacheDir, 'dev-local.json');
18
22
  const logPath = path.join(astroCacheDir, 'dev.log');
19
- const command = process.argv[2] ?? 'start';
23
+ const args = process.argv.slice(2);
24
+ const knownCommands = new Set(['start', 'lan', 'status', 'logs', 'restart', 'stop']);
25
+ const command = args.find((arg) => knownCommands.has(arg)) ?? args.find((arg) => !arg.startsWith('-')) ?? 'start';
26
+ const shouldKillBlockingPort = args.includes('--kill');
27
+ const shouldFollowLogs = args.includes('--follow');
20
28
 
21
29
  const runAstro = async (args, options = {}) => execFileAsync(process.execPath, getAstroArgs(args), {
22
30
  cwd: siteProjectRoot,
@@ -57,7 +65,7 @@ const readState = async () => {
57
65
  const getLanUrls = () => Object.values(networkInterfaces())
58
66
  .flat()
59
67
  .filter((network) => network?.family === 'IPv4' && !network.internal)
60
- .map((network) => `http://${network.address}:${port}/`);
68
+ .map((network) => `http://${network.address}:${port}${projectConfig.site.basePath}`);
61
69
 
62
70
  const writeState = async (pid, host) => {
63
71
  await mkdir(path.dirname(statePath), { recursive: true });
@@ -98,7 +106,7 @@ const isPortFreeOnHost = (loopbackHost) => new Promise((resolve, reject) => {
98
106
  });
99
107
 
100
108
  const isPortFree = async () => {
101
- for (const loopbackHost of ['127.0.0.1', '::1']) {
109
+ for (const loopbackHost of ['127.0.0.1']) {
102
110
  if (!(await isPortFreeOnHost(loopbackHost))) {
103
111
  return false;
104
112
  }
@@ -155,6 +163,35 @@ const waitForPidToStopListening = async (pid) => {
155
163
  return false;
156
164
  };
157
165
 
166
+ const killPortPids = async (pids) => {
167
+ for (const pid of pids) {
168
+ console.log(`Stopping process ${pid} on port ${port}.`);
169
+ try {
170
+ process.kill(pid, 'SIGTERM');
171
+ } catch (error) {
172
+ if (error.code !== 'ESRCH') {
173
+ throw error;
174
+ }
175
+ }
176
+ }
177
+
178
+ for (const pid of pids) {
179
+ if (await waitForPidToStopListening(pid)) {
180
+ continue;
181
+ }
182
+
183
+ console.log(`Process ${pid} did not stop; sending SIGKILL.`);
184
+ try {
185
+ process.kill(pid, 'SIGKILL');
186
+ } catch (error) {
187
+ if (error.code !== 'ESRCH') {
188
+ throw error;
189
+ }
190
+ }
191
+ await waitForPidToStopListening(pid);
192
+ }
193
+ };
194
+
158
195
  const openBrowser = async () => {
159
196
  if (skipOpen) {
160
197
  console.log(`Browser open skipped. Open ${localUrl}`);
@@ -210,12 +247,22 @@ const stopServer = async ({ quiet = false } = {}) => {
210
247
  }
211
248
  };
212
249
 
213
- const startServer = async ({ host = localHost, open = true } = {}) => {
250
+ const startServer = async ({ host = localHost, open = true, killBlockingPort = false } = {}) => {
214
251
  await stopServer({ quiet: true });
215
252
 
216
- const existingPids = await getPortPids();
253
+ let existingPids = await getPortPids();
217
254
  if (existingPids.length > 0 || !(await isPortFree())) {
218
- throw new Error(`Port ${port} is already in use. Stop the process using it, then rerun the dev command.`);
255
+ if (!killBlockingPort || existingPids.length === 0) {
256
+ throw new Error(`Port ${port} is already in use. Stop the process using it, then rerun the dev command, or pass --kill.`);
257
+ }
258
+
259
+ await killPortPids(existingPids);
260
+ await removeState();
261
+ existingPids = await getPortPids();
262
+
263
+ if (existingPids.length > 0 || !(await isPortFree())) {
264
+ throw new Error(`Port ${port} is still in use after trying to stop ${existingPids.join(', ')}.`);
265
+ }
219
266
  }
220
267
 
221
268
  await syncSitePublic();
@@ -271,9 +318,7 @@ const showStatus = async () => {
271
318
  };
272
319
 
273
320
  const showLogs = async () => {
274
- const shouldFollow = process.argv.includes('--follow');
275
-
276
- if (shouldFollow) {
321
+ if (shouldFollowLogs) {
277
322
  const tail = spawn('tail', ['-n', '80', '-f', logPath], { stdio: 'inherit' });
278
323
  await new Promise((resolve, reject) => {
279
324
  tail.once('exit', resolve);
@@ -296,16 +341,20 @@ const showLogs = async () => {
296
341
  };
297
342
 
298
343
  if (command === 'start') {
299
- await startServer({ open: !skipOpen });
344
+ await startServer({ open: !skipOpen, killBlockingPort: shouldKillBlockingPort });
300
345
  } else if (command === 'lan') {
301
- await startServer({ host: '0.0.0.0', open: !skipOpen });
346
+ await startServer({ host: '0.0.0.0', open: !skipOpen, killBlockingPort: shouldKillBlockingPort });
302
347
  } else if (command === 'status') {
303
348
  await showStatus();
304
349
  } else if (command === 'logs') {
305
350
  await showLogs();
306
351
  } else if (command === 'restart') {
307
352
  const state = await readState();
308
- await startServer({ host: state?.host === '0.0.0.0' ? '0.0.0.0' : localHost, open: false });
353
+ await startServer({
354
+ host: state?.host === '0.0.0.0' ? '0.0.0.0' : localHost,
355
+ open: false,
356
+ killBlockingPort: shouldKillBlockingPort,
357
+ });
309
358
  } else if (command === 'stop') {
310
359
  await stopServer();
311
360
  } else {
@@ -156,7 +156,7 @@ const addGalleryDependency = (packageJson, version) => {
156
156
  const addGalleryScripts = (packageJson, scripts, { includePureAliases }) => {
157
157
  packageJson.scripts ??= {};
158
158
  const wantedScripts = {
159
- ...(includePureAliases ? { dev: 'npm run norna:dev' } : {}),
159
+ ...(includePureAliases ? { dev: 'npm run norna:dev --' } : {}),
160
160
  ...scripts,
161
161
  ...(includePureAliases ? { build: 'npm run norna:build' } : {}),
162
162
  };
@@ -176,6 +176,27 @@ const readUrl = (object, key, path) => {
176
176
  }
177
177
  };
178
178
 
179
+ const readBasePath = (object, key, path) => {
180
+ const value = object[key] ?? '/';
181
+
182
+ if (typeof value !== 'string' || value.trim() === '') {
183
+ throw new Error(`${path}.${key} must be a non-empty URL path in ${siteConfigLabel}.`);
184
+ }
185
+
186
+ const normalizedValue = value.trim();
187
+
188
+ if (
189
+ !normalizedValue.startsWith('/')
190
+ || !normalizedValue.endsWith('/')
191
+ || normalizedValue.includes('//')
192
+ || /[\s?#]/.test(normalizedValue)
193
+ ) {
194
+ throw new Error(`${path}.${key} must start and end with "/" and must not contain whitespace, "?", "#", or "//" in ${siteConfigLabel}.`);
195
+ }
196
+
197
+ return normalizedValue;
198
+ };
199
+
179
200
  const readSmoothScroll = (navigation) => {
180
201
  const rawSmoothScroll = assertObject(navigation.smoothScroll ?? {}, 'navigation.smoothScroll');
181
202
  const minimumDurationMs = readPositiveInteger(rawSmoothScroll, 'minimumDurationMs', 'navigation.smoothScroll', 2_000);
@@ -274,6 +295,7 @@ const defaultFontFamily = "Arial, 'Helvetica Neue', Helvetica, sans-serif";
274
295
 
275
296
  export const projectConfig = Object.freeze({
276
297
  site: Object.freeze({
298
+ basePath: readBasePath(rawSite, 'basePath', 'site'),
277
299
  url: readUrl(rawSite, 'url', 'site'),
278
300
  }),
279
301
  layout: Object.freeze({
@@ -18,12 +18,13 @@ const explicitHeadingIdRegex = /\s*\{#([a-z0-9-]+)\}\s*$/;
18
18
  const inlineStyleReferenceRegex = /\[[^\]\n]+\]\{\.([a-z][a-z0-9-]*)\}/g;
19
19
  const frontmatterDelimiterRegex = /^---\s*$/;
20
20
  const knownContentTopLevelFrontmatterKeys = new Set(['title', 'description', 'slug', 'navigation', 'presentation', 'frame', 'sections']);
21
- const knownThemeTopLevelFrontmatterKeys = new Set(['presentation', 'frame']);
21
+ const knownThemeTopLevelFrontmatterKeys = new Set(['navigation', 'presentation', 'frame']);
22
22
  const knownNestedFrontmatterKeys = new Set([
23
23
  'align',
24
24
  'alt',
25
25
  'backgroundColor',
26
26
  'body',
27
+ 'brand',
27
28
  'caption',
28
29
  'carousel',
29
30
  'color',
@@ -20,15 +20,19 @@ export const invocationRoot = configuredInvocationRoot
20
20
  ? path.resolve(configuredInvocationRoot)
21
21
  : process.cwd();
22
22
 
23
- const hasSiteFiles = (projectRoot, siteDirectory) => {
24
- const siteDir = path.resolve(projectRoot, siteDirectory);
25
-
23
+ const hasSiteFilesInDirectory = (siteDir) => {
26
24
  return (
27
25
  existsSync(path.join(siteDir, 'config.mjs'))
28
26
  && existsSync(path.join(siteDir, 'content.md'))
29
27
  );
30
28
  };
31
29
 
30
+ const hasSiteFiles = (projectRoot, siteDirectory) => {
31
+ const siteDir = path.resolve(projectRoot, siteDirectory);
32
+
33
+ return hasSiteFilesInDirectory(siteDir);
34
+ };
35
+
32
36
  const findSiteProjectRoot = (startDirectory, siteDirectory) => {
33
37
  let current = path.resolve(startDirectory);
34
38
 
@@ -67,6 +71,17 @@ const resolveSitePaths = () => {
67
71
  };
68
72
  }
69
73
 
74
+ if (!hasConfiguredSiteDirectory && hasSiteFilesInDirectory(invocationRoot)) {
75
+ const siteDir = invocationRoot;
76
+ const siteProjectRoot = path.dirname(siteDir);
77
+
78
+ return {
79
+ siteDirectory: path.relative(siteProjectRoot, siteDir) || path.basename(siteDir),
80
+ siteDir,
81
+ siteProjectRoot,
82
+ };
83
+ }
84
+
70
85
  const discoveredRoot = findSiteProjectRoot(invocationRoot, fallbackSiteDirectory);
71
86
  const siteProjectRoot = discoveredRoot ?? invocationRoot;
72
87
 
@@ -29,13 +29,13 @@ export const typographyPresets = {
29
29
  'compact-gallery': {
30
30
  heading: {
31
31
  align: { desktop: 'left', mobile: 'left' },
32
- size: 'small',
32
+ size: 'medium',
33
33
  lineHeight: 1.08,
34
34
  spacing: '0.45em',
35
35
  },
36
36
  body: {
37
37
  align: { desktop: 'left', mobile: 'left' },
38
- size: 'small',
38
+ size: 'medium',
39
39
  lineHeight: 1.42,
40
40
  paragraphSpacing: '0.6em',
41
41
  },
@@ -55,7 +55,7 @@ export const typographyPresets = {
55
55
  },
56
56
  body: {
57
57
  align: { desktop: 'left', mobile: 'left' },
58
- size: 'large',
58
+ size: 'medium',
59
59
  lineHeight: 1.62,
60
60
  paragraphSpacing: '1em',
61
61
  },
@@ -69,13 +69,13 @@ export const typographyPresets = {
69
69
  statement: {
70
70
  heading: {
71
71
  align: { desktop: 'left', mobile: 'left' },
72
- size: 'large',
72
+ size: 'medium',
73
73
  lineHeight: 1.04,
74
74
  spacing: '0.5em',
75
75
  },
76
76
  body: {
77
77
  align: { desktop: 'left', mobile: 'left' },
78
- size: 'large',
78
+ size: 'medium',
79
79
  lineHeight: 1.42,
80
80
  paragraphSpacing: '0.75em',
81
81
  },
@@ -7,6 +7,8 @@ import {
7
7
  typographyPresets,
8
8
  } from './lib/typography.mjs';
9
9
  import {
10
+ getContentFiles,
11
+ readSiteFile,
10
12
  splitSiteFile,
11
13
  validateContentFrontmatterStructure,
12
14
  validateFrontmatterIndentation,
@@ -21,6 +23,14 @@ import {
21
23
 
22
24
  const mode = process.argv[2] ?? 'show';
23
25
 
26
+ const typographyRoles = ['heading', 'body', 'caption'];
27
+ const typographyFields = {
28
+ heading: ['align', 'size', 'lineHeight', 'spacing'],
29
+ body: ['align', 'size', 'lineHeight', 'paragraphSpacing'],
30
+ caption: ['align', 'size', 'lineHeight', 'spacing'],
31
+ };
32
+ const responsiveFields = new Set(['align']);
33
+
24
34
  const countIndent = (line) => line.match(/^\s*/)?.[0].length ?? 0;
25
35
 
26
36
  const parseScalar = (rawValue) => {
@@ -83,6 +93,213 @@ const findMap = (lines, label, parentStart = 0, parentEnd = lines.length, requir
83
93
  return null;
84
94
  };
85
95
 
96
+ const isPlainObject = (value) => (
97
+ value !== null &&
98
+ typeof value === 'object' &&
99
+ !Array.isArray(value)
100
+ );
101
+
102
+ const hasPath = (value, path) => {
103
+ let current = value;
104
+
105
+ for (const segment of path) {
106
+ if (!isPlainObject(current) || !(segment in current)) {
107
+ return false;
108
+ }
109
+
110
+ current = current[segment];
111
+ }
112
+
113
+ return true;
114
+ };
115
+
116
+ const getPath = (value, path) => path.reduce((current, segment) => current?.[segment], value);
117
+
118
+ const setPath = (value, path, entry) => {
119
+ let current = value;
120
+
121
+ for (const segment of path.slice(0, -1)) {
122
+ current[segment] ??= {};
123
+ current = current[segment];
124
+ }
125
+
126
+ current[path.at(-1)] = entry;
127
+ };
128
+
129
+ const annotateResolvedValues = (resolved, sources) => {
130
+ const annotated = {};
131
+
132
+ for (const role of typographyRoles) {
133
+ for (const field of typographyFields[role]) {
134
+ if (responsiveFields.has(field)) {
135
+ for (const viewport of ['desktop', 'mobile']) {
136
+ const path = [role, field, viewport];
137
+ const source = getPath(sources, path);
138
+ setPath(annotated, path, {
139
+ value: getPath(resolved.values, path),
140
+ source: source.source,
141
+ ...(source.inherited ? { inherited: true } : {}),
142
+ });
143
+ }
144
+ continue;
145
+ }
146
+
147
+ const path = [role, field];
148
+ const source = getPath(sources, path);
149
+ setPath(annotated, path, {
150
+ value: getPath(resolved.values, path),
151
+ source: source.source,
152
+ ...(source.inherited ? { inherited: true } : {}),
153
+ });
154
+ }
155
+ }
156
+
157
+ return annotated;
158
+ };
159
+
160
+ const presetSources = (presetName) => {
161
+ const sources = {};
162
+
163
+ for (const role of typographyRoles) {
164
+ for (const field of typographyFields[role]) {
165
+ if (responsiveFields.has(field)) {
166
+ for (const viewport of ['desktop', 'mobile']) {
167
+ setPath(sources, [role, field, viewport], {
168
+ source: `preset:${presetName}`,
169
+ inherited: false,
170
+ });
171
+ }
172
+ continue;
173
+ }
174
+
175
+ setPath(sources, [role, field], {
176
+ source: `preset:${presetName}`,
177
+ inherited: false,
178
+ });
179
+ }
180
+ }
181
+
182
+ return sources;
183
+ };
184
+
185
+ const applyOverrideSources = (sources, typographyConfig, sourceLabel) => {
186
+ const overrides = typographyConfig?.overrides;
187
+ if (!overrides) return sources;
188
+
189
+ for (const role of typographyRoles) {
190
+ for (const field of typographyFields[role]) {
191
+ if (responsiveFields.has(field)) {
192
+ for (const viewport of ['desktop', 'mobile']) {
193
+ const path = [role, field, viewport];
194
+ if (hasPath(overrides, path)) {
195
+ setPath(sources, path, {
196
+ source: `${sourceLabel} override`,
197
+ inherited: false,
198
+ });
199
+ }
200
+ }
201
+ continue;
202
+ }
203
+
204
+ const path = [role, field];
205
+ if (hasPath(overrides, path)) {
206
+ setPath(sources, path, {
207
+ source: `${sourceLabel} override`,
208
+ inherited: false,
209
+ });
210
+ }
211
+ }
212
+ }
213
+
214
+ return sources;
215
+ };
216
+
217
+ const inheritedSources = (sources) => {
218
+ const inherited = structuredClone(sources);
219
+
220
+ for (const role of typographyRoles) {
221
+ for (const field of typographyFields[role]) {
222
+ if (responsiveFields.has(field)) {
223
+ for (const viewport of ['desktop', 'mobile']) {
224
+ const path = [role, field, viewport];
225
+ setPath(inherited, path, {
226
+ ...getPath(sources, path),
227
+ inherited: true,
228
+ });
229
+ }
230
+ continue;
231
+ }
232
+
233
+ const path = [role, field];
234
+ setPath(inherited, path, {
235
+ ...getPath(sources, path),
236
+ inherited: true,
237
+ });
238
+ }
239
+ }
240
+
241
+ return inherited;
242
+ };
243
+
244
+ const resolveAnnotatedTypographyConfig = (typographyConfig, sourceLabel) => {
245
+ const resolved = resolveTypographyConfig(typographyConfig ?? defaultTypography);
246
+ const sources = applyOverrideSources(
247
+ presetSources(resolved.preset),
248
+ typographyConfig,
249
+ sourceLabel,
250
+ );
251
+
252
+ return {
253
+ preset: {
254
+ value: resolved.preset,
255
+ source: typographyConfig?.preset ? sourceLabel : 'engine default',
256
+ ...(typographyConfig?.preset ? {} : { inherited: true }),
257
+ },
258
+ resolved,
259
+ sources,
260
+ };
261
+ };
262
+
263
+ const resolveAnnotatedTypographyOverride = (baseAnnotated, typographyConfig, sourceLabel) => {
264
+ if (typographyConfig?.preset) {
265
+ return resolveAnnotatedTypographyConfig(typographyConfig, sourceLabel);
266
+ }
267
+
268
+ if (typographyConfig?.overrides) {
269
+ const resolved = resolveTypographyOverride(baseAnnotated.resolved, typographyConfig);
270
+ const sources = applyOverrideSources(
271
+ inheritedSources(baseAnnotated.sources),
272
+ typographyConfig,
273
+ sourceLabel,
274
+ );
275
+
276
+ return {
277
+ preset: {
278
+ value: resolved.preset,
279
+ source: baseAnnotated.preset.source,
280
+ inherited: true,
281
+ },
282
+ resolved,
283
+ sources,
284
+ };
285
+ }
286
+
287
+ return {
288
+ preset: {
289
+ value: baseAnnotated.resolved.preset,
290
+ source: baseAnnotated.preset.source,
291
+ inherited: true,
292
+ },
293
+ resolved: baseAnnotated.resolved,
294
+ sources: inheritedSources(baseAnnotated.sources),
295
+ };
296
+ };
297
+
298
+ const formatAnnotatedTypography = (annotated) => ({
299
+ preset: annotated.preset,
300
+ resolved: annotateResolvedValues(annotated.resolved, annotated.sources),
301
+ });
302
+
86
303
  const getSectionBlocks = (lines) => {
87
304
  const sectionsMap = findMap(lines, 'sections');
88
305
  if (!sectionsMap) return [];
@@ -118,8 +335,7 @@ const getSectionBlocks = (lines) => {
118
335
  return sections;
119
336
  };
120
337
 
121
- const readSiteTypography = async () => {
122
- const { frontmatter, frontmatterBody } = splitSiteFile(await readFile(siteContentPath, 'utf8'));
338
+ const readThemeTypography = async () => {
123
339
  const themeFile = await readFile(siteThemePath, 'utf8').catch((error) => {
124
340
  if (error?.code === 'ENOENT') {
125
341
  return '---\n---\n';
@@ -129,29 +345,41 @@ const readSiteTypography = async () => {
129
345
  });
130
346
  const { frontmatter: themeFrontmatter, frontmatterBody: themeFrontmatterBody } = splitSiteFile(themeFile, siteThemeLabel);
131
347
  const indentationIssues = [];
132
- validateFrontmatterIndentation(frontmatter, (issue) => indentationIssues.push(issue));
133
- validateContentFrontmatterStructure(frontmatter, (issue) => indentationIssues.push(issue));
134
348
  validateFrontmatterIndentation(themeFrontmatter, (issue) => indentationIssues.push(issue));
135
349
  validateThemeFrontmatterStructure(themeFrontmatter, (issue) => indentationIssues.push(issue));
136
350
  if (indentationIssues.length > 0) {
137
351
  throw new Error([
138
- `Cannot inspect typography because ${siteContentLabel} or ${siteThemeLabel} has invalid frontmatter.`,
352
+ `Cannot inspect typography because ${siteThemeLabel} has invalid frontmatter.`,
139
353
  ...indentationIssues.map((issue) => `- ${issue.message}`),
140
354
  ].join('\n'));
141
355
  }
142
356
 
143
- const lines = frontmatterBody.split(/\r?\n/);
144
357
  const themeLines = themeFrontmatterBody.split(/\r?\n/);
145
358
  const themePresentation = findMap(themeLines, 'presentation', 0, themeLines.length, 0);
146
359
  const themeTypographyConfig = themePresentation
147
360
  ? findMap(themeLines, 'typography', themePresentation.index + 1, themePresentation.nextIndex)?.value
148
361
  : null;
362
+ return resolveAnnotatedTypographyConfig(themeTypographyConfig ?? defaultTypography, siteThemeLabel);
363
+ };
364
+
365
+ const readPageTypography = async (contentFile, themeTypography) => {
366
+ const { frontmatter, frontmatterBody } = await readSiteFile(contentFile.contentPath, contentFile.contentLabel);
367
+ const indentationIssues = [];
368
+ validateFrontmatterIndentation(frontmatter, (issue) => indentationIssues.push(issue));
369
+ validateContentFrontmatterStructure(frontmatter, (issue) => indentationIssues.push(issue));
370
+ if (indentationIssues.length > 0) {
371
+ throw new Error([
372
+ `Cannot inspect typography because ${contentFile.contentLabel} has invalid frontmatter.`,
373
+ ...indentationIssues.map((issue) => `- ${issue.message}`),
374
+ ].join('\n'));
375
+ }
376
+
377
+ const lines = frontmatterBody.split(/\r?\n/);
149
378
  const pagePresentation = findMap(lines, 'presentation', 0, lines.length, 0);
150
379
  const pageTypographyConfig = pagePresentation
151
380
  ? findMap(lines, 'typography', pagePresentation.index + 1, pagePresentation.nextIndex)?.value
152
381
  : null;
153
- const themeTypography = resolveTypographyConfig(themeTypographyConfig ?? defaultTypography);
154
- const pageTypography = resolveTypographyOverride(themeTypography, pageTypographyConfig ?? undefined);
382
+ const pageTypography = resolveAnnotatedTypographyOverride(themeTypography, pageTypographyConfig ?? undefined, contentFile.contentLabel);
155
383
  const sections = getSectionBlocks(lines).map((section) => {
156
384
  const presentation = findMap(lines, 'presentation', section.start + 1, section.end);
157
385
  const typography = presentation
@@ -160,13 +388,13 @@ const readSiteTypography = async () => {
160
388
 
161
389
  return {
162
390
  id: section.id,
163
- typography,
164
- resolved: resolveTypographyOverride(pageTypography, typography ?? undefined),
391
+ typography: resolveAnnotatedTypographyOverride(pageTypography, typography ?? undefined, `${contentFile.contentLabel} sections.${section.id}`),
165
392
  };
166
393
  });
167
394
 
168
395
  return {
169
- themeTypography,
396
+ source: contentFile.contentLabel,
397
+ route: contentFile.isHome ? '/' : `/${contentFile.routeFolder}/`,
170
398
  pageTypography,
171
399
  sections,
172
400
  };
@@ -175,31 +403,26 @@ const readSiteTypography = async () => {
175
403
  if (mode === 'presets') {
176
404
  console.log(toYamlLines(typographyPresets).join('\n'));
177
405
  } else if (mode === 'show') {
178
- const siteTypography = await readSiteTypography();
406
+ const themeTypography = await readThemeTypography();
407
+ const pages = await Promise.all((await getContentFiles()).map((contentFile) => readPageTypography(contentFile, themeTypography)));
179
408
  const output = {
180
- source: siteContentLabel,
181
409
  theme: {
182
410
  source: siteThemeLabel,
183
411
  presentation: {
184
- typography: {
185
- preset: siteTypography.themeTypography.preset,
186
- resolved: siteTypography.themeTypography.values,
187
- },
188
- },
189
- },
190
- page: {
191
- typography: {
192
- preset: siteTypography.pageTypography.preset,
193
- resolved: siteTypography.pageTypography.values,
412
+ typography: formatAnnotatedTypography(themeTypography),
194
413
  },
195
414
  },
196
- sections: Object.fromEntries(siteTypography.sections.map((section) => [
197
- section.id,
415
+ pages: Object.fromEntries(pages.map((page) => [
416
+ page.route,
198
417
  {
199
- typography: {
200
- preset: section.resolved.preset,
201
- resolved: section.resolved.values,
202
- },
418
+ source: page.source,
419
+ typography: formatAnnotatedTypography(page.pageTypography),
420
+ sections: Object.fromEntries(page.sections.map((section) => [
421
+ section.id,
422
+ {
423
+ typography: formatAnnotatedTypography(section.typography),
424
+ },
425
+ ])),
203
426
  },
204
427
  ])),
205
428
  };