erudit 4.3.1 → 4.3.3

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 (35) hide show
  1. package/app/app.vue +1 -0
  2. package/app/components/aside/major/PaneHolder.vue +3 -3
  3. package/app/components/aside/major/contentNav/items/Flags.vue +4 -8
  4. package/app/components/main/MainTopicPartPage.vue +3 -1
  5. package/app/composables/analytics.ts +15 -8
  6. package/app/composables/favicon.ts +62 -15
  7. package/app/composables/formatText.ts +9 -9
  8. package/app/composables/jsonLd.ts +101 -0
  9. package/app/composables/lastmod.ts +6 -0
  10. package/app/composables/og.ts +21 -0
  11. package/app/pages/book/[...bookId].vue +3 -1
  12. package/app/pages/group/[...groupId].vue +3 -1
  13. package/app/pages/page/[...pageId].vue +3 -1
  14. package/app/plugins/prerender.server.ts +1 -0
  15. package/modules/erudit/setup/runtimeConfig.ts +39 -3
  16. package/nuxt.config.ts +1 -1
  17. package/package.json +12 -11
  18. package/server/api/main/content/[...contentTypePath].ts +2 -0
  19. package/server/api/prerender/favicons.ts +31 -0
  20. package/server/erudit/build.ts +2 -0
  21. package/server/erudit/content/lastmod.ts +206 -0
  22. package/server/erudit/content/repository/lastmod.ts +12 -0
  23. package/server/erudit/db/schema/content.ts +1 -0
  24. package/server/erudit/favicon/convertToPng.ts +48 -0
  25. package/server/erudit/favicon/loadSource.ts +139 -0
  26. package/server/erudit/favicon/shared.ts +48 -0
  27. package/server/erudit/language/list/en.ts +0 -9
  28. package/server/erudit/language/list/ru.ts +2 -11
  29. package/server/erudit/repository.ts +2 -0
  30. package/server/routes/favicon/[...path].ts +89 -0
  31. package/server/routes/sitemap.xml.ts +19 -10
  32. package/shared/types/language.ts +0 -6
  33. package/shared/types/mainContent.ts +1 -0
  34. package/shared/types/runtimeConfig.ts +4 -1
  35. package/app/composables/lastChanged.ts +0 -61
package/app/app.vue CHANGED
@@ -2,6 +2,7 @@
2
2
  initFavicon();
3
3
  initOgImage();
4
4
  initOgSiteName();
5
+ initWebSiteJsonLd();
5
6
  initAnalytics();
6
7
  initScrollUpWatcher();
7
8
 
@@ -71,9 +71,9 @@ await usePhrases(
71
71
  'no_content',
72
72
  'to_index',
73
73
  'about_textbook',
74
- 'flag_title_dev',
75
- 'flag_title_advanced',
76
- 'flag_title_secondary',
74
+ 'flag_dev',
75
+ 'flag_advanced',
76
+ 'flag_secondary',
77
77
  );
78
78
  </script>
79
79
 
@@ -5,28 +5,24 @@ import type { MyIconName } from '#my-icons';
5
5
 
6
6
  defineProps<{ flags: ContentFlags }>();
7
7
 
8
- const phrase = await usePhrases(
9
- 'flag_title_dev',
10
- 'flag_title_advanced',
11
- 'flag_title_secondary',
12
- );
8
+ const phrase = await usePhrases('flag_dev', 'flag_advanced', 'flag_secondary');
13
9
 
14
10
  const flagData = ((flagType: ContentFlag) => {
15
11
  switch (flagType) {
16
12
  case 'dev':
17
13
  return {
18
14
  icon: 'construction',
19
- title: phrase.flag_title_dev,
15
+ title: phrase.flag_dev,
20
16
  };
21
17
  case 'advanced':
22
18
  return {
23
19
  icon: 'asterisk',
24
- title: phrase.flag_title_advanced,
20
+ title: phrase.flag_advanced,
25
21
  };
26
22
  case 'secondary':
27
23
  return {
28
24
  icon: 'puzzle',
29
- title: phrase.flag_title_secondary,
25
+ title: phrase.flag_secondary,
30
26
  };
31
27
  }
32
28
  }) as (flagType: ContentFlag) => { icon: MyIconName; title: string };
