erudit 4.3.2 → 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.
- package/app/app.vue +1 -0
- package/app/components/main/MainTopicPartPage.vue +3 -1
- package/app/composables/analytics.ts +15 -8
- package/app/composables/favicon.ts +48 -25
- package/app/composables/jsonLd.ts +101 -0
- package/app/composables/lastmod.ts +6 -0
- package/app/composables/og.ts +21 -0
- package/app/pages/book/[...bookId].vue +3 -1
- package/app/pages/group/[...groupId].vue +3 -1
- package/app/pages/page/[...pageId].vue +3 -1
- package/app/plugins/prerender.server.ts +1 -0
- package/modules/erudit/setup/runtimeConfig.ts +39 -3
- package/nuxt.config.ts +1 -1
- package/package.json +11 -10
- package/server/api/main/content/[...contentTypePath].ts +2 -0
- package/server/api/prerender/favicons.ts +31 -0
- package/server/erudit/build.ts +2 -0
- package/server/erudit/content/lastmod.ts +206 -0
- package/server/erudit/content/repository/lastmod.ts +12 -0
- package/server/erudit/db/schema/content.ts +1 -0
- package/server/erudit/favicon/convertToPng.ts +48 -0
- package/server/erudit/favicon/loadSource.ts +139 -0
- package/server/erudit/favicon/shared.ts +48 -0
- package/server/erudit/repository.ts +2 -0
- package/server/routes/favicon/[...path].ts +89 -0
- package/server/routes/sitemap.xml.ts +19 -10
- package/shared/types/mainContent.ts +1 -0
- package/shared/types/runtimeConfig.ts +4 -1
- package/app/composables/lastChanged.ts +0 -61
package/app/app.vue
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
77
|
-
k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
|
78
|
-
})(window, document,
|
|
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(
|
|
80
|
+
ym(
|
|
81
|
+
${analytics.metricsId},
|
|
82
|
+
'init',
|
|
83
|
+
{
|
|
84
|
+
ssr:true,
|
|
85
|
+
webvisor:true,
|
|
81
86
|
clickmap:true,
|
|
82
|
-
|
|
87
|
+
referrer: document.referrer,
|
|
88
|
+
url: location.href,
|
|
83
89
|
accurateTrackBounce:true,
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
90
|
+
trackLinks:true
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
`.trim(),
|
|
87
94
|
},
|
|
88
95
|
],
|
|
89
96
|
});
|
|
@@ -1,9 +1,9 @@
|
|
|
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
|
|
4
|
+
const FALLBACK_FAVICON_EXT = '.svg';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
const mimeByExt: Record<string, string> = {
|
|
7
7
|
'.svg': 'image/svg+xml',
|
|
8
8
|
'.png': 'image/png',
|
|
9
9
|
'.ico': 'image/x-icon',
|
|
@@ -14,24 +14,28 @@ const faviconMimeByExt: Record<string, string> = {
|
|
|
14
14
|
'.bmp': 'image/bmp',
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
function
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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();
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
return
|
|
27
|
-
}
|
|
27
|
+
function mimeFromConfigHref(key: string): string | undefined {
|
|
28
|
+
return mimeByExt[extFromConfigHref(key)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const useFaviconKey = () =>
|
|
32
|
+
useState<string>('favicon-key', () => 'default');
|
|
28
33
|
|
|
29
34
|
export function useFavicon() {
|
|
30
|
-
const
|
|
35
|
+
const faviconKey = useFaviconKey();
|
|
31
36
|
|
|
32
37
|
function showDefaultFavicon() {
|
|
33
|
-
|
|
34
|
-
faviconHref.value = defaultFaviconHref || fallbackFaviconHref;
|
|
38
|
+
faviconKey.value = 'default';
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
function showContentFavicon(
|
|
@@ -40,15 +44,13 @@ export function useFavicon() {
|
|
|
40
44
|
| { type: 'topic'; part: TopicPart },
|
|
41
45
|
) {
|
|
42
46
|
if (args.type === 'topic') {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
faviconHref.value = topicPartHref;
|
|
47
|
+
if (ERUDIT.config.favicon?.[args.part]) {
|
|
48
|
+
faviconKey.value = args.part;
|
|
46
49
|
return;
|
|
47
50
|
}
|
|
48
51
|
} else {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
faviconHref.value = typeHref;
|
|
52
|
+
if (ERUDIT.config.favicon?.[args.type]) {
|
|
53
|
+
faviconKey.value = args.type;
|
|
52
54
|
return;
|
|
53
55
|
}
|
|
54
56
|
}
|
|
@@ -64,22 +66,43 @@ export function useFavicon() {
|
|
|
64
66
|
|
|
65
67
|
export function initFavicon() {
|
|
66
68
|
const withBaseUrl = useBaseUrl();
|
|
67
|
-
const
|
|
69
|
+
const faviconKey = useFaviconKey();
|
|
68
70
|
const { showDefaultFavicon, showContentFavicon } = useFavicon();
|
|
69
71
|
showDefaultFavicon();
|
|
70
72
|
|
|
71
73
|
useHead({
|
|
72
74
|
link: [
|
|
73
75
|
computed(() => {
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
+
const key = faviconKey.value;
|
|
77
|
+
const ext = extFromConfigHref(key);
|
|
78
|
+
const mime = mimeFromConfigHref(key);
|
|
76
79
|
return {
|
|
77
80
|
key: 'favicon',
|
|
78
81
|
rel: 'icon',
|
|
79
|
-
href,
|
|
80
|
-
...(
|
|
82
|
+
href: withBaseUrl(`/favicon/${key}/source${ext}`),
|
|
83
|
+
...(mime && { type: mime }),
|
|
81
84
|
};
|
|
82
85
|
}),
|
|
86
|
+
computed(() => ({
|
|
87
|
+
key: 'favicon-png-48',
|
|
88
|
+
rel: 'icon',
|
|
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`),
|
|
105
|
+
})),
|
|
83
106
|
],
|
|
84
107
|
});
|
|
85
108
|
|
|
@@ -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
|
+
}
|
package/app/composables/og.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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.
|
|
3
|
+
"version": "4.3.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "🤓 CMS for perfect educational sites.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,12 +24,11 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@erudit-js/cli": "4.3.
|
|
28
|
-
"@erudit-js/core": "4.3.
|
|
29
|
-
"@erudit-js/prose": "4.3.
|
|
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
|
-
"
|
|
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",
|
|
@@ -40,16 +39,18 @@
|
|
|
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
|
+
});
|
package/server/erudit/build.ts
CHANGED
|
@@ -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();
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
3
|
+
import type { LastmodProvider } from '@erudit-js/core/eruditConfig/lastmod';
|
|
4
|
+
|
|
5
|
+
export async function buildContentLastmod() {
|
|
6
|
+
const lastmodConfig = ERUDIT.config.lastmod;
|
|
7
|
+
|
|
8
|
+
if (!lastmodConfig) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
ERUDIT.log.debug.start('Collecting lastmod dates...');
|
|
13
|
+
|
|
14
|
+
let collected = false;
|
|
15
|
+
|
|
16
|
+
if (lastmodConfig.type === 'git') {
|
|
17
|
+
collected = await collectGitLastmod();
|
|
18
|
+
} else if (lastmodConfig.type === 'custom' && lastmodConfig.scriptPath) {
|
|
19
|
+
collected = await collectCustomLastmod(lastmodConfig.scriptPath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (collected) {
|
|
23
|
+
ERUDIT.log.success('Lastmod dates collected!');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getGitContentPrefix(): string | undefined {
|
|
28
|
+
const projectRoot = ERUDIT.paths.project();
|
|
29
|
+
|
|
30
|
+
const result = spawnSync('git', ['rev-parse', '--show-prefix'], {
|
|
31
|
+
cwd: projectRoot,
|
|
32
|
+
encoding: 'utf-8',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (result.status !== 0) return undefined;
|
|
36
|
+
|
|
37
|
+
// gitPrefix is e.g. "playground/" or "" (if project is at repo root)
|
|
38
|
+
return result.stdout.trim() + 'content/';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function collectGitLastmod(): Promise<boolean> {
|
|
42
|
+
const projectRoot = ERUDIT.paths.project();
|
|
43
|
+
const contentPrefix = getGitContentPrefix();
|
|
44
|
+
|
|
45
|
+
if (!contentPrefix) {
|
|
46
|
+
ERUDIT.log.warn(
|
|
47
|
+
'Failed to detect git prefix — lastmod dates will be unavailable. ' +
|
|
48
|
+
'Is this a git repository?',
|
|
49
|
+
);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = spawnSync(
|
|
54
|
+
'git',
|
|
55
|
+
['log', '--format=format:%cI', '--name-only', '--', contentPrefix],
|
|
56
|
+
{
|
|
57
|
+
cwd: projectRoot,
|
|
58
|
+
encoding: 'utf-8',
|
|
59
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
if (result.status !== 0) {
|
|
64
|
+
ERUDIT.log.warn(
|
|
65
|
+
'Failed to run git log — lastmod dates will be unavailable. ' +
|
|
66
|
+
'Is this a git repository?',
|
|
67
|
+
);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const stdout = result.stdout;
|
|
72
|
+
|
|
73
|
+
if (!stdout.trim()) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Parse git log output: blocks separated by double newlines
|
|
78
|
+
// Each block: first line = ISO date, remaining lines = file paths
|
|
79
|
+
const dateMap = new Map<string, string>();
|
|
80
|
+
const blocks = stdout.split('\n\n');
|
|
81
|
+
|
|
82
|
+
for (const block of blocks) {
|
|
83
|
+
const lines = block.split('\n').filter(Boolean);
|
|
84
|
+
if (lines.length < 2) continue;
|
|
85
|
+
|
|
86
|
+
const date = lines[0]!;
|
|
87
|
+
for (let i = 1; i < lines.length; i++) {
|
|
88
|
+
const filePath = lines[i]!;
|
|
89
|
+
if (!filePath.startsWith(contentPrefix)) continue;
|
|
90
|
+
|
|
91
|
+
// Strip the prefix to get the content-relative path
|
|
92
|
+
// e.g. "playground/content/1-test/page.tsx" → "1-test"
|
|
93
|
+
const relPath = filePath.slice(contentPrefix.length);
|
|
94
|
+
const lastSlash = relPath.lastIndexOf('/');
|
|
95
|
+
const contentRelPath =
|
|
96
|
+
lastSlash > 0 ? relPath.slice(0, lastSlash) : relPath;
|
|
97
|
+
|
|
98
|
+
// Keep only the first (most recent) date per path
|
|
99
|
+
if (!dateMap.has(contentRelPath)) {
|
|
100
|
+
dateMap.set(contentRelPath, date);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Detect shallow clone: if all dates are identical, warn
|
|
106
|
+
if (dateMap.size > 1) {
|
|
107
|
+
const dates = new Set(dateMap.values());
|
|
108
|
+
if (dates.size === 1) {
|
|
109
|
+
ERUDIT.log.warn(
|
|
110
|
+
'All git lastmod dates are identical — this likely means a shallow clone. ' +
|
|
111
|
+
'Use "fetch-depth: 0" in GitHub Actions checkout for accurate dates.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Build a map keyed by fullId from the contentRelPath-keyed dateMap
|
|
117
|
+
const ownDates = new Map<string, string>();
|
|
118
|
+
for (const [, navNode] of ERUDIT.contentNav.id2Node) {
|
|
119
|
+
const date = dateMap.get(navNode.contentRelPath);
|
|
120
|
+
if (date) {
|
|
121
|
+
ownDates.set(navNode.fullId, date);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await propagateAndSave(ownDates);
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function collectCustomLastmod(absPath: string): Promise<boolean> {
|
|
130
|
+
let provider: LastmodProvider;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const module = await ERUDIT.import(absPath);
|
|
134
|
+
provider = (module as any).default;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
ERUDIT.log.warn(
|
|
137
|
+
`Failed to import lastmod provider from "${absPath}":\n${error}`,
|
|
138
|
+
);
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (typeof provider !== 'function') {
|
|
143
|
+
ERUDIT.log.warn(
|
|
144
|
+
`Lastmod provider at "${absPath}" does not have a default-exported function!`,
|
|
145
|
+
);
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Collect dates from custom provider
|
|
150
|
+
const customDates = new Map<string, string>();
|
|
151
|
+
|
|
152
|
+
for (const [, navNode] of ERUDIT.contentNav.id2Node) {
|
|
153
|
+
try {
|
|
154
|
+
const date = await provider({
|
|
155
|
+
fullId: navNode.fullId,
|
|
156
|
+
mode: ERUDIT.mode,
|
|
157
|
+
projectPath: ERUDIT.paths.project(),
|
|
158
|
+
});
|
|
159
|
+
if (date instanceof Date && !isNaN(date.getTime())) {
|
|
160
|
+
customDates.set(navNode.fullId, date.toISOString());
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
ERUDIT.log.warn(
|
|
164
|
+
`Lastmod provider error for "${navNode.fullId}": ${error}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
await propagateAndSave(customDates);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Propagate dates upward through the nav tree (parents inherit the most
|
|
175
|
+
* recent date from their children) and write resolved dates to the DB.
|
|
176
|
+
*/
|
|
177
|
+
async function propagateAndSave(ownDates: Map<string, string>) {
|
|
178
|
+
// Process deepest nodes first so child dates are finalized before parents
|
|
179
|
+
const sortedNodes = Array.from(ERUDIT.contentNav.id2Node.values()).sort(
|
|
180
|
+
(a, b) => b.fullId.split('/').length - a.fullId.split('/').length,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const resolvedDates = new Map<string, string>();
|
|
184
|
+
|
|
185
|
+
for (const navNode of sortedNodes) {
|
|
186
|
+
let bestDate = ownDates.get(navNode.fullId);
|
|
187
|
+
|
|
188
|
+
for (const child of navNode.children ?? []) {
|
|
189
|
+
const childDate = resolvedDates.get(child.fullId);
|
|
190
|
+
if (childDate && (!bestDate || childDate > bestDate)) {
|
|
191
|
+
bestDate = childDate;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (bestDate) {
|
|
196
|
+
resolvedDates.set(navNode.fullId, bestDate);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (const [fullId, date] of resolvedDates) {
|
|
201
|
+
await ERUDIT.db
|
|
202
|
+
.update(ERUDIT.db.schema.content)
|
|
203
|
+
.set({ lastmod: date })
|
|
204
|
+
.where(eq(ERUDIT.db.schema.content.fullId, fullId));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
|
|
3
|
+
export async function getContentLastmod(
|
|
4
|
+
fullId: string,
|
|
5
|
+
): Promise<string | undefined> {
|
|
6
|
+
const result = await ERUDIT.db.query.content.findFirst({
|
|
7
|
+
columns: { lastmod: true },
|
|
8
|
+
where: eq(ERUDIT.db.schema.content.fullId, fullId),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
return result?.lastmod ?? undefined;
|
|
12
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
3
|
+
import type { FaviconSource } from './loadSource';
|
|
4
|
+
|
|
5
|
+
const cache = new Map<string, Buffer>();
|
|
6
|
+
|
|
7
|
+
export async function convertFaviconToPng(
|
|
8
|
+
faviconKey: string,
|
|
9
|
+
source: FaviconSource,
|
|
10
|
+
size: number,
|
|
11
|
+
): Promise<Buffer> {
|
|
12
|
+
const cacheKey = `${faviconKey}:${size}`;
|
|
13
|
+
const cached = cache.get(cacheKey);
|
|
14
|
+
if (cached) return cached;
|
|
15
|
+
|
|
16
|
+
const png =
|
|
17
|
+
source.mime === 'image/svg+xml'
|
|
18
|
+
? await svgToPng(source.buffer, size)
|
|
19
|
+
: await rasterToPng(source.buffer, size);
|
|
20
|
+
|
|
21
|
+
cache.set(cacheKey, png);
|
|
22
|
+
return png;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function svgToPng(svgBuffer: Buffer, size: number): Promise<Buffer> {
|
|
26
|
+
const resvg = new Resvg(svgBuffer.toString('utf-8'), {
|
|
27
|
+
fitTo: { mode: 'width', value: size },
|
|
28
|
+
});
|
|
29
|
+
const rendered = Buffer.from(resvg.render().asPng());
|
|
30
|
+
|
|
31
|
+
return sharp(rendered)
|
|
32
|
+
.resize(size, size, {
|
|
33
|
+
fit: 'contain',
|
|
34
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
35
|
+
})
|
|
36
|
+
.png()
|
|
37
|
+
.toBuffer();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function rasterToPng(buffer: Buffer, size: number): Promise<Buffer> {
|
|
41
|
+
return sharp(buffer)
|
|
42
|
+
.resize(size, size, {
|
|
43
|
+
fit: 'contain',
|
|
44
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
45
|
+
})
|
|
46
|
+
.png()
|
|
47
|
+
.toBuffer();
|
|
48
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { imageSize } from 'image-size';
|
|
4
|
+
import { mimeFromExt } from './shared';
|
|
5
|
+
|
|
6
|
+
export interface FaviconSource {
|
|
7
|
+
buffer: Buffer;
|
|
8
|
+
mime: string;
|
|
9
|
+
/** Undefined for SVG (treated as infinitely scalable) */
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function mimeFromBuffer(buffer: Buffer): string | undefined {
|
|
15
|
+
if (buffer.length < 12) return undefined;
|
|
16
|
+
|
|
17
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
|
|
18
|
+
return 'image/jpeg';
|
|
19
|
+
|
|
20
|
+
if (
|
|
21
|
+
buffer[0] === 0x89 &&
|
|
22
|
+
buffer[1] === 0x50 &&
|
|
23
|
+
buffer[2] === 0x4e &&
|
|
24
|
+
buffer[3] === 0x47
|
|
25
|
+
)
|
|
26
|
+
return 'image/png';
|
|
27
|
+
|
|
28
|
+
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46)
|
|
29
|
+
return 'image/gif';
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
buffer[0] === 0x52 &&
|
|
33
|
+
buffer[1] === 0x49 &&
|
|
34
|
+
buffer[2] === 0x46 &&
|
|
35
|
+
buffer[3] === 0x46 &&
|
|
36
|
+
buffer[8] === 0x57 &&
|
|
37
|
+
buffer[9] === 0x45 &&
|
|
38
|
+
buffer[10] === 0x42 &&
|
|
39
|
+
buffer[11] === 0x50
|
|
40
|
+
)
|
|
41
|
+
return 'image/webp';
|
|
42
|
+
|
|
43
|
+
if (buffer[0] === 0x42 && buffer[1] === 0x4d) return 'image/bmp';
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
buffer[0] === 0x00 &&
|
|
47
|
+
buffer[1] === 0x00 &&
|
|
48
|
+
buffer[2] === 0x01 &&
|
|
49
|
+
buffer[3] === 0x00
|
|
50
|
+
)
|
|
51
|
+
return 'image/x-icon';
|
|
52
|
+
|
|
53
|
+
const head = buffer.subarray(0, 512).toString('utf-8').trim();
|
|
54
|
+
if (head.startsWith('<svg') || head.startsWith('<?xml'))
|
|
55
|
+
return 'image/svg+xml';
|
|
56
|
+
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveMime(
|
|
61
|
+
buffer: Buffer,
|
|
62
|
+
href: string,
|
|
63
|
+
serverMime?: string,
|
|
64
|
+
): string {
|
|
65
|
+
return (
|
|
66
|
+
mimeFromBuffer(buffer) ||
|
|
67
|
+
mimeFromExt(href) ||
|
|
68
|
+
(serverMime?.startsWith('image/') ? serverMime : undefined) ||
|
|
69
|
+
'application/octet-stream'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function detectDimensions(
|
|
74
|
+
buffer: Buffer,
|
|
75
|
+
mime: string,
|
|
76
|
+
): { width?: number; height?: number } {
|
|
77
|
+
if (mime === 'image/svg+xml') return {};
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const size = imageSize(buffer);
|
|
81
|
+
return { width: size.width, height: size.height };
|
|
82
|
+
} catch {
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildSource(buffer: Buffer, mime: string): FaviconSource {
|
|
88
|
+
return { buffer, mime, ...detectDimensions(buffer, mime) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cache = new Map<string, FaviconSource | null>();
|
|
92
|
+
|
|
93
|
+
export async function loadFaviconSource(
|
|
94
|
+
href: string,
|
|
95
|
+
): Promise<FaviconSource | undefined> {
|
|
96
|
+
if (cache.has(href)) return cache.get(href) ?? undefined;
|
|
97
|
+
|
|
98
|
+
const source = await doLoad(href);
|
|
99
|
+
cache.set(href, source ?? null);
|
|
100
|
+
return source;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function doLoad(href: string): Promise<FaviconSource | undefined> {
|
|
104
|
+
if (href.startsWith('http://') || href.startsWith('https://'))
|
|
105
|
+
return loadFromUrl(href);
|
|
106
|
+
|
|
107
|
+
return loadFromFilesystem(href);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function loadFromUrl(url: string): Promise<FaviconSource | undefined> {
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(url);
|
|
113
|
+
if (!response.ok) return undefined;
|
|
114
|
+
|
|
115
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
116
|
+
const serverMime = (response.headers.get('content-type') || '')
|
|
117
|
+
.split(';')[0]
|
|
118
|
+
?.trim();
|
|
119
|
+
return buildSource(buffer, resolveMime(buffer, url, serverMime));
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function loadFromFilesystem(
|
|
126
|
+
href: string,
|
|
127
|
+
): Promise<FaviconSource | undefined> {
|
|
128
|
+
const cleanPath = href.replace(/^\.\//, '');
|
|
129
|
+
const localPath = isAbsolute(cleanPath)
|
|
130
|
+
? cleanPath
|
|
131
|
+
: ERUDIT.paths.project(cleanPath);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const buffer = await readFile(localPath);
|
|
135
|
+
return buildSource(buffer, resolveMime(buffer, href));
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const FAVICON_SIZES = [32, 48, 180] as const;
|
|
2
|
+
export type FaviconSize = (typeof FAVICON_SIZES)[number];
|
|
3
|
+
|
|
4
|
+
// Mirrors contentTypes (minus 'topic') + topicParts from @erudit-js/core
|
|
5
|
+
export const FAVICON_KEYS = [
|
|
6
|
+
'default',
|
|
7
|
+
'book',
|
|
8
|
+
'group',
|
|
9
|
+
'page',
|
|
10
|
+
'article',
|
|
11
|
+
'summary',
|
|
12
|
+
'practice',
|
|
13
|
+
] as const;
|
|
14
|
+
export type FaviconKey = (typeof FAVICON_KEYS)[number];
|
|
15
|
+
|
|
16
|
+
function fallbackFaviconPath(): string {
|
|
17
|
+
return ERUDIT.paths.erudit('public', 'favicons', 'default.svg');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getFaviconHref(key: string): string | undefined {
|
|
21
|
+
const href = (
|
|
22
|
+
ERUDIT.config.public.favicon as Record<string, string> | undefined
|
|
23
|
+
)?.[key];
|
|
24
|
+
if (href) return href;
|
|
25
|
+
if (key === 'default') return fallbackFaviconPath();
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mimeByExt: Record<string, string> = {
|
|
30
|
+
'.svg': 'image/svg+xml',
|
|
31
|
+
'.png': 'image/png',
|
|
32
|
+
'.jpg': 'image/jpeg',
|
|
33
|
+
'.jpeg': 'image/jpeg',
|
|
34
|
+
'.gif': 'image/gif',
|
|
35
|
+
'.webp': 'image/webp',
|
|
36
|
+
'.bmp': 'image/bmp',
|
|
37
|
+
'.ico': 'image/x-icon',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function extFromHref(href: string): string {
|
|
41
|
+
const path = href.split(/[?#]/)[0] ?? '';
|
|
42
|
+
const dot = path.lastIndexOf('.');
|
|
43
|
+
return dot === -1 ? '' : path.slice(dot).toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function mimeFromExt(href: string): string | undefined {
|
|
47
|
+
return mimeByExt[extFromHref(href)];
|
|
48
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import { getContentSeo } from './content/repository/seo';
|
|
38
38
|
import { getContentElementSnippets } from './content/repository/elementSnippets';
|
|
39
39
|
import { isContentHidden } from './content/repository/hidden';
|
|
40
|
+
import { getContentLastmod } from './content/repository/lastmod';
|
|
40
41
|
import { serverRawToProse } from './prose/repository/rawToProse';
|
|
41
42
|
|
|
42
43
|
export const repository = {
|
|
@@ -77,6 +78,7 @@ export const repository = {
|
|
|
77
78
|
updateSchemaCounts: updateContentSchemaCounts,
|
|
78
79
|
contentContributions: getContentContributions,
|
|
79
80
|
seo: getContentSeo,
|
|
81
|
+
lastmod: getContentLastmod,
|
|
80
82
|
},
|
|
81
83
|
prose: {
|
|
82
84
|
fromRaw: serverRawToProse,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { loadFaviconSource } from '#layers/erudit/server/erudit/favicon/loadSource';
|
|
2
|
+
import { convertFaviconToPng } from '#layers/erudit/server/erudit/favicon/convertToPng';
|
|
3
|
+
import {
|
|
4
|
+
FAVICON_SIZES,
|
|
5
|
+
FAVICON_KEYS,
|
|
6
|
+
getFaviconHref,
|
|
7
|
+
type FaviconSize,
|
|
8
|
+
type FaviconKey,
|
|
9
|
+
} from '#layers/erudit/server/erudit/favicon/shared';
|
|
10
|
+
|
|
11
|
+
export default defineEventHandler(async (event) => {
|
|
12
|
+
const rawPath = event.context.params?.path;
|
|
13
|
+
if (!rawPath) {
|
|
14
|
+
throw createError({
|
|
15
|
+
statusCode: 404,
|
|
16
|
+
statusMessage: 'Invalid favicon path',
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const slashIdx = rawPath.indexOf('/');
|
|
21
|
+
if (slashIdx === -1) {
|
|
22
|
+
throw createError({
|
|
23
|
+
statusCode: 404,
|
|
24
|
+
statusMessage: 'Invalid favicon path',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const key = rawPath.slice(0, slashIdx);
|
|
29
|
+
const file = rawPath.slice(slashIdx + 1);
|
|
30
|
+
|
|
31
|
+
if (!FAVICON_KEYS.includes(key as FaviconKey)) {
|
|
32
|
+
throw createError({
|
|
33
|
+
statusCode: 404,
|
|
34
|
+
statusMessage: `Unknown favicon key: ${key}`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const href = getFaviconHref(key);
|
|
39
|
+
if (!href) {
|
|
40
|
+
throw createError({
|
|
41
|
+
statusCode: 404,
|
|
42
|
+
statusMessage: `No favicon configured for: ${key}`,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const source = await loadFaviconSource(href);
|
|
47
|
+
if (!source) {
|
|
48
|
+
throw createError({
|
|
49
|
+
statusCode: 404,
|
|
50
|
+
statusMessage: 'Failed to load favicon source',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Source route: {key}/source.{ext}
|
|
55
|
+
if (file.startsWith('source.')) {
|
|
56
|
+
setHeader(event, 'Content-Type', source.mime);
|
|
57
|
+
setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=86400');
|
|
58
|
+
return source.buffer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// PNG route: {key}/{size}.png
|
|
62
|
+
if (!file.endsWith('.png')) {
|
|
63
|
+
throw createError({
|
|
64
|
+
statusCode: 404,
|
|
65
|
+
statusMessage: 'Invalid favicon path',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const size = Number(file.slice(0, -4)) as FaviconSize;
|
|
70
|
+
|
|
71
|
+
if (!FAVICON_SIZES.includes(size)) {
|
|
72
|
+
throw createError({
|
|
73
|
+
statusCode: 404,
|
|
74
|
+
statusMessage: `Invalid favicon size: ${file}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (source.width !== undefined && source.width < size) {
|
|
79
|
+
throw createError({
|
|
80
|
+
statusCode: 404,
|
|
81
|
+
statusMessage: `Favicon source too small (${source.width}px) for size ${size}px`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const png = await convertFaviconToPng(key, source, size);
|
|
86
|
+
setHeader(event, 'Content-Type', 'image/png');
|
|
87
|
+
setHeader(event, 'Cache-Control', 'public, max-age=86400, s-maxage=86400');
|
|
88
|
+
return png;
|
|
89
|
+
});
|
|
@@ -2,7 +2,14 @@ import { sn } from 'unslash';
|
|
|
2
2
|
|
|
3
3
|
export default defineEventHandler(async (event) => {
|
|
4
4
|
const urls = new Set<string>();
|
|
5
|
-
|
|
5
|
+
const urlLastmod = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
function addUrl(url: string, lastmod?: string) {
|
|
8
|
+
urls.add(url);
|
|
9
|
+
if (lastmod) urlLastmod.set(url, lastmod);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
addUrl(PAGES.index);
|
|
6
13
|
|
|
7
14
|
//
|
|
8
15
|
// Contributors
|
|
@@ -34,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
|
|
34
41
|
|
|
35
42
|
{
|
|
36
43
|
const dbContentItems = await ERUDIT.db.query.content.findMany({
|
|
37
|
-
columns: { fullId: true },
|
|
44
|
+
columns: { fullId: true, lastmod: true },
|
|
38
45
|
});
|
|
39
46
|
|
|
40
47
|
for (const dbContentItem of dbContentItems) {
|
|
@@ -45,14 +52,15 @@ export default defineEventHandler(async (event) => {
|
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
const contentNode = ERUDIT.contentNav.getNodeOrThrow(fullId);
|
|
55
|
+
const lastmod = dbContentItem.lastmod ?? undefined;
|
|
48
56
|
|
|
49
57
|
if (contentNode.type === 'topic') {
|
|
50
58
|
const parts = await ERUDIT.repository.content.topicParts(fullId);
|
|
51
59
|
for (const part of parts) {
|
|
52
|
-
|
|
60
|
+
addUrl(PAGES.topic(part, contentNode.shortId), lastmod);
|
|
53
61
|
}
|
|
54
62
|
} else {
|
|
55
|
-
|
|
63
|
+
addUrl(PAGES[contentNode.type](fullId), lastmod);
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
const elementSnippets =
|
|
@@ -60,7 +68,7 @@ export default defineEventHandler(async (event) => {
|
|
|
60
68
|
|
|
61
69
|
for (const snippet of elementSnippets || []) {
|
|
62
70
|
if (snippet.seo) {
|
|
63
|
-
|
|
71
|
+
addUrl(snippet.link, lastmod);
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
}
|
|
@@ -75,11 +83,12 @@ export default defineEventHandler(async (event) => {
|
|
|
75
83
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
76
84
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
77
85
|
${Array.from(urls)
|
|
78
|
-
.map(
|
|
79
|
-
(url)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
.map((url) => {
|
|
87
|
+
const lastmod = urlLastmod.get(url);
|
|
88
|
+
return ` <url>
|
|
89
|
+
<loc>${sn(runtimeConfig.public.siteUrl, url)}</loc>${lastmod ? `\n <lastmod>${lastmod}</lastmod>` : ''}
|
|
90
|
+
</url>`;
|
|
91
|
+
})
|
|
83
92
|
.join('\n')}
|
|
84
93
|
</urlset>`.trim();
|
|
85
94
|
|
|
@@ -13,6 +13,10 @@ export interface EruditRuntimeConfig {
|
|
|
13
13
|
elements: string[];
|
|
14
14
|
countElements: (string | string[])[];
|
|
15
15
|
indexPage?: EruditIndexPage;
|
|
16
|
+
lastmod?: {
|
|
17
|
+
type: 'git' | 'custom';
|
|
18
|
+
scriptPath?: string;
|
|
19
|
+
};
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface EruditPublicRuntimeConfig {
|
|
@@ -27,7 +31,6 @@ export interface EruditPublicRuntimeConfig {
|
|
|
27
31
|
ads: boolean;
|
|
28
32
|
fakeApi: {
|
|
29
33
|
repository: boolean;
|
|
30
|
-
lastChanged: boolean | string;
|
|
31
34
|
};
|
|
32
35
|
analytics?: boolean;
|
|
33
36
|
};
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
type LastChangedSource =
|
|
2
|
-
| { type: 'date'; value: string }
|
|
3
|
-
| { type: 'github'; url: string; path: string };
|
|
4
|
-
|
|
5
|
-
function useLastChangedSource(
|
|
6
|
-
contentRelativePath: MaybeRefOrGetter<string | undefined>,
|
|
7
|
-
) {
|
|
8
|
-
return computed((): LastChangedSource | undefined => {
|
|
9
|
-
const path = toValue(contentRelativePath);
|
|
10
|
-
if (!path) return undefined;
|
|
11
|
-
|
|
12
|
-
const debug = ERUDIT.config.debug.fakeApi.lastChanged;
|
|
13
|
-
if (debug === true) return { type: 'date', value: '2024-01-15T12:00:00Z' };
|
|
14
|
-
if (typeof debug === 'string') return { type: 'date', value: debug };
|
|
15
|
-
|
|
16
|
-
const repo = ERUDIT.config.repository;
|
|
17
|
-
if (!repo || repo.type !== 'github') return undefined;
|
|
18
|
-
const parts = repo.name.split('/');
|
|
19
|
-
if (parts.length !== 2) return undefined;
|
|
20
|
-
const [owner, repoName] = parts;
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
type: 'github',
|
|
24
|
-
url: `https://api.github.com/repos/${owner}/${repoName}/commits`,
|
|
25
|
-
path: `content/${path}`,
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function useLastChanged(
|
|
31
|
-
contentRelativePath: MaybeRefOrGetter<string | undefined>,
|
|
32
|
-
) {
|
|
33
|
-
const source = useLastChangedSource(contentRelativePath);
|
|
34
|
-
const date = ref<Date | undefined>(undefined);
|
|
35
|
-
|
|
36
|
-
onMounted(async () => {
|
|
37
|
-
const s = source.value;
|
|
38
|
-
if (!s) return;
|
|
39
|
-
|
|
40
|
-
if (s.type === 'date') {
|
|
41
|
-
date.value = new Date(s.value);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (s.type === 'github') {
|
|
46
|
-
try {
|
|
47
|
-
const data = await $fetch<any[]>(s.url, {
|
|
48
|
-
query: { path: s.path, per_page: 1 },
|
|
49
|
-
responseType: 'json',
|
|
50
|
-
});
|
|
51
|
-
if (Array.isArray(data) && data[0]?.commit?.committer?.date) {
|
|
52
|
-
date.value = new Date(data[0].commit.committer.date);
|
|
53
|
-
}
|
|
54
|
-
} catch {
|
|
55
|
-
// silently ignore API errors
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
return date;
|
|
61
|
-
}
|