erudit 4.3.0 → 4.3.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.
@@ -1,100 +1,9 @@
1
- import type { FormatTextState, FormatText } from '@erudit-js/core/formatText';
2
-
3
- type LanguageFormatText = (text: string) => string;
4
-
5
- const formatTextLoaders: Partial<
6
- Record<LanguageCode, () => Promise<{ default: LanguageFormatText }>>
7
- > = {
8
- ru: () => import('../formatters/ru'),
9
- };
10
-
11
- export let formatText: FormatText;
12
-
13
- export async function initFormatText() {
14
- const languageCode = ERUDIT.config.language.current;
15
-
16
- const formatTextLoader =
17
- languageCode in formatTextLoaders
18
- ? formatTextLoaders[languageCode]
19
- : undefined;
20
-
21
- let languageFormatText: LanguageFormatText = (text) => text;
22
- if (formatTextLoader) {
23
- languageFormatText = (await formatTextLoader()).default;
24
- }
25
-
26
- function _formatText(text: string, state?: FormatTextState): string;
27
- function _formatText(text: undefined, state?: FormatTextState): undefined;
28
- function _formatText(
29
- text?: string,
30
- state?: FormatTextState,
31
- ): string | undefined;
32
- function _formatText(
33
- text?: string,
34
- state?: FormatTextState,
35
- ): string | undefined {
36
- if (text === undefined) {
37
- return text;
38
- }
39
-
40
- //
41
- // Normalize spacing (new lines, spaces)
42
- //
43
-
44
- {
45
- text = text
46
- .trim()
47
- .replace(/\r\n/gm, '\n')
48
- .replace(/\n{3,}/gm, '\n\n')
49
- .replace(/[ \t]+/gm, ' ');
50
- }
51
-
52
- //
53
- // Normalize dashes
54
- //
55
-
56
- {
57
- text = text.replace(/(^| )--($| )/gm, '$1—$2');
58
- }
59
-
60
- //
61
- // Normalize quotes
62
- //
63
-
64
- {
65
- const quoteSymbols: [string, string] = (() => {
66
- switch (languageCode) {
67
- case 'ru':
68
- return ['«', '»'];
69
- default:
70
- return ['“', '”'];
71
- }
72
- })();
73
-
74
- let quoteOpen = state?.quote === 'opened';
75
- text = text.replaceAll(/"/gm, () => {
76
- quoteOpen = !quoteOpen;
77
- if (state) {
78
- state.quote = quoteOpen ? 'opened' : 'closed';
79
- }
80
- return quoteOpen ? quoteSymbols[0] : quoteSymbols[1];
81
- });
82
- }
83
-
84
- //
85
- // Normalize ellipsis
86
- //
87
-
88
- {
89
- text = text.replace(/\.{3}/gm, '…');
90
- }
91
-
92
- //
93
- // Language-specific formatting
94
- //
95
-
96
- return languageFormatText(text);
97
- }
98
-
99
- formatText = _formatText;
100
- }
1
+ import type { FormatText } from '@erudit-js/core/formatText';
2
+ import { createFormatTextFn } from '../../shared/utils/formatText';
3
+
4
+ export let formatText: FormatText;
5
+
6
+ export async function initFormatText() {
7
+ const languageCode = ERUDIT.config.language.current;
8
+ formatText = createFormatTextFn(languageCode);
9
+ }
@@ -15,22 +15,18 @@ export function initOgSiteName() {
15
15
  }
16
16
 
