erudit 4.1.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.
Files changed (116) hide show
  1. package/app/assets/icons/update.svg +3 -0
  2. package/app/components/Prose.vue +7 -7
  3. package/app/components/SmartMedia.vue +4 -4
  4. package/app/components/aside/major/contentNav/PaneBookNav.vue +1 -4
  5. package/app/components/aside/minor/content/Toc.vue +24 -1
  6. package/app/components/aside/minor/content/TocItem.vue +2 -1
  7. package/app/components/aside/minor/news/AsideMinorNews.vue +2 -4
  8. package/app/components/aside/minor/news/NewsItem.vue +3 -4
  9. package/app/components/aside/minor/news/RenderNewsElement.vue +4 -11
  10. package/app/components/aside/minor/news/elements/Mix.vue +2 -3
  11. package/app/components/aside/minor/news/elements/P.vue +3 -3
  12. package/app/components/aside/minor/news/elements/Ref.vue +3 -3
  13. package/app/components/aside/minor/news/elements/Text.vue +2 -2
  14. package/app/components/main/MainContentChild.vue +3 -3
  15. package/app/components/main/{MainQuickLink.vue → MainKeyLink.vue} +11 -11
  16. package/app/components/main/{MainQuickLinks.vue → MainKeyLinks.vue} +7 -7
  17. package/app/components/main/MainQuoteLoader.vue +2 -4
  18. package/app/components/main/MainStickyHeader.vue +1 -1
  19. package/app/components/main/MainStickyHeaderPreamble.vue +6 -2
  20. package/app/components/main/MainTopicPartPage.vue +8 -3
  21. package/app/components/main/connections/DepUnique.vue +45 -0
  22. package/app/components/main/connections/Deps.vue +14 -2
  23. package/app/components/main/connections/Externals.vue +1 -1
  24. package/app/components/main/connections/MainConnections.vue +1 -0
  25. package/app/components/main/contentStats/ItemLastChanged.vue +68 -0
  26. package/app/components/main/contentStats/MainContentStats.vue +36 -28
  27. package/app/components/preview/PreviewScreen.vue +2 -2
  28. package/app/components/preview/screen/ContentPage.vue +1 -4
  29. package/app/components/preview/screen/Unique.vue +3 -5
  30. package/app/composables/appElements.ts +2 -4
  31. package/app/composables/asideMajorPane.ts +3 -3
  32. package/app/composables/fetchJson.ts +4 -0
  33. package/app/composables/lastChanged.ts +28 -0
  34. package/app/composables/mainContent.ts +1 -4
  35. package/app/composables/og.ts +43 -35
  36. package/app/composables/phrases.ts +2 -3
  37. package/app/composables/scrollUp.ts +1 -1
  38. package/app/pages/book/[...bookId].vue +5 -1
  39. package/app/pages/contributor/[contributorId].vue +3 -5
  40. package/app/pages/contributors.vue +1 -1
  41. package/app/pages/group/[...groupId].vue +5 -1
  42. package/app/pages/index.vue +1 -1
  43. package/app/pages/page/[...pageId].vue +8 -3
  44. package/app/pages/sponsors.vue +1 -1
  45. package/app/plugins/appSetup/index.ts +0 -5
  46. package/app/plugins/fetchJson.ts +11 -0
  47. package/app/plugins/prerender.server.ts +1 -1
  48. package/app/router.options.ts +1 -1
  49. package/modules/erudit/globals/prose.ts +3 -4
  50. package/modules/erudit/setup/elements/appTemplate.ts +6 -7
  51. package/modules/erudit/setup/elements/{globalTypes.ts → elementGlobalTypes.ts} +21 -21
  52. package/modules/erudit/setup/elements/globalTemplate.ts +29 -23
  53. package/modules/erudit/setup/elements/setup.ts +18 -16
  54. package/modules/erudit/setup/elements/shared.ts +2 -2
  55. package/modules/erudit/setup/elements/tagsTable.ts +1 -1
  56. package/modules/erudit/setup/runtimeConfig.ts +2 -0
  57. package/nuxt.config.ts +2 -2
  58. package/package.json +14 -13
  59. package/server/api/main/content/[...contentTypePath].ts +5 -4
  60. package/server/api/prerender/content.ts +1 -3
  61. package/server/api/preview/contentPage/[...contentTypePath].ts +1 -2
  62. package/server/api/preview/contentUnique/[...contentTypePathUnique].ts +16 -31
  63. package/server/api/problemScript/[...problemScriptPath].ts +81 -4
  64. package/server/erudit/content/global/build.ts +64 -10
  65. package/server/erudit/content/nav/build.ts +4 -4
  66. package/server/erudit/content/repository/children.ts +3 -3
  67. package/server/erudit/content/repository/deps.ts +110 -13
  68. package/server/erudit/content/repository/elementSnippets.ts +16 -16
  69. package/server/erudit/content/repository/stats.ts +30 -22
  70. package/server/erudit/content/repository/topicParts.ts +1 -1
  71. package/server/erudit/content/repository/unique.ts +14 -15
  72. package/server/erudit/content/resolve/page.ts +15 -35
  73. package/server/erudit/content/resolve/topic.ts +33 -164
  74. package/server/erudit/content/resolve/utils/insertContentResolved.ts +59 -31
  75. package/server/erudit/content/search.ts +5 -22
  76. package/server/erudit/contributors/build.ts +7 -8
  77. package/server/erudit/db/repository/pushFile.ts +10 -3
  78. package/server/erudit/db/repository/pushProblemScript.ts +14 -3
  79. package/server/erudit/db/schema/contentDeps.ts +3 -0
  80. package/server/erudit/db/schema/contentSnippets.ts +3 -3
  81. package/server/erudit/db/schema/contentUniques.ts +2 -2
  82. package/server/erudit/db/schema/contributors.ts +2 -2
  83. package/server/erudit/db/schema/news.ts +2 -2
  84. package/server/erudit/db/schema/pages.ts +2 -2
  85. package/server/erudit/db/schema/topics.ts +4 -4
  86. package/server/erudit/global.ts +4 -0
  87. package/server/erudit/importer.ts +16 -8
  88. package/server/erudit/index.ts +0 -3
  89. package/server/erudit/language/list/en.ts +1 -0
  90. package/server/erudit/language/list/ru.ts +1 -0
  91. package/server/erudit/news/build.ts +6 -6
  92. package/server/erudit/news/repository/batch.ts +2 -2
  93. package/server/erudit/prose/repository/finalize.ts +22 -25
  94. package/server/erudit/prose/repository/get.ts +3 -5
  95. package/server/erudit/prose/repository/rawToProse.ts +31 -0
  96. package/server/erudit/prose/storage/callout.ts +9 -7
  97. package/server/erudit/prose/storage/image.ts +8 -11
  98. package/server/erudit/prose/storage/link.ts +24 -32
  99. package/server/erudit/prose/storage/problemScript.ts +8 -14
  100. package/server/erudit/prose/storage/video.ts +9 -7
  101. package/server/erudit/repository.ts +4 -4
  102. package/server/routes/file/[...path].ts +1 -1
  103. package/shared/types/contentChildren.ts +5 -2
  104. package/shared/types/contentConnections.ts +9 -0
  105. package/shared/types/elementSnippet.ts +1 -1
  106. package/shared/types/indexPage.ts +3 -0
  107. package/shared/types/language.ts +1 -83
  108. package/shared/types/mainContent.ts +11 -5
  109. package/shared/types/news.ts +2 -2
  110. package/shared/types/preview.ts +3 -2
  111. package/shared/types/runtimeConfig.ts +1 -0
  112. package/shared/types/search.ts +2 -0
  113. package/shared/utils/pages.ts +4 -2
  114. package/shared/utils/stringColor.ts +16 -6
  115. package/server/erudit/prose/repository/resolve.ts +0 -17
  116. package/server/erudit/prose/transform/bundleProblemScript.ts +0 -6
