erudit 4.2.0-dev.1 → 4.3.0-dev.1

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 (62) hide show
  1. package/app/components/Prose.vue +2 -0
  2. package/app/components/aside/major/contentNav/items/ContentNavTopic.vue +12 -1
  3. package/app/components/aside/major/search/SearchResult.vue +16 -2
  4. package/app/components/aside/minor/contributor/AsideMinorContributor.vue +7 -1
  5. package/app/components/aside/minor/news/AsideMinorNews.vue +1 -1
  6. package/app/components/main/MainStickyHeader.vue +5 -2
  7. package/app/components/main/MainStickyHeaderPreamble.vue +5 -2
  8. package/app/components/main/MainTopicPartPage.vue +3 -2
  9. package/app/components/main/MainTopicPartSwitch.vue +18 -7
  10. package/app/components/main/connections/Deps.vue +1 -4
  11. package/app/components/main/connections/MainConnections.vue +9 -3
  12. package/app/components/main/contentStats/ItemLastChanged.vue +3 -32
  13. package/app/components/main/contentStats/MainContentStats.vue +3 -4
  14. package/app/components/preview/Preview.vue +8 -6
  15. package/app/components/preview/PreviewScreen.vue +9 -7
  16. package/app/components/preview/screen/Unique.vue +3 -2
  17. package/app/composables/ads.ts +1 -1
  18. package/app/composables/analytics.ts +1 -1
  19. package/app/composables/lastChanged.ts +38 -5
  20. package/app/composables/og.ts +5 -5
  21. package/app/composables/phrases.ts +2 -0
  22. package/app/composables/scrollUp.ts +3 -1
  23. package/app/pages/book/[...bookId].vue +3 -2
  24. package/app/pages/group/[...groupId].vue +3 -2
  25. package/app/pages/page/[...pageId].vue +4 -2
  26. package/app/plugins/appSetup/config.ts +1 -0
  27. package/app/plugins/appSetup/global.ts +3 -0
  28. package/app/plugins/appSetup/index.ts +4 -1
  29. package/app/plugins/devReload.client.ts +13 -0
  30. package/app/router.options.ts +17 -3
  31. package/app/styles/main.css +2 -2
  32. package/modules/erudit/dependencies.ts +16 -0
  33. package/modules/erudit/index.ts +8 -1
  34. package/modules/erudit/setup/autoImports.ts +143 -0
  35. package/modules/erudit/setup/elements/globalTemplate.ts +10 -2
  36. package/modules/erudit/setup/elements/setup.ts +8 -14
  37. package/modules/erudit/setup/elements/tagsTable.ts +2 -18
  38. package/modules/erudit/setup/fullRestart.ts +5 -3
  39. package/modules/erudit/setup/namesTable.ts +33 -0
  40. package/modules/erudit/setup/problemChecks/setup.ts +60 -0
  41. package/modules/erudit/setup/problemChecks/shared.ts +4 -0
  42. package/modules/erudit/setup/problemChecks/template.ts +33 -0
  43. package/modules/erudit/setup/runtimeConfig.ts +12 -7
  44. package/nuxt.config.ts +14 -6
  45. package/package.json +5 -6
  46. package/server/api/problemScript/[...problemScriptPath].ts +245 -52
  47. package/server/erudit/build.ts +10 -4
  48. package/server/erudit/content/global/build.ts +43 -3
  49. package/server/erudit/content/nav/build.ts +5 -5
  50. package/server/erudit/content/nav/front.ts +1 -0
  51. package/server/erudit/content/repository/deps.ts +45 -6
  52. package/server/erudit/content/resolve/index.ts +3 -3
  53. package/server/erudit/content/resolve/utils/contentError.ts +2 -2
  54. package/server/erudit/content/resolve/utils/insertContentResolved.ts +29 -27
  55. package/server/erudit/global.ts +5 -1
  56. package/server/erudit/importer.ts +69 -0
  57. package/server/erudit/index.ts +2 -2
  58. package/server/erudit/logger.ts +18 -10
  59. package/server/erudit/reloadSignal.ts +14 -0
  60. package/server/routes/_reload.ts +27 -0
  61. package/shared/types/contentConnections.ts +1 -0
  62. package/shared/types/frontContentNav.ts +2 -0
@@ -4,7 +4,22 @@ import { build, type Plugin } from 'esbuild';
4
4
 
