erudit 4.3.1 → 4.3.3-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.
- package/app/app.vue +1 -0
- package/app/components/aside/major/PaneHolder.vue +3 -3
- package/app/components/aside/major/contentNav/items/Flags.vue +4 -8
- package/app/components/main/MainBreadcrumbs.vue +27 -19
- package/app/components/main/MainKeyLinks.vue +13 -7
- package/app/components/main/MainSubTitle.vue +4 -3
- package/app/components/main/MainTopicPartPage.vue +3 -1
- package/app/components/main/connections/MainConnections.vue +47 -40
- package/app/components/main/contentStats/MainContentStats.vue +16 -17
- package/app/composables/analytics.ts +15 -8
- package/app/composables/favicon.ts +63 -16
- package/app/composables/formatText.ts +9 -9
- package/app/composables/jsonLd.ts +123 -0
- package/app/composables/lastmod.ts +6 -0
- package/app/composables/og.ts +23 -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 +12 -11
- 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/language/list/en.ts +1 -9
- package/server/erudit/language/list/ru.ts +3 -11
- 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/language.ts +1 -6
- package/shared/types/mainContent.ts +1 -0
- package/shared/types/runtimeConfig.ts +4 -1
- package/app/composables/lastChanged.ts +0 -61
|
@@ -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
|
+
}
|
|
@@ -24,15 +24,6 @@ export const phrases: LanguagePhrases = {
|
|
|
24
24
|
no_content: 'No content.',
|
|
25
25
|
to_index: 'To index',
|
|
26
26
|
about_textbook: 'About textbook',
|
|
27
|
-
flag_title_dev: 'Development',
|
|
28
|
-
flag_hint_dev:
|
|
29
|
-
'This material is not complete, may contain error and will change in the future! Use with caution!',
|
|
30
|
-
flag_title_advanced: 'Advanced',
|
|
31
|
-
flag_hint_advanced:
|
|
32
|
-
'This material is for learners with a high level of knowledge. It contains information that is not suitable for beginners!',
|
|
33
|
-
flag_title_secondary: 'Additional',
|
|
34
|
-
flag_hint_secondary:
|
|
35
|
-
'This is an optional material is for learners who want to dive deeper and gain additional knowledge and context.',
|
|
36
27
|
ads_replacer:
|
|
37
28
|
'We help you. Help us back.<br><strong style="color: inherit;">Disable your ads blocker!</strong>',
|
|
38
29
|
direct_link: 'Direct link',
|
|
@@ -55,6 +46,7 @@ export const phrases: LanguagePhrases = {
|
|
|
55
46
|
flag_secondary: 'Additional',
|
|
56
47
|
flag_secondary_description:
|
|
57
48
|
'This is an optional material is for learners who want to dive deeper and gain additional knowledge and context.',
|
|
49
|
+
breadcrumb: 'Breadcrumb',
|
|
58
50
|
key_elements: 'Key elements',
|
|
59
51
|
stats: 'Statistics',
|
|
60
52
|
connections: 'Connections',
|
|
@@ -24,15 +24,6 @@ export const phrases: LanguagePhrases = {
|
|
|
24
24
|
no_content: 'Контента нет.',
|
|
25
25
|
to_index: 'К оглавлению',
|
|
26
26
|
about_textbook: 'Об учебнике',
|
|
27
|
-
flag_title_dev: 'Разработка',
|
|
28
|
-
flag_hint_dev:
|
|
29
|
-
'Этот материал не завершен, может содержать ошибки и измениться в будущем! Используйте с осторожностью!',
|
|
30
|
-
flag_title_advanced: 'Профиль',
|
|
31
|
-
flag_hint_advanced:
|
|
32
|
-
'Этот материал предназначен для учеников с хорошим уровнем понимания предмета. Информация здесь не предназначена для новичков!',
|
|
33
|
-
flag_title_secondary: 'Дополнение',
|
|
34
|
-
flag_hint_secondary:
|
|
35
|
-
'Это материал для тех, кто хочет глубже погрузиться предмет и получить дополнительные знания и контекст.',
|
|
36
27
|
ads_replacer:
|
|
37
28
|
'Помогите улучшить проект.<br><strong style="color: inherit;">Включите показ рекламы!</strong>',
|
|
38
29
|
direct_link: 'Прямая ссылка',
|
|
@@ -55,7 +46,8 @@ export const phrases: LanguagePhrases = {
|
|
|
55
46
|
'Этот материал предназначен для учеников с высоким уровнем понимания предмета. Информация здесь не предназначена для новичков!',
|
|
56
47
|
flag_secondary: 'Дополнение',
|
|
57
48
|
flag_secondary_description:
|
|
58
|
-
'Это дополнительный материал для тех, кто хочет глубже погрузиться предмет и получить дополнительные знания и контекст.',
|
|
49
|
+
'Это дополнительный материал для тех, кто хочет глубже погрузиться в предмет и получить дополнительные знания и контекст.',
|
|
50
|
+
breadcrumb: 'Путь',
|
|
59
51
|
key_elements: 'Ключевые элементы',
|
|
60
52
|
stats: 'Статистика',
|
|
61
53
|
connections: 'Связи',
|
|
@@ -96,7 +88,7 @@ export const phrases: LanguagePhrases = {
|
|
|
96
88
|
article_seo_description: (contentTitle: string) =>
|
|
97
89
|
`Понятное и интересное объяснение темы «${contentTitle}». Показательные примеры, важные свойства, интересные факты, применение в жизни, понятные доказательства. Здесь вы точно разберетесь!`,
|
|
98
90
|
summary_seo_description: (contentTitle: string) =>
|
|
99
|
-
`Конспект темы «${contentTitle}»: ключевые определения, теоремы, свойства и примеры их
|
|
91
|
+
`Конспект темы «${contentTitle}»: ключевые определения, теоремы, свойства и примеры их использования. Все самое важное и в кратком виде!`,
|
|
100
92
|
practice_seo_description: (contentTitle: string) =>
|
|
101
93
|
`Разнообразные задачи с подсказками и ответами по теме «${contentTitle}». Интересные условия, подсказки и подробные решения. Превратите знания в навык!`,
|
|
102
94
|
externals_own: 'Собственные',
|
|
@@ -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
|
|
package/shared/types/language.ts
CHANGED
|
@@ -34,12 +34,6 @@ export type LanguagePhrases = Phrases<{
|
|
|
34
34
|
no_content: string;
|
|
35
35
|
to_index: string;
|
|
36
36
|
about_textbook: string;
|
|
37
|
-
flag_title_dev: string;
|
|
38
|
-
flag_hint_dev: string;
|
|
39
|
-
flag_title_advanced: string;
|
|
40
|
-
flag_hint_advanced: string;
|
|
41
|
-
flag_title_secondary: string;
|
|
42
|
-
flag_hint_secondary: string;
|
|
43
37
|
ads_replacer: string;
|
|
44
38
|
direct_link: string;
|
|
45
39
|
direct_link_explain: string;
|
|
@@ -58,6 +52,7 @@ export type LanguagePhrases = Phrases<{
|
|
|
58
52
|
flag_advanced_description: string;
|
|
59
53
|
flag_secondary: string;
|
|
60
54
|
flag_secondary_description: string;
|
|
55
|
+
breadcrumb: string;
|
|
61
56
|
key_elements: string;
|
|
62
57
|
stats: string;
|
|
63
58
|
connections: string;
|
|
@@ -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
|
-
}
|