@hagicode/hagilight-starlight 0.3.2-dev.55.1.1e29eba → 0.3.2-dev.57.1.ee1f17e
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/AIDisclosureNotice.astro +1 -1
- package/ArticlePromotion.astro +1 -1
- package/ContentLayoutToggle.astro +2 -2
- package/Footer.astro +10 -2
- package/Header.astro +1 -1
- package/LanguageChooser.astro +2 -2
- package/MarkdownContent.astro +2 -2
- package/NotFoundHero.astro +1 -1
- package/PromotoFooter.astro +1 -1
- package/SEOHead.astro +2 -4
- package/dist/ai-disclosures.d.ts +31 -0
- package/dist/ai-disclosures.js +105 -0
- package/dist/article-promotion.d.ts +15 -0
- package/dist/article-promotion.js +134 -0
- package/dist/content-width-i18n.d.ts +6 -0
- package/dist/content-width-i18n.js +15 -0
- package/dist/content-width.d.ts +5 -0
- package/dist/content-width.js +42 -0
- package/dist/index.d.ts +58 -0
- package/dist/index.js +490 -0
- package/dist/language-routing.d.ts +18 -0
- package/dist/language-routing.js +95 -0
- package/dist/locales.d.ts +48 -0
- package/dist/locales.js +13 -0
- package/dist/not-found.d.ts +9 -0
- package/dist/not-found.js +28 -0
- package/dist/rss-renderer.d.ts +14 -0
- package/dist/rss-renderer.js +30 -0
- package/dist/rss-utils.d.ts +27 -0
- package/dist/rss-utils.js +65 -0
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +25 -0
- package/dist/seo-utils.d.ts +74 -0
- package/dist/seo-utils.js +219 -0
- package/package.json +22 -27
- package/rss.[language].xml.ts +1 -1
- package/rss.xml.ts +1 -1
- package/ai-disclosure-schema.d.ts +0 -8
- package/ai-disclosure-schema.mjs +0 -9
- package/ai-disclosures.mjs +0 -108
- package/article-promotion-schema.d.ts +0 -7
- package/article-promotion-schema.mjs +0 -8
- package/article-promotion.mjs +0 -138
- package/content-width-i18n.mjs +0 -16
- package/content-width.mjs +0 -46
- package/index.d.ts +0 -17
- package/index.mjs +0 -535
- package/language-routing.mjs +0 -109
- package/locales.d.ts +0 -8
- package/locales.mjs +0 -14
- package/not-found.mjs +0 -33
- package/rss-renderer.mjs +0 -31
- package/rss-schema.d.ts +0 -7
- package/rss-schema.mjs +0 -8
- package/rss-utils.mjs +0 -62
- package/seo-schema.d.ts +0 -5
- package/seo-schema.mjs +0 -5
- package/seo-utils.mjs +0 -266
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const homeLabels = {
|
|
2
|
+
de: 'Zur Startseite',
|
|
3
|
+
en: 'Back to home',
|
|
4
|
+
es: 'Volver al inicio',
|
|
5
|
+
fr: 'Retour à l’accueil',
|
|
6
|
+
ja: 'ホームに戻る',
|
|
7
|
+
ko: '홈으로 돌아가기',
|
|
8
|
+
pt: 'Voltar ao início',
|
|
9
|
+
ru: 'На главную',
|
|
10
|
+
'zh-cn': '返回首页',
|
|
11
|
+
'zh-hant': '返回首頁',
|
|
12
|
+
};
|
|
13
|
+
export function isNotFoundEntry(entryId) {
|
|
14
|
+
return entryId === '404' || entryId.endsWith('/404');
|
|
15
|
+
}
|
|
16
|
+
export function getNotFoundHomeHref({ basePath = '/', locale, trailingSlash = 'ignore' }) {
|
|
17
|
+
const base = basePath.replace(/\/+$/u, '');
|
|
18
|
+
const localePath = locale && locale !== 'root' ? `/${locale}` : '';
|
|
19
|
+
const path = `${base}${localePath}` || '/';
|
|
20
|
+
if (trailingSlash === 'never') {
|
|
21
|
+
return path.replace(/\/+$/u, '') || '/';
|
|
22
|
+
}
|
|
23
|
+
return `${path.replace(/\/+$/u, '')}/`;
|
|
24
|
+
}
|
|
25
|
+
export function getNotFoundHomeLabel(lang) {
|
|
26
|
+
const language = lang?.replaceAll('_', '-').toLowerCase() ?? 'en';
|
|
27
|
+
return homeLabels[language] ?? homeLabels[language.split('-')[0]] ?? homeLabels.en;
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RssLocale } from '@hagicode/hagilight-core/rss';
|
|
2
|
+
import { type ResolvedRssContentOptions, type RssDocsEntry } from './rss-utils.js';
|
|
3
|
+
/** Serialized configuration exposed to the Starlight RSS routes. */
|
|
4
|
+
export interface StarlightRssRuntimeConfig {
|
|
5
|
+
options: ResolvedRssContentOptions;
|
|
6
|
+
locales: readonly RssLocale[];
|
|
7
|
+
}
|
|
8
|
+
export interface StarlightRssFeedInput extends StarlightRssRuntimeConfig {
|
|
9
|
+
site: string | URL | undefined;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
filename: string | undefined;
|
|
12
|
+
entries: readonly RssDocsEntry[];
|
|
13
|
+
}
|
|
14
|
+
export declare function generateRssFeed({ site, baseUrl, filename, locales, options, entries, }: StarlightRssFeedInput): Promise<Response>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { generateRssFeed as generateCoreRssFeed } from '@hagicode/hagilight-core/rss';
|
|
2
|
+
import { selectRssEntries } from './rss-utils.js';
|
|
3
|
+
export function generateRssFeed({ site, baseUrl, filename, locales, options, entries, }) {
|
|
4
|
+
if (!site)
|
|
5
|
+
throw new Error('Hagilight RSS requires the Astro site option.');
|
|
6
|
+
const locale = locales.find((item) => item.filename === filename);
|
|
7
|
+
if (!locale && filename !== 'en') {
|
|
8
|
+
throw new Error(`Hagilight RSS has no configured locale for "${filename}".`);
|
|
9
|
+
}
|
|
10
|
+
const items = selectRssEntries(entries, { filename, locales, options }).map(({ id, data }) => {
|
|
11
|
+
const slug = id.replace(/(?:^|\/)index$/u, '');
|
|
12
|
+
const lastUpdated = data.lastUpdated;
|
|
13
|
+
return {
|
|
14
|
+
title: data.title,
|
|
15
|
+
description: data.description,
|
|
16
|
+
...(lastUpdated instanceof Date && !Number.isNaN(lastUpdated.getTime())
|
|
17
|
+
? { date: lastUpdated }
|
|
18
|
+
: {}),
|
|
19
|
+
link: slug ? `${slug}/` : '/',
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
return generateCoreRssFeed({
|
|
23
|
+
site,
|
|
24
|
+
baseUrl,
|
|
25
|
+
language: locale?.lang ?? 'en',
|
|
26
|
+
title: 'Documentation',
|
|
27
|
+
description: 'Recently updated documentation pages',
|
|
28
|
+
items,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { RssLocale } from '@hagicode/hagilight-core/rss';
|
|
2
|
+
export interface RssContentOptions {
|
|
3
|
+
/** Include non-blog documentation pages. Defaults to `true`. */
|
|
4
|
+
includeDocs?: boolean;
|
|
5
|
+
/** Include pages under `blog/`. Defaults to `true`. */
|
|
6
|
+
includeBlog?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export type ResolvedRssContentOptions = Required<RssContentOptions>;
|
|
9
|
+
/** The subset of a Starlight docs collection entry used to build feeds. */
|
|
10
|
+
export interface RssDocsEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
data: {
|
|
13
|
+
title: string;
|
|
14
|
+
description?: string | undefined;
|
|
15
|
+
draft?: boolean | undefined;
|
|
16
|
+
rss?: unknown;
|
|
17
|
+
lastUpdated?: unknown;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface SelectRssEntriesOptions {
|
|
21
|
+
filename: string | undefined;
|
|
22
|
+
locales: readonly RssLocale[];
|
|
23
|
+
options: ResolvedRssContentOptions;
|
|
24
|
+
}
|
|
25
|
+
export declare function resolveRssOptions(options?: RssContentOptions): ResolvedRssContentOptions;
|
|
26
|
+
export declare function isBlogEntry(id: string): boolean;
|
|
27
|
+
export declare function selectRssEntries<Entry extends RssDocsEntry>(entries: readonly Entry[], { filename, locales, options }: SelectRssEntriesOptions): Entry[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export function resolveRssOptions(options = {}) {
|
|
2
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
3
|
+
throw new TypeError('Hagilight rss options must be an object.');
|
|
4
|
+
}
|
|
5
|
+
for (const key of ['includeDocs', 'includeBlog']) {
|
|
6
|
+
if (options[key] !== undefined && typeof options[key] !== 'boolean') {
|
|
7
|
+
throw new TypeError(`Hagilight rss ${key} option must be a boolean.`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return {
|
|
11
|
+
includeDocs: options.includeDocs ?? true,
|
|
12
|
+
includeBlog: options.includeBlog ?? true,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function getLocaleForEntry(id, locales) {
|
|
16
|
+
const localized = locales
|
|
17
|
+
.filter(({ route }) => route !== 'root')
|
|
18
|
+
.sort((a, b) => b.route.length - a.route.length)
|
|
19
|
+
.find(({ route }) => id === route || id.startsWith(`${route}/`));
|
|
20
|
+
if (localized)
|
|
21
|
+
return { locale: localized, relativeId: id.slice(localized.route.length).replace(/^\/+/, '') };
|
|
22
|
+
const root = locales.find(({ route }) => route === 'root');
|
|
23
|
+
if (root)
|
|
24
|
+
return { locale: root, relativeId: id };
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
export function isBlogEntry(id) {
|
|
28
|
+
const segments = id.split('/').filter(Boolean);
|
|
29
|
+
if (segments.at(-1) === 'index')
|
|
30
|
+
segments.pop();
|
|
31
|
+
return segments[0] === 'blog' && segments.length > 1;
|
|
32
|
+
}
|
|
33
|
+
function timestamp(value) {
|
|
34
|
+
return value instanceof Date ? value.getTime() : undefined;
|
|
35
|
+
}
|
|
36
|
+
export function selectRssEntries(entries, { filename, locales, options }) {
|
|
37
|
+
const locale = locales.find((item) => item.filename === filename);
|
|
38
|
+
if (!locale) {
|
|
39
|
+
if (filename === 'en')
|
|
40
|
+
return [];
|
|
41
|
+
throw new Error(`Hagilight RSS has no configured locale for "${filename}".`);
|
|
42
|
+
}
|
|
43
|
+
return entries
|
|
44
|
+
.filter(({ id, data }) => {
|
|
45
|
+
if (data.rss !== undefined && typeof data.rss !== 'boolean') {
|
|
46
|
+
throw new TypeError(`Hagilight RSS frontmatter field "rss" for "${id}" must be a boolean.`);
|
|
47
|
+
}
|
|
48
|
+
if (data.draft || data.rss === false)
|
|
49
|
+
return false;
|
|
50
|
+
const entryLocale = getLocaleForEntry(id, locales);
|
|
51
|
+
if (!entryLocale || entryLocale.locale.filename !== filename)
|
|
52
|
+
return false;
|
|
53
|
+
const isBlog = isBlogEntry(entryLocale.relativeId);
|
|
54
|
+
return isBlog ? options.includeBlog : options.includeDocs;
|
|
55
|
+
})
|
|
56
|
+
.sort((a, b) => {
|
|
57
|
+
const aDate = timestamp(a.data.lastUpdated);
|
|
58
|
+
const bDate = timestamp(b.data.lastUpdated);
|
|
59
|
+
if (aDate === undefined || Number.isNaN(aDate))
|
|
60
|
+
return bDate === undefined || Number.isNaN(bDate) ? 0 : 1;
|
|
61
|
+
if (bDate === undefined || Number.isNaN(bDate))
|
|
62
|
+
return -1;
|
|
63
|
+
return bDate - aDate;
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
/** Per-page AI disclosure flags that override the plugin `aiDisclosures` defaults. */
|
|
3
|
+
export declare const aiDisclosureSchema: z.ZodObject<{
|
|
4
|
+
isAITranslation: z.ZodOptional<z.ZodBoolean>;
|
|
5
|
+
isAIAuthor: z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
/** Per-page switch for the end-of-article HagiCode introduction. */
|
|
8
|
+
export declare const articlePromotionSchema: z.ZodObject<{
|
|
9
|
+
hagicodePromotion: z.ZodOptional<z.ZodBoolean>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
/** Per-page switch that excludes a page from generated RSS feeds when `false`. */
|
|
12
|
+
export declare const rssSchema: z.ZodObject<{
|
|
13
|
+
rss: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
/**
|
|
16
|
+
* Every frontmatter field read by the Hagilight Starlight plugin, including the
|
|
17
|
+
* shared `seo` object. Pass it to Starlight as `docsSchema({ extend: hagilightSchema })`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const hagilightSchema: z.ZodObject<{
|
|
20
|
+
seo: z.ZodOptional<z.ZodObject<{
|
|
21
|
+
title: z.ZodOptional<z.ZodString>;
|
|
22
|
+
description: z.ZodOptional<z.ZodString>;
|
|
23
|
+
image: z.ZodOptional<z.ZodString & z.ZodType<string, string, z.core.$ZodTypeInternals<string, string>>>;
|
|
24
|
+
author: z.ZodOptional<z.ZodString>;
|
|
25
|
+
publishedDate: z.ZodOptional<z.ZodCoercedDate<unknown>>;
|
|
26
|
+
}, z.core.$strip>>;
|
|
27
|
+
rss: z.ZodOptional<z.ZodBoolean>;
|
|
28
|
+
hagicodePromotion: z.ZodOptional<z.ZodBoolean>;
|
|
29
|
+
isAITranslation: z.ZodOptional<z.ZodBoolean>;
|
|
30
|
+
isAIAuthor: z.ZodOptional<z.ZodBoolean>;
|
|
31
|
+
}, z.core.$strip>;
|
|
32
|
+
export type HagilightFrontmatter = z.infer<typeof hagilightSchema>;
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'astro/zod';
|
|
2
|
+
import { seoSchema } from '@hagicode/hagilight-core/seo-schema';
|
|
3
|
+
/** Per-page AI disclosure flags that override the plugin `aiDisclosures` defaults. */
|
|
4
|
+
export const aiDisclosureSchema = z.object({
|
|
5
|
+
isAITranslation: z.boolean().optional(),
|
|
6
|
+
isAIAuthor: z.boolean().optional(),
|
|
7
|
+
});
|
|
8
|
+
/** Per-page switch for the end-of-article HagiCode introduction. */
|
|
9
|
+
export const articlePromotionSchema = z.object({
|
|
10
|
+
hagicodePromotion: z.boolean().optional(),
|
|
11
|
+
});
|
|
12
|
+
/** Per-page switch that excludes a page from generated RSS feeds when `false`. */
|
|
13
|
+
export const rssSchema = z.object({
|
|
14
|
+
rss: z.boolean().optional(),
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Every frontmatter field read by the Hagilight Starlight plugin, including the
|
|
18
|
+
* shared `seo` object. Pass it to Starlight as `docsSchema({ extend: hagilightSchema })`.
|
|
19
|
+
*/
|
|
20
|
+
export const hagilightSchema = z.object({
|
|
21
|
+
...aiDisclosureSchema.shape,
|
|
22
|
+
...articlePromotionSchema.shape,
|
|
23
|
+
...rssSchema.shape,
|
|
24
|
+
...seoSchema.shape,
|
|
25
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type HeadEntry, type StructuredData } from '@hagicode/hagilight-core/seo';
|
|
2
|
+
import type { TrailingSlash } from './not-found.js';
|
|
3
|
+
export interface SeoLocale {
|
|
4
|
+
route: string;
|
|
5
|
+
lang: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SeoPage {
|
|
8
|
+
route: string;
|
|
9
|
+
slug: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SeoDocsEntry {
|
|
12
|
+
id: string;
|
|
13
|
+
body?: string | undefined;
|
|
14
|
+
data: {
|
|
15
|
+
title: string;
|
|
16
|
+
description?: string | undefined;
|
|
17
|
+
draft?: boolean | undefined;
|
|
18
|
+
author?: string | undefined;
|
|
19
|
+
publishedDate?: Date | string | number | undefined;
|
|
20
|
+
lastUpdated?: Date | string | number | undefined | unknown;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface PublishedDocsLookup<Entry extends SeoDocsEntry = SeoDocsEntry> {
|
|
25
|
+
get(route: string, slug: string): Entry | undefined;
|
|
26
|
+
has(route: string, slug: string): boolean;
|
|
27
|
+
}
|
|
28
|
+
export type BuildFormat = 'directory' | 'file' | 'preserve';
|
|
29
|
+
export interface SeoOrganization {
|
|
30
|
+
name: string;
|
|
31
|
+
url: string;
|
|
32
|
+
logo?: string;
|
|
33
|
+
}
|
|
34
|
+
export type SeoLocalesInput = Readonly<Record<string, string | {
|
|
35
|
+
lang?: string | undefined;
|
|
36
|
+
} | undefined>>;
|
|
37
|
+
export declare function resolveSeoLocales(locales: SeoLocalesInput | undefined): SeoLocale[];
|
|
38
|
+
export declare function resolveSeoPage(entryId: string | undefined, locales: readonly SeoLocale[]): SeoPage;
|
|
39
|
+
export declare function createPublishedDocsLookup<Entry extends SeoDocsEntry>(entries: readonly Entry[], locales: readonly SeoLocale[]): PublishedDocsLookup<Entry>;
|
|
40
|
+
export interface FilterAlternatesOptions {
|
|
41
|
+
entryId: string;
|
|
42
|
+
locales: readonly SeoLocale[];
|
|
43
|
+
lookup: PublishedDocsLookup<SeoDocsEntry>;
|
|
44
|
+
basePath?: string;
|
|
45
|
+
explicitHead?: readonly (readonly HeadEntry[] | undefined)[];
|
|
46
|
+
}
|
|
47
|
+
export declare function filterPublishedLocaleAlternates<Entry extends HeadEntry>(head: readonly Entry[], { entryId, locales, lookup, basePath, explicitHead, }: FilterAlternatesOptions): Entry[];
|
|
48
|
+
export interface DocsPageUrlOptions {
|
|
49
|
+
site: string | URL;
|
|
50
|
+
basePath?: string;
|
|
51
|
+
format?: BuildFormat;
|
|
52
|
+
trailingSlash?: TrailingSlash;
|
|
53
|
+
}
|
|
54
|
+
export declare function buildDocsPageUrl(route: string, slug: string, { site, basePath, format, trailingSlash, }: DocsPageUrlOptions): string;
|
|
55
|
+
export interface BuildStructuredDataOptions {
|
|
56
|
+
entry: SeoDocsEntry | undefined;
|
|
57
|
+
entryId: string;
|
|
58
|
+
canonicalUrl: string | undefined;
|
|
59
|
+
site: string | URL;
|
|
60
|
+
siteTitle?: string | undefined;
|
|
61
|
+
pageSeo?: {
|
|
62
|
+
description?: string | undefined;
|
|
63
|
+
author?: string | undefined;
|
|
64
|
+
publishedDate?: Date | string | number | undefined;
|
|
65
|
+
};
|
|
66
|
+
organization?: SeoOrganization | undefined;
|
|
67
|
+
locales: readonly SeoLocale[];
|
|
68
|
+
defaultLocale?: string;
|
|
69
|
+
lookup: PublishedDocsLookup<SeoDocsEntry>;
|
|
70
|
+
basePath?: string;
|
|
71
|
+
format?: BuildFormat;
|
|
72
|
+
trailingSlash?: TrailingSlash;
|
|
73
|
+
}
|
|
74
|
+
export declare function buildStructuredData({ entry, entryId, canonicalUrl, site, siteTitle, pageSeo, organization, locales, defaultLocale, lookup, basePath, format, trailingSlash, }: BuildStructuredDataOptions): StructuredData[];
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { buildArticleStructuredData, buildBreadcrumbStructuredData, buildOrganizationStructuredData, } from '@hagicode/hagilight-core/seo';
|
|
2
|
+
import { isBlogEntry } from './rss-utils.js';
|
|
3
|
+
const normalizeSlug = (slug) => slug
|
|
4
|
+
.replace(/^\/+|\/+$/gu, '')
|
|
5
|
+
.replace(/(?:^|\/)index$/u, '');
|
|
6
|
+
const publishedLookupCache = new WeakMap();
|
|
7
|
+
export function resolveSeoLocales(locales) {
|
|
8
|
+
const entries = locales === undefined
|
|
9
|
+
? [['root', { lang: 'en' }]]
|
|
10
|
+
: Object.entries(locales);
|
|
11
|
+
const usedLanguages = new Map();
|
|
12
|
+
const resolved = entries.map(([route, config]) => {
|
|
13
|
+
const lang = typeof config === 'string'
|
|
14
|
+
? config
|
|
15
|
+
: config?.lang ?? (route === 'root' ? undefined : route);
|
|
16
|
+
if (typeof lang !== 'string' || !lang.trim()) {
|
|
17
|
+
throw new TypeError(`Hagilight SEO locale "${route}" must have a language tag.`);
|
|
18
|
+
}
|
|
19
|
+
let normalizedLang;
|
|
20
|
+
try {
|
|
21
|
+
[normalizedLang] = Intl.getCanonicalLocales(lang);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new TypeError(`Hagilight SEO locale "${route}" has an invalid language tag "${lang}".`);
|
|
25
|
+
}
|
|
26
|
+
const collisionKey = normalizedLang.toLowerCase();
|
|
27
|
+
const existingRoute = usedLanguages.get(collisionKey);
|
|
28
|
+
if (existingRoute) {
|
|
29
|
+
throw new Error(`Hagilight SEO locales "${existingRoute}" and "${route}" use the same language tag "${normalizedLang}".`);
|
|
30
|
+
}
|
|
31
|
+
usedLanguages.set(collisionKey, route);
|
|
32
|
+
return { route, lang: normalizedLang };
|
|
33
|
+
});
|
|
34
|
+
if (resolved.length === 0) {
|
|
35
|
+
throw new Error('Hagilight SEO requires at least one configured Starlight locale.');
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
}
|
|
39
|
+
export function resolveSeoPage(entryId, locales) {
|
|
40
|
+
const id = normalizeSlug(entryId ?? '');
|
|
41
|
+
const localizedRoutes = locales
|
|
42
|
+
.filter(({ route }) => route !== 'root')
|
|
43
|
+
.sort((left, right) => right.route.length - left.route.length);
|
|
44
|
+
const localized = localizedRoutes.find(({ route }) => id === route || id.startsWith(`${route}/`));
|
|
45
|
+
if (localized) {
|
|
46
|
+
return {
|
|
47
|
+
route: localized.route,
|
|
48
|
+
slug: normalizeSlug(id.slice(localized.route.length)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return { route: 'root', slug: id };
|
|
52
|
+
}
|
|
53
|
+
export function createPublishedDocsLookup(entries, locales) {
|
|
54
|
+
const localeKey = JSON.stringify(locales);
|
|
55
|
+
let lookups = publishedLookupCache.get(entries);
|
|
56
|
+
const cached = lookups?.get(localeKey);
|
|
57
|
+
if (cached)
|
|
58
|
+
return cached;
|
|
59
|
+
const pages = new Map();
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry || entry.data?.draft === true)
|
|
62
|
+
continue;
|
|
63
|
+
const page = resolveSeoPage(entry.id, locales);
|
|
64
|
+
pages.set(`${page.route}\u0000${page.slug}`, entry);
|
|
65
|
+
}
|
|
66
|
+
const lookup = {
|
|
67
|
+
get(route, slug) {
|
|
68
|
+
return pages.get(`${route}\u0000${normalizeSlug(slug)}`);
|
|
69
|
+
},
|
|
70
|
+
has(route, slug) {
|
|
71
|
+
return pages.has(`${route}\u0000${normalizeSlug(slug)}`);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
if (!lookups) {
|
|
75
|
+
lookups = new Map();
|
|
76
|
+
publishedLookupCache.set(entries, lookups);
|
|
77
|
+
}
|
|
78
|
+
lookups.set(localeKey, lookup);
|
|
79
|
+
return lookup;
|
|
80
|
+
}
|
|
81
|
+
function parseCandidatePage(href, { locales, basePath }) {
|
|
82
|
+
let pathname;
|
|
83
|
+
try {
|
|
84
|
+
pathname = new URL(href).pathname;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const base = `/${(basePath ?? '/').replace(/^\/+|\/+$/gu, '')}`;
|
|
90
|
+
if (base !== '/' && pathname !== base && !pathname.startsWith(`${base}/`))
|
|
91
|
+
return undefined;
|
|
92
|
+
if (base !== '/')
|
|
93
|
+
pathname = pathname.slice(base.length) || '/';
|
|
94
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
95
|
+
const last = segments.at(-1);
|
|
96
|
+
if (last === 'index.html')
|
|
97
|
+
segments.pop();
|
|
98
|
+
else if (last?.endsWith('.html')) {
|
|
99
|
+
segments[segments.length - 1] = last.slice(0, -5);
|
|
100
|
+
}
|
|
101
|
+
const routes = locales
|
|
102
|
+
.filter(({ route }) => route !== 'root')
|
|
103
|
+
.sort((left, right) => right.route.length - left.route.length);
|
|
104
|
+
let route = 'root';
|
|
105
|
+
const first = segments[0];
|
|
106
|
+
if (first?.endsWith('.html')) {
|
|
107
|
+
const matched = routes.find((locale) => locale.route === first.slice(0, -5));
|
|
108
|
+
if (matched) {
|
|
109
|
+
route = matched.route;
|
|
110
|
+
segments.shift();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const matched = routes.find(({ route: localeRoute }) => first === localeRoute);
|
|
115
|
+
if (matched) {
|
|
116
|
+
route = matched.route;
|
|
117
|
+
segments.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { route, slug: normalizeSlug(segments.join('/')) };
|
|
121
|
+
}
|
|
122
|
+
function isAlternateLink(entry) {
|
|
123
|
+
return entry?.tag === 'link'
|
|
124
|
+
&& entry.attrs?.rel === 'alternate'
|
|
125
|
+
&& typeof entry.attrs.hreflang === 'string'
|
|
126
|
+
&& typeof entry.attrs.href === 'string';
|
|
127
|
+
}
|
|
128
|
+
function explicitAlternateKeys(heads) {
|
|
129
|
+
return new Set(heads.flatMap((head) => (Array.isArray(head) ? head : [])
|
|
130
|
+
.filter(isAlternateLink)
|
|
131
|
+
.map(({ attrs }) => `${attrs.hreflang.toLowerCase()}\u0000${attrs.href}`)));
|
|
132
|
+
}
|
|
133
|
+
export function filterPublishedLocaleAlternates(head, { entryId, locales, lookup, basePath = '/', explicitHead = [], }) {
|
|
134
|
+
const page = resolveSeoPage(entryId, locales);
|
|
135
|
+
const explicit = explicitAlternateKeys(explicitHead);
|
|
136
|
+
const routesByLanguage = new Map(locales.map(({ route, lang }) => [lang.toLowerCase(), route]));
|
|
137
|
+
return head.filter((entry) => {
|
|
138
|
+
if (!isAlternateLink(entry))
|
|
139
|
+
return true;
|
|
140
|
+
const { href, hreflang } = entry.attrs;
|
|
141
|
+
if (explicit.has(`${hreflang.toLowerCase()}\u0000${href}`))
|
|
142
|
+
return true;
|
|
143
|
+
const candidate = parseCandidatePage(href, { locales, basePath });
|
|
144
|
+
if (!candidate || candidate.slug !== page.slug)
|
|
145
|
+
return true;
|
|
146
|
+
if (hreflang.toLowerCase() === 'x-default') {
|
|
147
|
+
return lookup.has(candidate.route, candidate.slug);
|
|
148
|
+
}
|
|
149
|
+
const configuredRoute = routesByLanguage.get(hreflang.toLowerCase());
|
|
150
|
+
if (!configuredRoute || configuredRoute !== candidate.route)
|
|
151
|
+
return true;
|
|
152
|
+
return lookup.has(configuredRoute, candidate.slug);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function normalizeBasePath(basePath = '/') {
|
|
156
|
+
const normalized = `/${basePath.replace(/^\/+|\/+$/gu, '')}`;
|
|
157
|
+
return normalized === '/' ? '' : normalized;
|
|
158
|
+
}
|
|
159
|
+
export function buildDocsPageUrl(route, slug, { site, basePath = '/', format = 'directory', trailingSlash = 'always', }) {
|
|
160
|
+
const id = [route === 'root' ? '' : route, normalizeSlug(slug)].filter(Boolean).join('/');
|
|
161
|
+
let pagePath;
|
|
162
|
+
if (format === 'file') {
|
|
163
|
+
pagePath = `/${id ? `${id}.html` : 'index.html'}`;
|
|
164
|
+
}
|
|
165
|
+
else if (!id) {
|
|
166
|
+
pagePath = '/';
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
pagePath = `/${id}${trailingSlash === 'never' ? '' : '/'}`;
|
|
170
|
+
}
|
|
171
|
+
const base = normalizeBasePath(basePath);
|
|
172
|
+
return new URL(`${base}${pagePath}`, site).href;
|
|
173
|
+
}
|
|
174
|
+
export function buildStructuredData({ entry, entryId, canonicalUrl, site, siteTitle, pageSeo = {}, organization, locales, defaultLocale = 'root', lookup, basePath = '/', format = 'directory', trailingSlash = 'always', }) {
|
|
175
|
+
if (!entry || entry.data?.draft === true || !canonicalUrl)
|
|
176
|
+
return [];
|
|
177
|
+
const page = resolveSeoPage(entryId, locales);
|
|
178
|
+
if (!lookup.has(page.route, page.slug))
|
|
179
|
+
return [];
|
|
180
|
+
const data = entry.data ?? {};
|
|
181
|
+
const structuredData = [];
|
|
182
|
+
if (isBlogEntry(page.slug)) {
|
|
183
|
+
structuredData.push(buildArticleStructuredData({
|
|
184
|
+
url: canonicalUrl,
|
|
185
|
+
title: data.title,
|
|
186
|
+
description: pageSeo.description ?? data.description,
|
|
187
|
+
author: pageSeo.author ?? data.author,
|
|
188
|
+
publishedDate: pageSeo.publishedDate ?? data.publishedDate,
|
|
189
|
+
modifiedDate: data.lastUpdated,
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
const slugParts = page.slug.split('/').filter(Boolean);
|
|
193
|
+
const crumbs = [];
|
|
194
|
+
for (let index = 0; index <= slugParts.length; index += 1) {
|
|
195
|
+
const ancestorSlug = slugParts.slice(0, index).join('/');
|
|
196
|
+
const ancestor = lookup.get(page.route, ancestorSlug);
|
|
197
|
+
if (!ancestor)
|
|
198
|
+
continue;
|
|
199
|
+
const url = ancestorSlug === page.slug
|
|
200
|
+
? canonicalUrl
|
|
201
|
+
: buildDocsPageUrl(page.route, ancestorSlug, {
|
|
202
|
+
site,
|
|
203
|
+
basePath,
|
|
204
|
+
format,
|
|
205
|
+
trailingSlash,
|
|
206
|
+
});
|
|
207
|
+
crumbs.push({ name: ancestor.data.title, url });
|
|
208
|
+
}
|
|
209
|
+
if (crumbs.length > 0) {
|
|
210
|
+
structuredData.push(buildBreadcrumbStructuredData(crumbs));
|
|
211
|
+
}
|
|
212
|
+
if (organization && page.route === defaultLocale && page.slug === '') {
|
|
213
|
+
structuredData.push(buildOrganizationStructuredData({
|
|
214
|
+
...organization,
|
|
215
|
+
...(siteTitle === undefined ? {} : { alternateName: siteTitle }),
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
return structuredData;
|
|
219
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hagicode/hagilight-starlight",
|
|
3
|
-
"version": "0.3.2-dev.
|
|
3
|
+
"version": "0.3.2-dev.57.1.ee1f17e",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Starlight
|
|
5
|
+
"description": "Starlight plugin and components for HagiCode documentation",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/HagiCode-org/hagilight.git",
|
|
@@ -12,30 +12,22 @@
|
|
|
12
12
|
"access": "public",
|
|
13
13
|
"provenance": true
|
|
14
14
|
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -b",
|
|
17
|
+
"prepack": "tsc -b"
|
|
18
|
+
},
|
|
15
19
|
"exports": {
|
|
16
20
|
".": {
|
|
17
|
-
"types": "./index.d.ts",
|
|
18
|
-
"default": "./index.
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
19
23
|
},
|
|
20
24
|
"./locales": {
|
|
21
|
-
"types": "./locales.d.ts",
|
|
22
|
-
"default": "./locales.
|
|
23
|
-
},
|
|
24
|
-
"./article-promotion-schema": {
|
|
25
|
-
"types": "./article-promotion-schema.d.ts",
|
|
26
|
-
"default": "./article-promotion-schema.mjs"
|
|
27
|
-
},
|
|
28
|
-
"./ai-disclosure-schema": {
|
|
29
|
-
"types": "./ai-disclosure-schema.d.ts",
|
|
30
|
-
"default": "./ai-disclosure-schema.mjs"
|
|
25
|
+
"types": "./dist/locales.d.ts",
|
|
26
|
+
"default": "./dist/locales.js"
|
|
31
27
|
},
|
|
32
|
-
"./
|
|
33
|
-
"types": "./
|
|
34
|
-
"default": "./
|
|
35
|
-
},
|
|
36
|
-
"./seo-schema": {
|
|
37
|
-
"types": "./seo-schema.d.ts",
|
|
38
|
-
"default": "./seo-schema.mjs"
|
|
28
|
+
"./schema": {
|
|
29
|
+
"types": "./dist/schema.d.ts",
|
|
30
|
+
"default": "./dist/schema.js"
|
|
39
31
|
},
|
|
40
32
|
"./ContentLayoutToggle": "./ContentLayoutToggle.astro",
|
|
41
33
|
"./Header": "./Header.astro",
|
|
@@ -47,27 +39,30 @@
|
|
|
47
39
|
"./PromotoFooter": "./PromotoFooter.astro"
|
|
48
40
|
},
|
|
49
41
|
"files": [
|
|
50
|
-
"
|
|
51
|
-
"
|
|
42
|
+
"dist/**/*.js",
|
|
43
|
+
"dist/**/*.d.ts",
|
|
44
|
+
"*.astro",
|
|
52
45
|
"*.css",
|
|
53
|
-
"*.mjs",
|
|
54
|
-
"*.d.ts",
|
|
55
46
|
"rss*.xml.ts",
|
|
56
|
-
"*.astro",
|
|
57
47
|
"assets/*.png"
|
|
58
48
|
],
|
|
59
49
|
"dependencies": {
|
|
60
|
-
"@hagicode/hagilight": "0.3.2-dev.
|
|
50
|
+
"@hagicode/hagilight-core": "0.3.2-dev.57.1.ee1f17e"
|
|
61
51
|
},
|
|
62
52
|
"peerDependencies": {
|
|
63
53
|
"@astrojs/starlight": "^0.42.4",
|
|
64
54
|
"astro": "^7.3.5"
|
|
65
55
|
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@astrojs/starlight": "^0.42.4",
|
|
58
|
+
"astro": "^7.3.5"
|
|
59
|
+
},
|
|
66
60
|
"keywords": [
|
|
67
61
|
"astro-component",
|
|
68
62
|
"withastro",
|
|
69
63
|
"astro-seo",
|
|
70
64
|
"starlight",
|
|
65
|
+
"starlight-plugin",
|
|
71
66
|
"multilingual-documentation",
|
|
72
67
|
"hreflang",
|
|
73
68
|
"structured-data"
|
package/rss.[language].xml.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getCollection } from 'astro:content';
|
|
2
2
|
import type { APIRoute, GetStaticPaths } from 'astro';
|
|
3
3
|
import rssConfig from 'virtual:hagilight-starlight/rss-config';
|
|
4
|
-
import { generateRssFeed } from './rss-renderer.
|
|
4
|
+
import { generateRssFeed } from './dist/rss-renderer.js';
|
|
5
5
|
|
|
6
6
|
export const getStaticPaths: GetStaticPaths = () => {
|
|
7
7
|
const filenames = new Set(['en', ...rssConfig.locales.map(({ filename }) => filename)]);
|
package/rss.xml.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getCollection } from 'astro:content';
|
|
2
2
|
import type { APIRoute } from 'astro';
|
|
3
3
|
import rssConfig from 'virtual:hagilight-starlight/rss-config';
|
|
4
|
-
import { generateRssFeed } from './rss-renderer.
|
|
4
|
+
import { generateRssFeed } from './dist/rss-renderer.js';
|
|
5
5
|
|
|
6
6
|
export const GET: APIRoute = async ({ site }) => {
|
|
7
7
|
const entries = await getCollection('docs');
|
package/ai-disclosure-schema.mjs
DELETED