erudit 4.2.0-dev.1 → 4.2.0

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.
@@ -98,7 +98,7 @@ const phrase = await usePhrases('news', 'no_news', 'show_more');
98
98
  <div class="flex h-full w-full flex-col">
99
99
  <AsideMinorPlainHeader
100
100
  icon="bell"
101
- :title="phrase!.news"
101
+ :title="phrase.news"
102
102
  :count="newsTotal === 0 ? undefined : newsTotal"
103
103
  />
104
104
  <section v-if="newsItems.length === 0">
@@ -23,7 +23,6 @@ const lastChangedSource = useLastChangedSource(() => props.contentRelativePath);
23
23
  class="micro:justify-start gap-small micro:gap-normal flex flex-wrap
24
24
  justify-center"
25
25
  >
26
- <ItemLastChanged v-if="lastChangedSource" :source="lastChangedSource" />
27
26
  <ItemMaterials
28
27
  v-if="stats?.materials"
29
28
  :count="stats.materials"
@@ -36,6 +35,7 @@ const lastChangedSource = useLastChangedSource(() => props.contentRelativePath);
36
35
  :count
37
36
  mode="detailed"
38
37
  />
38
+ <ItemLastChanged v-if="lastChangedSource" :source="lastChangedSource" />
39
39
  </div>
40
40
  </section>
41
41
  <div
@@ -39,6 +39,7 @@ export function usePhrases<const T extends readonly LanguagePhraseKey[]>(
39
39
 
40
40
  const strFunctions = await $fetch<Record<string, string>>(
41
41
  '/api/language/functions',
42
+ { responseType: 'json' },
42
43
  );
43
44
 
44
45
  payloadLanguage.functions = strFunctions;
@@ -69,6 +70,7 @@ export function usePhrases<const T extends readonly LanguagePhraseKey[]>(
69
70
  try {
70
71
  payloadPhraseValue = await $fetch<PayloadLanguagePhraseValue>(
71
72
  `/api/language/phrase/${phraseKey}`,
73
+ { responseType: 'json' },
72
74
  );
73
75
 
74
76
  payloadLanguage.phrases[phraseKey] = payloadPhraseValue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "erudit",
3
- "version": "4.2.0-dev.1",
3
+ "version": "4.2.0",
4
4
  "type": "module",
5
5
  "description": "🤓 CMS for perfect educational sites.",
6
6
  "license": "MIT",
@@ -24,9 +24,9 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@erudit-js/cli": "4.2.0-dev.1",
28
- "@erudit-js/core": "4.2.0-dev.1",
29
- "@erudit-js/prose": "4.2.0-dev.1",
27
+ "@erudit-js/cli": "4.2.0",
28
+ "@erudit-js/core": "4.2.0",
29
+ "@erudit-js/prose": "4.2.0",
30
30
  "unslash": "^2.0.0",
31
31
  "@floating-ui/vue": "^1.1.10",
32
32
  "tsprose": "^1.0.0",
@@ -73,12 +73,20 @@ const jsxRuntimePlugin: Plugin = {
73
73
  },
74
74
  };
75
75
 
76
- // Collect all tag names that are registered in globalThis
77
- const proseTagNames = new Set<string>(
78
- Object.values(coreElements).flatMap((el: any) =>
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) =>
79
87
  (el.tags ?? []).map((t: any) => String(t.tagName)),
80
88
  ),
81
- );
89
+ ]);
82
90
 
83
91
  // Pre-transform: rewrite any import of a known globalThis tag name → const from globalThis.
84
92
  // Non-tag imports are left as real imports and bundled normally.
@@ -106,7 +114,7 @@ const proseGlobalsPlugin: Plugin = {
106
114
  continue;
107
115
  }
108
116
  const localName = m[2] ?? m[1]!;