5
5
  import { STATIC_ASSET_EXTENSIONS } from '#layers/erudit/server/erudit/prose/transform/extensions';
6
6
  import { createGlobalContent } from '@erudit-js/core/content/global';
7
- import { coreElements } from '#erudit/prose/global';
7
+ import { eruditGlobalNames } from '#erudit/prose/global';
8
+ import { autoImportNames } from '#erudit/autoImports';
9
+
10
+ /**
11
+ * Pure function calls irrelevant to the problem script runtime.
12
+ * Their entire call expressions (including all arguments) are replaced with
13
+ * `undefined` before bundling so esbuild never follows transitive imports
14
+ * inside their arguments (e.g. heavy checker handlers importing `mathjs`).
15
+ */
16
+ const WIPE_OUT_FUNCTIONS: string[] = [
17
+ 'defineProblemChecker',
18
+ 'defineContributor',
19
+ 'defineTopic',
20
+ 'defineBook',
21
+ 'definePage',
22
+ ];
8
23
 
9
24
  export default defineEventHandler<Promise<string>>(async (event) => {
10
25
  // <filepathToScriptFile>.js
@@ -25,7 +40,7 @@ export default defineEventHandler<Promise<string>>(async (event) => {
25
40
  $CONTRIBUTOR: '{}',
26
41
  },
27
42
  jsx: 'automatic',
28
- plugins: [jsxRuntimePlugin, proseGlobalsPlugin, staticFilesPlugin],
43
+ plugins: [jsxRuntimePlugin, sourceTransformPlugin, staticFilesPlugin],
29
44
  alias: {
30
45
  '#project': ERUDIT.paths.project() + '/',
31
46
  '#content': ERUDIT.paths.project('content') + '/',
@@ -34,6 +49,11 @@ export default defineEventHandler<Promise<string>>(async (event) => {
34
49
 
35
50
  let code = buildResult.outputFiles[0]!.text;
36
51
 
52
+ // Post-build: strip redundant ERUDIT_GLOBAL declarations emitted by esbuild,
53
+ // normalize JSX identifiers, and prepend a selective destructuring preamble
54
+ // with only the global names actually used in the bundle.
55
+ code = normalizeEruditGlobals(code);
56
+
37
57
  // Transform $CONTENT patterns to link objects
38
58
  code = code.replace(/\$CONTENT(\.[a-zA-Z_$][\w$]*)+/g, (match) => {
39
59
  const path = match
@@ -63,65 +83,192 @@ const jsxRuntimePlugin: Plugin = {
63
83
  }));
64
84
 
65
85
  build.onLoad({ filter: /.*/, namespace: 'jsx-runtime-shim' }, () => ({
86
+ // Export both the canonical and underscore-prefixed names so that
87
+ // pre-built modules importing `jsx as _jsx` also resolve correctly.
66
88
  contents: `
67
- export const jsx = globalThis.jsx;
68
- export const jsxs = globalThis.jsxs;
69
- export const Fragment = globalThis.Fragment;
89
+ export const jsx = globalThis.ERUDIT_GLOBAL.jsx;
90
+ export const jsxs = globalThis.ERUDIT_GLOBAL.jsxs;
91
+ export const Fragment = globalThis.ERUDIT_GLOBAL.Fragment;
92
+ export const _jsx = globalThis.ERUDIT_GLOBAL.jsx;
93
+ export const _jsxs = globalThis.ERUDIT_GLOBAL.jsxs;
94
+ export const _Fragment = globalThis.ERUDIT_GLOBAL.Fragment;
70
95
  `,
71
96
  loader: 'js',
72
97
  }));
73
98
  },
74
99
  };
75
100
 
76
- // Collect all tag names that are registered in globalThis
77
- const proseTagNames = new Set<string>(
78
- Object.values(coreElements).flatMap((el: any) =>
79
- (el.tags ?? []).map((t: any) => String(t.tagName)),
80
- ),
81
- );
82
-
83
- // Pre-transform: rewrite any import of a known globalThis tag name → const from globalThis.
84
- // Non-tag imports are left as real imports and bundled normally.
85
- // Applies recursively to every .ts/.tsx file esbuild processes (including utility files).
86
- const proseGlobalsPlugin: Plugin = {
87
- name: 'prose-globals',
101
+ // Maps underscore-prefixed JSX names (emitted by some bundlers/transpilers) to
102
+ // the canonical globalThis key where the value actually lives.
103
+ const JSX_UNDERSCORE_ALIASES: Record<string, string> = {
104
+ _jsx: 'jsx',
105
+ _jsxs: 'jsxs',
106
+ _Fragment: 'Fragment',
107
+ };
108
+
109
+ // Names that are available on globalThis.ERUDIT_GLOBAL and should not be bundled as real imports.
110
+ function getGlobalNames(): Set<string> {
111
+ return new Set<string>([
112
+ ...eruditGlobalNames,
113
+ // Auto-imported names from erudit config
114
+ ...autoImportNames,
115
+ // Underscore-prefixed aliases for JSX
116
+ '_jsx',
117
+ '_jsxs',
118
+ '_Fragment',
119
+ ]);
120
+ }
121
+
122
+ /**
123
+ * Replace calls to functions listed in `WIPE_OUT_FUNCTIONS` with `undefined`.
124
+ * Uses balanced-parenthesis scanning so nested calls/objects inside arguments
125
+ * are handled correctly.
126
+ *
127
+ * Example:
128
+ * export default defineProblemChecker(def, async (d, i) => { ... });
129
+ * → export default undefined;
130
+ */
131
+ function wipeOutCalls(source: string): string {
132
+ const pattern = new RegExp(
133
+ '\\b(' + WIPE_OUT_FUNCTIONS.join('|') + ')\\s*\\(',
134
+ );
135
+
136
+ let result = '';
137
+ let remaining = source;
138
+
139
+ while (true) {
140
+ const m = pattern.exec(remaining);
141
+ if (!m) {
142
+ result += remaining;
143
+ break;
144
+ }
145
+
146
+ // Skip function/method declarations (e.g. `function defineProblemChecker(`)
147
+ const prefix = remaining.slice(Math.max(0, m.index - 20), m.index);
148
+ if (/\bfunction\s*$/.test(prefix)) {
149
+ result += remaining.slice(0, m.index + m[0].length);
150
+ remaining = remaining.slice(m.index + m[0].length);
151
+ continue;
152
+ }
153
+
154
+ // Append everything before the function name
155
+ result += remaining.slice(0, m.index);
156
+
157
+ // Find the opening paren position (right after the function name + optional whitespace)
158
+ const openParenIndex = m.index + m[0].length - 1;
159
+ let depth = 1;
160
+ let i = openParenIndex + 1;
161
+
162
+ // Scan forward to find the matching closing paren, skipping strings/templates
163
+ while (i < remaining.length && depth > 0) {
164
+ const ch = remaining[i]!;
165
+
166
+ if (ch === '(') {
167
+ depth++;
168
+ } else if (ch === ')') {
169
+ depth--;
170
+ } else if (ch === "'" || ch === '"' || ch === '`') {
171
+ // Skip string/template literal
172
+ const quote = ch;
173
+ i++;
174
+ while (i < remaining.length) {
175
+ const sc = remaining[i]!;
176
+ if (sc === '\\') {
177
+ i += 2; // skip escaped char
178
+ continue;
179
+ }
180
+ if (sc === quote) break;
181
+ i++;
182
+ }
183
+ } else if (ch === '/' && remaining[i + 1] === '/') {
184
+ // Skip single-line comment
185
+ while (i < remaining.length && remaining[i] !== '\n') i++;
186
+ continue;
187
+ } else if (ch === '/' && remaining[i + 1] === '*') {
188
+ // Skip block comment
189
+ i += 2;
190
+ while (i < remaining.length - 1) {
191
+ if (remaining[i] === '*' && remaining[i + 1] === '/') {
192
+ i += 2;
193
+ break;
194
+ }
195
+ i++;
196
+ }
197
+ continue;
198
+ }
199
+
200
+ i++;
201
+ }
202
+
203
+ // Replace the entire call expression with `undefined`
204
+ result += 'undefined';
205
+ remaining = remaining.slice(i);
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ /**
212
+ * Rewrite imports of known globalThis names (JSX runtime + prose tags) from
213
+ * real import statements to `const X = globalThis["X"]` lookups.
214
+ * Non-global imports are left as real imports and bundled normally.
215
+ */
216
+ function rewriteGlobalImports(
217
+ source: string,
218
+ globalNames: Set<string>,
219
+ ): string {
220
+ return source.replace(
221
+ /^import\s+\{([^}]+)\}\s+from\s+(['"])([^'"]+)\2.*$/gm,
222
+ (_match, bindings: string, _quote: string, pkg: string) => {
223
+ const keepParts: string[] = [];
224
+ const shimLines: string[] = [];
225
+
226
+ for (const part of bindings
227
+ .split(',')
228
+ .map((s) => s.trim())
229
+ .filter(Boolean)) {
230
+ // handle "ExportName as LocalName"
231
+ const m = part.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
232
+ if (!m) {
233
+ keepParts.push(part);
234
+ continue;
235
+ }
236
+ const localName = m[2] ?? m[1]!;
237
+ if (globalNames.has(localName)) {
238
+ // Underscore-prefixed JSX names (_jsx, _jsxs, _Fragment) must resolve
239
+ // to the canonical globalThis key (jsx, jsxs, Fragment) because the
240
+ // runtime only registers the un-prefixed versions.
241
+ const globalKey = JSX_UNDERSCORE_ALIASES[localName] ?? localName;
242
+ shimLines.push(
243
+ `var ${localName} = globalThis.ERUDIT_GLOBAL[${JSON.stringify(globalKey)}];`,
244
+ );
245
+ } else {
246
+ keepParts.push(part);
247
+ }
248
+ }
249
+
250
+ const lines: string[] = [];
251
+ if (keepParts.length > 0)
252
+ lines.push(`import { ${keepParts.join(', ')} } from '${pkg}';`);
253
+ lines.push(...shimLines);
254
+ return lines.join('\n');
255
+ },
256
+ );
257
+ }
258
+
259
+ // Pre-transform: wipe out pure function calls irrelevant to problem scripts,
260
+ // then rewrite imports of known globalThis tag names → const from globalThis.
261
+ // Applies to every .ts/.tsx/.js/.jsx file esbuild processes (including utility files).
262
+
263
+ const sourceTransformPlugin: Plugin = {
264
+ name: 'source-transform',
88
265
  setup(build) {
266
+ const globalNames = getGlobalNames();
89
267
  build.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
90
- const source = readFileSync(args.path, 'utf8');
91
-
92
- const transformed = source.replace(
93
- /^import\s+\{([^}]+)\}\s+from\s+(['"])([^'"]+)\2.*$/gm,
94
- (_match, bindings: string, _quote: string, pkg: string) => {
95
- const keepParts: string[] = [];
96
- const shimLines: string[] = [];
97
-
98
- for (const part of bindings
99
- .split(',')
100
- .map((s) => s.trim())
101
- .filter(Boolean)) {
102
- // handle "ExportName as LocalName"
103
- const m = part.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
104
- if (!m) {
105
- keepParts.push(part);
106
- continue;
107
- }
108
- const localName = m[2] ?? m[1]!;
109
- if (proseTagNames.has(localName)) {
110
- shimLines.push(
111
- `const ${localName} = globalThis[${JSON.stringify(localName)}];`,
112
- );
113
- } else {
114
- keepParts.push(part);
115
- }
116
- }
268
+ let source = readFileSync(args.path, 'utf8');
117
269
 
118
- const lines: string[] = [];
119
- if (keepParts.length > 0)
120
- lines.push(`import { ${keepParts.join(', ')} } from '${pkg}';`);
121
- lines.push(...shimLines);
122
- return lines.join('\n');
123
- },
124
- );
270
+ source = wipeOutCalls(source);
271
+ source = rewriteGlobalImports(source, globalNames);
125
272
 
126
273
  const ext = (args.path.match(/[jt]sx?$/)?.[0] ?? 'js') as
127
274
  | 'js'
@@ -129,13 +276,59 @@ const proseGlobalsPlugin: Plugin = {
129
276
  | 'ts'
130
277
  | 'tsx';
131
278
  return {
132
- contents: transformed,
279
+ contents: source,
133
280
  loader: ext,
134
281
  };
135
282
  });
136
283
  },
137
284
  };
138
285
 
286
+ /**
287
+ * Post-build pass that:
288
+ * 1. Strips all `var X = globalThis.ERUDIT_GLOBAL[...]` declarations (from
289
+ * rewriteGlobalImports and the jsxRuntimePlugin shim) and the shim comment.
290
+ * 2. Normalizes underscore-prefixed JSX call-site names (_jsx2 → jsx, etc.).
291
+ * 3. Detects which ERUDIT_GLOBAL names are actually referenced in the code.
292
+ * 4. Prepends a single destructuring preamble with only those names.
293
+ */
294
+ function normalizeEruditGlobals(code: string): string {
295
+ // Strip the jsx-runtime-shim comment
296
+ code = code.replace(/^\/\/ jsx-runtime-shim:jsx-runtime-shim\n/gm, '');
297
+
298
+ // Strip ALL var declarations pulling from globalThis.ERUDIT_GLOBAL
299
+ // var jsx = globalThis.ERUDIT_GLOBAL.jsx;
300
+ // var P = globalThis.ERUDIT_GLOBAL["P"];
301
+ // var _jsx2 = globalThis.ERUDIT_GLOBAL.jsx;
302
+ code = code.replace(
303
+ /^var \w+ = globalThis\.ERUDIT_GLOBAL(?:\.[a-zA-Z_$][\w$]*|\[["'][a-zA-Z_$][\w$]*["']\]);[ \t]*\n?/gm,
304
+ '',
305
+ );
306
+
307
+ // Normalize call-site names (_jsxs must precede _jsx to avoid partial match):
308
+ // _jsxs2(…) → jsxs(…)
309
+ // _jsx2(…) → jsx(…)
310
+ // _Fragment2 → Fragment
311
+ code = code.replace(/_jsxs\d*\b/g, 'jsxs');
312
+ code = code.replace(/_jsx\d*\b/g, 'jsx');
313
+ code = code.replace(/_Fragment\d*\b/g, 'Fragment');
314
+
315
+ // Detect which ERUDIT_GLOBAL names are actually used in the code
316
+ const allNames = getGlobalNames();
317
+ const usedNames = [...allNames]
318
+ .filter((n) => /^[a-zA-Z_$]\w*$/.test(n) && !n.startsWith('_'))
319
+ .filter((n) => new RegExp('\\b' + n + '\\b').test(code));
320
+
321
+ if (usedNames.length > 0) {
322
+ code =
323
+ 'var { ' +
324
+ usedNames.join(', ') +
325
+ ' } = globalThis.ERUDIT_GLOBAL;\n' +
326
+ code;
327
+ }
328
+
329
+ return code;
330
+ }
331
+
139
332
  const staticFilesPlugin: Plugin = {
140
333
  name: 'static-files',
141
334
  setup(build) {
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import { styleText } from 'node:util';
2
2
  import chokidar from 'chokidar';
3
3
  import { debounce } from 'perfect-debounce';
4
4
  import { sn } from 'unslash';
@@ -11,6 +11,7 @@ import { buildContentNav } from './content/nav/build';
11
11
  import { requestFullContentResolve, resolveContent } from './content/resolve';
12
12
  import { buildGlobalContent } from './content/global/build';
13
13
  import { buildNews } from './news/build';
14
+ import { triggerReload } from './reloadSignal';
14
15
 
15
16
  export type EruditServerChangedFiles = Set<string>;
16
17
  export type EruditServerBuildError = Error | undefined;
@@ -28,7 +29,7 @@ export async function buildServerErudit() {
28
29
  await buildContentNav();
29
30
  await buildGlobalContent();
30
31
  await resolveContent();
31
- ERUDIT.log.success(chalk.green('Build Complete!'));
32
+ ERUDIT.log.success(styleText('green', 'Build Complete!'));
32
33
  } catch (buildError) {
33
34
  requestFullContentResolve();
34
35
 
@@ -73,11 +74,16 @@ export async function tryServerWatchProject() {
73
74
  const files = Array.from(ERUDIT.changedFiles);
74
75
  console.log();
75
76
  ERUDIT.log.warn(
76
- `${chalk.yellow('Rebuilding due to file change(s):')}\n\n` +
77
- files.map((p, i) => chalk.gray(`${i + 1} -`) + ` "${p}"`).join('\n'),
77
+ `${styleText('yellow', 'Rebuilding due to file change(s):')}\n\n` +
78
+ files
79
+ .map((p, i) => styleText('gray', `${i + 1} -`) + ` "${p}"`)
80
+ .join('\n'),
78
81
  );
79
82
  console.log();
80
83
  await buildServerErudit();
84
+ if (!ERUDIT.buildError) {
85
+ triggerReload();
86
+ }
81
87
  ERUDIT.changedFiles.clear();
82
88
  } finally {
83
89
  ERUDIT.changedFiles.clear();
@@ -11,6 +11,11 @@ const contentRoot = () => ERUDIT.paths.project('content');
11
11
 
12
12
  export let builtLinkObject: Record<string, any> | null = null;
13
13
 
14
+ /** All valid fully-qualified $CONTENT paths — content items, topic parts,
15
+ * public uniques, and internal (underscore) uniques.
16
+ * Used for server-side prose link validation. */
17
+ export let builtValidPaths: Set<string> | null = null;
18
+
14
19
  export async function buildGlobalContent() {
15
20
  ERUDIT.log.debug.start('Building global content...');
16
21
 
@@ -22,8 +27,9 @@ export async function buildGlobalContent() {
22
27
  return;
23
28
  }
24
29
 
25
- const linkObject = await buildLinkObject();
30
+ const { linkObject, validPaths } = await buildLinkObject();
26
31
  builtLinkObject = linkObject;
32
+ builtValidPaths = validPaths;
27
33
 
28
34
  const linkTypes = linkObjectToTypes(linkObject);
29
35
  writeFileSync(
@@ -120,6 +126,7 @@ ${body}
120
126
  */
121
127
  async function buildLinkObject() {
122
128
  const linkTree: any = {};
129
+ const validPaths = new Set<string>();
123
130
 
124
131
  await ERUDIT.contentNav.walk((navItem) => {
125
132
  // Navigate to the correct position in the tree based on the full path
@@ -161,6 +168,11 @@ ${jsdoc}
161
168
  navItem.contentRelPath,
162
169
  ),
163
170
  };
171
+
172
+ validPaths.add(navItem.fullId);
173
+ for (const name of getAllUniqueNames(moduleContent)) {
174
+ validPaths.add(`${navItem.fullId}/$${name}`);
175
+ }
164
176
  } else if (navItem.type === 'topic') {
165
177
  const pathToTopicFile = ERUDIT.paths.project(
166
178
  `content/${navItem.contentRelPath}/topic.ts`,
@@ -183,6 +195,8 @@ ${jsdoc}
183
195
  `.trim(),
184
196
  };
185
197
 
198
+ validPaths.add(navItem.fullId);
199
+
186
200
  for (const part of topicParts) {
187
201
  try {
188
202
  const pathToFile = ERUDIT.paths.project(
@@ -210,6 +224,11 @@ ${jsdoc}
210
224
  navItem.contentRelPath,
211
225
  ),
212
226
  };
227
+
228
+ validPaths.add(`${navItem.fullId}/${part}`);
229
+ for (const name of getAllUniqueNames(partContent)) {
230
+ validPaths.add(`${navItem.fullId}/${part}/$${name}`);
231
+ }
213
232
  } catch {}
214
233
  }
215
234
  } else {
@@ -233,10 +252,12 @@ ${jsdoc}
233
252
  */
234
253
  `.trim(),
235
254
  };
255
+
256
+ validPaths.add(navItem.fullId);
236
257
  }
237
258
  });
238
259
 
239
- return linkTree;
260
+ return { linkObject: linkTree, validPaths };
240
261
  }
241
262
 
242
263
  function tryGetTitle(moduleContent: string) {
@@ -254,6 +275,25 @@ function jsdocLines(lines: any[]) {
254
275
  .join('\n');
255
276
  }
256
277
 
278
+ /** Returns ALL unique names from a module — both public and internal. Used to
279
+ * populate builtValidPaths for server-side prose link validation. */
280
+ function getAllUniqueNames(moduleContent: string): string[] {
281
+ const uniquesMatch = moduleContent.match(/uniques:\s*\{([^}]*)\}/s);
282
+ if (!uniquesMatch) return [];
283
+
284
+ const names: string[] = [];
285
+ for (const line of uniquesMatch[1]!.split('\n')) {
286
+ if (line.trim().startsWith('//')) continue;
287
+
288
+ const pairMatch =
289
+ line.match(/\[['"](.*?)['"]\]:\s*(\w+)/) ||
290
+ line.match(/['"](.*?)['"]:\s*(\w+)/) ||
291
+ line.match(/(\w+):\s*(\w+)/);
292
+ if (pairMatch) names.push(pairMatch[1]!);
293
+ }
294
+ return names;
295
+ }
296
+
257
297
  function tryGetUniquesObject(
258
298
  moduleContent: string,
259
299
  pathToFile: string,
@@ -278,7 +318,7 @@ function tryGetUniquesObject(
278
318
  continue;
279
319
  }
280
320
 
281
- // Skip uniques starting with underscore
321
+ // Skip uniques starting with underscore (internal — excluded from $CONTENT types)
282
322
  if (line.trim().startsWith('_')) {
283
323
  continue;
284
324
  }
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import { styleText } from 'node:util';
2
2
  import { globSync } from 'glob';
3
3
  import { existsSync, readdirSync } from 'node:fs';
4
4
  import { contentTypes, type ContentType } from '@erudit-js/core/content/type';
@@ -132,7 +132,7 @@ export async function buildContentNav() {
132
132
 
133
133
  const stats = ERUDIT.contentNav.id2Node.size
134
134
  ? getContentNavStats(ERUDIT.contentNav.id2Node)
135
- : chalk.gray('empty');
135
+ : styleText('gray', 'empty');
136
136
 
137
137
  ERUDIT.log.success(
138
138
  isInitial
@@ -324,7 +324,7 @@ const duplicateIdValidator: ContentNavValidator = () => {
324
324
  Content navigation node short ID duplication!
325
325
  + ${seenShortIds.get(navNode.shortId)}
326
326
  + ${navNode.fullId}
327
- -> ${chalk.yellow(navNode.shortId)}
327
+ -> ${styleText('yellow', navNode.shortId)}
328
328
  `.trim(),
329
329
  );
330
330
  duplicateIds.push(navNode.fullId);
@@ -348,7 +348,7 @@ const subsetValidator: ContentNavValidator = () => {
348
348
  ERUDIT.log.warn(
349
349
  `
350
350
  Only groups and books can have children!
351
- -> ${chalk.yellow(navNode.fullId)}
351
+ -> ${styleText('yellow', navNode.fullId)}
352
352
  `.trim(),
353
353
  );
354
354
  wrongSubsetIds.push(navNode.fullId);
@@ -368,7 +368,7 @@ const bookCantSkipValidator: ContentNavValidator = () => {
368
368
  ERUDIT.log.warn(
369
369
  `
370
370
  Books can not be skipped!
371
- -> ${navNode.contentRelPath.split('/').slice(0, -1).join('/')}/${chalk.yellow(navNode.contentRelPath.split('/').pop())}
371
+ -> ${navNode.contentRelPath.split('/').slice(0, -1).join('/')}/${styleText('yellow', navNode.contentRelPath.split('/').pop()!)}
372
372
  `.trim(),
373
373
  );
374
374
  wrongBookIds.push(navNode.fullId);
@@ -86,6 +86,7 @@ async function createFrontContentNavItem(
86
86
  return {
87
87
  type: 'topic',
88
88
  ...baseItem,
89
+ parts: await ERUDIT.repository.content.topicParts(navNode.fullId),
89
90
  };
90
91
  case 'page':
91
92
  return {
@@ -4,7 +4,7 @@ export async function getContentDependencies(fullId: string) {
4
4
  const hardDependencies: ContentHardDep[] = [];
5
5
 
6
6
  const dbHardDependencies = await ERUDIT.db.query.contentDeps.findMany({
7
- columns: { toFullId: true, hard: true, reason: true },
7
+ columns: { toFullId: true, hard: true, reason: true, uniqueNames: true },
8
8
  where: and(
9
9
  or(
10
10
  eq(ERUDIT.db.schema.contentDeps.fromFullId, fullId),
@@ -21,13 +21,35 @@ export async function getContentDependencies(fullId: string) {
21
21
  return map;
22
22
  }, new Map<string, string>());
23
23
 
24
+ // Merge unique names across hard dep rows sharing the same toFullId
25
+ // (can happen when a topic and its children both hard-dep the same target).
26
+ const hardUniqueMap = new Map<string, Set<string>>();
27
+ for (const row of dbHardDependencies) {
28
+ if (!hardUniqueMap.has(row.toFullId)) {
29
+ hardUniqueMap.set(row.toFullId, new Set());
30
+ }
31
+ if (row.uniqueNames) {
32
+ for (const name of row.uniqueNames.split(',')) {
33
+ hardUniqueMap.get(row.toFullId)!.add(name);
34
+ }
35
+ }
36
+ }
37
+
24
38
  const hardToFullIds = ERUDIT.contentNav.orderIds(
25
39
  externalToFullIds(dbHardDependencies),
26
40
  );
27
41
 
28
42
  for (const toFullId of hardToFullIds) {
29
43
  const reason = fullId2Reason.get(toFullId)!;
30
- const hardDep = await createContentDep('hard', toFullId, undefined, reason);
44
+ const uniquePairs = Array.from(hardUniqueMap.get(toFullId) ?? []).map(
45
+ (uniqueName) => ({ contentFullId: toFullId, uniqueName }),
46
+ );
47
+ const hardDep = await createContentDep(
48
+ 'hard',
49
+ toFullId,
50
+ uniquePairs.length > 0 ? uniquePairs : undefined,
51
+ reason,
52
+ );
31
53
  if (hardDep) {
32
54
  hardDependencies.push(hardDep);
33
55
  }
@@ -166,7 +188,7 @@ async function createContentDep(
166
188
  async function createContentDep(
167
189
  type: 'hard',
168
190
  fullId: string,
169
- uniquePairs: undefined,
191
+ uniquePairs: { contentFullId: string; uniqueName: string }[] | undefined,
170
192
  reason: string,
171
193
  ): Promise<ContentHardDep | undefined>;
172
194
  async function createContentDep(
@@ -189,12 +211,20 @@ async function createContentDep(
189
211
  ]);
190
212
 
191
213
  if (type === 'hard') {
214
+ const hardUniques =
215
+ uniquePairs && uniquePairs.length > 0
216
+ ? await resolveUniqueEntries(uniquePairs)
217
+ : undefined;
218
+
192
219
  return {
193
220
  type: 'hard',
194
221
  reason: reason!,
195
222
  contentType,
196
223
  title,
197
224
  link,
225
+ ...(hardUniques && hardUniques.length > 0
226
+ ? { uniques: hardUniques }
227
+ : {}),
198
228
  };
199
229
  }
200
230
 
@@ -230,7 +260,7 @@ async function resolveUniqueEntries(
230
260
  const results = await Promise.all(
231
261
  unique.map(async ({ contentFullId, uniqueName }) => {
232
262
  const dbUnique = await ERUDIT.db.query.contentUniques.findFirst({
233
- columns: { title: true, prose: true },
263
+ columns: { title: true, prose: true, contentProseType: true },
234
264
  where: and(
235
265
  eq(ERUDIT.db.schema.contentUniques.contentFullId, contentFullId),
236
266
  eq(ERUDIT.db.schema.contentUniques.uniqueName, uniqueName),
@@ -239,15 +269,24 @@ async function resolveUniqueEntries(
239
269
 
240
270
  if (!dbUnique) return null;
241
271
 
242
- const pageLink = await ERUDIT.repository.content.link(contentFullId);
272
+ const navNode = ERUDIT.contentNav.getNodeOrThrow(contentFullId);
243
273
  const schemaName = dbUnique.prose.schema.name;
244
274
 
245
275
  if (!schemaName) return null;
246
276
 
277
+ const link =
278
+ navNode.type === 'topic'
279
+ ? PAGES.topic(
280
+ dbUnique.contentProseType as any,
281
+ navNode.shortId,
282
+ dbUnique.prose.id,
283
+ )
284
+ : PAGES.page(navNode.shortId, dbUnique.prose.id);
285
+
247
286
  return {
248
287
  name: uniqueName,
249
288
  title: dbUnique.title ?? undefined,
250
- link: `${pageLink}?element=${dbUnique.prose.id}`,
289
+ link,
251
290
  schemaName,
252
291
  } satisfies ContentDepUnique;
253
292
  }),
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import { styleText } from 'node:util';
2
2
  import { inArray, or } from 'drizzle-orm';
3
3
  import { contentPathToId } from '@erudit-js/core/content/path';
4
4
 
@@ -180,7 +180,7 @@ function renderChangedContentTree(ids: Set<string>): string {
180
180
  const walkTree = (node: Node, depth: number) => {
181
181
  if (node.name) {
182
182
  const indent = ' '.repeat(Math.max(0, depth - 1));
183
- lines.push(`${indent}- ${chalk.cyan(node.name)}`);
183
+ lines.push(`${indent}- ${styleText('cyan', node.name)}`);
184
184
  }
185
185
 
186
186
  const children = Array.from(node.children.values()).sort((a, b) =>
@@ -199,7 +199,7 @@ function renderChangedContentTree(ids: Set<string>): string {
199
199
  walkTree(child, 1);
200
200
  });
201
201
 
202
- const header = chalk.gray('Changed content:');
202
+ const header = styleText('gray', 'Changed content:');
203
203
  return [header, ...lines].join('\n');
204
204
  }
205
205