erudit 4.0.0-dev.1 → 4.0.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/app/app.vue +1 -2
  2. package/app/components/FancyBold.vue +0 -1
  3. package/app/components/FancyCard.vue +1 -2
  4. package/app/components/ads/AdsBannerAside.vue +1 -2
  5. package/app/components/ads/AdsReplacer.vue +2 -2
  6. package/app/components/aside/AsideListItem.vue +1 -1
  7. package/app/components/aside/AsideSwitch.vue +18 -8
  8. package/app/components/aside/major/PaneSwitcher.vue +1 -1
  9. package/app/components/aside/minor/AsideMinorPlainHeader.vue +2 -3
  10. package/app/components/aside/minor/content/AsideMinorContentTopic.vue +1 -4
  11. package/app/components/aside/minor/content/ButtonPaneContributions.vue +2 -4
  12. package/app/components/aside/minor/content/ButtonPaneImprove.vue +2 -3
  13. package/app/components/aside/minor/content/Contribution.vue +1 -1
  14. package/app/components/aside/minor/content/TocItem.vue +35 -21
  15. package/app/components/aside/minor/news/AsideMinorNews.vue +1 -2
  16. package/app/components/aside/minor/news/NewsItem.vue +2 -2
  17. package/app/components/aside/minor/news/elements/Ref.vue +1 -1
  18. package/app/components/main/MainContentChild.vue +2 -3
  19. package/app/components/main/MainDescription.vue +1 -1
  20. package/app/components/main/MainQuickLink.vue +20 -5
  21. package/app/components/main/MainQuickLinks.vue +1 -3
  22. package/app/components/main/MainQuote.vue +3 -6
  23. package/app/components/main/MainSection.vue +6 -21
  24. package/app/components/main/MainTitle.vue +1 -2
  25. package/app/components/main/MainTopicPartSwitch.vue +1 -1
  26. package/app/components/main/connections/Deps.vue +1 -1
  27. package/app/components/main/connections/Externals.vue +92 -34
  28. package/app/components/main/connections/MainConnections.vue +61 -8
  29. package/app/components/main/connections/MainConnectionsButton.vue +3 -2
  30. package/app/components/main/connections/ScrollPane.vue +2 -3
  31. package/app/components/main/contentStats/Item.vue +1 -2
  32. package/app/components/main/contentStats/MainContentStats.vue +6 -3
  33. package/app/components/preview/Preview.vue +1 -2
  34. package/app/components/preview/PreviewScreen.vue +1 -2
  35. package/app/components/site/SiteAside.vue +2 -4
  36. package/app/components/site/SiteMain.vue +1 -4
  37. package/app/components/tree/TreeItem.vue +1 -1
  38. package/app/composables/og.ts +19 -2
  39. package/app/pages/contributor/[contributorId].vue +1 -2
  40. package/app/pages/index.vue +1 -4
  41. package/app/styles/main.css +0 -1
  42. package/package.json +4 -4
  43. package/server/erudit/cameos/build.ts +77 -27
  44. package/server/erudit/content/global/build.ts +27 -1
  45. package/server/erudit/content/nav/build.ts +36 -4
  46. package/server/erudit/content/repository/elementSnippets.ts +51 -11
  47. package/server/erudit/content/repository/externals.ts +38 -9
  48. package/server/erudit/content/resolve/index.ts +172 -21
  49. package/server/erudit/content/resolve/topic.ts +93 -32
  50. package/server/erudit/content/resolve/utils/insertContentResolved.ts +48 -9
  51. package/server/erudit/content/search.ts +31 -3
  52. package/server/erudit/contributors/build.ts +106 -51
  53. package/server/erudit/db/repository/pushFile.ts +7 -4
  54. package/server/erudit/db/schema/content.ts +2 -2
  55. package/server/erudit/db/schema/contentSnippets.ts +3 -4
  56. package/server/erudit/language/list/en.ts +2 -0
  57. package/server/erudit/language/list/ru.ts +2 -0
  58. package/server/erudit/news/build.ts +85 -48
  59. package/server/erudit/sponsors/build.ts +77 -26
  60. package/shared/types/contentConnections.ts +2 -2
  61. package/shared/types/elementSnippet.ts +9 -3
  62. package/shared/types/language.ts +2 -0
