erudit 4.2.0 → 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 (59) 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/main/MainStickyHeader.vue +5 -2
  6. package/app/components/main/MainStickyHeaderPreamble.vue +5 -2
  7. package/app/components/main/MainTopicPartPage.vue +3 -2
  8. package/app/components/main/MainTopicPartSwitch.vue +18 -7
  9. package/app/components/main/connections/Deps.vue +1 -4
  10. package/app/components/main/connections/MainConnections.vue +9 -3
  11. package/app/components/main/contentStats/ItemLastChanged.vue +3 -32
  12. package/app/components/main/contentStats/MainContentStats.vue +3 -4
  13. package/app/components/preview/Preview.vue +8 -6
  14. package/app/components/preview/PreviewScreen.vue +9 -7
  15. package/app/components/preview/screen/Unique.vue +3 -2
  16. package/app/composables/ads.ts +1 -1
  17. package/app/composables/analytics.ts +1 -1
  18. package/app/composables/lastChanged.ts +38 -5
  19. package/app/composables/og.ts +5 -5
  20. package/app/composables/scrollUp.ts +3 -1
  21. package/app/pages/book/[...bookId].vue +3 -2
  22. package/app/pages/group/[...groupId].vue +3 -2
  23. package/app/pages/page/[...pageId].vue +4 -2
  24. package/app/plugins/appSetup/config.ts +1 -0
  25. package/app/plugins/appSetup/global.ts +3 -0
  26. package/app/plugins/appSetup/index.ts +4 -1
  27. package/app/plugins/devReload.client.ts +13 -0
  28. package/app/router.options.ts +17 -3
  29. package/app/styles/main.css +2 -2
  30. package/modules/erudit/dependencies.ts +16 -0
  31. package/modules/erudit/index.ts +8 -1
  32. package/modules/erudit/setup/autoImports.ts +143 -0
  33. package/modules/erudit/setup/elements/globalTemplate.ts +10 -2
  34. package/modules/erudit/setup/elements/setup.ts +8 -14
  35. package/modules/erudit/setup/elements/tagsTable.ts +2 -18
  36. package/modules/erudit/setup/fullRestart.ts +5 -3
  37. package/modules/erudit/setup/namesTable.ts +33 -0
  38. package/modules/erudit/setup/problemChecks/setup.ts +60 -0
  39. package/modules/erudit/setup/problemChecks/shared.ts +4 -0
  40. package/modules/erudit/setup/problemChecks/template.ts +33 -0
  41. package/modules/erudit/setup/runtimeConfig.ts +12 -7
  42. package/nuxt.config.ts +14 -6
  43. package/package.json +5 -6
  44. package/server/api/problemScript/[...problemScriptPath].ts +245 -60
  45. package/server/erudit/build.ts +10 -4
  46. package/server/erudit/content/nav/build.ts +5 -5
  47. package/server/erudit/content/nav/front.ts +1 -0
  48. package/server/erudit/content/repository/deps.ts +33 -3
  49. package/server/erudit/content/resolve/index.ts +3 -3
  50. package/server/erudit/content/resolve/utils/contentError.ts +2 -2
  51. package/server/erudit/content/resolve/utils/insertContentResolved.ts +22 -5
  52. package/server/erudit/global.ts +5 -1
  53. package/server/erudit/importer.ts +69 -0
  54. package/server/erudit/index.ts +2 -2
  55. package/server/erudit/logger.ts +18 -10
  56. package/server/erudit/reloadSignal.ts +14 -0
  57. package/server/routes/_reload.ts +27 -0
  58. package/shared/types/contentConnections.ts +1 -0
  59. 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,73 +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
- // Names that are available on globalThis and should not be bundled as real imports
77
- const globalNames = new Set<string>([
78
- // JSX runtime
79
- 'jsx',
80
- '_jsx',
81
- 'jsxs',
82
- '_jsxs',
83
- 'Fragment',
84
- '_Fragment',
85
- // Prose tag names registered in globalThis
86
- ...Object.values(coreElements).flatMap((el: any) =>
87
- (el.tags ?? []).map((t: any) => String(t.tagName)),
88
- ),
89
- ]);
90
-
91
- // Pre-transform: rewrite any import of a known globalThis tag name → const from globalThis.
92
- // Non-tag imports are left as real imports and bundled normally.
93
- // Applies recursively to every .ts/.tsx file esbuild processes (including utility files).
94
- const proseGlobalsPlugin: Plugin = {
95
- 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',
96
265
  setup(build) {
266
+ const globalNames = getGlobalNames();
97
267
  build.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
98
- const source = readFileSync(args.path, 'utf8');
99
-
100
- const transformed = source.replace(
101
- /^import\s+\{([^}]+)\}\s+from\s+(['"])([^'"]+)\2.*$/gm,
102
- (_match, bindings: string, _quote: string, pkg: string) => {
103
- const keepParts: string[] = [];
104
- const shimLines: string[] = [];
105
-
106
- for (const part of bindings
107
- .split(',')
108
- .map((s) => s.trim())
109
- .filter(Boolean)) {
110
- // handle "ExportName as LocalName"
111
- const m = part.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
112
- if (!m) {
113
- keepParts.push(part);
114
- continue;
115
- }
116
- const localName = m[2] ?? m[1]!;
117
- if (globalNames.has(localName)) {
118
- shimLines.push(
119
- `const ${localName} = globalThis[${JSON.stringify(localName)}];`,
120
- );
121
- } else {
122
- keepParts.push(part);
123
- }
124
- }
268
+ let source = readFileSync(args.path, 'utf8');
125
269
 
126
- const lines: string[] = [];
127
- if (keepParts.length > 0)
128
- lines.push(`import { ${keepParts.join(', ')} } from '${pkg}';`);
129
- lines.push(...shimLines);
130
- return lines.join('\n');
131
- },
132
- );
270
+ source = wipeOutCalls(source);
271
+ source = rewriteGlobalImports(source, globalNames);
133
272
 
134
273
  const ext = (args.path.match(/[jt]sx?$/)?.[0] ?? 'js') as
135
274
  | 'js'
@@ -137,13 +276,59 @@ const proseGlobalsPlugin: Plugin = {
137
276
  | 'ts'
138
277
  | 'tsx';
139
278
  return {
140
- contents: transformed,
279
+ contents: source,
141
280
  loader: ext,
142
281
  };
143
282
  });
144
283
  },
145
284
  };