17
17
  export function initOgImage() {
18
- const withSiteUrl = useSiteUrl();
19
-
20
- const fallbackOgImage = {
21
- src: eruditPublic('og.png'),
22
- width: 500,
23
- height: 500,
24
- };
25
-
26
- const ogImage = ERUDIT.config.seo?.image || fallbackOgImage;
27
- useSeoMeta({
28
- ogImage: {
29
- url: withSiteUrl(ogImage.src),
30
- width: ogImage.width,
31
- height: ogImage.height,
32
- },
33
- });
18
+ const ogImageConfig = ERUDIT.config.seo?.ogImage;
19
+
20
+ if (ogImageConfig?.type === 'manual') {
21
+ const withSiteUrl = useSiteUrl();
22
+ useSeoMeta({
23
+ ogImage: {
24
+ url: withSiteUrl(ogImageConfig.src),
25
+ width: ogImageConfig.width,
26
+ height: ogImageConfig.height,
27
+ },
28
+ });
29
+ }
34
30
  }
35
31
 
36
32
  export function useIndexSeo(indexPage: IndexPage) {
@@ -39,12 +35,25 @@ export function useIndexSeo(indexPage: IndexPage) {
39
35
  description: indexPage.seo?.description || indexPage.description,
40
36
  urlPath: '/',
41
37
  });
38
+
39
+ const ogImageConfig = ERUDIT.config.seo?.ogImage;
40
+ if (ogImageConfig?.type === 'auto') {
41
+ const withSiteUrl = useSiteUrl();
42
+ useSeoMeta({
43
+ ogImage: {
44
+ url: withSiteUrl('/og/site/index.png'),
45
+ width: 1200,
46
+ height: 630,
47
+ },
48
+ });
49
+ }
42
50
  }
43
51
 
44
52
  export function useStandartSeo(args: {
45
53
  title: string;
46
54
  description?: string;
47
55
  urlPath: string;
56
+ ogImagePath?: string;
48
57
  }) {
49
58
  const seoSiteTitle =
50
59
  ERUDIT.config.seo?.siteTitle ||
@@ -58,6 +67,20 @@ export function useStandartSeo(args: {
58
67
  description: args.description,
59
68
  urlPath: args.urlPath,
60
69
  });
70
+
71
+ if (args.ogImagePath) {
72
+ const ogImageConfig = ERUDIT.config.seo?.ogImage;
73
+ if (ogImageConfig?.type === 'auto') {
74
+ const withSiteUrl = useSiteUrl();
75
+ useSeoMeta({
76
+ ogImage: {
77
+ url: withSiteUrl(args.ogImagePath),
78
+ width: 1200,
79
+ height: 630,
80
+ },
81
+ });
82
+ }
83
+ }
61
84
  }
62
85
 