109
- if (proseTagNames.has(localName)) {
117
+ if (globalNames.has(localName)) {
110
118
  shimLines.push(
111
119
  `const ${localName} = globalThis[${JSON.stringify(localName)}];`,
112
120
  );
@@ -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
  }
@@ -230,7 +230,7 @@ async function resolveUniqueEntries(
230
230
  const results = await Promise.all(
231
231
  unique.map(async ({ contentFullId, uniqueName }) => {
232
232
  const dbUnique = await ERUDIT.db.query.contentUniques.findFirst({
233
- columns: { title: true, prose: true },
233
+ columns: { title: true, prose: true, contentProseType: true },
234
234
  where: and(
235
235
  eq(ERUDIT.db.schema.contentUniques.contentFullId, contentFullId),
236
236
  eq(ERUDIT.db.schema.contentUniques.uniqueName, uniqueName),
@@ -239,15 +239,24 @@ async function resolveUniqueEntries(
239
239
 
240
240
  if (!dbUnique) return null;
241
241
 
242
- const pageLink = await ERUDIT.repository.content.link(contentFullId);
242
+ const navNode = ERUDIT.contentNav.getNodeOrThrow(contentFullId);
243
243
  const schemaName = dbUnique.prose.schema.name;
244
244
 
245
245
  if (!schemaName) return null;
246
246
 
247
+ const link =
248
+ navNode.type === 'topic'
249
+ ? PAGES.topic(
250
+ dbUnique.contentProseType as any,
251
+ navNode.shortId,
252
+ dbUnique.prose.id,
253
+ )
254
+ : PAGES.page(navNode.shortId, dbUnique.prose.id);
255
+
247
256
  return {
248
257
  name: uniqueName,
249
258
  title: dbUnique.title ?? undefined,
250
- link: `${pageLink}?element=${dbUnique.prose.id}`,
259
+ link,
251
260
  schemaName,
252
261
  } satisfies ContentDepUnique;
253
262
  }),
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import type { ContentProseType } from '@erudit-js/core/content/prose';
4
- import { builtLinkObject } from '../../global/build';
4
+ import { builtValidPaths } from '../../global/build';
5
5
  import type {
6
6
  ContentLinks,
7
7
  ContentLinkUsage,
@@ -148,25 +148,11 @@ function filterTargetMap(
148
148
  }
149
149
 
150
150
  function globalContentToNavNode(globalContentPath: string) {
151
- // Validate the full path (including any $unique suffix) against the link
152
- // object that was built from the source files. This catches broken unique
153
- // names as well as broken content paths before we ever touch the nav tree.
154
- if (builtLinkObject) {
155
- const parts = globalContentPath.split('/');
156
- let cursor: any = builtLinkObject;
157
- let valid = true;
158
-
159
- for (const part of parts) {
160
- if (!cursor || typeof cursor !== 'object' || !(part in cursor)) {
161
- valid = false;
162
- break;
163
- }
164
- cursor = cursor[part];
165
- }
166
-
167
- if (!valid) {
168
- throw new Error(`Path not found in \$CONTENT: ${globalContentPath}`);
169
- }
151
+ // Validate the full path (including any $unique suffix) against the complete
152
+ // set of known valid paths built from source files. This catches broken
153
+ // unique names and content paths before we ever touch the nav tree.
154
+ if (builtValidPaths && !builtValidPaths.has(globalContentPath)) {
155
+ throw new Error(`Path not found in \$CONTENT: ${globalContentPath}`);
170
156
  }
171
157
 
172
158
  const parts = globalContentPath.split('/');
@@ -175,8 +161,7 @@ function globalContentToNavNode(globalContentPath: string) {
175
161
  parts.pop();
176
162
  }
177
163
 
178
- // Path already validated against builtLinkObject, so if the exact node
179
- // isn't found the last segment must be a topic part — fall back to parent.
164
+ // If the exact node isn't found the last segment is a topic part — fall back to parent.
180
165
  return (
181
166
  ERUDIT.contentNav.getNode(parts.join('/')) ??
182
167
  ERUDIT.contentNav.getNodeOrThrow(parts.slice(0, -1).join('/'))