erudit 4.3.0 → 4.3.1-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.
@@ -0,0 +1,110 @@
1
+ import {
2
+ type SatoriNode,
3
+ DIM_COLOR,
4
+ svgToDataUri,
5
+ truncate,
6
+ ogTitleColor,
7
+ ogRootContainer,
8
+ ogTopRow,
9
+ ogDescription,
10
+ ogBottomSpacer,
11
+ ogActionButton,
12
+ } from '../shared';
13
+
14
+ export interface SitePageOgParams {
15
+ title: string;
16
+ description?: string;
17
+ pageIconSvg: string;
18
+ logotypeDataUri?: string;
19
+ siteName?: string;
20
+ brandColor: string;
21
+ formatText?: (text: string) => string;
22
+ learnButtonText?: string;
23
+ }
24
+
25
+ export function buildSitePageOgTemplate(params: SitePageOgParams): SatoriNode {
26
+ const titleColor = ogTitleColor(params.brandColor);
27
+ const fmt = params.formatText ?? ((t: string) => t);
28
+
29
+ const children: SatoriNode[] = [];
30
+
31
+ // Top row: site logo + name
32
+ const top = ogTopRow({
33
+ logotypeDataUri: params.logotypeDataUri,
34
+ siteName: params.siteName,
35
+ formatText: params.formatText,
36
+ });
37
+ if (top) children.push(top);
38
+
39
+ // Center: icon + title inline
40
+ const titleTruncated = truncate(params.title, 60);
41
+ children.push({
42
+ type: 'div',
43
+ props: {
44
+ style: {
45
+ display: 'flex',
46
+ flexDirection: 'column' as const,
47
+ justifyContent: 'center',
48
+ flex: 1,
49
+ paddingLeft: 60,
50
+ paddingRight: 60,
51
+ },
52
+ children: [
53
+ {
54
+ type: 'div',
55
+ props: {
56
+ style: {
57
+ display: 'flex',
58
+ alignItems: 'center',
59
+ gap: 24,
60
+ },
61
+ children: [
62
+ {
63
+ type: 'img',
64
+ props: {
65
+ src: svgToDataUri(params.pageIconSvg, DIM_COLOR),
66
+ width: 44,
67
+ height: 44,
68
+ },
69
+ },
70
+ {
71
+ type: 'div',
72
+ props: {
73
+ style: {
74
+ fontSize: 52,
75
+ fontWeight: 700,
76
+ color: titleColor,
77
+ textAlign: 'left' as const,
78
+ lineHeight: 1.25,
79
+ maxHeight: 200,
80
+ overflow: 'hidden',
81
+ wordBreak: 'break-word' as const,
82
+ },
83
+ children: fmt(titleTruncated),
84
+ },
85
+ },
86
+ ],
87
+ },
88
+ },
89
+ // Action button
90
+ ...(params.learnButtonText
91
+ ? [ogActionButton(params.brandColor, params.learnButtonText)]
92
+ : []),
93
+ ],
94
+ },
95
+ });
96
+
97
+ // Bottom: description or spacer
98
+ if (params.description) {
99
+ children.push(
100
+ ogDescription({
101
+ description: params.description,
102
+ formatText: params.formatText,
103
+ }),
104
+ );
105
+ } else {
106
+ children.push(ogBottomSpacer());
107
+ }
108
+
109
+ return ogRootContainer(params.brandColor, children);
110
+ }
@@ -7,6 +7,9 @@ const mimeByExt: Record<string, string> = {
7
7
  '.jpeg': 'image/jpeg',
8
8
  '.gif': 'image/gif',
9
9
  '.webp': 'image/webp',
10
+ '.mp4': 'video/mp4',
11
+ '.webm': 'video/webm',
12
+ '.ogg': 'video/ogg',
10
13
  '.txt': 'text/plain; charset=utf-8',
11
14
  '.json': 'application/json; charset=utf-8',
12
15
  '.html': 'text/html; charset=utf-8',
@@ -0,0 +1,126 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { getIconSvg } from '#layers/erudit/server/erudit/ogImage/icons';
3
+ import { renderOgImage } from '#layers/erudit/server/erudit/ogImage/render';
4
+ import {
5
+ type ContentOgParams,
6
+ buildContentOgTemplate,
7
+ } from '#layers/erudit/server/erudit/ogImage/templates/content';
8
+ import { loadLogotypeDataUri } from '#layers/erudit/server/erudit/ogImage/logotype';
9
+ import { ogFormatText } from '#layers/erudit/server/erudit/ogImage/formatText';
10
+ import {
11
+ checkOgEnabled,
12
+ getOgBrandColor,
13
+ getOgSiteName,
14
+ getOgLearnPhrase,
15
+ getOgLogotypePath,
16
+ sendOgPng,
17
+ } from '#layers/erudit/server/erudit/ogImage/shared';
18
+ import { parseContentTypePath } from '#layers/erudit/shared/utils/contentTypePath';
19
+
20
+ export default defineEventHandler(async (event) => {
21
+ checkOgEnabled();
22
+
23
+ const rawPath = event.context.params?.contentTypePath;
24
+ if (!rawPath) {
25
+ throw createError({
26
+ statusCode: 400,
27
+ statusMessage: 'Missing content type path',
28
+ });
29
+ }
30
+
31
+ if (!rawPath.endsWith('.png')) {
32
+ throw createError({
33
+ statusCode: 400,
34
+ statusMessage: 'OG image path must end with .png',
35
+ });
36
+ }
37
+
38
+ const parsed = parseContentTypePath(rawPath.slice(0, -4));
39
+ const typeOrPart = parsed.type === 'topic' ? parsed.topicPart : parsed.type;
40
+ const contentId = parsed.contentId;
41
+
42
+ // Resolve content from nav tree
43
+ const navNode = ERUDIT.contentNav.getNodeOrThrow(contentId);
44
+ const fullId = navNode.fullId;
45
+
46
+ // Gather data
47
+ const title = await ERUDIT.repository.content.title(fullId);
48
+ const description = await ERUDIT.repository.content.description(fullId);
49
+
50
+ const bookNode = ERUDIT.contentNav.getBookFor(fullId);
51
+ const isBook = navNode.type === 'book';
52
+ const bookTitle = bookNode
53
+ ? await ERUDIT.repository.content.title(bookNode.fullId)
54
+ : undefined;
55
+
56
+ const brandColor = getOgBrandColor();
57
+ const siteName = getOgSiteName();
58
+
59
+ // Content type icon and label
60
+ const contentIconSvg = getIconSvg(typeOrPart);
61
+
62
+ const phrases = ERUDIT.language.phrases;
63
+ const contentLabel =
64
+ typeOrPart === 'book'
65
+ ? phrases.book
66
+ : typeOrPart === 'group'
67
+ ? phrases.group
68
+ : typeOrPart === 'page'
69
+ ? phrases.page
70
+ : typeOrPart === 'article'
71
+ ? phrases.article
72
+ : typeOrPart === 'summary'
73
+ ? phrases.summary
74
+ : typeOrPart === 'practice'
75
+ ? phrases.practice
76
+ : phrases.content;
77
+
78
+ // Book icon (for book row above title)
79
+ const bookIconSvg = bookTitle ? getIconSvg('book') : undefined;
80
+
81
+ // Decoration image
82
+ let decorationDataUri: string | undefined;
83
+ const decorationPath = await ERUDIT.repository.content.decoration(fullId);
84
+ if (decorationPath) {
85
+ const fullDecorationPath = ERUDIT.paths.project(decorationPath);
86
+ if (existsSync(fullDecorationPath)) {
87
+ const decorationBuffer = readFileSync(fullDecorationPath);
88
+ const ext = decorationPath.split('.').pop()?.toLowerCase();
89
+ const mime =
90
+ ext === 'svg'
91
+ ? 'image/svg+xml'
92
+ : ext === 'png'
93
+ ? 'image/png'
94
+ : ext === 'jpg' || ext === 'jpeg'
95
+ ? 'image/jpeg'
96
+ : ext === 'webp'
97
+ ? 'image/webp'
98
+ : 'image/png';
99
+ decorationDataUri = `data:${mime};base64,${decorationBuffer.toString('base64')}`;
100
+ }
101
+ }
102
+
103
+ const logotypeDataUri = await loadLogotypeDataUri(getOgLogotypePath());
104
+
105
+ const params: ContentOgParams = {
106
+ title,
107
+ description,
108
+ contentLabel,
109
+ contentIconSvg,
110
+ bookTitle,
111
+ bookIconSvg,
112
+ isBook,
113
+ decorationDataUri,
114
+ logotypeDataUri,
115
+ siteName,
116
+ brandColor,
117
+ formatText: ogFormatText,
118
+ learnButtonText: getOgLearnPhrase(),
119
+ };
120
+
121
+ const template = buildContentOgTemplate(params);
122
+ const cacheKey = `content_${typeOrPart}/${fullId}`;
123
+ const png = await renderOgImage(cacheKey, template);
124
+
125
+ return sendOgPng(event, png);
126
+ });
@@ -0,0 +1,60 @@
1
+ import { renderOgImage } from '#layers/erudit/server/erudit/ogImage/render';
2
+ import {
3
+ type SitePageOgParams,
4
+ buildSitePageOgTemplate,
5
+ } from '#layers/erudit/server/erudit/ogImage/templates/sitePage';
6
+ import { loadLogotypeDataUri } from '#layers/erudit/server/erudit/ogImage/logotype';
7
+ import { getIconSvg } from '#layers/erudit/server/erudit/ogImage/icons';
8
+ import { ogFormatText } from '#layers/erudit/server/erudit/ogImage/formatText';
9
+ import {
10
+ checkOgEnabled,
11
+ getOgBrandColor,
12
+ getOgSiteName,
13
+ getOgOpenPhrase,
14
+ getOgLogotypePath,
15
+ sendOgPng,
16
+ } from '#layers/erudit/server/erudit/ogImage/shared';
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ checkOgEnabled();
20
+
21
+ const rawId = event.context.params?.contributorId;
22
+ if (!rawId) {
23
+ throw createError({
24
+ statusCode: 400,
25
+ statusMessage: 'Missing contributor ID',
26
+ });
27
+ }
28
+
29
+ // Strip .png suffix if Nitro passes it as part of the param
30
+ const contributorId = rawId.replace(/\.png$/, '');
31
+
32
+ const phrases = ERUDIT.language.phrases;
33
+
34
+ const contributor = await $fetch<{
35
+ id: string;
36
+ displayName?: string;
37
+ short?: string;
38
+ }>(`/api/contributor/page/${contributorId}`);
39
+
40
+ const displayName = contributor.displayName || contributor.id;
41
+
42
+ const params: SitePageOgParams = {
43
+ title: displayName,
44
+ description: phrases.contributor_page_description(displayName),
45
+ pageIconSvg: getIconSvg('contributor'),
46
+ logotypeDataUri: await loadLogotypeDataUri(getOgLogotypePath()),
47
+ siteName: getOgSiteName(),
48
+ brandColor: getOgBrandColor(),
49
+ formatText: ogFormatText,
50
+ learnButtonText: getOgOpenPhrase(),
51
+ };
52
+
53
+ const template = buildSitePageOgTemplate(params);
54
+ const png = await renderOgImage(
55
+ `__site_contributor_${contributorId}`,
56
+ template,
57
+ );
58
+
59
+ return sendOgPng(event, png);
60
+ });
@@ -0,0 +1,38 @@
1
+ import { renderOgImage } from '#layers/erudit/server/erudit/ogImage/render';
2
+ import {
3
+ type SitePageOgParams,
4
+ buildSitePageOgTemplate,
5
+ } from '#layers/erudit/server/erudit/ogImage/templates/sitePage';
6
+ import { loadLogotypeDataUri } from '#layers/erudit/server/erudit/ogImage/logotype';
7
+ import { getIconSvg } from '#layers/erudit/server/erudit/ogImage/icons';
8
+ import { ogFormatText } from '#layers/erudit/server/erudit/ogImage/formatText';
9
+ import {
10
+ checkOgEnabled,
11
+ getOgBrandColor,
12
+ getOgSiteName,
13
+ getOgOpenPhrase,
14
+ getOgLogotypePath,
15
+ sendOgPng,
16
+ } from '#layers/erudit/server/erudit/ogImage/shared';
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ checkOgEnabled();
20
+
21
+ const phrases = ERUDIT.language.phrases;
22
+
23
+ const params: SitePageOgParams = {
24
+ title: phrases.contributors,
25
+ description: phrases.contributors_description,
26
+ pageIconSvg: getIconSvg('contributors'),
27
+ logotypeDataUri: await loadLogotypeDataUri(getOgLogotypePath()),
28
+ siteName: getOgSiteName(),
29
+ brandColor: getOgBrandColor(),
30
+ formatText: ogFormatText,
31
+ learnButtonText: getOgOpenPhrase(),
32
+ };
33
+
34
+ const template = buildSitePageOgTemplate(params);
35
+ const png = await renderOgImage('__site_contributors', template);
36
+
37
+ return sendOgPng(event, png);
38
+ });
@@ -0,0 +1,51 @@
1
+ import { renderOgImage } from '#layers/erudit/server/erudit/ogImage/render';
2
+ import {
3
+ type IndexOgParams,
4
+ buildIndexOgTemplate,
5
+ } from '#layers/erudit/server/erudit/ogImage/templates/index';
6
+ import { loadLogotypeDataUri } from '#layers/erudit/server/erudit/ogImage/logotype';
7
+ import { ogFormatText } from '#layers/erudit/server/erudit/ogImage/formatText';
8
+ import {
9
+ checkOgEnabled,
10
+ getOgBrandColor,
11
+ getOgSiteName,
12
+ getOgSiteShort,
13
+ getOgLogotypePath,
14
+ sendOgPng,
15
+ } from '#layers/erudit/server/erudit/ogImage/shared';
16
+
17
+ export default defineEventHandler(async (event) => {
18
+ checkOgEnabled();
19
+
20
+ const brandColor = getOgBrandColor();
21
+ const siteName = getOgSiteName();
22
+ const logotypeDataUri = await loadLogotypeDataUri(getOgLogotypePath());
23
+
24
+ const siteShort =
25
+ getOgSiteShort() || ERUDIT.config.public.asideMajor?.siteInfo?.short;
26
+
27
+ let description: string | undefined;
28
+ try {
29
+ const indexPage = await $fetch<{
30
+ description?: string;
31
+ seo?: { description?: string };
32
+ }>('/api/indexPage');
33
+ description = indexPage?.seo?.description || indexPage?.description;
34
+ } catch {
35
+ // Ignore
36
+ }
37
+
38
+ const params: IndexOgParams = {
39
+ logotypeDataUri,
40
+ siteName,
41
+ short: siteShort || undefined,
42
+ description,
43
+ brandColor,
44
+ formatText: ogFormatText,
45
+ };
46
+
47
+ const template = buildIndexOgTemplate(params);
48
+ const png = await renderOgImage('__index', template);
49
+
50
+ return sendOgPng(event, png);
51
+ });
@@ -0,0 +1,38 @@
1
+ import { renderOgImage } from '#layers/erudit/server/erudit/ogImage/render';
2
+ import {
3
+ type SitePageOgParams,
4
+ buildSitePageOgTemplate,
5
+ } from '#layers/erudit/server/erudit/ogImage/templates/sitePage';
6
+ import { loadLogotypeDataUri } from '#layers/erudit/server/erudit/ogImage/logotype';
7
+ import { getIconSvg } from '#layers/erudit/server/erudit/ogImage/icons';
8
+ import { ogFormatText } from '#layers/erudit/server/erudit/ogImage/formatText';
9
+ import {
10
+ checkOgEnabled,
11
+ getOgBrandColor,
12
+ getOgSiteName,
13
+ getOgOpenPhrase,
14
+ getOgLogotypePath,
15
+ sendOgPng,
16
+ } from '#layers/erudit/server/erudit/ogImage/shared';
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ checkOgEnabled();
20
+
21
+ const phrases = ERUDIT.language.phrases;
22
+
23
+ const params: SitePageOgParams = {
24
+ title: phrases.sponsors,
25
+ description: phrases.sponsors_description,
26
+ pageIconSvg: getIconSvg('sponsors'),
27
+ logotypeDataUri: await loadLogotypeDataUri(getOgLogotypePath()),
28
+ siteName: getOgSiteName(),
29
+ brandColor: getOgBrandColor(),
30
+ formatText: ogFormatText,
31
+ learnButtonText: getOgOpenPhrase(),
32
+ };
33
+
34
+ const template = buildSitePageOgTemplate(params);
35
+ const png = await renderOgImage('__site_sponsors', template);
36
+
37
+ return sendOgPng(event, png);
38
+ });
@@ -0,0 +1,73 @@
1
+ import type { FormatText, FormatTextState } from '@erudit-js/core/formatText';
2
+
3
+ type LanguageFormatText = (text: string) => string;
4
+
5
+ function enFormatter(text: string): string {
6
+ return text.replaceAll("'", '\u2019');
7
+ }
8
+
9
+ function ruFormatter(text: string): string {
10
+ return text.replace(
11
+ / (в|не|без|для|до|за|из|к|на|над|о|об|от|по|под|при|про|с|у|через|вокруг|около|после|перед|между|внутри|вне|из-за|из-под|ради|сквозь|среди|насчёт|вследствие|благодаря|несмотря|наперекор|вопреки|подле|возле|рядом|навстречу) /gimu,
12
+ ' $1\xa0',
13
+ );
14
+ }
15
+
16
+ const builtInFormatters: Partial<Record<string, LanguageFormatText>> = {
17
+ en: enFormatter,
18
+ ru: ruFormatter,
19
+ };
20
+
21
+ export function createFormatTextFn(
22
+ languageCode: string,
23
+ extraFormatter?: LanguageFormatText,
24
+ ): FormatText {
25
+ const langFormatter =
26
+ extraFormatter ?? builtInFormatters[languageCode] ?? ((t: string) => t);
27
+
28
+ const quoteSymbols: [string, string] =
29
+ languageCode === 'ru' ? ['\u00AB', '\u00BB'] : ['\u201C', '\u201D'];
30
+
31
+ function formatText(text: string, state?: FormatTextState): string;
32
+ function formatText(text: undefined, state?: FormatTextState): undefined;
33
+ function formatText(
34
+ text?: string,
35
+ state?: FormatTextState,
36
+ ): string | undefined;
37
+ function formatText(
38
+ text?: string,
39
+ state?: FormatTextState,
40
+ ): string | undefined {
41
+ if (text === undefined) {
42
+ return text;
43
+ }
44
+
45
+ // Normalize spacing
46
+ text = text
47
+ .trim()
48
+ .replace(/\r\n/gm, '\n')
49
+ .replace(/\n{3,}/gm, '\n\n')
50
+ .replace(/[ \t]+/gm, ' ');
51
+
52
+ // Normalize dashes
53
+ text = text.replace(/(^| )--($| )/gm, '$1\u2014$2');
54
+
55
+ // Normalize quotes
56
+ let quoteOpen = state?.quote === 'opened';
57
+ text = text.replaceAll(/"/gm, () => {
58
+ quoteOpen = !quoteOpen;
59
+ if (state) {
60
+ state.quote = quoteOpen ? 'opened' : 'closed';
61
+ }
62
+ return quoteOpen ? quoteSymbols[0] : quoteSymbols[1];
63
+ });
64
+
65
+ // Normalize ellipsis
66
+ text = text.replace(/\.{3}/gm, '\u2026');
67
+
68
+ // Language-specific formatting
69
+ return langFormatter(text);
70
+ }
71
+
72
+ return formatText;
73
+ }
@@ -1,14 +0,0 @@
1
- export default (text: string) => {
2
- text = ruStickyPrepositions(text);
3
- return text;
4
- };
5
-
6
- /**
7
- * Formats prepositions in Russian text so that they are always adjacent to the next word and are not left hanging “in the air” when the line breaks.
8
- */
9
- function ruStickyPrepositions(text: string): string {
10
- return text.replace(
11
- / (в|не|без|для|до|за|из|к|на|над|о|об|от|по|под|при|про|с|у|через|вокруг|около|после|перед|между|внутри|вне|из-за|из-под|ради|сквозь|среди|насчёт|вследствие|благодаря|несмотря|наперекор|вопреки|подле|возле|рядом|навстречу) /gimu,
12
- ' $1\xa0',
13
- );
14
- }
package/public/og.png DELETED
Binary file