63
86
  export async function useContentSeo(args: {
@@ -100,6 +123,25 @@ export async function useContentSeo(args: {
100
123
 
101
124
  setupSeo(baseSeo);
102
125
 
126
+ // Auto-generated OG image for content
127
+ // Manual OG image is handled globally by initOgImage() in app.vue
128
+ const ogImageConfig = ERUDIT.config.seo?.ogImage;
129
+ if (ogImageConfig?.type === 'auto') {
130
+ const withSiteUrl = useSiteUrl();
131
+ const ogTypePart =
132
+ args.contentTypePath.type === 'topic'
133
+ ? args.contentTypePath.topicPart
134
+ : args.contentTypePath.type;
135
+ const ogPath = `/og/content/${ogTypePart}/${args.contentTypePath.contentId}.png`;
136
+ useSeoMeta({
137
+ ogImage: {
138
+ url: withSiteUrl(ogPath),
139
+ width: 1200,
140
+ height: 630,
141
+ },
142
+ });
143
+ }
144
+
103
145
  //
104
146
  // SEO snippets
105
147
  //
@@ -45,6 +45,7 @@ useStandartSeo({
45
45
  pageContributor.displayName || pageContributor.id,
46
46
  ),
47
47
  urlPath: PAGES.contributor(contributorId.value),
48
+ ogImagePath: `/og/site/contributor/${contributorId.value}.png`,
48
49
  });
49
50
  </script>
50
51
 
@@ -33,6 +33,7 @@ useStandartSeo({
33
33
  title: phrase.contributors,
34
34
  description: phrase.contributors_description,
35
35
  urlPath: PAGES.contributors,
36
+ ogImagePath: '/og/site/contributors.png',
36
37
  });
37
38
  </script>
38
39
 
@@ -24,6 +24,7 @@ useStandartSeo({
24
24
  title: phrase.sponsors,
25
25
  description: phrase.sponsors_description,
26
26
  urlPath: PAGES.sponsors,
27
+ ogImagePath: '/og/site/sponsors.png',
27
28
  });
28
29
  </script>
29
30
 
@@ -25,6 +25,7 @@ export default defineNuxtPlugin({
25
25
  '/api/prerender/content',
26
26
  '/api/prerender/quotes',
27
27
  '/api/prerender/news',
28
+ '/api/prerender/ogImages',
28
29
  ];
29
30
 
30
31
  for (const provider of routeProviders) {
package/nuxt.config.ts CHANGED
@@ -53,7 +53,7 @@ export default defineNuxtConfig({
53
53
  rollupConfig: {
54
54
  // Prevent inlining some packages
55
55
  external(source) {
56
- const ignore = ['jiti', 'tsprose'];
56
+ const ignore = ['jiti', 'tsprose', '@resvg/resvg-js'];
57
57
 
58
58
  for (const ignoreItem of ignore) {
59
59
  if (source.includes(ignoreItem)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "erudit",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "type": "module",
5
5
  "description": "🤓 CMS for perfect educational sites.",
6
6
  "license": "MIT",
@@ -24,24 +24,26 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@erudit-js/cli": "4.3.0",
28
- "@erudit-js/core": "4.3.0",
29
- "@erudit-js/prose": "4.3.0",
27
+ "@erudit-js/cli": "4.3.1",
28
+ "@erudit-js/core": "4.3.1",
29
+ "@erudit-js/prose": "4.3.1",
30
30
  "unslash": "^2.0.0",
31
31
  "@floating-ui/vue": "^1.1.11",
32
32
  "tsprose": "^1.0.1",
33
33
  "@tailwindcss/vite": "^4.2.1",
34
- "better-sqlite3": "^12.6.2",
34
+ "better-sqlite3": "^12.8.0",
35
35
  "chokidar": "^5.0.0",
36
36
  "consola": "^3.4.2",
37
37
  "drizzle-kit": "^0.31.9",
38
38
  "drizzle-orm": "^0.45.1",
39
- "esbuild": "^0.27.3",
39
+ "esbuild": "^0.27.4",
40
40
  "flexsearch": "^0.8.212",
41
41
  "glob": "^13.0.6",
42
42
  "image-size": "^2.0.2",
43
+ "@resvg/resvg-js": "^2.6.2",
44
+ "satori": "^0.25.0",
43
45
  "jiti": "^2.6.1",
44
- "nuxt": "4.3.1",
46
+ "nuxt": "4.4.2",
45
47
  "nuxt-my-icons": "1.2.2",
46
48
  "perfect-debounce": "^2.1.0",
47
49
  "tailwindcss": "^4.2.1",
@@ -0,0 +1,46 @@
1
+ export default defineEventHandler(async () => {
2
+ const ogImageConfig = ERUDIT.config.public.seo?.ogImage;
3
+ if (ogImageConfig?.type !== 'auto') {
4
+ return [];
5
+ }
6
+
7
+ const routes: string[] = [];
8
+
9
+ // Index page
10
+ routes.push('/og/site/index.png');
11
+
12
+ // Contributors page
13
+ if (ERUDIT.config.public.contributors?.enabled) {
14
+ routes.push('/og/site/contributors.png');
15
+
16
+ const dbContributors = await ERUDIT.db.query.contributors.findMany({
17
+ columns: { contributorId: true },
18
+ });
19
+
20
+ for (const dbContributor of dbContributors) {
21
+ routes.push(`/og/site/contributor/${dbContributor.contributorId}.png`);
22
+ }
23
+ }
24
+
25
+ // Sponsors page
26
+ if (ERUDIT.config.public.sponsors?.enabled) {
27
+ routes.push('/og/site/sponsors.png');
28
+ }
29
+
30
+ // Content pages
31
+ for (const navNode of ERUDIT.contentNav.id2Node.values()) {
32
+ if (navNode.type === 'topic') {
33
+ const topicParts = await ERUDIT.repository.content.topicParts(
34
+ navNode.fullId,
35
+ );
36
+
37
+ for (const part of topicParts) {
38
+ routes.push(`/og/content/${part}/${navNode.shortId}.png`);
39
+ }
40
+ } else {
41
+ routes.push(`/og/content/${navNode.type}/${navNode.shortId}.png`);
42
+ }
43
+ }
44
+
45
+ return routes;
46
+ });
@@ -0,0 +1,12 @@
1
+ import type { FormatText } from '@erudit-js/core/formatText';
2
+ import { createFormatTextFn } from '../../../shared/utils/formatText';
3
+
4
+ let _ogFormatText: FormatText | undefined;
5
+
6
+ export function ogFormatText(text: string): string {
7
+ if (!_ogFormatText) {
8
+ const languageCode = ERUDIT.config.public.language.current;
9
+ _ogFormatText = createFormatTextFn(languageCode);
10
+ }
11
+ return _ogFormatText(text);
12
+ }
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { ICONS } from '../../../shared/utils/icons';
3
+
4
+ const ICON_MAP: Record<string, string> = {
5
+ ...ICONS,
6
+ contributors: 'users',
7
+ contributor: 'user',
8
+ sponsors: 'diamond',
9
+ };
10
+
11
+ const iconCache = new Map<string, string>();
12
+
13
+ export function getIconSvg(contentType: string): string {
14
+ const cached = iconCache.get(contentType);
15
+ if (cached) return cached;
16
+
17
+ const iconName = ICON_MAP[contentType] || 'lines';
18
+ const iconPath = ERUDIT.paths.erudit('app/assets/icons', iconName + '.svg');
19
+ const svg = readFileSync(iconPath, 'utf-8');
20
+ iconCache.set(contentType, svg);
21
+ return svg;
22
+ }
@@ -0,0 +1,51 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+
4
+ let cachedLogotypeDataUri: string | undefined | null;
5
+
6
+ export async function loadLogotypeDataUri(
7
+ explicitPath?: string,
8
+ ): Promise<string | undefined> {
9
+ if (cachedLogotypeDataUri !== undefined) {
10
+ return cachedLogotypeDataUri ?? undefined;
11
+ }
12
+
13
+ const logotypeUrl =
14
+ explicitPath || ERUDIT.config.public.asideMajor?.siteInfo?.logotype;
15
+ if (!logotypeUrl || logotypeUrl === '') {
16
+ cachedLogotypeDataUri = null;
17
+ return undefined;
18
+ }
19
+
20
+ if (logotypeUrl.startsWith('http://') || logotypeUrl.startsWith('https://')) {
21
+ try {
22
+ const response = await fetch(logotypeUrl);
23
+ if (response.ok) {
24
+ const contentType =
25
+ response.headers.get('content-type') || 'image/svg+xml';
26
+ const buffer = Buffer.from(await response.arrayBuffer());
27
+ cachedLogotypeDataUri = `data:${contentType};base64,${buffer.toString('base64')}`;
28
+ return cachedLogotypeDataUri;
29
+ }
30
+ } catch {
31
+ // Ignore fetch errors
32
+ }
33
+ cachedLogotypeDataUri = null;
34
+ return undefined;
35
+ }
36
+
37
+ // Local path — resolve relative to project root, or use as-is if absolute
38
+ const localPath = isAbsolute(logotypeUrl)
39
+ ? logotypeUrl
40
+ : ERUDIT.paths.project(logotypeUrl.replace(/^\/+/, ''));
41
+ if (existsSync(localPath)) {
42
+ const buffer = readFileSync(localPath);
43
+ const ext = logotypeUrl.split('.').pop()?.toLowerCase();
44
+ const mime = ext === 'svg' ? 'image/svg+xml' : `image/${ext}`;
45
+ cachedLogotypeDataUri = `data:${mime};base64,${buffer.toString('base64')}`;
46
+ return cachedLogotypeDataUri;
47
+ }
48
+
49
+ cachedLogotypeDataUri = null;
50
+ return undefined;
51
+ }
@@ -0,0 +1,90 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import satori from 'satori';
3
+ import { Resvg } from '@resvg/resvg-js';
4
+ import { type SatoriNode, OG_WIDTH, OG_HEIGHT } from './shared';
5
+
6
+ let regularFontData: ArrayBuffer | undefined;
7
+ let boldFontData: ArrayBuffer | undefined;
8
+
9
+ function loadFontFile(filename: string): ArrayBuffer {
10
+ const fontPath = ERUDIT.paths.erudit('server/erudit/ogImage/fonts', filename);
11
+ const buffer = readFileSync(fontPath);
12
+ return buffer.buffer.slice(
13
+ buffer.byteOffset,
14
+ buffer.byteOffset + buffer.byteLength,
15
+ );
16
+ }
17
+
18
+ function loadFonts(): { regular: ArrayBuffer; bold: ArrayBuffer } {
19
+ if (!regularFontData) {
20
+ regularFontData = loadFontFile('NotoSans-Regular.ttf');
21
+ }
22
+ if (!boldFontData) {
23
+ boldFontData = loadFontFile('NotoSans-Bold.ttf');
24
+ }
25
+ return { regular: regularFontData, bold: boldFontData };
26
+ }
27
+
28
+ const CACHE_CAPACITY = 50;
29
+ const cacheSlotKeys: (string | undefined)[] = new Array(CACHE_CAPACITY);
30
+ const cacheSlotValues: (Buffer | undefined)[] = new Array(CACHE_CAPACITY);
31
+ const cacheKeyToSlot = new Map<string, number>();
32
+ let cachePointer = 0;
33
+
34
+ export async function renderOgImage(
35
+ cacheKey: string,
36
+ template: SatoriNode,
37
+ ): Promise<Buffer> {
38
+ const slot = cacheKeyToSlot.get(cacheKey);
39
+ if (slot !== undefined) return cacheSlotValues[slot]!;
40
+
41
+ const fonts = loadFonts();
42
+
43
+ const svg = await satori(template as any, {
44
+ width: OG_WIDTH,
45
+ height: OG_HEIGHT,
46
+ fonts: [
47
+ {
48
+ name: 'Noto Sans',
49
+ data: fonts.regular,
50
+ weight: 400,
51
+ style: 'normal',
52
+ },
53
+ {
54
+ name: 'Noto Sans',
55
+ data: fonts.bold,
56
+ weight: 600,
57
+ style: 'normal',
58
+ },
59
+ {
60
+ name: 'Noto Sans',
61
+ data: fonts.bold,
62
+ weight: 700,
63
+ style: 'normal',
64
+ },
65
+ ],
66
+ });
67
+
68
+ const resvg = new Resvg(svg, {
69
+ fitTo: {
70
+ mode: 'width',
71
+ value: OG_WIDTH,
72
+ },
73
+ });
74
+
75
+ const pngData = resvg.render();
76
+ const pngBuffer = Buffer.from(pngData.asPng());
77
+
78
+ // Evict oldest entry when the circular buffer is full
79
+ const evictedKey = cacheSlotKeys[cachePointer];
80
+ if (evictedKey !== undefined) {
81
+ cacheKeyToSlot.delete(evictedKey);
82
+ }
83
+
84
+ cacheSlotKeys[cachePointer] = cacheKey;
85
+ cacheSlotValues[cachePointer] = pngBuffer;
86
+ cacheKeyToSlot.set(cacheKey, cachePointer);
87
+ cachePointer = (cachePointer + 1) % CACHE_CAPACITY;
88
+
89
+ return pngBuffer;
90
+ }