lightnet 4.7.0 → 4.8.0
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/CHANGELOG.md +28 -0
- package/package.json +8 -8
- package/src/components/CategoriesSection.astro +0 -1
- package/src/components/MediaGallerySection.astro +0 -1
- package/src/content/schema/media-type.ts +16 -0
- package/src/i18n/locals.ts +0 -1
- package/src/i18n/record-translation.ts +14 -0
- package/src/i18n/resolve-current-locale.ts +1 -6
- package/src/i18n/translate.ts +13 -37
- package/src/i18n/translations.ts +40 -9
- package/src/layouts/Page.astro +22 -6
- package/src/layouts/components/footer/Credits.astro +8 -1
- package/src/layouts/components/get-language-selection-menu-items.ts +2 -2
- package/src/layouts/components/meta/SearchEngineMetadata.astro +28 -0
- package/src/pages/details-page/AudioDetailsPage.astro +11 -1
- package/src/pages/details-page/DefaultDetailsPage.astro +11 -1
- package/src/pages/details-page/VideoDetailsPage.astro +11 -1
- package/src/pages/details-page/components/DetailsPage.astro +19 -1
- package/src/pages/details-page/utils/create-media-item-json-ld.ts +176 -0
- package/src/pages/details-page/utils/get-details-page-json-ld.ts +48 -0
- package/src/pages/search-page/hooks/use-search.ts +1 -2
- package/src/utils/paths.ts +19 -40
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# lightnet
|
|
2
2
|
|
|
3
|
+
## 4.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Add an optional `schemaType` field to media types, supporting `CreativeWork`, `Book`, `Article`, `DigitalDocument`, `AudioObject`, `VideoObject`, and `ImageObject`. Explicit types determine the main JSON-LD type independently of the page layout, while audio, video, and document files retain their own types. Omitting the field preserves automatic inference. The field is also available in the admin interface.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Add canonical information to pages.
|
|
12
|
+
|
|
13
|
+
- [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Add a `head` slot to `Page` for custom head content and an optional `jsonLd` prop to `DetailsPage` for safely rendering structured data in the page head.
|
|
14
|
+
|
|
15
|
+
Generate JSON-LD for audio, video, and default media detail pages, including descriptive item metadata, recognized file encodings, video player URLs, and individual audio tracks.
|
|
16
|
+
|
|
17
|
+
- [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Fix carousel accessibility by removing group role from items.
|
|
18
|
+
|
|
19
|
+
- [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Removed behaviour around adding Astro base path.
|
|
20
|
+
|
|
21
|
+
- [#473](https://github.com/LightNetDev/LightNet/pull/473) [`9e673cf`](https://github.com/LightNetDev/LightNet/commit/9e673cfdc58cab88f96d2f3a22d23b327be626a6) - Update dependencies.
|
|
22
|
+
|
|
23
|
+
## 4.7.1
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- [#469](https://github.com/LightNetDev/LightNet/pull/469) [`a0961c4`](https://github.com/LightNetDev/LightNet/commit/a0961c40fdc30a93d3d5c814343f9c12ec26b2ef) - Warn about user-defined ln.* translation overrides that no longer match a built-in English key. Include source filenames and check unused keys without failing check-translations for stale overrides alone.
|
|
28
|
+
|
|
29
|
+
- [#469](https://github.com/LightNetDev/LightNet/pull/469) [`a0961c4`](https://github.com/LightNetDev/LightNet/commit/a0961c40fdc30a93d3d5c814343f9c12ec26b2ef) - Only record translation keys requested during the build, so check-translations no longer reports missing translations for unused built-in or user keys.
|
|
30
|
+
|
|
3
31
|
## 4.7.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "LightNet makes it easy to run your own digital media library.",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "4.
|
|
6
|
+
"version": "4.8.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/LightNetDev/lightnet",
|
|
@@ -54,19 +54,19 @@
|
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@astrojs/react": "^5.0.7",
|
|
56
56
|
"@astrojs/sitemap": "^3.7.4",
|
|
57
|
-
"@iconify-json/lucide": "^1.2.
|
|
57
|
+
"@iconify-json/lucide": "^1.2.150",
|
|
58
58
|
"@iconify-json/mdi": "^1.2.3",
|
|
59
59
|
"@iconify/tailwind": "^1.2.0",
|
|
60
60
|
"@tailwindcss/typography": "^0.5.20",
|
|
61
|
-
"@tanstack/react-virtual": "^3.14.
|
|
62
|
-
"autoprefixer": "^10.6.
|
|
61
|
+
"@tanstack/react-virtual": "^3.14.24",
|
|
62
|
+
"autoprefixer": "^10.6.2",
|
|
63
63
|
"embla-carousel": "^8.6.0",
|
|
64
64
|
"embla-carousel-wheel-gestures": "^8.1.0",
|
|
65
65
|
"fuse.js": "^7.5.0",
|
|
66
|
-
"i18next": "^26.
|
|
67
|
-
"lucide-react": "^1.
|
|
68
|
-
"marked": "^18.1.
|
|
69
|
-
"postcss": "^8.5.
|
|
66
|
+
"i18next": "^26.6.0",
|
|
67
|
+
"lucide-react": "^1.66.0",
|
|
68
|
+
"marked": "^18.1.2",
|
|
69
|
+
"postcss": "^8.5.32",
|
|
70
70
|
"postcss-load-config": "^6.0.1",
|
|
71
71
|
"yaml": "^2.9.1"
|
|
72
72
|
},
|
|
@@ -84,7 +84,6 @@ function getImage({ image, id }: Category) {
|
|
|
84
84
|
{categories.map((category) => (
|
|
85
85
|
<li
|
|
86
86
|
class="carousel-item--narrow"
|
|
87
|
-
role="group"
|
|
88
87
|
aria-roledescription="slide"
|
|
89
88
|
class:list={[
|
|
90
89
|
/** Category cards without images should take the full height of other cards */
|
|
@@ -12,6 +12,22 @@ export const mediaTypeSchema = z.object({
|
|
|
12
12
|
* Label translated for the default locale. Other configured site locales are optional.
|
|
13
13
|
*/
|
|
14
14
|
label: translationMapSchema,
|
|
15
|
+
/**
|
|
16
|
+
* Schema.org type of the work described on media detail pages.
|
|
17
|
+
* Independent of the page layout and downloadable file formats.
|
|
18
|
+
* If omitted, structured data is inferred from the layout and content.
|
|
19
|
+
*/
|
|
20
|
+
schemaType: z
|
|
21
|
+
.enum([
|
|
22
|
+
"CreativeWork",
|
|
23
|
+
"Book",
|
|
24
|
+
"Article",
|
|
25
|
+
"DigitalDocument",
|
|
26
|
+
"AudioObject",
|
|
27
|
+
"VideoObject",
|
|
28
|
+
"ImageObject",
|
|
29
|
+
])
|
|
30
|
+
.optional(),
|
|
15
31
|
/**
|
|
16
32
|
* Defines how the cover image for a media item of this type is rendered.
|
|
17
33
|
*
|
package/src/i18n/locals.ts
CHANGED
|
@@ -12,7 +12,6 @@ export const onRequest: MiddlewareHandler = async ({ locals, url }, next) => {
|
|
|
12
12
|
const locales = config.locales
|
|
13
13
|
const currentLocale = resolveCurrentLocaleFromPathname({
|
|
14
14
|
pathname: url.pathname,
|
|
15
|
-
base: import.meta.env.BASE_URL,
|
|
16
15
|
locales,
|
|
17
16
|
defaultLocale,
|
|
18
17
|
})
|
|
@@ -25,6 +25,20 @@ const recordedTranslations = new Set<string>()
|
|
|
25
25
|
|
|
26
26
|
const translationStore = lazy(() => createTranslationStore())
|
|
27
27
|
|
|
28
|
+
export async function writeStaleTranslationOverrides(
|
|
29
|
+
overrides: { key: string; path: string }[],
|
|
30
|
+
) {
|
|
31
|
+
if (!import.meta.env.PROD) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
await mkdir(lightnetCachePath, { recursive: true })
|
|
35
|
+
await writeFile(
|
|
36
|
+
resolve(lightnetCachePath, "stale-translation-overrides.json"),
|
|
37
|
+
JSON.stringify(overrides),
|
|
38
|
+
"utf-8",
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
const writeLanguagesManifest = async () => {
|
|
29
43
|
const manifestPath = resolve(lightnetCachePath, "languages.json")
|
|
30
44
|
const { defaultLocale, locales } = config
|
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
import { pathWithoutBase } from "../utils/paths"
|
|
2
|
-
|
|
3
1
|
export function resolveCurrentLocaleFromPathname({
|
|
4
2
|
pathname,
|
|
5
|
-
base,
|
|
6
3
|
locales,
|
|
7
4
|
defaultLocale,
|
|
8
5
|
}: {
|
|
9
6
|
pathname: string
|
|
10
|
-
base?: string
|
|
11
7
|
locales: string[]
|
|
12
8
|
defaultLocale: string
|
|
13
9
|
}) {
|
|
14
|
-
const
|
|
15
|
-
const firstPathSegment = pathnameWithoutBase.split("/")[1]
|
|
10
|
+
const firstPathSegment = pathname.split("/")[1]
|
|
16
11
|
|
|
17
12
|
return locales.includes(firstPathSegment) ? firstPathSegment : defaultLocale
|
|
18
13
|
}
|
package/src/i18n/translate.ts
CHANGED
|
@@ -68,7 +68,6 @@ export async function useTranslate(
|
|
|
68
68
|
const resolvedLocale = bcp47 ?? config.defaultLocale
|
|
69
69
|
const translations = await i18nextTranslations.get()
|
|
70
70
|
const availableTranslationKeys = new Set(await translationKeys.get())
|
|
71
|
-
recordAllTranslations()
|
|
72
71
|
|
|
73
72
|
const i18n = i18next.createInstance()
|
|
74
73
|
|
|
@@ -87,6 +86,19 @@ export async function useTranslate(
|
|
|
87
86
|
"en",
|
|
88
87
|
]
|
|
89
88
|
return (input, options) => {
|
|
89
|
+
if (import.meta.env.PROD) {
|
|
90
|
+
recordTranslation({
|
|
91
|
+
key: input,
|
|
92
|
+
type: input.startsWith("ln.") ? "lightnet" : "user",
|
|
93
|
+
values: Object.fromEntries(
|
|
94
|
+
config.locales.map((locale) => [
|
|
95
|
+
locale,
|
|
96
|
+
translations[locale]?.translation[input],
|
|
97
|
+
]),
|
|
98
|
+
),
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
90
102
|
const t = i18n.getFixedT(resolvedLocale) as (
|
|
91
103
|
key: TranslationKey,
|
|
92
104
|
options?: TOptions,
|
|
@@ -101,39 +113,3 @@ export async function useTranslate(
|
|
|
101
113
|
return value
|
|
102
114
|
}
|
|
103
115
|
}
|
|
104
|
-
|
|
105
|
-
async function recordAllTranslations() {
|
|
106
|
-
if (!import.meta.env.PROD) {
|
|
107
|
-
// only record translations during build time
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const { locales } = config
|
|
112
|
-
const localesIncludingEnglish = [...locales.filter((l) => l !== "en"), "en"]
|
|
113
|
-
const keys = await translationKeys.get()
|
|
114
|
-
const translations = await i18nextTranslations.get()
|
|
115
|
-
|
|
116
|
-
const collectValues = (key: string, locales: string[]) => {
|
|
117
|
-
const values = {} as Record<string, string | undefined>
|
|
118
|
-
for (const locale of locales) {
|
|
119
|
-
values[locale] = translations[locale]?.translation[key]
|
|
120
|
-
}
|
|
121
|
-
return values
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
for (const key of keys) {
|
|
125
|
-
if (key.startsWith("ln.")) {
|
|
126
|
-
recordTranslation({
|
|
127
|
-
key,
|
|
128
|
-
values: collectValues(key, localesIncludingEnglish),
|
|
129
|
-
type: "lightnet",
|
|
130
|
-
})
|
|
131
|
-
} else {
|
|
132
|
-
recordTranslation({
|
|
133
|
-
key,
|
|
134
|
-
values: collectValues(key, locales),
|
|
135
|
-
type: "user",
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
package/src/i18n/translations.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from "astro/zod"
|
|
2
2
|
import YAML from "yaml"
|
|
3
3
|
|
|
4
|
+
import { lazy } from "../utils/lazy"
|
|
5
|
+
import { writeStaleTranslationOverrides } from "./record-translation"
|
|
6
|
+
|
|
4
7
|
const builtInTranslations = {
|
|
5
8
|
ar: () => import("./translations/ar.yml?raw"),
|
|
6
9
|
bn: () => import("./translations/bn.yml?raw"),
|
|
@@ -22,24 +25,52 @@ type BuiltInLanguage = keyof typeof builtInTranslations
|
|
|
22
25
|
|
|
23
26
|
const translationFileSchema = z.record(z.string(), z.string())
|
|
24
27
|
|
|
28
|
+
const userTranslationFiles = import.meta.glob<string>(
|
|
29
|
+
["/src/translations/*.(yml|yaml)"],
|
|
30
|
+
{ query: "?raw", import: "default" },
|
|
31
|
+
)
|
|
32
|
+
|
|
25
33
|
const userTranslations = Object.fromEntries(
|
|
26
|
-
Object.entries(
|
|
27
|
-
import.meta.glob(["/src/translations/*.(yml|yaml)"], {
|
|
28
|
-
query: "?raw",
|
|
29
|
-
import: "default",
|
|
30
|
-
}),
|
|
31
|
-
).map(([path, translationImport]) => {
|
|
34
|
+
Object.entries(userTranslationFiles).map(([path, translationImport]) => {
|
|
32
35
|
const [fileName] = path.split("/").slice(-1)
|
|
33
36
|
const lang = fileName.replace(/\.ya?ml/, "")
|
|
34
37
|
return [lang, translationImport]
|
|
35
38
|
}),
|
|
36
39
|
)
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
const staleOverrides = lazy(async () => {
|
|
42
|
+
const english = await loadBuiltInTranslations(builtInTranslations, "en")
|
|
43
|
+
await writeStaleTranslationOverrides(
|
|
44
|
+
await findStaleTranslationOverrides(english, userTranslationFiles),
|
|
45
|
+
)
|
|
41
46
|
})
|
|
42
47
|
|
|
48
|
+
export async function findStaleTranslationOverrides(
|
|
49
|
+
english: Record<string, string>,
|
|
50
|
+
files: Record<string, () => Promise<string>>,
|
|
51
|
+
) {
|
|
52
|
+
const overrides: { key: string; path: string }[] = []
|
|
53
|
+
for (const [path, load] of Object.entries(files)) {
|
|
54
|
+
const translations = translationFileSchema.parse(YAML.parse(await load()))
|
|
55
|
+
for (const key of Object.keys(translations)) {
|
|
56
|
+
if (key.startsWith("ln.") && !Object.hasOwn(english, key)) {
|
|
57
|
+
overrides.push({ key, path: path.replace(/^\//, "") })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return overrides
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const loadTranslations = async (bcp47: string) => {
|
|
65
|
+
if (import.meta.env.PROD) {
|
|
66
|
+
await staleOverrides.get()
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...(await loadBuiltInTranslations(builtInTranslations, bcp47)),
|
|
70
|
+
...(await loadUserTranslations(bcp47)),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
43
74
|
function hasTranslations(
|
|
44
75
|
translationMap: Record<string, unknown>,
|
|
45
76
|
bcp47: string,
|
package/src/layouts/Page.astro
CHANGED
|
@@ -6,10 +6,10 @@ import CustomHead from "virtual:lightnet/components/CustomHead"
|
|
|
6
6
|
import config from "virtual:lightnet/config"
|
|
7
7
|
|
|
8
8
|
import { resolveLanguage } from "../i18n/resolve-language"
|
|
9
|
-
import { pathWithBase } from "../utils/paths"
|
|
10
9
|
import Footer from "./components/footer/Footer.astro"
|
|
11
10
|
import Header from "./components/header/Header.astro"
|
|
12
11
|
import Favicon from "./components/meta/Favicon.astro"
|
|
12
|
+
import SearchEngineMetadata from "./components/meta/SearchEngineMetadata.astro"
|
|
13
13
|
import ViewTransition from "./components/meta/ViewTransition.astro"
|
|
14
14
|
|
|
15
15
|
export interface Props {
|
|
@@ -47,6 +47,17 @@ export interface Props {
|
|
|
47
47
|
* default: false
|
|
48
48
|
*/
|
|
49
49
|
disallowSearchIndexing?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Absolute URL of the preferred version of this page, used in the
|
|
52
|
+
* `<link rel="canonical">` tag to help search engines consolidate duplicate URLs.
|
|
53
|
+
*
|
|
54
|
+
* Defaults to the current pathname with a trailing slash, resolved against
|
|
55
|
+
* Astro's `site` URL, without query parameters or a fragment.
|
|
56
|
+
* Only included when search indexing is enabled.
|
|
57
|
+
*
|
|
58
|
+
* @example "https://example.com/about/"
|
|
59
|
+
*/
|
|
60
|
+
canonicalUrl?: string | URL
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
const {
|
|
@@ -56,6 +67,7 @@ const {
|
|
|
56
67
|
locale,
|
|
57
68
|
disableShouldTrack,
|
|
58
69
|
disallowSearchIndexing,
|
|
70
|
+
canonicalUrl,
|
|
59
71
|
} = Astro.props
|
|
60
72
|
const { currentLocale: pathCurrentLocale, tConfigField } = Astro.locals.i18n
|
|
61
73
|
const currentLocale = locale ?? pathCurrentLocale
|
|
@@ -72,16 +84,20 @@ const { direction } = resolveLanguage(currentLocale)
|
|
|
72
84
|
<head>
|
|
73
85
|
<meta charset="UTF-8" />
|
|
74
86
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
75
|
-
{(disallowSearchIndexing ?? config.disallowSearchIndexing) && (
|
|
76
|
-
<meta name="robots" content="noindex" />
|
|
77
|
-
)}
|
|
78
|
-
{CustomHead && <CustomHead />}
|
|
79
87
|
<title>{title ? `${title} | ${configTitle}` : configTitle}</title>
|
|
80
88
|
{description && <meta name="description" content={description} />}
|
|
81
89
|
{config.manifest && <link rel="manifest" href={config.manifest} />}
|
|
82
|
-
<link rel="prefetch" href=
|
|
90
|
+
<link rel="prefetch" href="/api/internal/search.json" />
|
|
83
91
|
<Favicon />
|
|
84
92
|
<ViewTransition />
|
|
93
|
+
<SearchEngineMetadata
|
|
94
|
+
disallowSearchIndexing={disallowSearchIndexing}
|
|
95
|
+
canonicalUrl={
|
|
96
|
+
typeof canonicalUrl === "string" ? new URL(canonicalUrl) : canonicalUrl
|
|
97
|
+
}
|
|
98
|
+
/>
|
|
99
|
+
{CustomHead && <CustomHead />}
|
|
100
|
+
<slot name="head" />
|
|
85
101
|
</head>
|
|
86
102
|
<body class="flex min-h-screen flex-col overflow-y-scroll bg-gray-50 text-gray-900">
|
|
87
103
|
<Header />
|
|
@@ -9,6 +9,13 @@ const { t } = Astro.locals.i18n
|
|
|
9
9
|
class="inline-flex items-center gap-2 whitespace-nowrap underline-offset-4 hover:underline"
|
|
10
10
|
{...getLinkAttributes("https://lightnet.community")}
|
|
11
11
|
>
|
|
12
|
-
<img
|
|
12
|
+
<img
|
|
13
|
+
src={LightNetLogo.src}
|
|
14
|
+
width="141"
|
|
15
|
+
height="173"
|
|
16
|
+
alt=""
|
|
17
|
+
class="h-5 w-auto"
|
|
18
|
+
loading="lazy"
|
|
19
|
+
/>
|
|
13
20
|
<span>{t("ln.footer.powered-by-lightnet")}</span>
|
|
14
21
|
</a>
|
|
@@ -2,7 +2,7 @@ import config from "virtual:lightnet/config"
|
|
|
2
2
|
|
|
3
3
|
import { resolveLanguage } from "../../i18n/resolve-language"
|
|
4
4
|
import type { TranslateConfigFieldFn } from "../../i18n/translate-map"
|
|
5
|
-
import { localizePath
|
|
5
|
+
import { localizePath } from "../../utils/paths"
|
|
6
6
|
|
|
7
7
|
export type LanguageLink = {
|
|
8
8
|
active: boolean
|
|
@@ -20,7 +20,7 @@ export function getLanguageSelectionMenuItems({
|
|
|
20
20
|
pathname: string
|
|
21
21
|
tConfigField: TranslateConfigFieldFn
|
|
22
22
|
}) {
|
|
23
|
-
const currentPath =
|
|
23
|
+
const currentPath = pathname
|
|
24
24
|
const hasLocale = Boolean(
|
|
25
25
|
currentLocale &&
|
|
26
26
|
(currentPath.startsWith(`/${currentLocale}/`) ||
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
import config from "virtual:lightnet/config"
|
|
3
|
+
|
|
4
|
+
import { ensureTrailingSlash } from "../../../utils/paths"
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
disallowSearchIndexing?: boolean
|
|
8
|
+
canonicalUrl?: URL
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { disallowSearchIndexing, canonicalUrl: canonicalUrlProp } = Astro.props
|
|
12
|
+
const searchIndexing = !(
|
|
13
|
+
disallowSearchIndexing ?? config.disallowSearchIndexing
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const canonicalUrl =
|
|
17
|
+
canonicalUrlProp ??
|
|
18
|
+
new URL(ensureTrailingSlash(Astro.url.pathname), Astro.site)
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{!searchIndexing && <meta name="robots" content="noindex" />}
|
|
22
|
+
|
|
23
|
+
{searchIndexing && (
|
|
24
|
+
<>
|
|
25
|
+
<link rel="sitemap" href="/sitemap-index.xml" />
|
|
26
|
+
<link rel="canonical" href={canonicalUrl.href} />
|
|
27
|
+
</>
|
|
28
|
+
)}
|
|
@@ -6,15 +6,25 @@ import DetailsPage from "./components/DetailsPage.astro"
|
|
|
6
6
|
import MainDetailsSection from "./components/MainDetailsSection.astro"
|
|
7
7
|
import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
|
|
8
8
|
import MoreDetailsSection from "./components/MoreDetailsSection.astro"
|
|
9
|
+
import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
|
|
9
10
|
|
|
10
11
|
export type Props = {
|
|
11
12
|
mediaId: string
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
const { mediaId } = Astro.props
|
|
16
|
+
const { defaultLocale, tContentField } = Astro.locals.i18n
|
|
17
|
+
|
|
18
|
+
const jsonLd = await getDetailsPageJsonLd({
|
|
19
|
+
mediaId,
|
|
20
|
+
layout: "audio",
|
|
21
|
+
site: Astro.site,
|
|
22
|
+
defaultLocale,
|
|
23
|
+
tContentField,
|
|
24
|
+
})
|
|
15
25
|
---
|
|
16
26
|
|
|
17
|
-
<DetailsPage mediaId={mediaId}>
|
|
27
|
+
<DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
|
|
18
28
|
<MainDetailsSection imageSize="md" mediaId={mediaId} />
|
|
19
29
|
<AudioPanel mediaId={mediaId} className="mt-10" />
|
|
20
30
|
<DescriptionSection mediaId={mediaId} />
|
|
@@ -6,14 +6,24 @@ import OpenButton from "./components/main-details/OpenButton.astro"
|
|
|
6
6
|
import MainDetailsSection from "./components/MainDetailsSection.astro"
|
|
7
7
|
import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
|
|
8
8
|
import MoreDetailsSection from "./components/MoreDetailsSection.astro"
|
|
9
|
+
import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
|
|
9
10
|
|
|
10
11
|
export type Props = {
|
|
11
12
|
mediaId: string
|
|
12
13
|
}
|
|
13
14
|
const { mediaId } = Astro.props
|
|
15
|
+
const { defaultLocale, tContentField } = Astro.locals.i18n
|
|
16
|
+
|
|
17
|
+
const jsonLd = await getDetailsPageJsonLd({
|
|
18
|
+
mediaId,
|
|
19
|
+
layout: "default",
|
|
20
|
+
site: Astro.site,
|
|
21
|
+
defaultLocale,
|
|
22
|
+
tContentField,
|
|
23
|
+
})
|
|
14
24
|
---
|
|
15
25
|
|
|
16
|
-
<DetailsPage mediaId={mediaId}>
|
|
26
|
+
<DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
|
|
17
27
|
<MainDetailsSection mediaId={mediaId}>
|
|
18
28
|
<OpenButton className="mt-8" mediaId={mediaId} />
|
|
19
29
|
</MainDetailsSection>
|
|
@@ -5,15 +5,25 @@ import DetailsPage from "./components/DetailsPage.astro"
|
|
|
5
5
|
import MediaCollectionsSection from "./components/MediaCollectionsSection.astro"
|
|
6
6
|
import MoreDetailsSection from "./components/MoreDetailsSection.astro"
|
|
7
7
|
import VideoDetailsSection from "./components/VideoDetailsSection.astro"
|
|
8
|
+
import { getDetailsPageJsonLd } from "./utils/get-details-page-json-ld"
|
|
8
9
|
|
|
9
10
|
export type Props = {
|
|
10
11
|
mediaId: string
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
const { mediaId } = Astro.props
|
|
15
|
+
const { defaultLocale, tContentField } = Astro.locals.i18n
|
|
16
|
+
|
|
17
|
+
const jsonLd = await getDetailsPageJsonLd({
|
|
18
|
+
mediaId,
|
|
19
|
+
layout: "video",
|
|
20
|
+
site: Astro.site,
|
|
21
|
+
defaultLocale,
|
|
22
|
+
tContentField,
|
|
23
|
+
})
|
|
14
24
|
---
|
|
15
25
|
|
|
16
|
-
<DetailsPage mediaId={mediaId}>
|
|
26
|
+
<DetailsPage mediaId={mediaId} jsonLd={jsonLd}>
|
|
17
27
|
<VideoDetailsSection mediaId={mediaId} />
|
|
18
28
|
<DescriptionSection mediaId={mediaId} />
|
|
19
29
|
<ContentSection mediaId={mediaId} />
|
|
@@ -2,17 +2,35 @@
|
|
|
2
2
|
import { getMediaItem } from "../../../content/get-media-items"
|
|
3
3
|
import Page from "../../../layouts/Page.astro"
|
|
4
4
|
import { markdownToText } from "../../../utils/markdown"
|
|
5
|
+
import { detailsPagePath } from "../../../utils/paths"
|
|
5
6
|
|
|
6
7
|
type Props = {
|
|
7
8
|
mediaId: string
|
|
9
|
+
/** Structured data to include in the page head as JSON-LD. */
|
|
10
|
+
jsonLd?: Record<string, unknown>
|
|
8
11
|
}
|
|
9
12
|
|
|
10
|
-
const
|
|
13
|
+
const { mediaId: id, jsonLd } = Astro.props
|
|
14
|
+
const mediaItem = (await getMediaItem(id)).data
|
|
11
15
|
---
|
|
12
16
|
|
|
13
17
|
<Page
|
|
14
18
|
title={mediaItem.title}
|
|
15
19
|
description={markdownToText(mediaItem.description)}
|
|
20
|
+
canonicalUrl={
|
|
21
|
+
new URL(
|
|
22
|
+
detailsPagePath(Astro.locals.i18n.defaultLocale, { id }),
|
|
23
|
+
Astro.site,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
16
26
|
>
|
|
27
|
+
<Fragment slot="head">
|
|
28
|
+
{jsonLd && (
|
|
29
|
+
<script
|
|
30
|
+
type="application/ld+json"
|
|
31
|
+
set:html={JSON.stringify(jsonLd).replace(/</g, "\\u003c")}
|
|
32
|
+
/>
|
|
33
|
+
)}
|
|
34
|
+
</Fragment>
|
|
17
35
|
<slot />
|
|
18
36
|
</Page>
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { MediaItemEntry } from "../../../content/schema/media-item"
|
|
2
|
+
import type { MediaTypeEntry } from "../../../content/schema/media-type"
|
|
3
|
+
import { markdownToText } from "../../../utils/markdown"
|
|
4
|
+
import { createContentMetadata } from "./create-content-metadata"
|
|
5
|
+
|
|
6
|
+
const FILE_FORMATS: Record<string, { type: string; mime: string }> = {
|
|
7
|
+
mp3: { type: "AudioObject", mime: "audio/mpeg" },
|
|
8
|
+
wav: { type: "AudioObject", mime: "audio/wav" },
|
|
9
|
+
aac: { type: "AudioObject", mime: "audio/aac" },
|
|
10
|
+
ogg: { type: "AudioObject", mime: "audio/ogg" },
|
|
11
|
+
mp4: { type: "VideoObject", mime: "video/mp4" },
|
|
12
|
+
webm: { type: "VideoObject", mime: "video/webm" },
|
|
13
|
+
ogv: { type: "VideoObject", mime: "video/ogg" },
|
|
14
|
+
jpg: { type: "ImageObject", mime: "image/jpeg" },
|
|
15
|
+
jpeg: { type: "ImageObject", mime: "image/jpeg" },
|
|
16
|
+
png: { type: "ImageObject", mime: "image/png" },
|
|
17
|
+
gif: { type: "ImageObject", mime: "image/gif" },
|
|
18
|
+
webp: { type: "ImageObject", mime: "image/webp" },
|
|
19
|
+
avif: { type: "ImageObject", mime: "image/avif" },
|
|
20
|
+
svg: { type: "ImageObject", mime: "image/svg+xml" },
|
|
21
|
+
pdf: { type: "MediaObject", mime: "application/pdf" },
|
|
22
|
+
epub: { type: "MediaObject", mime: "application/epub+zip" },
|
|
23
|
+
txt: { type: "MediaObject", mime: "text/plain" },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type DetailsPageLayout = "default" | "audio" | "video"
|
|
27
|
+
|
|
28
|
+
export function createMediaItemJsonLd({
|
|
29
|
+
item,
|
|
30
|
+
layout,
|
|
31
|
+
schemaType,
|
|
32
|
+
canonicalUrl,
|
|
33
|
+
imageUrl,
|
|
34
|
+
categoryNames = [],
|
|
35
|
+
contentLabels = [],
|
|
36
|
+
}: {
|
|
37
|
+
item: MediaItemEntry["data"]
|
|
38
|
+
layout: DetailsPageLayout
|
|
39
|
+
schemaType?: MediaTypeEntry["data"]["schemaType"]
|
|
40
|
+
canonicalUrl: URL
|
|
41
|
+
imageUrl: string
|
|
42
|
+
categoryNames?: string[]
|
|
43
|
+
contentLabels?: (string | undefined)[]
|
|
44
|
+
}): Record<string, unknown> {
|
|
45
|
+
const base = {
|
|
46
|
+
"@context": "https://schema.org",
|
|
47
|
+
"@type": schemaType ?? "CreativeWork",
|
|
48
|
+
"@id": `${canonicalUrl.href}#work`,
|
|
49
|
+
url: canonicalUrl.href,
|
|
50
|
+
name: item.title,
|
|
51
|
+
description: markdownToText(item.description) || undefined,
|
|
52
|
+
image: imageUrl,
|
|
53
|
+
inLanguage: item.language,
|
|
54
|
+
// Author names can refer to people or organizations; the model does not distinguish them.
|
|
55
|
+
author: item.authors?.length
|
|
56
|
+
? item.authors.map((name) => ({ name }))
|
|
57
|
+
: undefined,
|
|
58
|
+
about: categoryNames.length
|
|
59
|
+
? categoryNames.map((name) => ({ "@type": "Thing", name }))
|
|
60
|
+
: undefined,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const media = item.content.map(({ url }, index) => {
|
|
64
|
+
let resolvedUrl: URL
|
|
65
|
+
try {
|
|
66
|
+
resolvedUrl = new URL(url, canonicalUrl)
|
|
67
|
+
} catch {
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
if (!["http:", "https:"].includes(resolvedUrl.protocol)) {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const metadata = createContentMetadata({
|
|
75
|
+
url: resolvedUrl.href,
|
|
76
|
+
labelText: contentLabels[index],
|
|
77
|
+
})
|
|
78
|
+
const format = FILE_FORMATS[metadata.extension]
|
|
79
|
+
const embedUrl = getVideoEmbedUrl(resolvedUrl)
|
|
80
|
+
if (!format && !embedUrl) {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
"@type": embedUrl ? "VideoObject" : format.type,
|
|
86
|
+
name: metadata.labelText,
|
|
87
|
+
inLanguage: item.language,
|
|
88
|
+
...(embedUrl
|
|
89
|
+
? { embedUrl }
|
|
90
|
+
: { contentUrl: resolvedUrl.href, encodingFormat: format.mime }),
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const encodings = media.filter((entry) => entry !== undefined)
|
|
95
|
+
if (
|
|
96
|
+
schemaType === "AudioObject" ||
|
|
97
|
+
schemaType === "VideoObject" ||
|
|
98
|
+
schemaType === "ImageObject"
|
|
99
|
+
) {
|
|
100
|
+
const matching = encodings.filter((entry) => entry["@type"] === schemaType)
|
|
101
|
+
if (schemaType === "AudioObject" && matching.length > 1) {
|
|
102
|
+
return {
|
|
103
|
+
...base,
|
|
104
|
+
hasPart: matching,
|
|
105
|
+
encoding: encodings.filter((entry) => entry["@type"] !== schemaType),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const primary = media[0]?.["@type"] === schemaType ? media[0] : undefined
|
|
109
|
+
const alternatives = encodings.filter((entry) => entry !== primary)
|
|
110
|
+
return {
|
|
111
|
+
...primary,
|
|
112
|
+
...base,
|
|
113
|
+
...(schemaType === "VideoObject" ? { thumbnailUrl: imageUrl } : {}),
|
|
114
|
+
encoding: alternatives.length ? alternatives : undefined,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (
|
|
119
|
+
!schemaType &&
|
|
120
|
+
layout === "video" &&
|
|
121
|
+
media[0]?.["@type"] === "VideoObject"
|
|
122
|
+
) {
|
|
123
|
+
return {
|
|
124
|
+
...media[0],
|
|
125
|
+
...base,
|
|
126
|
+
"@type": "VideoObject",
|
|
127
|
+
thumbnailUrl: imageUrl,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (layout === "audio") {
|
|
132
|
+
const tracks = encodings.filter((entry) => entry["@type"] === "AudioObject")
|
|
133
|
+
if (!schemaType && tracks.length === 1 && item.content.length === 1) {
|
|
134
|
+
return { ...tracks[0], ...base, "@type": "AudioObject" }
|
|
135
|
+
}
|
|
136
|
+
if (tracks.length && item.content.length > 1) {
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
hasPart: tracks,
|
|
140
|
+
encoding: encodings.filter((entry) => entry["@type"] !== "AudioObject"),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { ...base, encoding: encodings.length ? encodings : undefined }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getVideoEmbedUrl(url: URL) {
|
|
149
|
+
if (url.hostname === "www.youtube.com" || url.hostname === "youtube.com") {
|
|
150
|
+
const id = url.pathname.startsWith("/embed/")
|
|
151
|
+
? url.pathname.slice(7)
|
|
152
|
+
: url.pathname === "/watch"
|
|
153
|
+
? url.searchParams.get("v")
|
|
154
|
+
: undefined
|
|
155
|
+
if (id) return `https://www.youtube-nocookie.com/embed/${id}`
|
|
156
|
+
}
|
|
157
|
+
if (url.hostname === "youtu.be" && url.pathname.slice(1)) {
|
|
158
|
+
return `https://www.youtube-nocookie.com/embed/${url.pathname.slice(1)}`
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
url.hostname === "www.youtube-nocookie.com" &&
|
|
162
|
+
url.pathname.startsWith("/embed/") &&
|
|
163
|
+
url.pathname.slice(7)
|
|
164
|
+
) {
|
|
165
|
+
return `https://www.youtube-nocookie.com${url.pathname}`
|
|
166
|
+
}
|
|
167
|
+
if (url.hostname === "vimeo.com" && /^\/\d+\/?$/.test(url.pathname)) {
|
|
168
|
+
return `https://player.vimeo.com/video/${url.pathname.split("/")[1]}`
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
url.hostname === "player.vimeo.com" &&
|
|
172
|
+
/^\/video\/\d+\/?$/.test(url.pathname)
|
|
173
|
+
) {
|
|
174
|
+
return url.href
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getImage } from "astro:assets"
|
|
2
|
+
|
|
3
|
+
import { getCategory } from "../../../content/get-categories"
|
|
4
|
+
import { getMediaItem } from "../../../content/get-media-items"
|
|
5
|
+
import { getMediaType } from "../../../content/get-media-types"
|
|
6
|
+
import type { TranslateContentFieldFn } from "../../../i18n/translate-map"
|
|
7
|
+
import { detailsPagePath } from "../../../utils/paths"
|
|
8
|
+
import type { DetailsPageLayout } from "./create-media-item-json-ld"
|
|
9
|
+
import { createMediaItemJsonLd } from "./create-media-item-json-ld"
|
|
10
|
+
|
|
11
|
+
export async function getDetailsPageJsonLd({
|
|
12
|
+
mediaId,
|
|
13
|
+
layout,
|
|
14
|
+
site,
|
|
15
|
+
defaultLocale,
|
|
16
|
+
tContentField,
|
|
17
|
+
}: {
|
|
18
|
+
mediaId: string
|
|
19
|
+
layout: DetailsPageLayout
|
|
20
|
+
site: URL | undefined
|
|
21
|
+
defaultLocale: string
|
|
22
|
+
tContentField: TranslateContentFieldFn
|
|
23
|
+
}) {
|
|
24
|
+
const item = await getMediaItem(mediaId)
|
|
25
|
+
const mediaType = await getMediaType(item.data.type.id)
|
|
26
|
+
const canonicalUrl = new URL(
|
|
27
|
+
detailsPagePath(defaultLocale, { id: mediaId }),
|
|
28
|
+
site,
|
|
29
|
+
)
|
|
30
|
+
const image = await getImage({ src: item.data.image, format: "webp" })
|
|
31
|
+
const categories = await Promise.all(
|
|
32
|
+
(item.data.categories ?? []).map(({ id }) =>
|
|
33
|
+
getCategory(id, tContentField),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
return createMediaItemJsonLd({
|
|
38
|
+
item: item.data,
|
|
39
|
+
layout,
|
|
40
|
+
schemaType: mediaType.data.schemaType,
|
|
41
|
+
canonicalUrl,
|
|
42
|
+
imageUrl: new URL(image.src, site).href,
|
|
43
|
+
categoryNames: categories.map((category) => category.labelText),
|
|
44
|
+
contentLabels: item.data.content.map(({ label }) =>
|
|
45
|
+
label ? tContentField(label, item) : undefined,
|
|
46
|
+
),
|
|
47
|
+
})
|
|
48
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import Fuse from "fuse.js"
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
|
|
4
|
-
import { pathWithBase } from "../../../utils/paths"
|
|
5
4
|
import type { SearchItem, SearchResponse } from "../api/search-response"
|
|
6
5
|
import { observeSearchQuery, type SearchQuery } from "../utils/search-query"
|
|
7
6
|
|
|
@@ -17,7 +16,7 @@ type EnrichedSearchItem = SearchItem & {
|
|
|
17
16
|
translatedLanguage: string
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
const SEARCH_API_PATH =
|
|
19
|
+
const SEARCH_API_PATH = "/api/internal/search.json"
|
|
21
20
|
|
|
22
21
|
export function useSearch({ categories, mediaTypes, languages }: Context) {
|
|
23
22
|
const fuse = useRef<Fuse<EnrichedSearchItem>>(undefined)
|
package/src/utils/paths.ts
CHANGED
|
@@ -1,40 +1,5 @@
|
|
|
1
1
|
import { isAbsoluteUrl } from "./urls"
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Prefix a site-internal path with Astro's configured base path.
|
|
5
|
-
*
|
|
6
|
-
* This helper trims any trailing slash from `BASE_URL`, ensures the input
|
|
7
|
-
* path starts with a leading slash, and concatenates the two values.
|
|
8
|
-
* Absolute URLs are out of scope for this helper.
|
|
9
|
-
*
|
|
10
|
-
* @param path internal path such as "/en/media", "/api/internal/search.json", or "/"
|
|
11
|
-
* @returns base-aware internal path
|
|
12
|
-
*/
|
|
13
|
-
export function pathWithBase(path: string) {
|
|
14
|
-
const normalizedBase = import.meta.env.BASE_URL.replace(/\/+$/, "")
|
|
15
|
-
const normalizedPath = path.startsWith("/") ? path : `/${path}`
|
|
16
|
-
return `${normalizedBase}${normalizedPath}`
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Remove Astro's configured base path from a pathname when present.
|
|
21
|
-
*
|
|
22
|
-
* @param pathname pathname such as "/docs/en/media" or "/en/media"
|
|
23
|
-
* @param base Astro base path. Defaults to `import.meta.env.BASE_URL`.
|
|
24
|
-
* @returns pathname without the base prefix
|
|
25
|
-
*/
|
|
26
|
-
export function pathWithoutBase(
|
|
27
|
-
pathname: string,
|
|
28
|
-
base = import.meta.env.BASE_URL,
|
|
29
|
-
) {
|
|
30
|
-
const normalizedBase = base !== "/" ? base.replace(/\/+$/, "") : ""
|
|
31
|
-
|
|
32
|
-
return normalizedBase &&
|
|
33
|
-
(pathname === normalizedBase || pathname.startsWith(`${normalizedBase}/`))
|
|
34
|
-
? pathname.slice(normalizedBase.length) || "/"
|
|
35
|
-
: pathname
|
|
36
|
-
}
|
|
37
|
-
|
|
38
3
|
/**
|
|
39
4
|
* Build path to media item page.
|
|
40
5
|
*
|
|
@@ -46,7 +11,7 @@ export function detailsPagePath(
|
|
|
46
11
|
locale: string | undefined,
|
|
47
12
|
{ id }: { id: string },
|
|
48
13
|
) {
|
|
49
|
-
return
|
|
14
|
+
return `/${locale}/media/${id}/`
|
|
50
15
|
}
|
|
51
16
|
|
|
52
17
|
/**
|
|
@@ -79,7 +44,7 @@ export function searchPagePath(
|
|
|
79
44
|
searchParams.append("type", filter.type)
|
|
80
45
|
}
|
|
81
46
|
const query = searchParams.size ? `?${searchParams.toString()}` : ""
|
|
82
|
-
return
|
|
47
|
+
return `/${locale}/media/${query}`
|
|
83
48
|
}
|
|
84
49
|
|
|
85
50
|
/**
|
|
@@ -96,8 +61,22 @@ export function localizePath(locale: string | undefined, path: string) {
|
|
|
96
61
|
if (isAbsoluteUrl(path)) {
|
|
97
62
|
return path
|
|
98
63
|
}
|
|
64
|
+
const localizedPath = `${locale ? `/${locale}` : ""}/${path.replace(/^\//, "")}`
|
|
65
|
+
return ensureTrailingSlash(localizedPath)
|
|
66
|
+
}
|
|
99
67
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Ensure an internal path's pathname ends with a slash, before any query or hash.
|
|
70
|
+
* Uses URL parsing to normalize the path and add a leading slash when missing.
|
|
71
|
+
* Empty input resolves to "/". Absolute URL origins are not included in the result.
|
|
72
|
+
*
|
|
73
|
+
* @param path internal path, optionally including a query string or fragment
|
|
74
|
+
* @returns normalized path with a trailing slash, query string, and fragment
|
|
75
|
+
* @example ensureTrailingSlash("about?ref=menu#team") // "/about/?ref=menu#team"
|
|
76
|
+
* @example ensureTrailingSlash("/about/") // "/about/"
|
|
77
|
+
*/
|
|
78
|
+
export function ensureTrailingSlash(path: string) {
|
|
79
|
+
const { pathname, search, hash } = new URL(path, "https://localhost:4321")
|
|
80
|
+
const pathWithSlash = pathname.endsWith("/") ? pathname : `${pathname}/`
|
|
81
|
+
return `${pathWithSlash}${search}${hash}`
|
|
103
82
|
}
|