erudit 3.0.0-dev.20 → 3.0.0-dev.21
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 +2 -0
- package/app/assets/icons/cameo-add.svg +3 -0
- package/app/assets/icons/diamond.svg +3 -0
- package/app/components/Avatar.vue +118 -0
- package/app/components/EruditLink.vue +17 -0
- package/app/components/SiteMain.vue +4 -4
- package/app/components/ads/Ads.vue +1 -3
- package/app/components/ads/AdsProviderYandex.vue +59 -22
- package/app/components/aside/AsideListItem.vue +21 -4
- package/app/components/aside/AsideMinor.vue +1 -1
- package/app/components/aside/major/SiteInfo.vue +4 -4
- package/app/components/aside/major/panes/Pages.vue +20 -1
- package/app/components/aside/major/panes/nav/fnav/FNavSeparator.vue +2 -2
- package/app/components/aside/minor/AsideMinorTopLink.vue +3 -4
- package/app/components/aside/minor/contributor/AsideMinorContributor.vue +9 -9
- package/app/components/aside/minor/topic/AsideMinorTopic.vue +3 -1
- package/app/components/aside/minor/topic/TopicContributors.vue +9 -3
- package/app/components/aside/minor/topic/TopicNav.vue +1 -1
- package/app/components/aside/minor/topic/TopicToc.vue +12 -13
- package/app/components/aside/minor/topic/TopicTocItem.vue +1 -14
- package/app/components/bitran/BitranContent.vue +0 -1
- package/app/components/contributor/ContributorListItem.vue +13 -5
- package/app/components/main/MainActionButton.vue +51 -0
- package/app/components/main/MainBitranContent.vue +11 -3
- package/app/components/main/MainBreadcrumb.vue +2 -6
- package/app/components/main/MainSection.vue +58 -0
- package/app/components/main/cameo/MainCameo.vue +135 -0
- package/app/components/main/cameo/MainCameoData.vue +232 -0
- package/app/components/main/content/ContentPopovers.vue +1 -1
- package/app/components/main/topic/MainTopic.vue +13 -18
- package/app/components/main/topic/TopicPartSwitch.vue +7 -12
- package/app/components/preview/PreviewFooterAction.vue +1 -1
- package/app/components/sponsor/SponsorTier1.vue +89 -0
- package/app/components/sponsor/SponsorTier2.vue +109 -0
- package/app/components/tree/TreeItem.vue +8 -4
- package/app/composables/asset.ts +12 -0
- package/app/composables/contentData.ts +1 -1
- package/app/composables/head.ts +24 -0
- package/app/composables/majorPane.ts +1 -0
- package/app/composables/url.ts +17 -7
- package/app/pages/contributor/[contributorId].vue +9 -6
- package/app/pages/contributors.vue +73 -72
- package/app/pages/group/[...groupId].vue +4 -3
- package/app/pages/sponsors.vue +95 -0
- package/app/plugins/prerender.server.ts +14 -2
- package/app/scripts/og.ts +2 -1
- package/const.ts +0 -1
- package/globals/cameo.ts +5 -0
- package/globals/register.ts +5 -0
- package/globals/sponsor.ts +17 -0
- package/languages/en.ts +8 -3
- package/languages/ru.ts +8 -3
- package/module/imports.ts +13 -6
- package/nuxt.config.ts +2 -7
- package/package.json +4 -4
- package/server/api/cameo/data/[cameoId].ts +42 -0
- package/server/api/cameo/ids.ts +5 -0
- package/server/api/prerender/assets/cameo.ts +14 -0
- package/server/api/prerender/assets/contributor.ts +12 -0
- package/server/api/prerender/assets/sponsor.ts +13 -0
- package/server/api/prerender/cameos.ts +12 -0
- package/server/api/{prerender.ts → prerender/language.ts} +3 -13
- package/server/api/sponsor/cameo/data/[sponsorId].ts +51 -0
- package/server/api/sponsor/cameo/ids.ts +5 -0
- package/server/api/sponsor/count.ts +5 -0
- package/server/api/sponsor/list.ts +36 -0
- package/server/plugin/build/process.ts +2 -0
- package/server/plugin/build/rebuild.ts +2 -0
- package/server/plugin/global.ts +2 -0
- package/server/plugin/repository/cameo.ts +16 -0
- package/server/plugin/repository/contributor.ts +35 -4
- package/server/plugin/sponsor/build.ts +82 -0
- package/server/plugin/sponsor/index.ts +5 -0
- package/server/plugin/sponsor/repository.ts +56 -0
- package/server/routes/asset/[...assetPath].ts +34 -0
- package/server/routes/robots.txt.ts +9 -0
- package/server/routes/sitemap.xml.ts +103 -0
- package/shared/asset.ts +0 -5
- package/shared/contributor.ts +1 -0
- package/shared/link.ts +4 -4
- package/shared/types/language.ts +6 -1
- package/test/utils/url.test.ts +99 -0
- package/utils/ext.ts +41 -0
- package/utils/url.ts +23 -0
- package/app/components/contributor/ContributorAvatar.vue +0 -45
- package/app/components/main/content/ContentSection.vue +0 -45
- package/app/public/user.svg +0 -10
- package/app/scripts/aside/minor/topic.ts +0 -3
- package/utils/slash.ts +0 -11
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { glob } from 'glob';
|
|
3
|
+
import type { Sponsor, SponsorConfig } from '@erudit-js/cog/schema';
|
|
4
|
+
|
|
5
|
+
import { PROJECT_DIR } from '#erudit/globalPaths';
|
|
6
|
+
import { ERUDIT_SERVER } from '@server/global';
|
|
7
|
+
import { debug, logger } from '@server/logger';
|
|
8
|
+
|
|
9
|
+
import { getSponsorCount, readSponsorConfig } from './repository';
|
|
10
|
+
|
|
11
|
+
export async function buildSponsors() {
|
|
12
|
+
if (!ERUDIT_SERVER.CONFIG.sponsors) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
debug.start('Building sponsors...');
|
|
17
|
+
|
|
18
|
+
const sponsorIds = await searchSponsorIds();
|
|
19
|
+
|
|
20
|
+
if (sponsorIds.length === 0) {
|
|
21
|
+
logger.warn('No sponsors found!');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ERUDIT_SERVER.SPONSORS = {
|
|
26
|
+
tier1Ids: [],
|
|
27
|
+
tier2Ids: [],
|
|
28
|
+
avatars: {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
for (const sponsorId of sponsorIds) {
|
|
32
|
+
const sponsorAvatar = await searchSponsorAvatar(sponsorId);
|
|
33
|
+
|
|
34
|
+
let config: SponsorConfig<Sponsor>;
|
|
35
|
+
try {
|
|
36
|
+
config = await readSponsorConfig(sponsorId);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
logger.error(
|
|
39
|
+
`Failed to read sponsor config for: ${sponsorId}! ${error}`,
|
|
40
|
+
);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (config.tier === 'tier1') {
|
|
45
|
+
ERUDIT_SERVER.SPONSORS.tier1Ids.push(sponsorId);
|
|
46
|
+
} else if (config.tier === 'tier2') {
|
|
47
|
+
ERUDIT_SERVER.SPONSORS.tier2Ids.push(sponsorId);
|
|
48
|
+
} else {
|
|
49
|
+
logger.warn(
|
|
50
|
+
`Sponsor ${sponsorId} has an unknown tier: ${config.tier}!`,
|
|
51
|
+
);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ERUDIT_SERVER.SPONSORS.avatars[sponsorId] = sponsorAvatar;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.success(
|
|
59
|
+
`Sponsors built successfully!`,
|
|
60
|
+
chalk.dim(`(${getSponsorCount()})`),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function searchSponsorIds() {
|
|
65
|
+
const dirs = await glob(PROJECT_DIR + '/sponsors/*/sponsor.{ts,js}', {
|
|
66
|
+
posix: true,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return dirs.map((dir) => dir.split('/').slice(-2, -1)[0]!);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function searchSponsorAvatar(
|
|
73
|
+
sponsorId: string,
|
|
74
|
+
): Promise<string | undefined> {
|
|
75
|
+
const pattern = `sponsors/${sponsorId}/avatar.*`;
|
|
76
|
+
const avatars = await glob(pattern, {
|
|
77
|
+
cwd: PROJECT_DIR,
|
|
78
|
+
posix: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return avatars.pop();
|
|
82
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SponsorConfig, Sponsor } from '@erudit-js/cog/schema';
|
|
2
|
+
|
|
3
|
+
import { PROJECT_DIR } from '#erudit/globalPaths';
|
|
4
|
+
import { ERUDIT_SERVER } from '@server/global';
|
|
5
|
+
import { IMPORT } from '@server/importer';
|
|
6
|
+
|
|
7
|
+
export function getSponsorIds() {
|
|
8
|
+
const sponsors = ERUDIT_SERVER?.SPONSORS;
|
|
9
|
+
|
|
10
|
+
if (!sponsors) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tier1Ids = sponsors.tier1Ids || [];
|
|
15
|
+
const tier2Ids = sponsors.tier2Ids || [];
|
|
16
|
+
|
|
17
|
+
return [...tier1Ids, ...tier2Ids];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getSponsorCount() {
|
|
21
|
+
return getSponsorIds().length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function readSponsorConfig(sponsorId: string) {
|
|
25
|
+
const config = (await IMPORT(
|
|
26
|
+
`${PROJECT_DIR}/sponsors/${sponsorId}/sponsor`,
|
|
27
|
+
{ default: true },
|
|
28
|
+
)) as SponsorConfig<Sponsor>;
|
|
29
|
+
|
|
30
|
+
return config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function retrieveSponsor(sponsorId: string): Promise<Sponsor> {
|
|
34
|
+
const config = await readSponsorConfig(sponsorId);
|
|
35
|
+
const avatar = ERUDIT_SERVER.SPONSORS?.avatars[sponsorId];
|
|
36
|
+
|
|
37
|
+
if (!config) {
|
|
38
|
+
throw new Error(`Sponsor config not found for ID: ${sponsorId}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...config,
|
|
43
|
+
sponsorId,
|
|
44
|
+
avatar,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getTier2SponsorIds() {
|
|
49
|
+
const sponsors = ERUDIT_SERVER?.SPONSORS;
|
|
50
|
+
|
|
51
|
+
if (!sponsors) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return sponsors.tier2Ids || [];
|
|
56
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
import { PROJECT_DIR } from '#erudit/globalPaths';
|
|
5
|
+
|
|
6
|
+
export default defineEventHandler(async (event) => {
|
|
7
|
+
setHeader(event, 'Content-Type', 'application/octet-stream');
|
|
8
|
+
const assetPath = event.context.params?.assetPath?.trim();
|
|
9
|
+
|
|
10
|
+
if (typeof assetPath !== 'string' || !assetPath) {
|
|
11
|
+
throw createError({
|
|
12
|
+
statusCode: 400,
|
|
13
|
+
message: 'Invalid asset path!',
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const fsPath = PROJECT_DIR + '/' + assetPath;
|
|
18
|
+
|
|
19
|
+
if (!existsSync(fsPath)) {
|
|
20
|
+
throw createError({
|
|
21
|
+
statusCode: 404,
|
|
22
|
+
message: `Asset not found: ${assetPath}`,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const fileContent = await readFile(fsPath).catch((err) => {
|
|
27
|
+
throw createError({
|
|
28
|
+
statusCode: 500,
|
|
29
|
+
message: `Failed to read asset "${assetPath}"! Error: ${err.message}`,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return fileContent;
|
|
34
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Not } from 'typeorm';
|
|
2
|
+
import type { ContentType } from '@erudit-js/cog/schema';
|
|
3
|
+
|
|
4
|
+
import { ERUDIT_SERVER } from '@server/global';
|
|
5
|
+
import { DbTopic } from '@server/db/entities/Topic';
|
|
6
|
+
import { getShortContentId } from '@server/repository/contentId';
|
|
7
|
+
import { DbContent } from '@server/db/entities/Content';
|
|
8
|
+
import { DbContributor } from '@server/db/entities/Contributor';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
createContentLink,
|
|
12
|
+
createContributorLink,
|
|
13
|
+
createTopicPartLink,
|
|
14
|
+
} from '@shared/link';
|
|
15
|
+
import { trailingSlash } from '@erudit/utils/url';
|
|
16
|
+
|
|
17
|
+
export default defineEventHandler(async (event) => {
|
|
18
|
+
setHeader(event, 'Content-Type', 'application/xml');
|
|
19
|
+
|
|
20
|
+
const routes = [
|
|
21
|
+
...staticRoutes(),
|
|
22
|
+
...(await topicRoutes()),
|
|
23
|
+
...(await contentRoutes()),
|
|
24
|
+
...(await contributorsRoutes()),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const buildUrl = ERUDIT_SERVER.CONFIG.site?.buildUrl || '';
|
|
28
|
+
const baseUrl = ERUDIT_SERVER.CONFIG.site?.baseUrl || '/';
|
|
29
|
+
|
|
30
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
31
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
32
|
+
${routes
|
|
33
|
+
.map(
|
|
34
|
+
(route) =>
|
|
35
|
+
` <url>
|
|
36
|
+
<loc>${trailingSlash(buildUrl + baseUrl, false) + route}</loc>
|
|
37
|
+
<lastmod>${new Date().toISOString().slice(0, 10)}</lastmod>
|
|
38
|
+
</url>`,
|
|
39
|
+
)
|
|
40
|
+
.join('\n')}
|
|
41
|
+
</urlset>`;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function staticRoutes() {
|
|
45
|
+
const routes = ['/', '/contributors/'];
|
|
46
|
+
|
|
47
|
+
if (ERUDIT_SERVER.CONFIG.sponsors) {
|
|
48
|
+
routes.push('/sponsors/');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return routes;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function topicRoutes() {
|
|
55
|
+
const dbTopics = await ERUDIT_SERVER.DB.manager.find(DbTopic, {
|
|
56
|
+
select: ['contentId', 'parts'],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const topicRoutes: string[] = [];
|
|
60
|
+
|
|
61
|
+
for (const dbTopic of dbTopics) {
|
|
62
|
+
const shortId = getShortContentId(dbTopic.contentId);
|
|
63
|
+
for (const part of dbTopic.parts) {
|
|
64
|
+
topicRoutes.push(createTopicPartLink(part, shortId));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return topicRoutes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function contentRoutes() {
|
|
72
|
+
const dbContentItems = await ERUDIT_SERVER.DB.manager.find(DbContent, {
|
|
73
|
+
select: ['contentId', 'type'],
|
|
74
|
+
where: {
|
|
75
|
+
type: Not('topic' as ContentType),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const contentRoutes: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const dbContent of dbContentItems) {
|
|
82
|
+
const shortId = getShortContentId(dbContent.contentId);
|
|
83
|
+
contentRoutes.push(createContentLink(dbContent.type, shortId));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return contentRoutes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function contributorsRoutes() {
|
|
90
|
+
const dbContributors = await ERUDIT_SERVER.DB.manager.find(DbContributor, {
|
|
91
|
+
select: ['contributorId'],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const contributorRoutes: string[] = [];
|
|
95
|
+
|
|
96
|
+
for (const dbContributor of dbContributors) {
|
|
97
|
+
contributorRoutes.push(
|
|
98
|
+
createContributorLink(dbContributor.contributorId),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return contributorRoutes;
|
|
103
|
+
}
|
package/shared/asset.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PUBLIC_CONTENT_ASSET,
|
|
3
|
-
PUBLIC_CONTRIBUTOR_ASSET,
|
|
4
3
|
PUBLIC_ERUDIT_ASSET,
|
|
5
4
|
PUBLIC_USER_ASSET,
|
|
6
5
|
} from '@erudit/const';
|
|
@@ -9,10 +8,6 @@ export function eruditAsset(path: string) {
|
|
|
9
8
|
return PUBLIC_ERUDIT_ASSET + path;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
export function contributorAsset(path: string) {
|
|
13
|
-
return PUBLIC_CONTRIBUTOR_ASSET + path;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
export function contentAsset(path: string) {
|
|
17
12
|
return PUBLIC_CONTENT_ASSET + path;
|
|
18
13
|
}
|
package/shared/contributor.ts
CHANGED
package/shared/link.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
} from '@erudit-js/cog/schema';
|
|
6
6
|
|
|
7
7
|
export function createBitranLocationLink(location: BitranLocation) {
|
|
8
|
-
let link = `/${location.type}/${location.path}
|
|
8
|
+
let link = `/${location.type}/${location.path}/`;
|
|
9
9
|
|
|
10
10
|
if (location.unique) link += `#${location.unique}`;
|
|
11
11
|
|
|
@@ -16,13 +16,13 @@ export function createContentLink(contentType: ContentType, contentId: string) {
|
|
|
16
16
|
// if (contentType === 'topic')
|
|
17
17
|
// throw Error(`Use 'createTopicPartLink' to create links to topics!`);
|
|
18
18
|
|
|
19
|
-
return `/${contentType}/${contentId}
|
|
19
|
+
return `/${contentType}/${contentId}/`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function createTopicPartLink(topicPart: TopicPart, contentId: string) {
|
|
23
|
-
return `/${topicPart}/${contentId}
|
|
23
|
+
return `/${topicPart}/${contentId}/`;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function createContributorLink(contributorId: string) {
|
|
27
|
-
return `/contributor/${contributorId}
|
|
27
|
+
return `/contributor/${contributorId}/`;
|
|
28
28
|
}
|
package/shared/types/language.ts
CHANGED
|
@@ -23,7 +23,8 @@ export interface EruditPhrases {
|
|
|
23
23
|
main_page: string;
|
|
24
24
|
contributors: string;
|
|
25
25
|
contributors_page_description: string;
|
|
26
|
-
contributors_page_invite:
|
|
26
|
+
contributors_page_invite: string;
|
|
27
|
+
become_contributor: string;
|
|
27
28
|
contributor: string;
|
|
28
29
|
contribution: string;
|
|
29
30
|
contributions_explain: (count: number) => string;
|
|
@@ -73,6 +74,10 @@ export interface EruditPhrases {
|
|
|
73
74
|
references: string;
|
|
74
75
|
reference_source_featured: string;
|
|
75
76
|
references_description: string;
|
|
77
|
+
// Sponsors
|
|
78
|
+
sponsors: string;
|
|
79
|
+
sponsors_description: string;
|
|
80
|
+
become_sponsor: string;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
export type EruditPhraseId = keyof Partial<EruditPhrases>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { trailingSlash, normalizeUrl } from '../../utils/url';
|
|
2
|
+
|
|
3
|
+
describe('trailingSlash', () => {
|
|
4
|
+
describe('when add is true', () => {
|
|
5
|
+
it('should add trailing slash to path without one', () => {
|
|
6
|
+
expect(trailingSlash('/path', true)).toBe('/path/');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should preserve trailing slash when path already has one', () => {
|
|
10
|
+
expect(trailingSlash('/path/', true)).toBe('/path/');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should return root path unchanged', () => {
|
|
14
|
+
expect(trailingSlash('/', true)).toBe('/');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should add trailing slash to empty path', () => {
|
|
18
|
+
expect(trailingSlash('', true)).toBe('/');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('when add is false', () => {
|
|
23
|
+
it('should remove trailing slash from path', () => {
|
|
24
|
+
expect(trailingSlash('/path/', false)).toBe('/path');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should preserve path without trailing slash', () => {
|
|
28
|
+
expect(trailingSlash('/path', false)).toBe('/path');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return root path unchanged', () => {
|
|
32
|
+
expect(trailingSlash('/', false)).toBe('/');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle empty path', () => {
|
|
36
|
+
expect(trailingSlash('', false)).toBe('');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('normalizeUrl', () => {
|
|
42
|
+
describe('with protocol', () => {
|
|
43
|
+
it('should normalize multiple slashes in path', () => {
|
|
44
|
+
expect(
|
|
45
|
+
normalizeUrl('https://example.com//path//to///resource'),
|
|
46
|
+
).toBe('https://example.com/path/to/resource');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should preserve protocol and domain', () => {
|
|
50
|
+
expect(normalizeUrl('https://example.com/path')).toBe(
|
|
51
|
+
'https://example.com/path',
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle different protocols', () => {
|
|
56
|
+
expect(normalizeUrl('http://example.com//path')).toBe(
|
|
57
|
+
'http://example.com/path',
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should handle custom protocols', () => {
|
|
62
|
+
expect(normalizeUrl('custom+protocol://example.com//path')).toBe(
|
|
63
|
+
'custom+protocol://example.com/path',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('without protocol', () => {
|
|
69
|
+
it('should normalize multiple slashes in relative path', () => {
|
|
70
|
+
expect(normalizeUrl('/path//to///resource')).toBe(
|
|
71
|
+
'/path/to/resource',
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should handle single slash', () => {
|
|
76
|
+
expect(normalizeUrl('/')).toBe('/');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should normalize empty segments', () => {
|
|
80
|
+
expect(normalizeUrl('///')).toBe('/');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should handle path without leading slash', () => {
|
|
84
|
+
expect(normalizeUrl('path//to/resource')).toBe('path/to/resource');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('edge cases', () => {
|
|
89
|
+
it('should handle empty string', () => {
|
|
90
|
+
expect(normalizeUrl('')).toBe('');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should handle query parameters and fragments', () => {
|
|
94
|
+
expect(
|
|
95
|
+
normalizeUrl('https://example.com//path?query=1#fragment'),
|
|
96
|
+
).toBe('https://example.com/path?query=1#fragment');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
package/utils/ext.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const imageExtensions = [
|
|
2
|
+
'png',
|
|
3
|
+
'jpg',
|
|
4
|
+
'jpeg',
|
|
5
|
+
'gif',
|
|
6
|
+
'webp',
|
|
7
|
+
'avif',
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export const videoExtensions = ['mp4', 'webm', 'ogg'] as const;
|
|
11
|
+
|
|
12
|
+
export type FileType = 'image' | 'video' | 'unknown';
|
|
13
|
+
|
|
14
|
+
export function detectFileType(filename: string): FileType {
|
|
15
|
+
const ext = filename.split('.').pop()?.toLowerCase();
|
|
16
|
+
|
|
17
|
+
if (ext) {
|
|
18
|
+
if (imageExtensions.includes(ext as (typeof imageExtensions)[number])) {
|
|
19
|
+
return 'image';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (videoExtensions.includes(ext as (typeof videoExtensions)[number])) {
|
|
23
|
+
return 'video';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return 'unknown';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function detectStrictFileType<T extends FileType>(
|
|
31
|
+
filename: string,
|
|
32
|
+
...types: T[]
|
|
33
|
+
): T {
|
|
34
|
+
const fileType = detectFileType(filename);
|
|
35
|
+
if (!types.includes(fileType as T)) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Invalid file type "${fileType}" of file "${filename}"! Expected one of: ${types.join(', ')}.`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return fileType as T;
|
|
41
|
+
}
|
package/utils/url.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function trailingSlash(path: string, add: boolean): string {
|
|
2
|
+
if (path === '/') {
|
|
3
|
+
return '/';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
if (add) {
|
|
7
|
+
return path.endsWith('/') ? path : `${path}/`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return path.endsWith('/') ? path.slice(0, -1) : path;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeUrl(url: string): string {
|
|
14
|
+
// Handle protocol and origin separately
|
|
15
|
+
const protocolMatch = url.match(/^([a-z][a-z\d+\-.]*:\/\/[^\/]+)/i);
|
|
16
|
+
const protocol = protocolMatch ? protocolMatch[1] : '';
|
|
17
|
+
const pathPart = protocol ? url.slice(protocol.length) : url;
|
|
18
|
+
|
|
19
|
+
// Normalize path by removing empty segments
|
|
20
|
+
const normalizedPath = pathPart.replace(/\/+/g, '/');
|
|
21
|
+
|
|
22
|
+
return protocol + normalizedPath;
|
|
23
|
+
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
<script lang="ts" setup>
|
|
2
|
-
const props = defineProps<{ contributorId: string; avatar?: string }>();
|
|
3
|
-
const baseUrlPath = useBaseUrlPath();
|
|
4
|
-
|
|
5
|
-
const hasAvatar = computed(() => !!props.avatar);
|
|
6
|
-
</script>
|
|
7
|
-
|
|
8
|
-
<template>
|
|
9
|
-
<div
|
|
10
|
-
:class="$style.contributorAvatar"
|
|
11
|
-
:style="{
|
|
12
|
-
...(!hasAvatar
|
|
13
|
-
? {
|
|
14
|
-
backgroundImage: `url(${eruditAsset('user.svg')})`,
|
|
15
|
-
backgroundColor: stringColor(props.contributorId),
|
|
16
|
-
backgroundBlendMode: 'hard-light',
|
|
17
|
-
}
|
|
18
|
-
: {}),
|
|
19
|
-
}"
|
|
20
|
-
>
|
|
21
|
-
<img
|
|
22
|
-
v-if="hasAvatar"
|
|
23
|
-
:src="baseUrlPath(contributorAsset(avatar!))"
|
|
24
|
-
loading="lazy"
|
|
25
|
-
/>
|
|
26
|
-
</div>
|
|
27
|
-
</template>
|
|
28
|
-
|
|
29
|
-
<style lang="scss" module>
|
|
30
|
-
.contributorAvatar {
|
|
31
|
-
--_avatarSize: 40px;
|
|
32
|
-
--_avatarBlendColor: var(--brand);
|
|
33
|
-
|
|
34
|
-
position: relative;
|
|
35
|
-
width: var(--_avatarSize);
|
|
36
|
-
height: var(--_avatarSize);
|
|
37
|
-
border-radius: 50%;
|
|
38
|
-
overflow: hidden;
|
|
39
|
-
background: var(--border);
|
|
40
|
-
|
|
41
|
-
> img {
|
|
42
|
-
display: block;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
</style>
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<hr :class="$style.hr" />
|
|
3
|
-
<slot></slot>
|
|
4
|
-
</template>
|
|
5
|
-
|
|
6
|
-
<style lang="scss" module>
|
|
7
|
-
@mixin shade {
|
|
8
|
-
height: 50px;
|
|
9
|
-
border-bottom: 2px solid var(--border);
|
|
10
|
-
background: linear-gradient(to bottom, transparent, var(--bgAside));
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
@mixin simple {
|
|
14
|
-
height: var(--gap);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.hr {
|
|
18
|
-
border: none;
|
|
19
|
-
|
|
20
|
-
&::before,
|
|
21
|
-
&::after {
|
|
22
|
-
display: block;
|
|
23
|
-
content: '';
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
&:nth-of-type(odd) {
|
|
27
|
-
&::before {
|
|
28
|
-
@include shade;
|
|
29
|
-
}
|
|
30
|
-
&::after {
|
|
31
|
-
@include simple;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
&:nth-of-type(even) {
|
|
36
|
-
&::before {
|
|
37
|
-
@include simple;
|
|
38
|
-
}
|
|
39
|
-
&::after {
|
|
40
|
-
@include shade;
|
|
41
|
-
transform: scaleY(-1);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
</style>
|
package/app/public/user.svg
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
|
|
2
|
-
<defs>
|
|
3
|
-
<linearGradient id="gradient" x1="0" y1="500" x2="500" y2="0" gradientUnits="userSpaceOnUse">
|
|
4
|
-
<stop offset="0" stop-color="#bcbcbc"/>
|
|
5
|
-
<stop offset="1" stop-color="#e6e6e6"/>
|
|
6
|
-
</linearGradient>
|
|
7
|
-
</defs>
|
|
8
|
-
<rect width="500" height="500" fill="url(#gradient)"/>
|
|
9
|
-
<path d="M250,224c-20.62,0-38.28-7.34-52.97-22.03-14.69-14.69-22.03-32.34-22.03-52.97s7.34-38.28,22.03-52.97,32.34-22.03,52.97-22.03,38.28,7.34,52.97,22.03,22.03,32.34,22.03,52.97-7.34,38.28-22.03,52.97c-14.69,14.69-32.34,22.03-52.97,22.03ZM100,374v-52.5c0-10.62,2.73-20.39,8.2-29.3,5.47-8.91,12.73-15.7,21.8-20.39,19.38-9.69,39.06-16.95,59.06-21.8,20-4.84,40.31-7.27,60.94-7.27s40.94,2.42,60.94,7.27c20,4.84,39.69,12.11,59.06,21.8,9.06,4.69,16.33,11.48,21.8,20.39,5.47,8.91,8.2,18.67,8.2,29.3v52.5H100ZM137.5,336.5h225v-15c0-3.44-.86-6.56-2.58-9.37-1.72-2.81-3.98-5-6.8-6.56-16.87-8.44-33.91-14.77-51.09-18.98-17.19-4.22-34.53-6.33-52.03-6.33s-34.84,2.11-52.03,6.33c-17.19,4.22-34.22,10.55-51.09,18.98-2.81,1.56-5.08,3.75-6.8,6.56s-2.58,5.94-2.58,9.37v15ZM250,186.5c10.31,0,19.14-3.67,26.48-11.02,7.34-7.34,11.02-16.17,11.02-26.48s-3.67-19.14-11.02-26.48c-7.34-7.34-16.17-11.02-26.48-11.02s-19.14,3.67-26.48,11.02c-7.34,7.34-11.02,16.17-11.02,26.48s3.67,19.14,11.02,26.48c7.34,7.34,16.17,11.02,26.48,11.02Z" fill="#666"/>
|
|
10
|
-
</svg>
|
package/utils/slash.ts
DELETED