@@ -1,19 +1,48 @@
1
- import type { ContentExternal } from '@erudit-js/core/content/externals';
2
- import { eq } from 'drizzle-orm';
1
+ import { eq, like } from 'drizzle-orm';
2
+ import type { ContentExternals } from '@erudit-js/core/content/externals';
3
3
 
4
4
  export async function getContentExternals(
5
5
  fullId: string,
6
- ): Promise<ContentExternal[]> {
7
- const externals: ContentExternal[] = [];
6
+ ): Promise<ContentExternals> {
7
+ const externals: ContentExternals = [];
8
8
 
9
- const dbContentItems = await ERUDIT.db.query.content.findMany({
10
- columns: { fullId: true, type: true, externals: true },
9
+ const dbOwnContentItem = await ERUDIT.db.query.content.findFirst({
10
+ columns: { externals: true },
11
11
  where: eq(ERUDIT.db.schema.content.fullId, fullId),
12
12
  });
13
13
 
14
- for (const dbContentItem of dbContentItems) {
15
- if (dbContentItem?.externals) {
16
- externals.push(...dbContentItem.externals);
14
+ if (dbOwnContentItem?.externals) {
15
+ externals.push({
16
+ type: 'own',
17
+ items: dbOwnContentItem.externals,
18
+ });
19
+ }
20
+ const parts = fullId.split('/');
21
+ const dbParentContentItems = [];
22
+
23
+ for (let i = parts.length - 1; i > 0; i--) {
24
+ const parentId = parts.slice(0, i).join('/');
25
+ const dbParentContentItem = await ERUDIT.db.query.content.findFirst({
26
+ columns: {
27
+ fullId: true,
28
+ externals: true,
29
+ title: true,
30
+ },
31
+ where: eq(ERUDIT.db.schema.content.fullId, parentId),
32
+ });
33
+
34
+ if (dbParentContentItem) {
35
+ dbParentContentItems.push(dbParentContentItem);
36
+ }
37
+ }
38
+
39
+ for (const dbParentContentItem of dbParentContentItems) {
40
+ if (dbParentContentItem?.externals) {
41
+ externals.push({
42
+ type: 'parent',
43
+ title: dbParentContentItem.title,
44
+ items: dbParentContentItem.externals,
45
+ });
17
46
  }
18
47
  }
19
48
 
@@ -1,3 +1,4 @@
1
+ import chalk from 'chalk';
1
2
  import { inArray, or } from 'drizzle-orm';
2
3
  import { contentPathToId } from '@erudit-js/core/content/path';
3
4
 
@@ -8,35 +9,38 @@ import { resolveTopic } from './topic';
8
9
 
9
10
  let initialResolve = true;
10
11
 
12
+ const contentRoot = () => `${ERUDIT.config.paths.project}/content`;
13
+
11
14
  export async function resolveContent() {
12
15
  ERUDIT.log.debug.start('Resolving content...');
13
16
 
14
- let toResolveContentIds = new Set<string>();
15
- if (initialResolve) {
16
- initialResolve = false;
17
- toResolveContentIds = new Set(ERUDIT.contentNav.id2Node.keys());
18
- } else {
19
- for (const changedFile of ERUDIT.changedFiles.values()) {
20
- const contentId = contentPathToId(
21
- changedFile,
22
- ERUDIT.config.paths.project,
23
- 'full',
24
- );
25
-
26
- const navNode = ERUDIT.contentNav.getNode(contentId || '');
27
-
28
- if (navNode) {
29
- await ERUDIT.contentNav.walk((node) => {
30
- toResolveContentIds.add(node.fullId);
31
- }, navNode);
32
- }
33
- }
17
+ const isInitial = initialResolve;
18
+ initialResolve = false;
19
+
20
+ const toResolveContentIds = collectContentIdsToResolve(isInitial);
21
+
22
+ if (!toResolveContentIds.size) {
23
+ ERUDIT.log.info(
24
+ isInitial
25
+ ? 'Skipping content — no content found.'
26
+ : 'Skipping content — nothing changed.',
27
+ );
28
+ return;
29
+ }
30
+
31
+ if (!isInitial) {
32
+ ERUDIT.log.info(renderChangedContentTree(toResolveContentIds));
34
33
  }
35
34
 
36
35
  await clearOldContentData(Array.from(toResolveContentIds));
37
36
 
38
37
  for (const contentId of toResolveContentIds) {
39
- const navNode = ERUDIT.contentNav.getNodeOrThrow(contentId);
38
+ const navNode = ERUDIT.contentNav.getNode(contentId);
39
+
40
+ if (!navNode) {
41
+ continue;
42
+ }
43
+
40
44
  switch (navNode.type) {
41
45
  case 'book':
42
46
  await resolveBook(navNode);
@@ -52,6 +56,153 @@ export async function resolveContent() {
52
56
  break;
53
57
  }
54
58
  }
59
+
60
+ ERUDIT.log.success(
61
+ isInitial
62
+ ? `Content resolved! (${ERUDIT.log.stress(toResolveContentIds.size)})`
63
+ : `Content updated! (${ERUDIT.log.stress(
64
+ toResolveContentIds.size,
65
+ )})`,
66
+ );
67
+ }
68
+
69
+ function collectContentIdsToResolve(isInitial: boolean): Set<string> {
70
+ if (isInitial) {
71
+ return new Set(ERUDIT.contentNav.id2Node.keys());
72
+ }
73
+
74
+ if (!hasContentChanges()) {
75
+ return new Set();
76
+ }
77
+
78
+ const ids = new Set<string>();
79
+ const changedFiles = ERUDIT.changedFiles || new Set<string>();
80
+
81
+ for (const changedFile of changedFiles.values()) {
82
+ if (!changedFile.startsWith(`${contentRoot()}/`)) continue;
83
+
84
+ const contentId =
85
+ contentPathToId(changedFile, ERUDIT.config.paths.project, 'full') ||
86
+ deriveContentIdFromPath(changedFile);
87
+
88
+ if (!contentId) continue;
89
+
90
+ const navNode = ERUDIT.contentNav.getNode(contentId);
91
+
92
+ if (navNode) {
93
+ ERUDIT.contentNav.walkSync((node) => {
94
+ ids.add(node.fullId);
95
+ }, navNode);
96
+
97
+ ERUDIT.contentNav.walkUpSync((node) => {
98
+ ids.add(node.fullId);
99
+ }, navNode);
100
+ continue;
101
+ }
102
+
103
+ addAncestorIds(ids, contentId);
104
+ }
105
+
106
+ return ids;
107
+ }
108
+
109
+ function hasContentChanges() {
110
+ for (const file of (ERUDIT.changedFiles || new Set<string>()).values()) {
111
+ if (file.startsWith(`${contentRoot()}/`)) {
112
+ return true;
113
+ }
114
+ }
115
+
116
+ return false;
117
+ }
118
+
119
+ function addAncestorIds(target: Set<string>, contentId: string) {
120
+ const parts = contentId.split('/').filter(Boolean);
121
+
122
+ for (let i = 1; i <= parts.length; i++) {
123
+ target.add(parts.slice(0, i).join('/'));
124
+ }
125
+ }
126
+
127
+ function deriveContentIdFromPath(path: string): string | undefined {
128
+ if (!path.startsWith(`${contentRoot()}/`)) return;
129
+
130
+ const rel = path.slice(contentRoot().length + 1);
131
+ const segments = rel.split('/');
132
+
133
+ // Drop filename if present
134
+ if (segments.length && segments[segments.length - 1].includes('.')) {
135
+ segments.pop();
136
+ }
137
+
138
+ if (!segments.length) return;
139
+
140
+ const idParts: string[] = [];
141
+
142
+ for (const seg of segments) {
143
+ const match = seg.match(/^(\d+)[+-](.+)$/);
144
+ if (!match) return;
145
+ const [, , idPart] = match;
146
+ if (!idPart) return;
147
+ idParts.push(idPart);
148
+ }
149
+
150
+ return idParts.join('/');
151
+ }
152
+
153
+ function renderChangedContentTree(ids: Set<string>): string {
154
+ const sorted = Array.from(ids).sort((a, b) => {
155
+ const al = a.split('/').length;
156
+ const bl = b.split('/').length;
157
+ if (al !== bl) return al - bl;
158
+ return a.localeCompare(b);
159
+ });
160
+
161
+ type Node = { name: string; children: Map<string, Node> };
162
+ const root: Node = { name: '', children: new Map() };
163
+
164
+ const ensureNode = (parent: Node, part: string): Node => {
165
+ let next = parent.children.get(part);
166
+ if (!next) {
167
+ next = { name: part, children: new Map() };
168
+ parent.children.set(part, next);
169
+ }
170
+ return next;
171
+ };
172
+
173
+ for (const id of sorted) {
174
+ let cursor = root;
175
+ for (const part of id.split('/')) {
176
+ cursor = ensureNode(cursor, part);
177
+ }
178
+ }
179
+
180
+ const lines: string[] = [];
181
+
182
+ const walkTree = (node: Node, depth: number) => {
183
+ if (node.name) {
184
+ const indent = ' '.repeat(Math.max(0, depth - 1));
185
+ lines.push(`${indent}- ${chalk.cyan(node.name)}`);
186
+ }
187
+
188
+ const children = Array.from(node.children.values()).sort((a, b) =>
189
+ a.name.localeCompare(b.name),
190
+ );
191
+
192
+ children.forEach((child) => {
193
+ walkTree(child, depth + 1);
194
+ });
195
+ };
196
+
197
+ const roots = Array.from(root.children.values()).sort((a, b) =>
198
+ a.name.localeCompare(b.name),
199
+ );
200
+ roots.forEach((child) => {
201
+ walkTree(child, 1);
202
+ });
203
+
204
+ const header = chalk.gray('Changed content:');
205
+ return [header, ...lines].join('\n');
55
206
  }
56
207
 
57
208
  export async function clearOldContentData(contentIds: string[]) {
@@ -1,13 +1,10 @@
1
1
  import { eq } from 'drizzle-orm';
2
2
  import {
3
3
  isDocument,
4
- isProseElement,
5
4
  isRawElement,
6
5
  walkElements,
7
6
  type AnyDocument,
8
7
  type AnySchema,
9
- type ProseElement,
10
- type RawElement,
11
8
  } from '@jsprose/core';
12
9
  import {
13
10
  topicParts,
@@ -118,41 +115,105 @@ export async function resolveTopic(topicNode: ContentNavNode) {
118
115
  },
119
116
  );
120
117
 
118
+ let finalTocItems = resolvedTopicPart.tocItems;
119
+
121
120
  if (
122
- resolvedTopicPart.tocItems?.length ||
123
- (topicPart === 'practice' &&
124
- practiceProblemsTocItems.length)
121
+ topicPart === 'practice' &&
122
+ practiceProblemsTocItems.length
125
123
  ) {
126
- let tocItems = resolvedTopicPart.tocItems || [];
127
-
128
- if (topicPart === 'practice') {
129
- // Combine regular toc items with practice problems
130
- const combined = [
131
- ...tocItems,
132
- ...practiceProblemsTocItems,
133
- ];
134
-
135
- // Deduplicate based on elementId
136
- const seen = new Set<string>();
137
- tocItems = combined.filter((item) => {
138
- if (item.type === 'element' && item.elementId) {
139
- if (seen.has(item.elementId)) {
140
- return false;
141
- }
142
- seen.add(item.elementId);
143
- return true;
124
+ // Map elementId -> TocItem for both headings and problems
125
+ const itemMap = new Map<string, ResolvedTocItem>();
126
+
127
+ // Collect all existing TOC items (headings) recursively
128
+ const collectItems = (items: ResolvedTocItem[]) => {
129
+ for (const item of items) {
130
+ if (item.elementId) {
131
+ itemMap.set(item.elementId, item);
132
+ }
133
+ if (
134
+ item.type === 'heading' &&
135
+ item.children?.length
136
+ ) {
137
+ collectItems(item.children);
144
138
  }
145
- return true;
146
- });
147
- }
148
-
149
- await ERUDIT.db.insert(ERUDIT.db.schema.contentToc).values({
150
- fullId: topicNode.fullId,
151
- topicPart,
152
- toc: tocItems,
139
+ }
140
+ };
141
+ collectItems(resolvedTopicPart.tocItems || []);
142
+
143
+ // Add practice problems to the map
144
+ practiceProblemsTocItems.forEach((p) => {
145
+ if (p.elementId) {
146
+ itemMap.set(p.elementId, p);
147
+ }
153
148
  });
149
+
150
+ // Rebuild TOC in document order using walkElements
151
+ const result: ResolvedTocItem[] = [];
152
+ const stack: ResolvedTocItem[] = [];
153
+
154
+ await walkElements(
155
+ resolvedTopicPart.proseElement,
156
+ (element) => {
157
+ if (element.id && itemMap.has(element.id)) {
158
+ const item = itemMap.get(element.id)!;
159
+
160
+ if (item.type === 'heading') {
161
+ // Pop headings at same or deeper level
162
+ while (stack.length > 0) {
163
+ const last = stack[stack.length - 1];
164
+ if (
165
+ last.type === 'heading' &&
166
+ last.level >= item.level
167
+ ) {
168
+ stack.pop();
169
+ } else {
170
+ break;
171
+ }
172
+ }
173
+
174
+ // Create new heading with empty children array
175
+ const newItem: ResolvedTocItem = {
176
+ ...item,
177
+ children: [],
178
+ };
179
+
180
+ // Add to parent heading or root
181
+ if (stack.length > 0) {
182
+ const parent = stack[stack.length - 1];
183
+ if (parent.type === 'heading') {
184
+ parent.children.push(newItem);
185
+ }
186
+ } else {
187
+ result.push(newItem);
188
+ }
189
+
190
+ stack.push(newItem);
191
+ } else {
192
+ // Non-heading item (problems, etc.)
193
+ if (stack.length > 0) {
194
+ const parent = stack[stack.length - 1];
195
+ if (parent.type === 'heading') {
196
+ parent.children.push(item);
197
+ } else {
198
+ result.push(item);
199
+ }
200
+ } else {
201
+ result.push(item);
202
+ }
203
+ }
204
+ }
205
+ },
206
+ );
207
+
208
+ finalTocItems = result;
154
209
  }
155
210
 
211
+ await ERUDIT.db.insert(ERUDIT.db.schema.contentToc).values({
212
+ fullId: topicNode.fullId,
213
+ topicPart,
214
+ toc: finalTocItems,
215
+ });
216
+
156
217
  await ERUDIT.db
157
218
  .update(ERUDIT.db.schema.topics)
158
219
  .set({
@@ -1,6 +1,7 @@
1
1
  import { type ResolvedRawElement } from '@jsprose/core';
2
2
  import type { ContentProseType } from '@erudit-js/core/content/prose';
3
3
  import type { ResolvedEruditRawElement } from '@erudit-js/prose';
4
+ import { sql } from 'drizzle-orm';
4
5
 
5
6
  export async function insertContentResolved(
6
7
  contentFullId: string,
@@ -30,18 +31,22 @@ export async function insertContentResolved(
30
31
  contentProseType,
31
32
  elementId: snippet.elementId,
32
33
  schemaName: snippet.schemaName,
33
- title: snippet.title!,
34
- description: snippet.description,
35
- search: Boolean(snippet.search),
36
- searchSynonyms:
37
- typeof snippet.search === 'object'
38
- ? snippet.search.synonyms
39
- : undefined,
40
- quick: Boolean(snippet.quick),
41
- seo: Boolean(snippet.seo),
34
+ snippetData: snippet.snippetData,
35
+ search: !!snippet.snippetData.search,
36
+ quick: !!snippet.snippetData.quick,
37
+ seo: !!snippet.snippetData.seo,
42
38
  });
43
39
  }
44
40
 
41
+ // Deduplicate search flags for topic snippets
42
+ if (
43
+ contentProseType === 'article' ||
44
+ contentProseType === 'summary' ||
45
+ contentProseType === 'practice'
46
+ ) {
47
+ await deduplicateTopicSnippetsSearch(contentFullId);
48
+ }
49
+
45
50
  for (const problemScript of resolveResult.problemScripts) {
46
51
  await ERUDIT.repository.db.pushProblemScript(
47
52
  problemScript,
@@ -91,3 +96,37 @@ function globalContentToNavNode(globalContentPath: string) {
91
96
 
92
97
  return navNode;
93
98
  }
99
+
100
+ async function deduplicateTopicSnippetsSearch(contentFullId: string) {
101
+ // Disable search flag for duplicate snippets,
102
+ // keeping the highest-priority one per (title, schemaName)
103
+ await ERUDIT.db.run(sql`
104
+ UPDATE contentSnippets
105
+ SET search = 0
106
+ WHERE snippetId IN (
107
+ SELECT snippetId
108
+ FROM (
109
+ SELECT
110
+ snippetId,
111
+ ROW_NUMBER() OVER (
112
+ PARTITION BY
113
+ LOWER(TRIM(json_extract(snippetData, '$.title'))),
114
+ schemaName
115
+ ORDER BY
116
+ CASE contentProseType
117
+ WHEN 'article' THEN 0
118
+ WHEN 'summary' THEN 1
119
+ WHEN 'practice' THEN 2
120
+ ELSE 99
121
+ END
122
+ ) AS rn
123
+ FROM contentSnippets
124
+ WHERE
125
+ contentFullId = ${contentFullId}
126
+ AND json_extract(snippetData, '$.title') IS NOT NULL
127
+ AND search = 1
128
+ )
129
+ WHERE rn > 1
130
+ );
131
+ `);
132
+ }
@@ -121,13 +121,41 @@ export async function searchIndexSnippets(): Promise<SearchEntriesList[]> {
121
121
  });
122
122
  }
123
123
 
124
+ let searchTitle = dbSnippet.snippetData.title!;
125
+ let searchDescription = dbSnippet.snippetData.description;
126
+ if (
127
+ typeof dbSnippet.snippetData.search === 'object' &&
128
+ !Array.isArray(dbSnippet.snippetData.search)
129
+ ) {
130
+ if (dbSnippet.snippetData.search.title) {
131
+ searchTitle = dbSnippet.snippetData.search.title;
132
+ }
133
+
134
+ if (dbSnippet.snippetData.search.description) {
135
+ searchDescription = dbSnippet.snippetData.search.description;
136
+ }
137
+ }
138
+
139
+ let searchSynonyms: string[] | undefined = undefined;
140
+ if (Array.isArray(dbSnippet.snippetData.search)) {
141
+ searchSynonyms = dbSnippet.snippetData.search;
142
+ } else {
143
+ if (typeof dbSnippet.snippetData.search === 'object') {
144
+ searchSynonyms = dbSnippet.snippetData.search.synonyms;
145
+ }
146
+ }
147
+
124
148
  entryLists.get(dbSnippet.schemaName)!.entries.push({
125
149
  category: 'element:' + dbSnippet.schemaName,
126
- title: dbSnippet.title,
127
- description: dbSnippet.description || undefined,
150
+ title: searchTitle,
151
+ description: searchDescription,
128
152
  link,
129
153
  location: locationTitle,
130
- synonyms: dbSnippet.searchSynonyms || undefined,
154
+ synonyms: searchSynonyms
155
+ ? searchSynonyms.length > 0
156
+ ? searchSynonyms
157
+ : undefined
158
+ : undefined,
131
159
  });
132
160
  }
133
161