@@ -24,7 +24,7 @@ const phrase = await usePhrases(
24
24
  'summary_seo_description',
25
25
  'practice_seo_description',
26
26
  );
27
- const lastChangedDate = useLastChanged(() => mainContent.contentRelativePath);
27
+ const lastChangedDate = useLastChanged(() => mainContent.lastmod);
28
28
 
29
29
  await useContentSeo({
30
30
  title: mainContent.title,
@@ -47,6 +47,8 @@ await useContentSeo({
47
47
  : undefined,
48
48
  seo: mainContent.seo,
49
49
  snippets: mainContent.snippets,
50
+ breadcrumbs: mainContent.breadcrumbs,
51
+ lastmod: mainContent.lastmod,
50
52
  });
51
53
  </script>
52
54
 
@@ -73,17 +73,24 @@ function yandexAnalytics(analytics: YandexAnalytics) {
73
73
  (function(m,e,t,r,i,k,a){
74
74
  m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
75
75
  m[i].l=1*new Date();
76
- k=e.createElement(t),a=e.getElementsByTagName(t)[0],
77
- k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
78
- })(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
76
+ for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
77
+ k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
78
+ })(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=${analytics.metricsId}', 'ym');
79
79
 
80
- ym(${analytics.metricsId}, "init", {
80
+ ym(
81
+ ${analytics.metricsId},
82
+ 'init',
83
+ {
84
+ ssr:true,
85
+ webvisor:true,
81
86
  clickmap:true,
82
- trackLinks:true,
87
+ referrer: document.referrer,
88
+ url: location.href,
83
89
  accurateTrackBounce:true,
84
- webvisor:true
85
- });
86
- `.trim(),
90
+ trackLinks:true
91
+ }
92
+ );
93
+ `.trim(),
87
94
  },
88
95
  ],
89
96
  });
@@ -1,18 +1,41 @@
1
1
  import { isTopicPart, type TopicPart } from '@erudit-js/core/content/topic';
2
2
  import { isContentType, type ContentType } from '@erudit-js/core/content/type';
3
3
 
4
- const fallbackFaviconHref = eruditPublic('favicons/default.svg');
4
+ const FALLBACK_FAVICON_EXT = '.svg';
5
5
 
6
- export const useFaviconHref = () => {
7
- return useState<string>('favicon-href', () => fallbackFaviconHref);
6
+ const mimeByExt: Record<string, string> = {
7
+ '.svg': 'image/svg+xml',
8
+ '.png': 'image/png',
9
+ '.ico': 'image/x-icon',
10
+ '.jpg': 'image/jpeg',
11
+ '.jpeg': 'image/jpeg',
12
+ '.gif': 'image/gif',
13
+ '.webp': 'image/webp',
14
+ '.bmp': 'image/bmp',
8
15
  };
9
16
 
17
+ function extFromConfigHref(key: string): string {
18
+ const href =
19
+ (ERUDIT.config.favicon as Record<string, string> | undefined)?.[key] ||
20
+ ERUDIT.config.favicon?.default;
21
+ if (!href) return FALLBACK_FAVICON_EXT;
22
+ const path = href.split(/[?#]/)[0] ?? '';
23
+ const dot = path.lastIndexOf('.');
24
+ return dot === -1 ? '' : path.slice(dot).toLowerCase();
25
+ }
26
+
27
+ function mimeFromConfigHref(key: string): string | undefined {
28
+ return mimeByExt[extFromConfigHref(key)];
29
+ }
30
+
31
+ export const useFaviconKey = () =>
32
+ useState<string>('favicon-key', () => 'default');
33
+
10
34
  export function useFavicon() {
11
- const faviconHref = useFaviconHref();
35
+ const faviconKey = useFaviconKey();
12
36
 
13
37
  function showDefaultFavicon() {
14
- const defaultFaviconHref = ERUDIT.config.favicon?.default;
15
- faviconHref.value = defaultFaviconHref || fallbackFaviconHref;
38
+ faviconKey.value = 'default';
16
39
  }
17
40
 
18
41
  function showContentFavicon(
@@ -21,15 +44,13 @@ export function useFavicon() {
21
44
  | { type: 'topic'; part: TopicPart },
22
45
  ) {
23
46
  if (args.type === 'topic') {
24
- const topicPartHref = ERUDIT.config.favicon?.[args.part];
25
- if (topicPartHref) {
26
- faviconHref.value = topicPartHref;
47
+ if (ERUDIT.config.favicon?.[args.part]) {
48
+ faviconKey.value = args.part;
27
49
  return;
28
50
  }
29
51
  } else {
30
- const typeHref = ERUDIT.config.favicon?.[args.type];
31
- if (typeHref) {
32
- faviconHref.value = typeHref;
52
+ if (ERUDIT.config.favicon?.[args.type]) {
53
+ faviconKey.value = args.type;
33
54
  return;
34
55
  }
35
56
  }
@@ -45,16 +66,42 @@ export function useFavicon() {
45
66
 
46
67
  export function initFavicon() {
47
68
  const withBaseUrl = useBaseUrl();
48
- const faviconHref = useFaviconHref();
69
+ const faviconKey = useFaviconKey();
49
70
  const { showDefaultFavicon, showContentFavicon } = useFavicon();
50
71
  showDefaultFavicon();
51
72
 
52
73
  useHead({
53
74
  link: [
75
+ computed(() => {
76
+ const key = faviconKey.value;
77
+ const ext = extFromConfigHref(key);
78
+ const mime = mimeFromConfigHref(key);
79
+ return {
80
+ key: 'favicon',
81
+ rel: 'icon',
82
+ href: withBaseUrl(`/favicon/${key}/source${ext}`),
83
+ ...(mime && { type: mime }),
84
+ };
85
+ }),
54
86
  computed(() => ({
55
- key: 'favicon',
87
+ key: 'favicon-png-48',
56
88
  rel: 'icon',
57
- href: withBaseUrl(faviconHref.value),
89
+ type: 'image/png',
90
+ sizes: '48x48',
91
+ href: withBaseUrl(`/favicon/${faviconKey.value}/48.png`),
92
+ })),
93
+ computed(() => ({
94
+ key: 'favicon-png-32',
95
+ rel: 'icon',
96
+ type: 'image/png',
97
+ sizes: '32x32',
98
+ href: withBaseUrl(`/favicon/${faviconKey.value}/32.png`),
99
+ })),
100
+ computed(() => ({
101
+ key: 'apple-touch-icon',
102
+ rel: 'apple-touch-icon',
103
+ sizes: '180x180',
104
+ href: withBaseUrl(`/favicon/${faviconKey.value}/180.png`),
58
105
  })),
59
106
  ],
60
107
  });
@@ -1,9 +1,9 @@
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
- }
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
+ }
@@ -0,0 +1,101 @@
1
+ import type { Breadcrumbs } from '../../shared/types/breadcrumbs';
2
+
3
+ export function useJsonLd(key: string, data: Record<string, unknown>) {
4
+ useHead({
5
+ script: [
6
+ {
7
+ key,
8
+ type: 'application/ld+json',
9
+ innerHTML: JSON.stringify(data),
10
+ },
11
+ ],
12
+ });
13
+ }
14
+
15
+ export function initWebSiteJsonLd() {
16
+ const siteTitle =
17
+ ERUDIT.config.seo?.siteTitle || ERUDIT.config.asideMajor?.siteInfo?.title;
18
+
19
+ if (!siteTitle) {
20
+ return;
21
+ }
22
+
23
+ const runtimeConfig = useRuntimeConfig();
24
+ const siteUrl = runtimeConfig.public.siteUrl as string;
25
+
26
+ if (!siteUrl) {
27
+ return;
28
+ }
29
+
30
+ useJsonLd('jsonld-website', {
31
+ '@context': 'https://schema.org',
32
+ '@type': 'WebSite',
33
+ name: siteTitle,
34
+ url: siteUrl,
35
+ });
36
+ }
37
+
38
+ export function useContentBreadcrumbsJsonLd(breadcrumbs?: Breadcrumbs) {
39
+ if (!breadcrumbs || breadcrumbs.length === 0) {
40
+ return;
41
+ }
42
+
43
+ const withSiteUrl = useSiteUrl();
44
+
45
+ useJsonLd('jsonld-breadcrumbs', {
46
+ '@context': 'https://schema.org',
47
+ '@type': 'BreadcrumbList',
48
+ itemListElement: breadcrumbs.map((item, index) => ({
49
+ '@type': 'ListItem',
50
+ position: index + 1,
51
+ name: formatText(item.title),
52
+ item: withSiteUrl(item.link),
53
+ })),
54
+ });
55
+ }
56
+
57
+ export function useContentArticleJsonLd(args: {
58
+ title: string;
59
+ description?: string;
60
+ urlPath: string;
61
+ contentType: string;
62
+ lastmod?: string;
63
+ }) {
64
+ const withSiteUrl = useSiteUrl();
65
+
66
+ const siteTitle =
67
+ ERUDIT.config.seo?.siteTitle || ERUDIT.config.asideMajor?.siteInfo?.title;
68
+
69
+ const schemaType =
70
+ args.contentType === 'book'
71
+ ? 'Book'
72
+ : args.contentType === 'group'
73
+ ? 'Course'
74
+ : 'Article';
75
+
76
+ const data: Record<string, unknown> = {
77
+ '@context': 'https://schema.org',
78
+ '@type': schemaType,
79
+ ...(schemaType === 'Article'
80
+ ? { headline: formatText(args.title) }
81
+ : { name: formatText(args.title) }),
82
+ url: withSiteUrl(args.urlPath),
83
+ };
84
+
85
+ if (args.description) {
86
+ data.description = formatText(args.description);
87
+ }
88
+
89
+ if (args.lastmod) {
90
+ data.dateModified = args.lastmod;
91
+ }
92
+
93
+ if (siteTitle) {
94
+ data.isPartOf = {
95
+ '@type': 'WebSite',
96
+ name: siteTitle,
97
+ };
98
+ }
99
+
100
+ useJsonLd('jsonld-content', data);
101
+ }
@@ -0,0 +1,6 @@
1
+ export function useLastChanged(lastmod: MaybeRefOrGetter<string | undefined>) {
2
+ return computed(() => {
3
+ const val = toValue(lastmod);
4
+ return val ? new Date(val) : undefined;
5
+ });
6
+ }
@@ -1,6 +1,8 @@
1
1
  import type { ContentSeo } from '@erudit-js/core/content/seo';
2
2
  import { toSeoSnippet } from '@erudit-js/prose';
3
3
 
4
+ import type { Breadcrumbs } from '../../shared/types/breadcrumbs';
5
+
4
6
  export function initOgSiteName() {
5
7
  const siteTitle =
6
8
  ERUDIT.config.seo?.siteTitle || ERUDIT.config.asideMajor?.siteInfo?.title;
@@ -91,6 +93,8 @@ export async function useContentSeo(args: {
91
93
  description?: string;
92
94
  seo?: ContentSeo;
93
95
  snippets?: ElementSnippet[];
96
+ breadcrumbs?: Breadcrumbs;
97
+ lastmod?: string;
94
98
  }) {
95
99
  const canUseBookTitle = ERUDIT.config.seo?.useBookSiteTitle;
96
100
 
@@ -142,6 +146,23 @@ export async function useContentSeo(args: {
142
146
  });
143
147
  }
144
148
 
149
+ //
150
+ // JSON-LD structured data
151
+ //
152
+
153
+ useContentBreadcrumbsJsonLd(args.breadcrumbs);
154
+
155
+ useContentArticleJsonLd({
156
+ title: args.seo?.title || args.title,
157
+ description: args.seo?.description || args.description,
158
+ urlPath: canonicalPath,
159
+ contentType:
160
+ args.contentTypePath.type === 'topic'
161
+ ? 'article'
162
+ : args.contentTypePath.type,
163
+ lastmod: args.lastmod,
164
+ });
165
+
145
166
  //
146
167
  // SEO snippets
147
168
  //
@@ -19,7 +19,7 @@ if (ERUDIT.config.contributors?.enabled) {
19
19
  }
20
20
 
21
21
  const phrase = await usePhrases('begin_learning');
22
- const lastChangedDate = useLastChanged(() => mainContent.contentRelativePath);
22
+ const lastChangedDate = useLastChanged(() => mainContent.lastmod);
23
23
 
24
24
  await useContentSeo({
25
25
  title: mainContent.title,
@@ -29,6 +29,8 @@ await useContentSeo({
29
29
  contentId: mainContent.shortId,
30
30
  },
31
31
  seo: mainContent.seo,
32
+ breadcrumbs: mainContent.breadcrumbs,
33
+ lastmod: mainContent.lastmod,
32
34
  });
33
35
  </script>
34
36
 
@@ -19,7 +19,7 @@ if (ERUDIT.config.contributors?.enabled) {
19
19
  }
20
20
 
21
21
  const phrase = await usePhrases('group', 'begin_learning');
22
- const lastChangedDate = useLastChanged(() => mainContent.contentRelativePath);
22
+ const lastChangedDate = useLastChanged(() => mainContent.lastmod);
23
23
 
24
24
  await useContentSeo({
25
25
  title: mainContent.title,
@@ -31,6 +31,8 @@ await useContentSeo({
31
31
  contentId: mainContent.shortId,
32
32
  },
33
33
  seo: mainContent.seo,
34
+ breadcrumbs: mainContent.breadcrumbs,
35
+ lastmod: mainContent.lastmod,
34
36
  });
35
37
  </script>
36
38
 
@@ -17,7 +17,7 @@ async function proseMounted() {
17
17
  );
18
18
  }
19
19
 
20
- const lastChangedDate = useLastChanged(() => mainContent.contentRelativePath);
20
+ const lastChangedDate = useLastChanged(() => mainContent.lastmod);
21
21
 
22
22
  await useContentSeo({
23
23
  title: mainContent.title,
@@ -29,6 +29,8 @@ await useContentSeo({
29
29
  },
30
30
  seo: mainContent.seo,
31
31
  snippets: mainContent.snippets,
32
+ breadcrumbs: mainContent.breadcrumbs,
33
+ lastmod: mainContent.lastmod,
32
34
  });
33
35
  </script>
34
36
 
@@ -26,6 +26,7 @@ export default defineNuxtPlugin({
26
26
  '/api/prerender/quotes',
27
27
  '/api/prerender/news',
28
28
  '/api/prerender/ogImages',
29
+ '/api/prerender/favicons',
29
30
  ];
30
31
 
31
32
  for (const provider of routeProviders) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import type { Nuxt } from '@nuxt/schema';
3
3
  import { sn } from 'unslash';
4
+ import { findPath } from 'nuxt/kit';
4
5
  import type { EruditConfig } from '@erudit-js/core/eruditConfig/config';
5
6
  import { isDevLikeMode } from '@erudit-js/core/mode';
6
7
 
@@ -31,6 +32,7 @@ export async function setupEruditRuntimeConfig(nuxt: Nuxt) {
31
32
  elements: eruditConfig.elements || [],
32
33
  countElements: eruditConfig.countElements || [],
33
34
  indexPage: eruditConfig.indexPage,
35
+ lastmod: await resolveLastmodConfig(eruditConfig),
34
36
  }) satisfies EruditRuntimeConfig;
35
37
 
36
38
  //
@@ -46,9 +48,6 @@ export async function setupEruditRuntimeConfig(nuxt: Nuxt) {
46
48
  repository:
47
49
  eruditConfig.debug?.fakeApi?.repository ??
48
50
  (nuxt.options.dev || isDevLikeMode(ERUDIT_MODE)),
49
- lastChanged:
50
- eruditConfig.debug?.fakeApi?.lastChanged ??
51
- (nuxt.options.dev || isDevLikeMode(ERUDIT_MODE)),
52
51
  },
53
52
  analytics: eruditConfig.debug?.analytics,
54
53
  },
@@ -106,3 +105,40 @@ export async function setupEruditRuntimeConfig(nuxt: Nuxt) {
106
105
  nuxtAugmentations: eruditConfig.nuxtAugmentations,
107
106
  };
108
107
  }
108
+
109
+ async function resolveLastmodConfig(
110
+ eruditConfig: EruditConfig,
111
+ ): Promise<EruditRuntimeConfig['lastmod']> {
112
+ const lastmod = eruditConfig.lastmod;
113
+
114
+ if (!lastmod || lastmod.enabled === false) {
115
+ return undefined;
116
+ }
117
+
118
+ if (lastmod.type === 'git') {
119
+ return { type: 'git' };
120
+ }
121
+
122
+ if (lastmod.type === 'custom') {
123
+ if (!lastmod.scriptPath) {
124
+ throw new Error(
125
+ 'Lastmod config with type "custom" requires a "scriptPath"!',
126
+ );
127
+ }
128
+
129
+ const absPath = await findPath(lastmod.scriptPath, {
130
+ cwd: PROJECT_PATH,
131
+ extensions: ['.ts', '.js'],
132
+ });
133
+
134
+ if (!absPath) {
135
+ throw new Error(
136
+ `Failed to resolve lastmod provider path "${lastmod.scriptPath}"!`,
137
+ );
138
+ }
139
+
140
+ return { type: 'custom', scriptPath: absPath };
141
+ }
142
+
143
+ return undefined;
144
+ }
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', '@resvg/resvg-js'];
56
+ const ignore = ['jiti', 'tsprose', '@resvg/resvg-js', 'sharp'];
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.1",
3
+ "version": "4.3.3",
4
4
  "type": "module",
5
5
  "description": "🤓 CMS for perfect educational sites.",
6
6
  "license": "MIT",
@@ -24,32 +24,33 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@erudit-js/cli": "4.3.1",
28
- "@erudit-js/core": "4.3.1",
29
- "@erudit-js/prose": "4.3.1",
30
- "unslash": "^2.0.0",
27
+ "@erudit-js/cli": "4.3.3",
28
+ "@erudit-js/core": "4.3.3",
29
+ "@erudit-js/prose": "4.3.3",
31
30
  "@floating-ui/vue": "^1.1.11",
32
- "tsprose": "^1.0.1",
31
+ "@resvg/resvg-js": "^2.6.2",
33
32
  "@tailwindcss/vite": "^4.2.1",
34
33
  "better-sqlite3": "^12.8.0",
35
34
  "chokidar": "^5.0.0",
36
35
  "consola": "^3.4.2",
37
- "drizzle-kit": "^0.31.9",
36
+ "drizzle-kit": "^0.31.10",
38
37
  "drizzle-orm": "^0.45.1",
39
38
  "esbuild": "^0.27.4",
40
39
  "flexsearch": "^0.8.212",
41
40
  "glob": "^13.0.6",
42
41
  "image-size": "^2.0.2",
43
- "@resvg/resvg-js": "^2.6.2",
44
- "satori": "^0.25.0",
45
42
  "jiti": "^2.6.1",
46
43
  "nuxt": "4.4.2",
47
44
  "nuxt-my-icons": "1.2.2",
48
45
  "perfect-debounce": "^2.1.0",
46
+ "satori": "^0.25.0",
47
+ "sharp": "^0.34.5",
49
48
  "tailwindcss": "^4.2.1",
49
+ "ts-xor": "^1.3.0",
50
+ "tsprose": "^1.0.1",
51
+ "unslash": "^2.0.0",
50
52
  "vue": "latest",
51
- "vue-router": "latest",
52
- "ts-xor": "^1.3.0"
53
+ "vue-router": "latest"
53
54
  },
54
55
  "devDependencies": {
55
56
  "@types/better-sqlite3": "^7.6.13"
@@ -20,6 +20,7 @@ export default defineEventHandler<Promise<MainContent>>(async (event) => {
20
20
  ['topic', 'page'].includes(contentTypePath.type),
21
21
  );
22
22
  const seo = await ERUDIT.repository.content.seo(fullId);
23
+ const lastmod = await ERUDIT.repository.content.lastmod(fullId);
23
24
 
24
25
  const bookNode = ERUDIT.contentNav.getBookFor(fullId);
25
26
  const bookTitle = bookNode
@@ -37,6 +38,7 @@ export default defineEventHandler<Promise<MainContent>>(async (event) => {
37
38
  bookTitle,
38
39
  breadcrumbs: await ERUDIT.repository.content.breadcrumbs(fullId),
39
40
  seo,
41
+ lastmod,
40
42
  };
41
43
 
42
44
  if (description) {
@@ -0,0 +1,31 @@
1
+ import { loadFaviconSource } from '#layers/erudit/server/erudit/favicon/loadSource';
2
+ import {
3
+ FAVICON_KEYS,
4
+ FAVICON_SIZES,
5
+ getFaviconHref,
6
+ extFromHref,
7
+ } from '#layers/erudit/server/erudit/favicon/shared';
8
+
9
+ export default defineEventHandler(async () => {
10
+ const routes: string[] = [];
11
+
12
+ for (const key of FAVICON_KEYS) {
13
+ const href = getFaviconHref(key);
14
+ if (!href) continue;
15
+
16
+ const source = await loadFaviconSource(href);
17
+ if (!source) continue;
18
+
19
+ const ext = extFromHref(href);
20
+ if (ext) {
21
+ routes.push(`/favicon/${key}/source${ext}`);
22
+ }
23
+
24
+ for (const size of FAVICON_SIZES) {
25
+ if (source.width !== undefined && source.width < size) continue;
26
+ routes.push(`/favicon/${key}/${size}.png`);
27
+ }
28
+ }
29
+
30
+ return routes;
31
+ });
@@ -9,6 +9,7 @@ import { buildSponsors } from './sponsors/build';
9
9
  import { buildCameos } from './cameos/build';
10
10
  import { buildContentNav } from './content/nav/build';
11
11
  import { requestFullContentResolve, resolveContent } from './content/resolve';
12
+ import { buildContentLastmod } from './content/lastmod';
12
13
  import { buildGlobalContent } from './content/global/build';
13
14
  import { buildNews } from './news/build';
14
15
  import { triggerReload } from './reloadSignal';
@@ -29,6 +30,7 @@ export async function buildServerErudit() {
29
30
  await buildContentNav();
30
31
  await buildGlobalContent();
31
32
  await resolveContent();
33
+ await buildContentLastmod();
32
34
  ERUDIT.log.success(styleText('green', 'Build Complete!'));
33
35
  } catch (buildError) {
34
36
  requestFullContentResolve();