@@ -1,12 +1,10 @@
1
- import { isDocument, type AnyDocument } from '@jsprose/core';
1
+ import { isDocument, type Document } from 'tsprose';
2
2
  import { isContentItem } from '@erudit-js/core/content/item';
3
3
  import type { PageContentItem } from '@erudit-js/core/content/page';
4
- import { isIncludedRawElement } from '@erudit-js/prose';
5
4
 
6
5
  import type { ContentNavNode } from '../nav/types';
7
6
  import { logContentError } from './utils/contentError';
8
7
  import { insertContentItem } from './utils/insertContentItem';
9
- import { resolveEruditProse } from '../../prose/repository/resolve';
10
8
  import { insertContentResolved } from './utils/insertContentResolved';
11
9
 
12
10
  export async function resolvePage(pageNode: ContentNavNode) {
@@ -16,7 +14,7 @@ export async function resolvePage(pageNode: ContentNavNode) {
16
14
 
17
15
  try {
18
16
  const pageModule = await ERUDIT.import<{
19
- content: AnyDocument;
17
+ content: Document;
20
18
  page: PageContentItem;
21
19
  }>(ERUDIT.paths.project(`content/${pageNode.contentRelPath}/page`));
22
20
 
@@ -30,49 +28,31 @@ export async function resolvePage(pageNode: ContentNavNode) {
30
28
 
31
29
  await insertContentItem(pageNode, pageModule.page);
32
30
 
33
- const elementsCount: Record<string, number> = {};
34
-
35
31
  const proseDocument = pageModule.content;
36
- const resolveResult = await resolveEruditProse(
37
- proseDocument.content,
38
- true,
39
- async ({ rawElement }) => {
40
- // Counting elements for statistics
41
-
42
- if (isIncludedRawElement(rawElement)) {
43
- return;
44
- }
45
-
46
- if (
47
- ERUDIT.config.countElements.flat().includes(rawElement.schemaName)
48
- ) {
49
- elementsCount[rawElement.schemaName] =
50
- (elementsCount[rawElement.schemaName] || 0) + 1;
51
- }
52
- },
53
- );
32
+ const result = await ERUDIT.repository.prose.fromRaw({
33
+ rawProse: proseDocument.rawProse,
34
+ toc: { enabled: true },
35
+ snippets: { enabled: true },
36
+ });
54
37
 
55
- if (resolveResult.tocItems?.length) {
38
+ if (result.toc?.length) {
56
39
  await ERUDIT.db.insert(ERUDIT.db.schema.contentToc).values({
57
40
  fullId: pageNode.fullId,
58
- toc: resolveResult.tocItems,
41
+ toc: result.toc,
59
42
  });
60
43
  }
61
44
 
62
- for (const [schemaName, count] of Object.entries(elementsCount)) {
63
- await ERUDIT.repository.content.addElementCount(
64
- pageNode.fullId,
65
- schemaName,
66
- count,
67
- );
68
- }
45
+ await ERUDIT.repository.content.updateSchemaCounts(
46
+ pageNode.fullId,
47
+ result.schemaCounts,
48
+ );
69
49
 
70
50
  await ERUDIT.db.insert(ERUDIT.db.schema.pages).values({
71
51
  fullId: pageNode.fullId,
72
- prose: resolveResult.proseElement,
52
+ prose: result.prose,
73
53
  });
74
54
 
75
- await insertContentResolved(pageNode.fullId, 'page', resolveResult);
55
+ await insertContentResolved(pageNode.fullId, 'page', result);
76
56
  } catch (error) {
77
57
  logContentError(pageNode);
78
58
  throw error;
@@ -1,29 +1,18 @@
1
1
  import { eq } from 'drizzle-orm';
2
- import {
3
- isDocument,
4
- isRawElement,
5
- walkElements,
6
- type AnyDocument,
7
- type AnySchema,
8
- } from '@jsprose/core';
2
+ import { existsSync } from 'node:fs';
3
+ import { isDocument, type Document } from 'tsprose';
9
4
  import {
10
5
  topicParts,
11
6
  type TopicContentItem,
12
7
  } from '@erudit-js/core/content/topic';
13
8
  import { isContentItem } from '@erudit-js/core/content/item';
14
- import {
15
- isIncludedRawElement,
16
- type EruditRawElement,
17
- type ResolvedTocItem,
18
- } from '@erudit-js/prose';
19
- import { problemSchema } from '@erudit-js/prose/elements/problem/problem';
20
- import { problemsSchema } from '@erudit-js/prose/elements/problem/problems';
21
9
 
22
10
  import type { ContentNavNode } from '../nav/types';
23
11
  import { logContentError } from './utils/contentError';
24
12
  import { insertContentItem } from './utils/insertContentItem';
25
- import { resolveEruditProse } from '../../prose/repository/resolve';
26
13
  import { insertContentResolved } from './utils/insertContentResolved';
14
+ import { problemSchema } from '@erudit-js/prose/elements/problem/problem';
15
+ import { problemsSchema } from '@erudit-js/prose/elements/problem/problems';
27
16
 
28
17
  export async function resolveTopic(topicNode: ContentNavNode) {
29
18
  ERUDIT.log.debug.start(
@@ -44,179 +33,59 @@ export async function resolveTopic(topicNode: ContentNavNode) {
44
33
  fullId: topicNode.fullId,
45
34
  });
46
35
 
47
- const elementsCount: Record<string, number> = {};
48
-
49
36
  for (const topicPart of topicParts) {
50
- const topicPartDocument = await ERUDIT.import<{
51
- default: AnyDocument;
52
- }>(
53
- ERUDIT.paths.project(
54
- `content/${topicNode.contentRelPath}/${topicPart}`,
55
- ),
56
- { try: true },
37
+ const topicPartPath = ERUDIT.paths.project(
38
+ `content/${topicNode.contentRelPath}/${topicPart}`,
57
39
  );
58
40
 
59
- if (isDocument(topicPartDocument?.default)) {
60
- const practiceProblemsTocItems: ResolvedTocItem[] = [];
61
-
62
- const resolvedTopicPart = await resolveEruditProse(
63
- topicPartDocument.default.content,
64
- true,
65
- async ({ rawElement, proseElement }) => {
66
- //
67
- // Auto-adding problems to TOC if in practice topic part
68
- //
69
-
70
- if (topicPart === 'practice') {
71
- if (
72
- (rawElement as EruditRawElement<AnySchema>).title &&
73
- proseElement.id
74
- ) {
75
- if (
76
- isRawElement(rawElement, problemSchema) ||
77
- isRawElement(rawElement, problemsSchema)
78
- ) {
79
- practiceProblemsTocItems.push({
80
- type: 'element',
81
- elementId: proseElement.id,
82
- schemaName: rawElement.schemaName,
83
- title: (rawElement as EruditRawElement<AnySchema>).title!,
84
- });
85
- }
86
- }
87
- }
41
+ const topicPartExists = ['.js', '.jsx', '.ts', '.tsx'].some((ext) =>
42
+ existsSync(topicPartPath + ext),
43
+ );
88
44
 
89
- //
90
- // Counting elements for statistics
91
- //
45
+ if (!topicPartExists) continue;
92
46
 
93
- if (isIncludedRawElement(rawElement)) {
94
- return;
95
- }
47
+ const topicPartDocument = await ERUDIT.import<{
48
+ default: Document;
49
+ }>(topicPartPath, { try: true });
96
50
 
97
- if (
98
- ERUDIT.config.countElements.flat().includes(rawElement.schemaName)
99
- ) {
100
- elementsCount[rawElement.schemaName] =
101
- (elementsCount[rawElement.schemaName] || 0) + 1;
102
- }
51
+ if (isDocument(topicPartDocument?.default)) {
52
+ const result = await ERUDIT.repository.prose.fromRaw({
53
+ rawProse: topicPartDocument.default.rawProse,
54
+ toc: {
55
+ enabled: true,
56
+ addSchemas:
57
+ topicPart === 'practice'
58
+ ? [problemSchema, problemsSchema]
59
+ : undefined,
103
60
  },
104
- );
61
+ snippets: { enabled: true },
62
+ });
105
63
 
106
- let finalTocItems = resolvedTopicPart.tocItems;
107
-
108
- if (topicPart === 'practice' && practiceProblemsTocItems.length) {
109
- // Map elementId -> TocItem for both headings and problems
110
- const itemMap = new Map<string, ResolvedTocItem>();
111
-
112
- // Collect all existing TOC items (headings) recursively
113
- const collectItems = (items: ResolvedTocItem[]) => {
114
- for (const item of items) {
115
- if (item.elementId) {
116
- itemMap.set(item.elementId, item);
117
- }
118
- if (item.type === 'heading' && item.children?.length) {
119
- collectItems(item.children);
120
- }
121
- }
122
- };
123
- collectItems(resolvedTopicPart.tocItems || []);
124
-
125
- // Add practice problems to the map
126
- practiceProblemsTocItems.forEach((p) => {
127
- if (p.elementId) {
128
- itemMap.set(p.elementId, p);
129
- }
130
- });
131
-
132
- // Rebuild TOC in document order using walkElements
133
- const result: ResolvedTocItem[] = [];
134
- const stack: ResolvedTocItem[] = [];
135
-
136
- await walkElements(resolvedTopicPart.proseElement, (element) => {
137
- if (element.id && itemMap.has(element.id)) {
138
- const item = itemMap.get(element.id)!;
139
-
140
- if (item.type === 'heading') {
141
- // Pop headings at same or deeper level
142
- while (stack.length > 0) {
143
- const last = stack[stack.length - 1]!;
144
- if (last.type === 'heading' && last.level >= item.level) {
145
- stack.pop();
146
- } else {
147
- break;
148
- }
149
- }
150
-
151
- // Create new heading with empty children array
152
- const newItem: ResolvedTocItem = {
153
- ...item,
154
- children: [],
155
- };
156
-
157
- // Add to parent heading or root
158
- if (stack.length > 0) {
159
- const parent = stack[stack.length - 1]!;
160
- if (parent.type === 'heading') {
161
- parent.children.push(newItem);
162
- }
163
- } else {
164
- result.push(newItem);
165
- }
166
-
167
- stack.push(newItem);
168
- } else {
169
- // Non-heading item (problems, etc.)
170
- if (stack.length > 0) {
171
- const parent = stack[stack.length - 1]!;
172
- if (parent.type === 'heading') {
173
- parent.children.push(item);
174
- } else {
175
- result.push(item);
176
- }
177
- } else {
178
- result.push(item);
179
- }
180
- }
181
- }
182
- });
183
-
184
- finalTocItems = result;
185
- }
186
-
187
- if (finalTocItems?.length) {
64
+ if (result.toc?.length) {
188
65
  await ERUDIT.db.insert(ERUDIT.db.schema.contentToc).values({
189
66
  fullId: topicNode.fullId,
190
67
  topicPart,
191
- toc: finalTocItems,
68
+ toc: result.toc,
192
69
  });
193
70
  }
194
71
 
72
+ await ERUDIT.repository.content.updateSchemaCounts(
73
+ topicNode.fullId,
74
+ result.schemaCounts,
75
+ );
76
+
195
77
  await ERUDIT.db
196
78
  .update(ERUDIT.db.schema.topics)
197
79
  .set({
198
- [topicPart]: resolvedTopicPart.proseElement,
80
+ [topicPart]: result.prose,
199
81
  })
200
82
  .where(eq(ERUDIT.db.schema.topics.fullId, topicNode.fullId));
201
83
 
202
- await insertContentResolved(
203
- topicNode.fullId,
204
- topicPart,
205
- resolvedTopicPart,
206
- );
84
+ await insertContentResolved(topicNode.fullId, topicPart, result);
207
85
  }
208
86
  }
209
-
210
- for (const [schemaName, count] of Object.entries(elementsCount)) {
211
- await ERUDIT.repository.content.addElementCount(
212
- topicNode.fullId,
213
- schemaName,
214
- count,
215
- );
216
- }
217
87
  } catch (error) {
218
88
  logContentError(topicNode);
219
- //console.log(error);
220
89
  throw error;
221
90
  }
222
91
  }
@@ -1,42 +1,47 @@
1
1
  import chalk from 'chalk';
2
2
  import { sql } from 'drizzle-orm';
3
- import { type ResolvedRawElement } from '@jsprose/core';
4
3
  import type { ContentProseType } from '@erudit-js/core/content/prose';
5
- import type { ResolvedEruditRawElement } from '@erudit-js/prose';
4
+ import { builtValidPaths } from '../../global/build';
6
5
  import type {
7
6
  ContentLinks,
8
7
  ContentLinkUsage,
9
- } from '@erudit-js/prose/elements/link/step';
8
+ } from '@erudit-js/prose/elements/link/hook';
9
+ import {
10
+ toKeySnippet,
11
+ toSearchSnippet,
12
+ toSeoSnippet,
13
+ type EruditRawToProseResult,
14
+ } from '@erudit-js/prose';
10
15
 
11
16
  export async function insertContentResolved(
12
17
  contentFullId: string,
13
18
  contentProseType: ContentProseType,
14
- resolveResult: ResolvedRawElement & ResolvedEruditRawElement,
19
+ result: EruditRawToProseResult,
15
20
  ) {
16
- for (const file of resolveResult.files) {
21
+ for (const file of result.files) {
17
22
  await ERUDIT.repository.db.pushFile(file, `content-item:${contentFullId}`);
18
23
  }
19
24
 
20
- for (const [uniqueName, unique] of Object.entries(resolveResult.uniques)) {
25
+ for (const [uniqueName, unique] of Object.entries(result.uniques)) {
21
26
  await ERUDIT.db.insert(ERUDIT.db.schema.contentUniques).values({
22
27
  contentFullId,
23
28
  contentProseType,
24
29
  uniqueName,
25
- title: resolveResult.uniqueTitles[uniqueName],
30
+ title: result.uniqueTitles[uniqueName],
26
31
  prose: unique,
27
32
  });
28
33
  }
29
34
 
30
- for (const snippet of resolveResult.snippets) {
35
+ for (const snippet of result.snippets) {
31
36
  await ERUDIT.db.insert(ERUDIT.db.schema.contentSnippets).values({
32
37
  contentFullId,
33
38
  contentProseType,
34
39
  elementId: snippet.elementId,
35
40
  schemaName: snippet.schemaName,
36
- snippetData: snippet.snippetData,
37
- search: !!snippet.snippetData.search,
38
- quick: !!snippet.snippetData.quick,
39
- seo: !!snippet.snippetData.seo,
41
+ snippetData: snippet.snippet,
42
+ search: !!toSearchSnippet(snippet.snippet),
43
+ key: !!toKeySnippet(snippet.snippet),
44
+ seo: !!toSeoSnippet(snippet.snippet),
40
45
  });
41
46
  }
42
47
 
@@ -49,25 +54,26 @@ export async function insertContentResolved(
49
54
  await deduplicateTopicSnippetsSearch(contentFullId);
50
55
  }
51
56
 
52
- for (const problemScript of resolveResult.problemScripts) {
57
+ for (const problemScript of result.problemScripts) {
53
58
  await ERUDIT.repository.db.pushProblemScript(problemScript, contentFullId);
54
59
  }
55
60
 
56
- const targetFullIds = filterTargetFullIds(
57
- contentFullId,
58
- resolveResult.contentLinks,
59
- );
61
+ const targetMap = filterTargetMap(contentFullId, result.contentLinks);
60
62
 
61
- await insertContentDeps(contentFullId, Array.from(targetFullIds));
63
+ await insertContentDeps(contentFullId, targetMap);
62
64
  }
63
65
 
64
- async function insertContentDeps(fromFullId: string, toFullIds: string[]) {
65
- const contentDeps = toFullIds
66
- .filter((toFullId) => toFullId !== fromFullId)
67
- .map((toFullId) => ({
66
+ async function insertContentDeps(
67
+ fromFullId: string,
68
+ targetMap: Map<string, Set<string>>,
69
+ ) {
70
+ const contentDeps = Array.from(targetMap.entries())
71
+ .filter(([toFullId]) => toFullId !== fromFullId)
72
+ .map(([toFullId, uniqueSet]) => ({
68
73
  fromFullId,
69
74
  toFullId,
70
75
  hard: false,
76
+ uniqueNames: uniqueSet.size > 0 ? Array.from(uniqueSet).join(',') : null,
71
77
  }));
72
78
 
73
79
  if (contentDeps.length > 0) {
@@ -78,10 +84,12 @@ async function insertContentDeps(fromFullId: string, toFullIds: string[]) {
78
84
  }
79
85
  }
80
86
 
81
- function filterTargetFullIds(
87
+ // Returns a map from resolved toFullId → set of unique names targeted in that
88
+ // content item. An empty set means the dep targets the whole page (no unique).
89
+ function filterTargetMap(
82
90
  contentFullId: string,
83
91
  contentLinks: ContentLinks,
84
- ) {
92
+ ): Map<string, Set<string>> {
85
93
  const brokenLinkMessage = (message: string, metas: ContentLinkUsage[]) => {
86
94
  let output = `${message} in ${ERUDIT.log.stress(contentFullId)}:\n`;
87
95
  for (const { type, label } of metas) {
@@ -90,7 +98,7 @@ function filterTargetFullIds(
90
98
  return output;
91
99
  };
92
100
 
93
- const targetFullIds = new Set<string>();
101
+ const targetMap = new Map<string, Set<string>>();
94
102
 
95
103
  for (const [storageKey, metas] of contentLinks) {
96
104
  if (storageKey.startsWith('<link:unknown>/')) {
@@ -103,6 +111,14 @@ function filterTargetFullIds(
103
111
  } else if (storageKey.startsWith('<link:global>')) {
104
112
  try {
105
113
  const globalContentId = storageKey.replace('<link:global>/', '');
114
+
115
+ // Extract unique name before stripping it for nav resolution
116
+ const parts = globalContentId.split('/');
117
+ const lastPart = parts.at(-1);
118
+ const uniqueName = lastPart?.startsWith('$')
119
+ ? lastPart.slice(1)
120
+ : undefined;
121
+
106
122
  const targetFullId = globalContentToNavNode(globalContentId).fullId;
107
123
 
108
124
  if (
@@ -110,7 +126,12 @@ function filterTargetFullIds(
110
126
  (meta) => meta.type === 'Dep' || meta.type === 'Dependency',
111
127
  )
112
128
  ) {
113
- targetFullIds.add(targetFullId);
129
+ if (!targetMap.has(targetFullId)) {
130
+ targetMap.set(targetFullId, new Set());
131
+ }
132
+ if (uniqueName) {
133
+ targetMap.get(targetFullId)!.add(uniqueName);
134
+ }
114
135
  }
115
136
  } catch {
116
137
  ERUDIT.log.warn(
@@ -123,21 +144,28 @@ function filterTargetFullIds(
123
144
  }
124
145
  }
125
146
 
126
- return targetFullIds;
147
+ return targetMap;
127
148
  }
128
149
 
129
150
  function globalContentToNavNode(globalContentPath: string) {
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}`);
156
+ }
157
+
130
158
  const parts = globalContentPath.split('/');
131
159
 
132
160
  if (parts.at(-1)?.startsWith('$')) {
133
161
  parts.pop();
134
162
  }
135
163
 
136
- const navNode =
164
+ // If the exact node isn't found the last segment is a topic part — fall back to parent.
165
+ return (
137
166
  ERUDIT.contentNav.getNode(parts.join('/')) ??
138
- ERUDIT.contentNav.getNodeOrThrow(parts.slice(0, -1).join('/'));
139
-
140
- return navNode;
167
+ ERUDIT.contentNav.getNodeOrThrow(parts.slice(0, -1).join('/'))
168
+ );
141
169
  }
142
170
 
143
171
  async function deduplicateTopicSnippetsSearch(contentFullId: string) {
@@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm';
2
2
  import { contentTypes } from '@erudit-js/core/content/type';
3
3
  import { headingSchema } from '@erudit-js/prose/elements/heading/core';
4
4
  import type { TopicPart } from '@erudit-js/core/content/topic';
5
+ import { toSearchSnippet } from '@erudit-js/prose';
5
6
 
6
7
  export async function searchIndexContentTypes(): Promise<SearchEntriesList[]> {
7
8
  const entryLists: SearchEntriesList[] = [];
@@ -119,33 +120,15 @@ export async function searchIndexSnippets(): Promise<SearchEntriesList[]> {
119
120
  });
120
121
  }
121
122
 
122
- const snippetSearch = dbSnippet.snippetData.search;
123
- let searchTitle = dbSnippet.snippetData.title!;
124
- let searchDescription = dbSnippet.snippetData.description;
125
- let searchSynonyms: string[] | undefined = undefined;
126
- if (snippetSearch) {
127
- if (typeof snippetSearch === 'boolean') {
128
- } else if (typeof snippetSearch === 'string') {
129
- snippetSearch.trim() && (searchTitle = snippetSearch.trim());
130
- } else if (Array.isArray(snippetSearch)) {
131
- searchSynonyms = snippetSearch.length > 0 ? snippetSearch : undefined;
132
- } else {
133
- searchTitle = snippetSearch.title || searchTitle;
134
- searchDescription = snippetSearch.description || searchDescription;
135
- searchSynonyms =
136
- snippetSearch.synonyms && snippetSearch.synonyms.length > 0
137
- ? snippetSearch.synonyms
138
- : undefined;
139
- }
140
- }
123
+ const searchSnippet = toSearchSnippet(dbSnippet.snippetData)!;
141
124
 
142
125
  entryLists.get(dbSnippet.schemaName)!.entries.push({
143
126
  category: 'element:' + dbSnippet.schemaName,
144
- title: searchTitle,
145
- description: searchDescription,
127
+ title: searchSnippet.title,
128
+ description: searchSnippet.description,
146
129
  link,
147
130
  location: locationTitle,
148
- synonyms: searchSynonyms,
131
+ synonyms: searchSnippet.synonyms,
149
132
  });
150
133
  }
151
134
 
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { eq, like } from 'drizzle-orm';
3
3
  import { globSync } from 'glob';
4
- import { isRawElement, type AnySchema, type ProseElement } from '@jsprose/core';
4
+ import { isRawElement, type ProseElement } from 'tsprose';
5
5
  import {
6
6
  contributorIdToPropertyName,
7
7
  globalContributorsObject,
@@ -131,19 +131,18 @@ async function buildContributor(contributorId: string) {
131
131
  }
132
132
  }
133
133
 
134
- let description: ProseElement<AnySchema> | undefined;
134
+ let description: ProseElement | undefined;
135
135
 
136
136
  if (isRawElement(def?.description)) {
137
- const resolved = await ERUDIT.repository.prose.resolve(
138
- def.description,
139
- false,
140
- );
137
+ const result = await ERUDIT.repository.prose.fromRaw({
138
+ rawProse: def.description,
139
+ });
141
140
 
142
- for (const file of resolved.files) {
141
+ for (const file of result.files) {
143
142
  await ERUDIT.repository.db.pushFile(file, `contributor:${contributorId}`);
144
143
  }
145
144
 
146
- description = resolved.proseElement;
145
+ description = result.prose;
147
146
  }
148
147
 
149
148
  await ERUDIT.db.insert(ERUDIT.db.schema.contributors).values({
@@ -3,16 +3,23 @@ import { sn } from 'unslash';
3
3
  export async function pushFile(filepath: string, role: string): Promise<void> {
4
4
  filepath = sn(filepath);
5
5
 
6
- if (!filepath.startsWith(ERUDIT.paths.project())) {
6
+ let relativePath: string;
7
+
8
+ if (filepath.startsWith(ERUDIT.paths.project() + '/')) {
9
+ // Absolute path inside the project – convert to relative
10
+ relativePath = filepath.slice(ERUDIT.paths.project().length + 1);
11
+ } else if (/^([A-Za-z]:\/|\/)/.test(filepath)) {
12
+ // Absolute path outside the project – reject
7
13
  throw createError({
8
14
  statusCode: 400,
9
15
  statusMessage: 'File is outside of project directory!',
10
16
  message: `Can not add ${filepath} as it is outside of the project directory!`,
11
17
  });
18
+ } else {
19
+ // Already a project-relative path
20
+ relativePath = filepath;
12
21
  }
13
22
 
14
- const relativePath = filepath.replace(ERUDIT.paths.project() + '/', '');
15
-
16
23
  await ERUDIT.db
17
24
  .insert(ERUDIT.db.schema.files)
18
25
  .values({
@@ -1,19 +1,30 @@
1
+ import { sn } from 'unslash';
2
+
1
3
  export async function pushProblemScript(
2
4
  problemScriptSrc: string,
3
5
  contentFullId: string,
4
6
  ): Promise<void> {
5
- const parsedId = problemScriptSrc;
7
+ problemScriptSrc = sn(problemScriptSrc);
8
+
9
+ let relativePath: string;
6
10
 
7
- if (!problemScriptSrc.startsWith(ERUDIT.paths.project())) {
11
+ if (problemScriptSrc.startsWith(ERUDIT.paths.project() + '/')) {
12
+ // Absolute path inside the project – convert to relative
13
+ relativePath = problemScriptSrc.slice(ERUDIT.paths.project().length + 1);
14
+ } else if (/^([A-Za-z]:\/|\/)/.test(problemScriptSrc)) {
15
+ // Absolute path outside the project – reject
8
16
  throw createError({
9
17
  statusCode: 400,
10
18
  statusMessage: 'Problem script is outside of project directory!',
11
19
  message: `Can not add problem script from ${problemScriptSrc} as it is outside of the project directory!`,
12
20
  });
21
+ } else {
22
+ // Already a project-relative path
23
+ relativePath = problemScriptSrc;
13
24
  }
14
25
 
15
26
  await ERUDIT.db.insert(ERUDIT.db.schema.problemScripts).values({
16
- problemScriptSrc,
27
+ problemScriptSrc: relativePath,
17
28
  contentFullId,
18
29
  });
19
30
  }
@@ -12,6 +12,9 @@ export const contentDeps = sqliteTable(
12
12
  toFullId: text().notNull(),
13
13
  hard: integer({ mode: 'boolean' }).notNull(),
14
14
  reason: text(),
15
+ // Comma-separated unique names that were specifically targeted via <Dep>.
16
+ // Only set for auto deps; null means the dep targets the whole page.
17
+ uniqueNames: text(),
15
18
  },
16
19
  (table) => [
17
20
  primaryKey({