@hagicode/hagilight-starlight 0.1.0-dev.148.1.e0c630e

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.
Files changed (81) hide show
  1. package/AIDisclosureNotice.astro +12 -0
  2. package/ArticlePromotion.astro +630 -0
  3. package/ArticlePromotionPicker.astro +9 -0
  4. package/ContentLayoutToggle.astro +31 -0
  5. package/Footer.astro +201 -0
  6. package/Header.astro +131 -0
  7. package/LanguageChooser.astro +386 -0
  8. package/MarkdownContent.astro +45 -0
  9. package/NotFoundHero.astro +44 -0
  10. package/PageTitle.astro +7 -0
  11. package/PromotoFooter.astro +7 -0
  12. package/README.md +178 -0
  13. package/SEOHead.astro +67 -0
  14. package/ThemeSelect.astro +176 -0
  15. package/assets/showcase/hero-avatar.webp +0 -0
  16. package/assets/showcase/heroes.webp +0 -0
  17. package/assets/showcase/manifest.json +333 -0
  18. package/assets/showcase/pillar-efficient.svg +1 -0
  19. package/assets/showcase/pillar-fun.svg +1 -0
  20. package/assets/showcase/pillar-smart.svg +1 -0
  21. package/assets/showcase/proposal-workflow.webp +0 -0
  22. package/assets/showcase/workbench.webp +0 -0
  23. package/content-width.css +81 -0
  24. package/dist/ai-disclosures.d.ts +31 -0
  25. package/dist/ai-disclosures.js +105 -0
  26. package/dist/article-promotion-copy/de-DE.d.ts +34 -0
  27. package/dist/article-promotion-copy/de-DE.js +43 -0
  28. package/dist/article-promotion-copy/en-US.d.ts +34 -0
  29. package/dist/article-promotion-copy/en-US.js +43 -0
  30. package/dist/article-promotion-copy/es-ES.d.ts +34 -0
  31. package/dist/article-promotion-copy/es-ES.js +43 -0
  32. package/dist/article-promotion-copy/fr-FR.d.ts +34 -0
  33. package/dist/article-promotion-copy/fr-FR.js +43 -0
  34. package/dist/article-promotion-copy/index.d.ts +32 -0
  35. package/dist/article-promotion-copy/index.js +50 -0
  36. package/dist/article-promotion-copy/ja-JP.d.ts +34 -0
  37. package/dist/article-promotion-copy/ja-JP.js +43 -0
  38. package/dist/article-promotion-copy/ko-KR.d.ts +34 -0
  39. package/dist/article-promotion-copy/ko-KR.js +43 -0
  40. package/dist/article-promotion-copy/pt-BR.d.ts +34 -0
  41. package/dist/article-promotion-copy/pt-BR.js +43 -0
  42. package/dist/article-promotion-copy/ru-RU.d.ts +34 -0
  43. package/dist/article-promotion-copy/ru-RU.js +43 -0
  44. package/dist/article-promotion-copy/zh-CN.d.ts +34 -0
  45. package/dist/article-promotion-copy/zh-CN.js +43 -0
  46. package/dist/article-promotion-copy/zh-Hant.d.ts +34 -0
  47. package/dist/article-promotion-copy/zh-Hant.js +43 -0
  48. package/dist/article-promotion.d.ts +40 -0
  49. package/dist/article-promotion.js +14 -0
  50. package/dist/content-width-i18n.d.ts +6 -0
  51. package/dist/content-width-i18n.js +15 -0
  52. package/dist/content-width.d.ts +5 -0
  53. package/dist/content-width.js +42 -0
  54. package/dist/index.d.ts +105 -0
  55. package/dist/index.js +583 -0
  56. package/dist/language-routing.d.ts +18 -0
  57. package/dist/language-routing.js +95 -0
  58. package/dist/locales.d.ts +48 -0
  59. package/dist/locales.js +13 -0
  60. package/dist/not-found.d.ts +9 -0
  61. package/dist/not-found.js +28 -0
  62. package/dist/rss-renderer.d.ts +14 -0
  63. package/dist/rss-renderer.js +30 -0
  64. package/dist/rss-utils.d.ts +27 -0
  65. package/dist/rss-utils.js +65 -0
  66. package/dist/schema.d.ts +32 -0
  67. package/dist/schema.js +25 -0
  68. package/dist/seo-utils.d.ts +74 -0
  69. package/dist/seo-utils.js +219 -0
  70. package/dist/showcase.d.ts +6 -0
  71. package/dist/showcase.js +5 -0
  72. package/dist/site-metadata.d.ts +38 -0
  73. package/dist/site-metadata.js +109 -0
  74. package/dist/themes.d.ts +43 -0
  75. package/dist/themes.js +156 -0
  76. package/dist/windows-download.d.ts +4 -0
  77. package/dist/windows-download.js +22 -0
  78. package/package.json +88 -0
  79. package/rss.[language].xml.ts +20 -0
  80. package/rss.xml.ts +15 -0
  81. package/themes.css +160 -0
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { getArticlePromotionCopy } from './article-promotion.js';
2
+ export type { ArticlePromotionCopy as ShowcaseCopy, ArticlePromotionFeature as ShowcaseFeature, ArticlePromotionGalleryItem as ShowcaseGalleryItem, } from './article-promotion.js';
3
+ /** Returns the read-only showcase copy for a locale, or the English copy when the locale has none. */
4
+ export declare const getShowcaseCopy: typeof getArticlePromotionCopy;
5
+ /** Locales that have their own showcase copy. */
6
+ export declare const showcaseLocales: readonly string[];
@@ -0,0 +1,5 @@
1
+ import { COPY, getArticlePromotionCopy } from './article-promotion.js';
2
+ /** Returns the read-only showcase copy for a locale, or the English copy when the locale has none. */
3
+ export const getShowcaseCopy = getArticlePromotionCopy;
4
+ /** Locales that have their own showcase copy. */
5
+ export const showcaseLocales = Object.freeze(Object.keys(COPY));
@@ -0,0 +1,38 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import type { BuildInfo } from '@hagicode/hagilight-core/build-info';
3
+ import { type SiteMetadataAnalytics, type SiteMetadataFeatures } from '@hagicode/hagilight-core/site-metadata';
4
+ import { type SiteMetadataLogger } from '@hagicode/hagilight-core/site-metadata-writer';
5
+ export interface StarlightSiteMetadataSetup {
6
+ enabled: boolean;
7
+ /** Explicit `metadata.name`, when set. */
8
+ name?: string | undefined;
9
+ /** Explicit `metadata.description`, when set. */
10
+ description?: string | undefined;
11
+ /** Starlight `title`: a string or a localized record. */
12
+ title: unknown;
13
+ /** Starlight `description`, when the site configures one. */
14
+ siteDescription?: unknown;
15
+ /** Starlight `locales` map, for example `{ root: { lang: 'en' } }`. */
16
+ locales: unknown;
17
+ /** Starlight `defaultLocale`. */
18
+ defaultLocale: unknown;
19
+ site: unknown;
20
+ base: string | undefined;
21
+ publicDir: URL;
22
+ analytics: SiteMetadataAnalytics;
23
+ features: SiteMetadataFeatures;
24
+ }
25
+ export interface WriteStarlightSiteMetadataInput {
26
+ outDir: string;
27
+ setup: StarlightSiteMetadataSetup;
28
+ build: BuildInfo;
29
+ logger: SiteMetadataLogger;
30
+ }
31
+ /** Build and write the metadata file for a Starlight build. Skips with a warning when no absolute site URL exists. */
32
+ export declare function writeStarlightSiteMetadata({ outDir, setup, build, logger }: WriteStarlightSiteMetadataInput): void;
33
+ /**
34
+ * Integration appended after this plugin's sitemap, so the write runs after
35
+ * `@astrojs/sitemap` has written its index. The owner's write also waits for any
36
+ * deferring plain-Astro peer that reported before it.
37
+ */
38
+ export declare function createStarlightMetadataFinalizer(setup: StarlightSiteMetadataSetup, buildInfo: () => BuildInfo | undefined): AstroIntegration;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Facts for `.well-known/hagilight.metadata.json` from a Starlight build. The
3
+ * Starlight plugin owns the file when it is enabled, so this module collects the
4
+ * title, locales and resolved feature flags from its own configuration.
5
+ */
6
+ import { readFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { buildSiteMetadata, SITE_METADATA_PATH, } from '@hagicode/hagilight-core/site-metadata';
10
+ import { findDiscoveryOutputs, writeSiteMetadata } from '@hagicode/hagilight-core/site-metadata-writer';
11
+ import { completeSiteMetadataOwnerBuild } from '@hagicode/hagilight-core/site-metadata-ownership';
12
+ import { resolveSeoLocales } from './seo-utils.js';
13
+ const STARLIGHT_PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+ function absoluteSite(site) {
18
+ try {
19
+ const parsed = new URL(site);
20
+ return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password ? parsed.href : undefined;
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ function trimmedText(value) {
27
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
28
+ }
29
+ /** Starlight locale routes become metadata locales. The root route uses its language tag as the code. */
30
+ function toMetadataLocales(locales) {
31
+ const routes = new Map();
32
+ const entries = locales.map(({ route, lang }) => {
33
+ const code = route === 'root' ? lang : route;
34
+ routes.set(route, code);
35
+ return { code, lang, path: route === 'root' ? '/' : `/${route}/` };
36
+ });
37
+ return { locales: entries, routes };
38
+ }
39
+ /** Starlight's default locale is the route named in `defaultLocale`, or `root`. */
40
+ function defaultRoute(defaultLocale, locales) {
41
+ const configured = isRecord(defaultLocale) ? defaultLocale.locale ?? defaultLocale.lang : defaultLocale;
42
+ if (typeof configured !== 'string' || !configured.trim())
43
+ return locales.find(({ route }) => route === 'root')?.route ?? locales[0]?.route ?? 'root';
44
+ const match = locales.find(({ route, lang }) => route === configured || lang === configured);
45
+ return match?.route ?? configured;
46
+ }
47
+ /** Starlight's title, read for the default locale. A localized record uses `root` or its first entry. */
48
+ function siteTitle(title, route) {
49
+ if (typeof title === 'string')
50
+ return trimmedText(title);
51
+ if (!isRecord(title))
52
+ return undefined;
53
+ return trimmedText(title[route] ?? title.root ?? Object.values(title)[0]);
54
+ }
55
+ /** Build and write the metadata file for a Starlight build. Skips with a warning when no absolute site URL exists. */
56
+ export function writeStarlightSiteMetadata({ outDir, setup, build, logger }) {
57
+ const siteUrl = absoluteSite(setup.site);
58
+ if (!siteUrl) {
59
+ logger.warn(`${SITE_METADATA_PATH} requires an absolute Astro site URL, so it was not generated.`);
60
+ return;
61
+ }
62
+ const seoLocales = resolveSeoLocales(setup.locales);
63
+ const { locales, routes } = toMetadataLocales(seoLocales);
64
+ const defaultCode = routes.get(defaultRoute(setup.defaultLocale, seoLocales));
65
+ const route = defaultRoute(setup.defaultLocale, seoLocales);
66
+ const base = `${setup.base || '/'}`.replace(/\/?$/u, '/');
67
+ const metadata = buildSiteMetadata({
68
+ generator: { name: '@hagicode/hagilight-starlight', version: STARLIGHT_PACKAGE_VERSION },
69
+ site: {
70
+ name: setup.name ?? siteTitle(setup.title, route),
71
+ url: siteUrl,
72
+ base,
73
+ description: setup.description ?? trimmedText(setup.siteDescription),
74
+ },
75
+ locales,
76
+ defaultLocale: defaultCode,
77
+ fallbackLang: seoLocales.find((entry) => entry.route === route)?.lang,
78
+ build,
79
+ discovery: findDiscoveryOutputs(outDir),
80
+ analytics: setup.analytics,
81
+ features: setup.features,
82
+ });
83
+ writeSiteMetadata({
84
+ outDir,
85
+ metadata,
86
+ siteOwnedFiles: [join(fileURLToPath(setup.publicDir), SITE_METADATA_PATH)],
87
+ logger,
88
+ });
89
+ }
90
+ /**
91
+ * Integration appended after this plugin's sitemap, so the write runs after
92
+ * `@astrojs/sitemap` has written its index. The owner's write also waits for any
93
+ * deferring plain-Astro peer that reported before it.
94
+ */
95
+ export function createStarlightMetadataFinalizer(setup, buildInfo) {
96
+ return {
97
+ name: '@hagicode/hagilight-starlight:metadata',
98
+ hooks: {
99
+ 'astro:build:done'({ dir, logger }) {
100
+ completeSiteMetadataOwnerBuild(() => {
101
+ const build = buildInfo();
102
+ if (!build)
103
+ return;
104
+ writeStarlightSiteMetadata({ outDir: fileURLToPath(dir), setup, build, logger });
105
+ });
106
+ },
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hagilight theme picker: four themes (the Starlight default plus Ocean, Sakura, and Forest),
3
+ * each with a light and a dark appearance, plus a "default" choice that follows the system
4
+ * color scheme and renders the Forest palette. The palette in use is exposed to CSS through the
5
+ * `data-hagilight-theme` attribute on `<html>`; light/dark mode keeps using Starlight's
6
+ * `data-theme` attribute and `starlight-theme` storage so Starlight's pre-paint script
7
+ * continues to apply the correct mode.
8
+ */
9
+ export type ThemeId = 'default' | 'ocean' | 'sakura' | 'forest';
10
+ export type ThemeMode = 'light' | 'dark';
11
+ export type ThemeChoice = 'default' | 'default-light' | 'default-dark' | 'ocean-light' | 'ocean-dark' | 'sakura-light' | 'sakura-dark' | 'forest-light' | 'forest-dark';
12
+ /** Themes beyond the Starlight default; each must have light and dark palettes in `themes.css`. */
13
+ export declare const EXTRA_THEME_IDS: readonly ["ocean", "sakura", "forest"];
14
+ /** The palette the "default" choice renders, so first-visit users always see the same theme. */
15
+ export declare const DEFAULT_PALETTE_THEME_ID: Exclude<ThemeId, 'default'>;
16
+ export declare const DEFAULT_THEME_CHOICE: ThemeChoice;
17
+ export declare const THEME_CHOICES: readonly ["default", "default-light", "default-dark", "ocean-light", "ocean-dark", "sakura-light", "sakura-dark", "forest-light", "forest-dark"];
18
+ export declare const THEME_STORAGE_KEY = "hagilight-theme";
19
+ /** Starlight's own storage key; kept in sync with the picker so its pre-paint script applies the right mode. */
20
+ export declare const STARLIGHT_THEME_STORAGE_KEY = "starlight-theme";
21
+ export declare function parseThemeChoice(value: unknown): ThemeChoice;
22
+ export declare function splitThemeChoice(choice: ThemeChoice): {
23
+ themeId: ThemeId;
24
+ mode?: ThemeMode;
25
+ };
26
+ /** The theme id a choice renders as: the theme itself, or the Forest palette for "default". */
27
+ export declare function resolveThemeId(choice: ThemeChoice): ThemeId;
28
+ export declare function readStoredThemeChoice(): ThemeChoice;
29
+ /** Apply a choice to the document, persist it, and keep Starlight's mode storage in sync. */
30
+ export declare function applyThemeChoice(choice: ThemeChoice, root?: HTMLElement): void;
31
+ /**
32
+ * Inline pre-paint bootstrap run from `<head>`: it must stay dependency-free because it
33
+ * executes before any module script. Keep the behavior identical to `applyThemeChoice`.
34
+ */
35
+ export declare const THEME_BOOTSTRAP: string;
36
+ export interface ThemePickerLabels {
37
+ /** Label of the "default" choice: follows the system color scheme and renders the Forest theme. */
38
+ defaultOption: string;
39
+ /** Label of the option group holding the built-in theme's light and dark appearances. */
40
+ defaultThemeGroup: string;
41
+ themes: Record<Exclude<ThemeId, 'default'>, string>;
42
+ }
43
+ export declare function getThemePickerLabels(lang: string | undefined): ThemePickerLabels;
package/dist/themes.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Hagilight theme picker: four themes (the Starlight default plus Ocean, Sakura, and Forest),
3
+ * each with a light and a dark appearance, plus a "default" choice that follows the system
4
+ * color scheme and renders the Forest palette. The palette in use is exposed to CSS through the
5
+ * `data-hagilight-theme` attribute on `<html>`; light/dark mode keeps using Starlight's
6
+ * `data-theme` attribute and `starlight-theme` storage so Starlight's pre-paint script
7
+ * continues to apply the correct mode.
8
+ */
9
+ /** Themes beyond the Starlight default; each must have light and dark palettes in `themes.css`. */
10
+ export const EXTRA_THEME_IDS = ['ocean', 'sakura', 'forest'];
11
+ /** The palette the "default" choice renders, so first-visit users always see the same theme. */
12
+ export const DEFAULT_PALETTE_THEME_ID = 'forest';
13
+ export const DEFAULT_THEME_CHOICE = 'default';
14
+ export const THEME_CHOICES = [
15
+ 'default',
16
+ 'default-light',
17
+ 'default-dark',
18
+ 'ocean-light',
19
+ 'ocean-dark',
20
+ 'sakura-light',
21
+ 'sakura-dark',
22
+ 'forest-light',
23
+ 'forest-dark',
24
+ ];
25
+ export const THEME_STORAGE_KEY = 'hagilight-theme';
26
+ /** Starlight's own storage key; kept in sync with the picker so its pre-paint script applies the right mode. */
27
+ export const STARLIGHT_THEME_STORAGE_KEY = 'starlight-theme';
28
+ const THEME_CHOICE_SET = new Set(THEME_CHOICES);
29
+ export function parseThemeChoice(value) {
30
+ return THEME_CHOICE_SET.has(value) ? value : DEFAULT_THEME_CHOICE;
31
+ }
32
+ export function splitThemeChoice(choice) {
33
+ if (choice === DEFAULT_THEME_CHOICE)
34
+ return { themeId: 'default' };
35
+ const separator = choice.lastIndexOf('-');
36
+ return {
37
+ themeId: choice.slice(0, separator),
38
+ mode: choice.slice(separator + 1),
39
+ };
40
+ }
41
+ function readStorage(key) {
42
+ try {
43
+ return globalThis.localStorage?.getItem(key) ?? null;
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ function writeStorage(key, value) {
50
+ try {
51
+ globalThis.localStorage?.setItem(key, value);
52
+ }
53
+ catch {
54
+ return;
55
+ }
56
+ }
57
+ /** The theme id a choice renders as: the theme itself, or the Forest palette for "default". */
58
+ export function resolveThemeId(choice) {
59
+ const { themeId, mode } = splitThemeChoice(choice);
60
+ return mode === undefined ? DEFAULT_PALETTE_THEME_ID : themeId;
61
+ }
62
+ export function readStoredThemeChoice() {
63
+ return parseThemeChoice(readStorage(THEME_STORAGE_KEY));
64
+ }
65
+ /** Apply a choice to the document, persist it, and keep Starlight's mode storage in sync. */
66
+ export function applyThemeChoice(choice, root = document.documentElement) {
67
+ const { mode } = splitThemeChoice(choice);
68
+ root.dataset.hagilightTheme = resolveThemeId(choice);
69
+ if (mode !== undefined)
70
+ root.dataset.theme = mode;
71
+ writeStorage(THEME_STORAGE_KEY, choice);
72
+ writeStorage(STARLIGHT_THEME_STORAGE_KEY, mode ?? '');
73
+ }
74
+ /**
75
+ * Inline pre-paint bootstrap run from `<head>`: it must stay dependency-free because it
76
+ * executes before any module script. Keep the behavior identical to `applyThemeChoice`.
77
+ */
78
+ export const THEME_BOOTSTRAP = `(function () {
79
+ var choices = ${JSON.stringify(THEME_CHOICES)};
80
+ var choiceKey = ${JSON.stringify(THEME_STORAGE_KEY)};
81
+ var choice = 'default';
82
+ try {
83
+ var stored = localStorage.getItem(choiceKey);
84
+ if (choices.includes(stored)) choice = stored;
85
+ } catch (error) {}
86
+ var themeId = choice === 'default'
87
+ ? ${JSON.stringify(DEFAULT_PALETTE_THEME_ID)}
88
+ : choice.slice(0, choice.lastIndexOf('-'));
89
+ document.documentElement.dataset.hagilightTheme = themeId;
90
+ })();`;
91
+ const labels = {
92
+ 'de-de': {
93
+ defaultOption: 'Standard',
94
+ defaultThemeGroup: 'Standard-Design',
95
+ themes: { ocean: 'Ozean', sakura: 'Sakura', forest: 'Wald' },
96
+ },
97
+ 'en-us': {
98
+ defaultOption: 'Default',
99
+ defaultThemeGroup: 'Default theme',
100
+ themes: { ocean: 'Ocean', sakura: 'Sakura', forest: 'Forest' },
101
+ },
102
+ 'es-es': {
103
+ defaultOption: 'Predeterminado',
104
+ defaultThemeGroup: 'Tema predeterminado',
105
+ themes: { ocean: 'Océano', sakura: 'Sakura', forest: 'Bosque' },
106
+ },
107
+ 'fr-fr': {
108
+ defaultOption: 'Par défaut',
109
+ defaultThemeGroup: 'Thème par défaut',
110
+ themes: { ocean: 'Océan', sakura: 'Sakura', forest: 'Forêt' },
111
+ },
112
+ 'ja-jp': {
113
+ defaultOption: 'デフォルト',
114
+ defaultThemeGroup: 'デフォルトテーマ',
115
+ themes: { ocean: '海', sakura: '桜', forest: '森' },
116
+ },
117
+ 'ko-kr': {
118
+ defaultOption: '기본값',
119
+ defaultThemeGroup: '기본 테마',
120
+ themes: { ocean: '바다', sakura: '벚꽃', forest: '숲' },
121
+ },
122
+ 'pt-br': {
123
+ defaultOption: 'Padrão',
124
+ defaultThemeGroup: 'Tema padrão',
125
+ themes: { ocean: 'Oceano', sakura: 'Sakura', forest: 'Floresta' },
126
+ },
127
+ 'ru-ru': {
128
+ defaultOption: 'По умолчанию',
129
+ defaultThemeGroup: 'Стандартная тема',
130
+ themes: { ocean: 'Океан', sakura: 'Сакура', forest: 'Лес' },
131
+ },
132
+ 'zh-cn': {
133
+ defaultOption: '默认',
134
+ defaultThemeGroup: '默认主题',
135
+ themes: { ocean: '海洋', sakura: '樱花', forest: '森林' },
136
+ },
137
+ 'zh-hant': {
138
+ defaultOption: '預設',
139
+ defaultThemeGroup: '預設主題',
140
+ themes: { ocean: '海洋', sakura: '櫻花', forest: '森林' },
141
+ },
142
+ };
143
+ const enLabels = labels['en-us'];
144
+ export function getThemePickerLabels(lang) {
145
+ if (lang !== undefined) {
146
+ const key = lang.toLowerCase();
147
+ const exact = labels[key];
148
+ if (exact)
149
+ return exact;
150
+ const prefix = key.split('-')[0];
151
+ const prefixed = Object.entries(labels).find(([id]) => id.split('-')[0] === prefix);
152
+ if (prefixed)
153
+ return prefixed[1];
154
+ }
155
+ return enLabels;
156
+ }
@@ -0,0 +1,4 @@
1
+ export declare const DEFAULT_WINDOWS_STORE_PRODUCT_ID = "9N3PM0N3SVDW";
2
+ /** Same rule as the Docs site: the product id is the path segment after `detail/` in the Store URL. */
3
+ export declare function resolveMicrosoftStoreProductId(href?: string): string;
4
+ export declare function resolveMicrosoftStoreBadgeLanguage(locale?: string): string;
@@ -0,0 +1,22 @@
1
+ export const DEFAULT_WINDOWS_STORE_PRODUCT_ID = '9N3PM0N3SVDW';
2
+ /** Microsoft Store badge language for each showcase locale; `repos/docs` only maps zh and en. */
3
+ const BADGE_LANGUAGES = {
4
+ 'en-US': 'en-us',
5
+ 'zh-CN': 'zh-cn',
6
+ 'zh-Hant': 'zh-tw',
7
+ 'ja-JP': 'ja',
8
+ 'ko-KR': 'ko',
9
+ 'de-DE': 'de',
10
+ 'fr-FR': 'fr',
11
+ 'es-ES': 'es',
12
+ 'pt-BR': 'pt-br',
13
+ 'ru-RU': 'ru',
14
+ };
15
+ /** Same rule as the Docs site: the product id is the path segment after `detail/` in the Store URL. */
16
+ export function resolveMicrosoftStoreProductId(href) {
17
+ const match = href?.match(/(?:detail|store\/detail)\/([a-z0-9]+)/i);
18
+ return match?.[1]?.toUpperCase() ?? DEFAULT_WINDOWS_STORE_PRODUCT_ID;
19
+ }
20
+ export function resolveMicrosoftStoreBadgeLanguage(locale) {
21
+ return (locale === undefined ? undefined : BADGE_LANGUAGES[locale]) ?? 'en-us';
22
+ }