146
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
+
147
332
  const staticFilesPlugin: Plugin = {
148
333
  name: 'static-files',
149
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();
@@ -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
 
@@ -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
 
@@ -1,10 +1,10 @@
1
- import chalk from 'chalk';
1
+ import { styleText } from 'node:util';
2
2
  import type { ContentNavNode } from '../../nav/types';
3
3
 
4
4
  export function logContentError(contentNode: ContentNavNode) {
5
5
  ERUDIT.log.error(
6
6
  `Error parsing ${contentNode.type} ${ERUDIT.log.stress(
7
7
  contentNode.fullId,
8
- )}!\nLocation: ${chalk.red(ERUDIT.paths.project(`content/${contentNode.contentRelPath}`))}`,
8
+ )}!\nLocation: ${styleText('red', ERUDIT.paths.project(`content/${contentNode.contentRelPath}`))}`,
9
9
  );
10
10
  }
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import { styleText } from 'node:util';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import type { ContentProseType } from '@erudit-js/core/content/prose';
4
4
  import { builtValidPaths } from '../../global/build';
@@ -80,7 +80,24 @@ async function insertContentDeps(
80
80
  await ERUDIT.db
81
81
  .insert(ERUDIT.db.schema.contentDeps)
82
82
  .values(contentDeps)
83
- .onConflictDoNothing();
83
+ .onConflictDoUpdate({
84
+ target: [
85
+ ERUDIT.db.schema.contentDeps.fromFullId,
86
+ ERUDIT.db.schema.contentDeps.toFullId,
87
+ ],
88
+ set: {
89
+ // Merge unique names from the auto dep into the existing row
90
+ // (which may already be a hard dep that has no uniqueNames yet).
91
+ // Only uniqueNames is updated — hard/reason are left untouched.
92
+ uniqueNames: sql`CASE
93
+ WHEN ${ERUDIT.db.schema.contentDeps.uniqueNames} IS NULL
94
+ THEN excluded.uniqueNames
95
+ WHEN excluded.uniqueNames IS NULL
96
+ THEN ${ERUDIT.db.schema.contentDeps.uniqueNames}
97
+ ELSE ${ERUDIT.db.schema.contentDeps.uniqueNames} || ',' || excluded.uniqueNames
98
+ END`,
99
+ },
100
+ });
84
101
  }
85
102
  }
86
103
 
@@ -93,7 +110,7 @@ function filterTargetMap(
93
110
  const brokenLinkMessage = (message: string, metas: ContentLinkUsage[]) => {
94
111
  let output = `${message} in ${ERUDIT.log.stress(contentFullId)}:\n`;
95
112
  for (const { type, label } of metas) {
96
- output += ` ${chalk.gray('➔')} <${type}>${label}</${type}>\n`;
113
+ output += ` ${styleText('gray', '➔')} <${type}>${label}</${type}>\n`;
97
114
  }
98
115
  return output;
99
116
  };
@@ -104,7 +121,7 @@ function filterTargetMap(
104
121
  if (storageKey.startsWith('<link:unknown>/')) {
105
122
  ERUDIT.log.warn(
106
123
  brokenLinkMessage(
107
- `Unknown link ${chalk.red(storageKey.replace('<link:unknown>/', ''))}`,
124
+ `Unknown link ${styleText('red', storageKey.replace('<link:unknown>/', ''))}`,
108
125
  metas,
109
126
  ),
110
127
  );
@@ -136,7 +153,7 @@ function filterTargetMap(
136
153
  } catch {
137
154
  ERUDIT.log.warn(
138
155
  brokenLinkMessage(
139
- `Failed to resolve content link ${chalk.red(storageKey.replace('<link:global>/', ''))}`,
156
+ `Failed to resolve content link ${styleText('red', storageKey.replace('<link:global>/', ''))}`,
140
157
  metas,
141
158
  ),
142
159
  );
@@ -11,6 +11,7 @@ import type { EruditServerPaths } from './path';
11
11
  import type { EruditServerRepository } from './repository';
12
12
 
13
13
  import { registerProseGlobals } from '#erudit/prose/global';
14
+ import { registerAutoImportGlobals } from '#erudit/autoImports';
14
15
 
15
16
  export const ERUDIT: {
16
17
  buildError: EruditServerBuildError;
@@ -27,7 +28,9 @@ export const ERUDIT: {
27
28
  import: EruditServerImporter;
28
29
  } = {} as any;
29
30
 
30
- Object.assign(globalThis, {
31
+ (globalThis as any).ERUDIT_GLOBAL = (globalThis as any).ERUDIT_GLOBAL || {};
32
+
33
+ Object.assign((globalThis as any).ERUDIT_GLOBAL, {
31
34
  defineContributor,
32
35
  defineSponsor,
33
36
  defineCameo,
@@ -40,3 +43,4 @@ Object.assign(globalThis, {
40
43
  });
41
44
 
42
45
  registerProseGlobals();
46
+ registerAutoImportGlobals();