@hagicode/hagilight-starlight 0.5.1 → 0.5.2-dev.67.1.00da941

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hagicode/hagilight-starlight
2
2
 
3
- A Starlight plugin for HagiCode documentation sites. Adds a localized header and footer, language chooser, content-width toggle, custom 404 page, SEO metadata, RSS feeds, analytics, and AI-content disclosures.
3
+ A Starlight plugin for HagiCode documentation sites. Adds a localized header and footer, language chooser, content-width toggle, theme picker, custom 404 page, SEO metadata, RSS feeds, analytics, and AI-content disclosures.
4
4
 
5
5
  ## Install
6
6
 
@@ -29,6 +29,14 @@ export default defineConfig({
29
29
 
30
30
  The plugin also exposes `@hagicode/hagilight-starlight/schema` for optional SEO frontmatter and individual Astro components such as `/Header` and `/MarkdownContent` for custom Starlight overrides. Configure features through the typed `HagilightStarlightOptions` passed to `hagilight()`.
31
31
 
32
+ ### Theme picker
33
+
34
+ By default the plugin replaces Starlight's light/dark toggle with a theme picker offering nine choices: the default theme plus the Ocean, Sakura, and Forest themes in light and dark, and a "Default" choice that follows the visitor's system color scheme while a randomly assigned theme (stable per visitor, stored in `localStorage`) provides the palette. Disable it to keep Starlight's built-in toggle or compose `@hagicode/hagilight-starlight/ThemeSelect` yourself:
35
+
36
+ ```js
37
+ hagilight({ themes: { enabled: false } })
38
+ ```
39
+
32
40
  The plugin footer and direct `/Footer` and `/PromotoFooter` components append
33
41
  the literal `Powered By hagilight-starlight@<version>`. The version comes from
34
42
  this package's own manifest at build time, not from Astro or Starlight.
@@ -0,0 +1,176 @@
1
+ ---
2
+ import {
3
+ applyThemeChoice,
4
+ getThemePickerLabels,
5
+ parseThemeChoice,
6
+ readStoredThemeChoice,
7
+ type ThemeChoice,
8
+ } from './dist/themes.js';
9
+
10
+ const { lang } = Astro.locals.starlightRoute;
11
+ const labels = getThemePickerLabels(lang);
12
+ const accessibleLabel = Astro.locals.t('themeSelect.accessibleLabel');
13
+ const lightLabel = Astro.locals.t('themeSelect.light');
14
+ const darkLabel = Astro.locals.t('themeSelect.dark');
15
+
16
+ /* Inlined copies of Starlight's laptop, sun, moon, and down-caret icons; the Starlight
17
+ * Icon component is not part of the package's public exports. */
18
+ const icons = {
19
+ laptop: `<path d="M21 14h-1V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7H3a1 1 0 0 0-1 1v2a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-2a1 1 0 0 0-1-1ZM6 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7H6V7Zm14 10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-1h16v1Z"/>`,
20
+ sun: `<path d="M5 12a1 1 0 0 0-1-1H3a1 1 0 0 0 0 2h1a1 1 0 0 0 1-1Zm.64 5-.71.71a1 1 0 0 0 0 1.41 1 1 0 0 0 1.41 0l.71-.71A1 1 0 0 0 5.64 17ZM12 5a1 1 0 0 0 1-1V3a1 1 0 0 0-2 0v1a1 1 0 0 0 1 1Zm5.66 2.34a1 1 0 0 0 .7-.29l.71-.71a1 1 0 1 0-1.41-1.41l-.66.71a1 1 0 0 0 0 1.41 1 1 0 0 0 .66.29Zm-12-.29a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-.71-.71a1.004 1.004 0 1 0-1.43 1.41l.73.71ZM21 11h-1a1 1 0 0 0 0 2h1a1 1 0 0 0 0-2Zm-2.64 6A1 1 0 0 0 17 18.36l.71.71a1 1 0 0 0 1.41 0 1 1 0 0 0 0-1.41l-.76-.66ZM12 6.5a5.5 5.5 0 1 0 5.5 5.5A5.51 5.51 0 0 0 12 6.5Zm0 9a3.5 3.5 0 1 1 0-7 3.5 3.5 0 0 1 0 7Zm0 3.5a1 1 0 0 0-1 1v1a1 1 0 0 0 2 0v-1a1 1 0 0 0-1-1Z"/>`,
21
+ moon: `<path d="M21.64 13a1 1 0 0 0-1.05-.14 8.049 8.049 0 0 1-3.37.73 8.15 8.15 0 0 1-8.14-8.1 8.59 8.59 0 0 1 .25-2A1 1 0 0 0 8 2.36a10.14 10.14 0 1 0 14 11.69 1 1 0 0 0-.36-1.05Zm-9.5 6.69A8.14 8.14 0 0 1 7.08 5.22v.27a10.15 10.15 0 0 0 10.14 10.14 9.784 9.784 0 0 0 2.1-.22 8.11 8.11 0 0 1-7.18 4.32v-.04Z"/>`,
22
+ caret: `<path d="M17 9.17a1 1 0 0 0-1.41 0L12 12.71 8.46 9.17a1 1 0 1 0-1.41 1.42l4.24 4.24a1.002 1.002 0 0 0 1.42 0L17 10.59a1.002 1.002 0 0 0 0-1.42Z"/>`,
23
+ };
24
+
25
+ const groups = [
26
+ {
27
+ label: labels.defaultThemeGroup,
28
+ prefix: labels.defaultOption,
29
+ choices: ['default-light', 'default-dark'],
30
+ },
31
+ {
32
+ label: labels.themes.ocean,
33
+ prefix: labels.themes.ocean,
34
+ choices: ['ocean-light', 'ocean-dark'],
35
+ },
36
+ {
37
+ label: labels.themes.sakura,
38
+ prefix: labels.themes.sakura,
39
+ choices: ['sakura-light', 'sakura-dark'],
40
+ },
41
+ {
42
+ label: labels.themes.forest,
43
+ prefix: labels.themes.forest,
44
+ choices: ['forest-light', 'forest-dark'],
45
+ },
46
+ ] as const satisfies ReadonlyArray<{
47
+ label: string;
48
+ prefix: string;
49
+ choices: readonly ThemeChoice[];
50
+ }>;
51
+
52
+ const groupOptionLabel = (prefix: string, choice: ThemeChoice): string =>
53
+ `${prefix} · ${choice.endsWith('-light') ? lightLabel : darkLabel}`;
54
+ ---
55
+
56
+ <hagilight-theme-select class="theme-picker">
57
+ <label style="--sl-select-width: 12.5em">
58
+ <span class="sr-only">{accessibleLabel}</span>
59
+ <svg class="icon label-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" set:html={icons.laptop} />
60
+ <select autocomplete="off">
61
+ <option value="default" selected>{labels.defaultOption}</option>
62
+ {groups.map((group) => (
63
+ <optgroup label={group.label}>
64
+ {group.choices.map((choice) => (
65
+ <option value={choice}>{groupOptionLabel(group.prefix, choice)}</option>
66
+ ))}
67
+ </optgroup>
68
+ ))}
69
+ </select>
70
+ <svg class="icon caret" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" set:html={icons.caret} />
71
+ </label>
72
+ <template class="mode-icons">
73
+ <svg class="mode-auto" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" set:html={icons.laptop} />
74
+ <svg class="mode-light" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" set:html={icons.sun} />
75
+ <svg class="mode-dark" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" set:html={icons.moon} />
76
+ </template>
77
+ </hagilight-theme-select>
78
+
79
+ <script>
80
+ import { applyThemeChoice, parseThemeChoice, readStoredThemeChoice, type ThemeChoice } from './dist/themes.js';
81
+
82
+ const modeClass = (choice: ThemeChoice): string =>
83
+ choice.endsWith('-light') ? 'mode-light' : choice.endsWith('-dark') ? 'mode-dark' : 'mode-auto';
84
+
85
+ class HagilightThemeSelect extends HTMLElement {
86
+ connectedCallback() {
87
+ if (this.dataset.initialized) return;
88
+ this.dataset.initialized = 'true';
89
+
90
+ const select = this.querySelector('select');
91
+ if (!(select instanceof HTMLSelectElement)) return;
92
+
93
+ const sync = (choice: ThemeChoice) => {
94
+ select.value = choice;
95
+ const icon = this.querySelector('svg.label-icon');
96
+ const next = this.querySelector('template.mode-icons')?.content.querySelector(`.${modeClass(choice)}`);
97
+ if (icon && next) icon.replaceChildren(...next.cloneNode(true).childNodes);
98
+ };
99
+
100
+ sync(readStoredThemeChoice());
101
+
102
+ select.addEventListener('change', () => {
103
+ const choice = parseThemeChoice(select.value);
104
+ applyThemeChoice(choice);
105
+ sync(choice);
106
+ });
107
+
108
+ matchMedia('(prefers-color-scheme: light)').addEventListener('change', (event) => {
109
+ if (readStoredThemeChoice() !== 'default') return;
110
+ document.documentElement.dataset.theme = event.matches ? 'light' : 'dark';
111
+ });
112
+ }
113
+ }
114
+
115
+ if (!customElements.get('hagilight-theme-select')) {
116
+ customElements.define('hagilight-theme-select', HagilightThemeSelect);
117
+ }
118
+ </script>
119
+
120
+ <style>
121
+ .theme-picker {
122
+ position: relative;
123
+ display: flex;
124
+ align-items: center;
125
+ gap: 0.25rem;
126
+ color: var(--sl-color-gray-2);
127
+ }
128
+
129
+ .theme-picker:hover {
130
+ color: var(--sl-color-white);
131
+ }
132
+
133
+ .theme-picker .icon {
134
+ position: absolute;
135
+ top: 50%;
136
+ transform: translateY(-50%);
137
+ width: 1em;
138
+ height: 1em;
139
+ pointer-events: none;
140
+ }
141
+
142
+ .theme-picker select {
143
+ width: calc(var(--sl-select-width) + 1rem);
144
+ border: 0;
145
+ margin-inline: -0.5rem;
146
+ padding-block: 0.625rem;
147
+ padding-inline: calc(0.875rem + 0.75rem) calc(1.25rem + 0.75rem);
148
+ background-color: transparent;
149
+ color: inherit;
150
+ cursor: pointer;
151
+ text-overflow: ellipsis;
152
+ appearance: none;
153
+ }
154
+
155
+ .theme-picker option,
156
+ .theme-picker optgroup {
157
+ background-color: var(--sl-color-bg-nav);
158
+ color: var(--sl-color-gray-1);
159
+ }
160
+
161
+ .theme-picker .label-icon {
162
+ inset-inline-start: 0;
163
+ font-size: 0.875rem;
164
+ }
165
+
166
+ .theme-picker .caret {
167
+ inset-inline-end: 0;
168
+ font-size: 1.25rem;
169
+ }
170
+
171
+ @media (min-width: 50rem) {
172
+ .theme-picker select {
173
+ font-size: var(--sl-text-sm);
174
+ }
175
+ }
176
+ </style>
package/dist/index.d.ts CHANGED
@@ -34,6 +34,15 @@ export interface HagilightRssOptions extends RssContentOptions {
34
34
  /** Generate `/rss.xml` and localized feeds. Defaults to `true`; requires Astro `site`. */
35
35
  enabled?: boolean;
36
36
  }
37
+ export interface HagilightThemesOptions {
38
+ /**
39
+ * Replace Starlight's light/dark picker with the Hagilight theme picker: four themes
40
+ * (the default plus Ocean, Sakura, and Forest) in light and dark, plus a "default"
41
+ * choice that assigns first-visit users one of the extra themes at random.
42
+ * Defaults to `true`.
43
+ */
44
+ enabled?: boolean;
45
+ }
37
46
  export interface HagilightContentComponentsOptions {
38
47
  pageTitle?: boolean;
39
48
  markdownContent?: boolean;
@@ -47,6 +56,8 @@ export interface HagilightStarlightOptions {
47
56
  seo?: HagilightSeoOptions;
48
57
  contentComponents?: HagilightContentComponentsOptions;
49
58
  aiDisclosures?: HagilightAIDisclosureOptions;
59
+ /** Theme picker with three extra light/dark themes plus a randomized first-visit default. */
60
+ themes?: HagilightThemesOptions;
50
61
  /** End-of-article HagiCode introduction. */
51
62
  hagicodePromotion?: HagilightToggleOptions;
52
63
  /** Floating promotion banner rendered after the footer. */
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { RSS_OWNER, registerStarlightRssOwner, } from '@hagicode/hagilight-core/
6
6
  import { isValidSeoImageReference, resolveSeoImageUrl } from '@hagicode/hagilight-core/seo';
7
7
  import { resolveRssOptions } from './rss-utils.js';
8
8
  import { resolveSeoLocales } from './seo-utils.js';
9
+ import { THEME_BOOTSTRAP } from './themes.js';
9
10
  const packageRoot = new URL('../', import.meta.url);
10
11
  const packageFile = (name) => fileURLToPath(new URL(name, packageRoot));
11
12
  const coreExport = (name) => fileURLToPath(import.meta.resolve(`@hagicode/hagilight-core/${name}`));
@@ -32,6 +33,7 @@ const DEFAULT_GOOGLE_ANALYTICS_ID = 'G-EN03FMT2Q4';
32
33
  const DEFAULT_51LA_ID = 'L6b88a5yK4h2Xnci';
33
34
  const defaultLogo = coreExport('logo.png');
34
35
  const contentWidthCssPath = packageFile('content-width.css');
36
+ const themesCssPath = packageFile('themes.css');
35
37
  const contentWidthHeadScript = `(() => {
36
38
  let mode = 'wide';
37
39
  try {
@@ -336,6 +338,8 @@ export default function hagilight(options = {}) {
336
338
  const analytics = optionalRecord(input.analytics, 'analytics') ?? {};
337
339
  const seo = resolveSeoOptions(input.seo);
338
340
  const contentComponents = optionalRecord(input.contentComponents, 'contentComponents');
341
+ const themes = optionalRecord(input.themes, 'themes');
342
+ const themesEnabled = optionalBoolean(themes, 'enabled', 'themes enabled') !== false;
339
343
  const pageTitleEnabled = optionalBoolean(contentComponents, 'pageTitle', 'contentComponents pageTitle') !== false;
340
344
  const markdownContentEnabled = optionalBoolean(contentComponents, 'markdownContent', 'contentComponents markdownContent') !== false;
341
345
  const aiDisclosures = resolveAIDisclosures(input.aiDisclosures);
@@ -370,6 +374,7 @@ export default function hagilight(options = {}) {
370
374
  head: seo.enabled || googleAnalyticsMeasurementId
371
375
  ? `virtual:hagilight-starlight/${instanceId}/Head.astro`
372
376
  : undefined,
377
+ themeSelect: themesEnabled ? packageFile('ThemeSelect.astro') : undefined,
373
378
  };
374
379
  const serializedOptions = {
375
380
  promotoEnabled,
@@ -401,6 +406,9 @@ export default function hagilight(options = {}) {
401
406
  if (markdownContentEnabled && config.components?.MarkdownContent) {
402
407
  throw new Error('Hagilight cannot replace an existing Starlight MarkdownContent override. Set contentComponents: { markdownContent: false } and compose @hagicode/hagilight-starlight/MarkdownContent into your MarkdownContent component.');
403
408
  }
409
+ if (themesEnabled && config.components?.ThemeSelect) {
410
+ throw new Error('Hagilight cannot replace an existing Starlight ThemeSelect override. Set themes: { enabled: false } to keep it, or compose @hagicode/hagilight-starlight/ThemeSelect into your component.');
411
+ }
404
412
  if (componentIds.head && config.components?.Head) {
405
413
  if (seo.enabled) {
406
414
  throw new Error('Hagilight SEO cannot replace an existing Starlight Head override. Set seo: { enabled: false } and disable Google Analytics to keep your Head, or compose your site metadata explicitly.');
@@ -461,7 +469,11 @@ export default function hagilight(options = {}) {
461
469
  addIntegration(createConfiguredIntegration(instanceId, serializedOptions, { options: rssOptions, locales: rssLocales }, rssLocaleFeedUrls, componentIds, () => getRssFeedUrl(config) ?? rssFeedUrl, generateRss, unregisterRssOwner));
462
470
  updateConfig({
463
471
  ...(config.logo === undefined ? { logo: { src: defaultLogo, alt: 'HagiCode' } } : {}),
464
- customCss: [...(config.customCss ?? []), contentWidthCssPath],
472
+ customCss: [
473
+ ...(config.customCss ?? []),
474
+ contentWidthCssPath,
475
+ ...(themesEnabled ? [themesCssPath] : []),
476
+ ],
465
477
  head: [
466
478
  ...(config.head ?? []),
467
479
  ...(rssFeedUrl ? [{
@@ -469,6 +481,7 @@ export default function hagilight(options = {}) {
469
481
  attrs: { rel: 'alternate', type: 'application/rss+xml', href: rssFeedUrl },
470
482
  }] : []),
471
483
  { tag: 'script', content: contentWidthHeadScript },
484
+ ...(themesEnabled ? [{ tag: 'script', content: THEME_BOOTSTRAP }] : []),
472
485
  ...(() => {
473
486
  const entry = resolveFaviconHeadEntry(config.head, config.favicon === undefined ? {} : { href: config.favicon });
474
487
  return entry ? [entry] : [];
@@ -482,6 +495,7 @@ export default function hagilight(options = {}) {
482
495
  ...(componentIds.pageTitle ? { PageTitle: componentIds.pageTitle } : {}),
483
496
  ...(componentIds.markdownContent ? { MarkdownContent: componentIds.markdownContent } : {}),
484
497
  ...(componentIds.head ? { Head: componentIds.head } : {}),
498
+ ...(componentIds.themeSelect ? { ThemeSelect: componentIds.themeSelect } : {}),
485
499
  },
486
500
  });
487
501
  },
@@ -0,0 +1,44 @@
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 applies the site
4
+ * default experience. 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 a first-visit user is randomly assigned while the "default" choice is selected. */
13
+ export declare const RANDOM_THEME_IDS: readonly ["ocean", "sakura", "forest"];
14
+ export declare const DEFAULT_THEME_CHOICE: ThemeChoice;
15
+ export declare const THEME_CHOICES: readonly ["default", "default-light", "default-dark", "ocean-light", "ocean-dark", "sakura-light", "sakura-dark", "forest-light", "forest-dark"];
16
+ export declare const THEME_STORAGE_KEY = "hagilight-theme";
17
+ export declare const RANDOM_THEME_STORAGE_KEY = "hagilight-theme-random";
18
+ /** Starlight's own storage key; kept in sync with the picker so its pre-paint script applies the right mode. */
19
+ export declare const STARLIGHT_THEME_STORAGE_KEY = "starlight-theme";
20
+ export declare function parseThemeChoice(value: unknown): ThemeChoice;
21
+ export declare function splitThemeChoice(choice: ThemeChoice): {
22
+ themeId: ThemeId;
23
+ mode?: ThemeMode;
24
+ };
25
+ /** The theme randomly assigned to this user, picking and persisting one on the first visit. */
26
+ export declare function assignedRandomThemeId(): ThemeId;
27
+ /** The theme id a choice renders as: the theme itself, or the user's random assignment for "default". */
28
+ export declare function resolveThemeId(choice: ThemeChoice): ThemeId;
29
+ export declare function readStoredThemeChoice(): ThemeChoice;
30
+ /** Apply a choice to the document, persist it, and keep Starlight's mode storage in sync. */
31
+ export declare function applyThemeChoice(choice: ThemeChoice, root?: HTMLElement): void;
32
+ /**
33
+ * Inline pre-paint bootstrap run from `<head>`: it must stay dependency-free because it
34
+ * executes before any module script. Keep the behavior identical to `applyThemeChoice`.
35
+ */
36
+ export declare const THEME_BOOTSTRAP: string;
37
+ export interface ThemePickerLabels {
38
+ /** Label of the "default" choice: site default experience with an automatically assigned theme. */
39
+ defaultOption: string;
40
+ /** Label of the option group holding the built-in theme's light and dark appearances. */
41
+ defaultThemeGroup: string;
42
+ themes: Record<Exclude<ThemeId, 'default'>, string>;
43
+ }
44
+ export declare function getThemePickerLabels(lang: string | undefined): ThemePickerLabels;
package/dist/themes.js ADDED
@@ -0,0 +1,181 @@
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 applies the site
4
+ * default experience. 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 a first-visit user is randomly assigned while the "default" choice is selected. */
10
+ export const RANDOM_THEME_IDS = ['ocean', 'sakura', 'forest'];
11
+ export const DEFAULT_THEME_CHOICE = 'default';
12
+ export const THEME_CHOICES = [
13
+ 'default',
14
+ 'default-light',
15
+ 'default-dark',
16
+ 'ocean-light',
17
+ 'ocean-dark',
18
+ 'sakura-light',
19
+ 'sakura-dark',
20
+ 'forest-light',
21
+ 'forest-dark',
22
+ ];
23
+ export const THEME_STORAGE_KEY = 'hagilight-theme';
24
+ export const RANDOM_THEME_STORAGE_KEY = 'hagilight-theme-random';
25
+ /** Starlight's own storage key; kept in sync with the picker so its pre-paint script applies the right mode. */
26
+ export const STARLIGHT_THEME_STORAGE_KEY = 'starlight-theme';
27
+ const RANDOM_THEME_ID_SET = new Set(RANDOM_THEME_IDS);
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 randomly assigned to this user, picking and persisting one on the first visit. */
58
+ export function assignedRandomThemeId() {
59
+ let stored = readStorage(RANDOM_THEME_STORAGE_KEY);
60
+ if (stored === null || !RANDOM_THEME_ID_SET.has(stored)) {
61
+ stored = RANDOM_THEME_IDS[Math.floor(Math.random() * RANDOM_THEME_IDS.length)];
62
+ writeStorage(RANDOM_THEME_STORAGE_KEY, stored);
63
+ }
64
+ return stored;
65
+ }
66
+ /** The theme id a choice renders as: the theme itself, or the user's random assignment for "default". */
67
+ export function resolveThemeId(choice) {
68
+ const { themeId, mode } = splitThemeChoice(choice);
69
+ return mode === undefined ? assignedRandomThemeId() : themeId;
70
+ }
71
+ export function readStoredThemeChoice() {
72
+ return parseThemeChoice(readStorage(THEME_STORAGE_KEY));
73
+ }
74
+ /** Apply a choice to the document, persist it, and keep Starlight's mode storage in sync. */
75
+ export function applyThemeChoice(choice, root = document.documentElement) {
76
+ const { mode } = splitThemeChoice(choice);
77
+ root.dataset.hagilightTheme = resolveThemeId(choice);
78
+ if (mode !== undefined)
79
+ root.dataset.theme = mode;
80
+ writeStorage(THEME_STORAGE_KEY, choice);
81
+ writeStorage(STARLIGHT_THEME_STORAGE_KEY, mode ?? '');
82
+ }
83
+ /**
84
+ * Inline pre-paint bootstrap run from `<head>`: it must stay dependency-free because it
85
+ * executes before any module script. Keep the behavior identical to `applyThemeChoice`.
86
+ */
87
+ export const THEME_BOOTSTRAP = `(function () {
88
+ var choices = ${JSON.stringify(THEME_CHOICES)};
89
+ var randomThemes = ${JSON.stringify(RANDOM_THEME_IDS)};
90
+ var choiceKey = ${JSON.stringify(THEME_STORAGE_KEY)};
91
+ var randomKey = ${JSON.stringify(RANDOM_THEME_STORAGE_KEY)};
92
+ var choice = 'default';
93
+ try {
94
+ var stored = localStorage.getItem(choiceKey);
95
+ if (choices.includes(stored)) choice = stored;
96
+ } catch (error) {}
97
+ var themeId;
98
+ if (choice === 'default') {
99
+ try {
100
+ themeId = localStorage.getItem(randomKey);
101
+ if (!randomThemes.includes(themeId)) themeId = null;
102
+ } catch (error) {
103
+ themeId = null;
104
+ }
105
+ if (!themeId) {
106
+ themeId = randomThemes[Math.floor(Math.random() * randomThemes.length)];
107
+ try {
108
+ localStorage.setItem(randomKey, themeId);
109
+ } catch (error) {}
110
+ }
111
+ } else {
112
+ themeId = choice.slice(0, choice.lastIndexOf('-'));
113
+ }
114
+ document.documentElement.dataset.hagilightTheme = themeId;
115
+ })();`;
116
+ const labels = {
117
+ 'de-de': {
118
+ defaultOption: 'Standard',
119
+ defaultThemeGroup: 'Standard-Design',
120
+ themes: { ocean: 'Ozean', sakura: 'Sakura', forest: 'Wald' },
121
+ },
122
+ 'en-us': {
123
+ defaultOption: 'Default',
124
+ defaultThemeGroup: 'Default theme',
125
+ themes: { ocean: 'Ocean', sakura: 'Sakura', forest: 'Forest' },
126
+ },
127
+ 'es-es': {
128
+ defaultOption: 'Predeterminado',
129
+ defaultThemeGroup: 'Tema predeterminado',
130
+ themes: { ocean: 'Océano', sakura: 'Sakura', forest: 'Bosque' },
131
+ },
132
+ 'fr-fr': {
133
+ defaultOption: 'Par défaut',
134
+ defaultThemeGroup: 'Thème par défaut',
135
+ themes: { ocean: 'Océan', sakura: 'Sakura', forest: 'Forêt' },
136
+ },
137
+ 'ja-jp': {
138
+ defaultOption: 'デフォルト',
139
+ defaultThemeGroup: 'デフォルトテーマ',
140
+ themes: { ocean: '海', sakura: '桜', forest: '森' },
141
+ },
142
+ 'ko-kr': {
143
+ defaultOption: '기본값',
144
+ defaultThemeGroup: '기본 테마',
145
+ themes: { ocean: '바다', sakura: '벚꽃', forest: '숲' },
146
+ },
147
+ 'pt-br': {
148
+ defaultOption: 'Padrão',
149
+ defaultThemeGroup: 'Tema padrão',
150
+ themes: { ocean: 'Oceano', sakura: 'Sakura', forest: 'Floresta' },
151
+ },
152
+ 'ru-ru': {
153
+ defaultOption: 'По умолчанию',
154
+ defaultThemeGroup: 'Стандартная тема',
155
+ themes: { ocean: 'Океан', sakura: 'Сакура', forest: 'Лес' },
156
+ },
157
+ 'zh-cn': {
158
+ defaultOption: '默认',
159
+ defaultThemeGroup: '默认主题',
160
+ themes: { ocean: '海洋', sakura: '樱花', forest: '森林' },
161
+ },
162
+ 'zh-hant': {
163
+ defaultOption: '預設',
164
+ defaultThemeGroup: '預設主題',
165
+ themes: { ocean: '海洋', sakura: '櫻花', forest: '森林' },
166
+ },
167
+ };
168
+ const enLabels = labels['en-us'];
169
+ export function getThemePickerLabels(lang) {
170
+ if (lang !== undefined) {
171
+ const key = lang.toLowerCase();
172
+ const exact = labels[key];
173
+ if (exact)
174
+ return exact;
175
+ const prefix = key.split('-')[0];
176
+ const prefixed = Object.entries(labels).find(([id]) => id.split('-')[0] === prefix);
177
+ if (prefixed)
178
+ return prefixed[1];
179
+ }
180
+ return enLabels;
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-starlight",
3
- "version": "0.5.1",
3
+ "version": "0.5.2-dev.67.1.00da941",
4
4
  "type": "module",
5
5
  "description": "Starlight plugin and components for HagiCode documentation",
6
6
  "repository": {
@@ -30,6 +30,7 @@
30
30
  "default": "./dist/schema.js"
31
31
  },
32
32
  "./ContentLayoutToggle": "./ContentLayoutToggle.astro",
33
+ "./ThemeSelect": "./ThemeSelect.astro",
33
34
  "./Header": "./Header.astro",
34
35
  "./Footer": "./Footer.astro",
35
36
  "./MarkdownContent": "./MarkdownContent.astro",
@@ -48,7 +49,7 @@
48
49
  "assets/*.png"
49
50
  ],
50
51
  "dependencies": {
51
- "@hagicode/hagilight-core": "0.5.1"
52
+ "@hagicode/hagilight-core": "0.5.2-dev.67.1.00da941"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "@astrojs/starlight": "^0.42.4",
package/themes.css ADDED
@@ -0,0 +1,160 @@
1
+ /*
2
+ * Hagilight extra themes: Ocean, Sakura, and Forest. Each theme overrides Starlight's
3
+ * color custom properties for its dark and light appearance. The applied theme is exposed
4
+ * through `data-hagilight-theme` on `<html>` by the theme picker's pre-paint bootstrap
5
+ * script, while light/dark mode keeps using Starlight's `data-theme` attribute.
6
+ *
7
+ * Starlight's dark palette is the `:root` default, so dark overrides must exclude the light
8
+ * mode with `:not([data-theme='light'])`; otherwise they would out-specify the light palette
9
+ * because custom CSS loads after Starlight's own styles.
10
+ */
11
+
12
+ /* Ocean — cool blue accent. */
13
+ :root[data-hagilight-theme='ocean']:not([data-theme='light']),
14
+ :root[data-hagilight-theme='ocean']:not([data-theme='light']) ::backdrop {
15
+ --sl-color-white: hsl(0, 0%, 100%);
16
+ --sl-color-gray-1: hsl(212, 24%, 94%);
17
+ --sl-color-gray-2: hsl(212, 12%, 77%);
18
+ --sl-color-gray-3: hsl(212, 10%, 56%);
19
+ --sl-color-gray-4: hsl(212, 10%, 36%);
20
+ --sl-color-gray-5: hsl(212, 14%, 23%);
21
+ --sl-color-gray-6: hsl(212, 18%, 16%);
22
+ --sl-color-black: hsl(212, 20%, 10%);
23
+
24
+ --sl-hue-orange: 25;
25
+ --sl-hue-green: 160;
26
+ --sl-hue-blue: 206;
27
+ --sl-hue-purple: 265;
28
+ --sl-hue-red: 355;
29
+
30
+ --sl-color-accent-low: hsl(212, 64%, 22%);
31
+ --sl-color-accent: hsl(206, 100%, 63%);
32
+ --sl-color-accent-high: hsl(205, 100%, 84%);
33
+
34
+ --sl-color-backdrop-overlay: hsla(212, 40%, 8%, 0.66);
35
+ }
36
+
37
+ :root[data-hagilight-theme='ocean'][data-theme='light'],
38
+ :root[data-hagilight-theme='ocean'][data-theme='light'] ::backdrop {
39
+ --sl-color-white: hsl(212, 24%, 10%);
40
+ --sl-color-gray-1: hsl(212, 18%, 16%);
41
+ --sl-color-gray-2: hsl(212, 12%, 23%);
42
+ --sl-color-gray-3: hsl(212, 10%, 36%);
43
+ --sl-color-gray-4: hsl(212, 8%, 56%);
44
+ --sl-color-gray-5: hsl(212, 10%, 77%);
45
+ --sl-color-gray-6: hsl(212, 26%, 94%);
46
+ --sl-color-gray-7: hsl(212, 32%, 97%);
47
+ --sl-color-black: hsl(0, 0%, 100%);
48
+
49
+ --sl-hue-orange: 25;
50
+ --sl-hue-green: 160;
51
+ --sl-hue-blue: 206;
52
+ --sl-hue-purple: 265;
53
+ --sl-hue-red: 355;
54
+
55
+ --sl-color-accent-high: hsl(211, 80%, 30%);
56
+ --sl-color-accent: hsl(206, 90%, 42%);
57
+ --sl-color-accent-low: hsl(206, 88%, 90%);
58
+
59
+ --sl-color-backdrop-overlay: hsla(212, 30%, 35%, 0.66);
60
+ }
61
+
62
+ /* Sakura — rose accent with warm neutrals. */
63
+ :root[data-hagilight-theme='sakura']:not([data-theme='light']),
64
+ :root[data-hagilight-theme='sakura']:not([data-theme='light']) ::backdrop {
65
+ --sl-color-white: hsl(0, 0%, 100%);
66
+ --sl-color-gray-1: hsl(340, 24%, 94%);
67
+ --sl-color-gray-2: hsl(340, 12%, 77%);
68
+ --sl-color-gray-3: hsl(340, 10%, 56%);
69
+ --sl-color-gray-4: hsl(340, 10%, 36%);
70
+ --sl-color-gray-5: hsl(340, 14%, 23%);
71
+ --sl-color-gray-6: hsl(340, 18%, 16%);
72
+ --sl-color-black: hsl(340, 20%, 10%);
73
+
74
+ --sl-hue-orange: 20;
75
+ --sl-hue-green: 145;
76
+ --sl-hue-blue: 230;
77
+ --sl-hue-purple: 285;
78
+ --sl-hue-red: 350;
79
+
80
+ --sl-color-accent-low: hsl(338, 60%, 22%);
81
+ --sl-color-accent: hsl(340, 85%, 70%);
82
+ --sl-color-accent-high: hsl(340, 100%, 88%);
83
+
84
+ --sl-color-backdrop-overlay: hsla(340, 40%, 8%, 0.66);
85
+ }
86
+
87
+ :root[data-hagilight-theme='sakura'][data-theme='light'],
88
+ :root[data-hagilight-theme='sakura'][data-theme='light'] ::backdrop {
89
+ --sl-color-white: hsl(340, 24%, 10%);
90
+ --sl-color-gray-1: hsl(340, 18%, 16%);
91
+ --sl-color-gray-2: hsl(340, 12%, 23%);
92
+ --sl-color-gray-3: hsl(340, 10%, 36%);
93
+ --sl-color-gray-4: hsl(340, 8%, 56%);
94
+ --sl-color-gray-5: hsl(340, 10%, 77%);
95
+ --sl-color-gray-6: hsl(340, 26%, 94%);
96
+ --sl-color-gray-7: hsl(340, 32%, 97%);
97
+ --sl-color-black: hsl(0, 0%, 100%);
98
+
99
+ --sl-hue-orange: 20;
100
+ --sl-hue-green: 145;
101
+ --sl-hue-blue: 230;
102
+ --sl-hue-purple: 285;
103
+ --sl-hue-red: 350;
104
+
105
+ --sl-color-accent-high: hsl(338, 80%, 34%);
106
+ --sl-color-accent: hsl(340, 80%, 44%);
107
+ --sl-color-accent-low: hsl(340, 90%, 91%);
108
+
109
+ --sl-color-backdrop-overlay: hsla(340, 30%, 35%, 0.66);
110
+ }
111
+
112
+ /* Forest — green accent with cool neutrals. */
113
+ :root[data-hagilight-theme='forest']:not([data-theme='light']),
114
+ :root[data-hagilight-theme='forest']:not([data-theme='light']) ::backdrop {
115
+ --sl-color-white: hsl(0, 0%, 100%);
116
+ --sl-color-gray-1: hsl(150, 20%, 94%);
117
+ --sl-color-gray-2: hsl(150, 10%, 77%);
118
+ --sl-color-gray-3: hsl(150, 8%, 56%);
119
+ --sl-color-gray-4: hsl(150, 8%, 36%);
120
+ --sl-color-gray-5: hsl(150, 12%, 23%);
121
+ --sl-color-gray-6: hsl(150, 16%, 16%);
122
+ --sl-color-black: hsl(150, 18%, 10%);
123
+
124
+ --sl-hue-orange: 35;
125
+ --sl-hue-green: 155;
126
+ --sl-hue-blue: 200;
127
+ --sl-hue-purple: 262;
128
+ --sl-hue-red: 5;
129
+
130
+ --sl-color-accent-low: hsl(152, 60%, 20%);
131
+ --sl-color-accent: hsl(158, 80%, 52%);
132
+ --sl-color-accent-high: hsl(150, 90%, 78%);
133
+
134
+ --sl-color-backdrop-overlay: hsla(150, 40%, 8%, 0.66);
135
+ }
136
+
137
+ :root[data-hagilight-theme='forest'][data-theme='light'],
138
+ :root[data-hagilight-theme='forest'][data-theme='light'] ::backdrop {
139
+ --sl-color-white: hsl(150, 20%, 10%);
140
+ --sl-color-gray-1: hsl(150, 16%, 16%);
141
+ --sl-color-gray-2: hsl(150, 10%, 23%);
142
+ --sl-color-gray-3: hsl(150, 8%, 36%);
143
+ --sl-color-gray-4: hsl(150, 6%, 56%);
144
+ --sl-color-gray-5: hsl(150, 8%, 77%);
145
+ --sl-color-gray-6: hsl(150, 24%, 94%);
146
+ --sl-color-gray-7: hsl(150, 30%, 97%);
147
+ --sl-color-black: hsl(0, 0%, 100%);
148
+
149
+ --sl-hue-orange: 35;
150
+ --sl-hue-green: 155;
151
+ --sl-hue-blue: 200;
152
+ --sl-hue-purple: 262;
153
+ --sl-hue-red: 5;
154
+
155
+ --sl-color-accent-high: hsl(152, 100%, 24%);
156
+ --sl-color-accent: hsl(155, 100%, 26%);
157
+ --sl-color-accent-low: hsl(152, 85%, 89%);
158
+
159
+ --sl-color-backdrop-overlay: hsla(150, 30%, 35%, 0.66);
160
+ }