@hagicode/hagilight-starlight 0.11.0 → 0.11.1-dev.133.1.338317a

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.
@@ -57,7 +57,8 @@ const gallery = copy.gallery.flatMap((item) => {
57
57
  href={homeHref}
58
58
  {...showcaseTag('navigation', 'home')}
59
59
  >
60
- {copy.visitLabel}
60
+ <span>{copy.visitLabel}</span>
61
+ <svg class="hagilight-article-promotion__arrow" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10h11M11 5l5 5-5 5" /></svg>
61
62
  </a>
62
63
  <a
63
64
  class="hagilight-article-promotion__cta hagilight-article-promotion__cta--secondary"
@@ -137,7 +138,7 @@ const gallery = copy.gallery.flatMap((item) => {
137
138
  class="hagilight-article-promotion__downloads"
138
139
  href={downloadsHref}
139
140
  {...showcaseTag('download', 'downloadClient')}
140
- >{copy.allDownloadsLabel}</a>
141
+ ><span>{copy.allDownloadsLabel}</span><svg class="hagilight-article-promotion__arrow" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10h11M11 5l5 5-5 5" /></svg></a>
141
142
  </div>
142
143
  </div>
143
144
 
@@ -151,43 +152,74 @@ const gallery = copy.gallery.flatMap((item) => {
151
152
 
152
153
  <style>
153
154
  /*
154
- * Zones, top to bottom: pitch (what it is, where to go, the three pillars), gallery (proof),
155
- * closing (get it, share it). Zones are separated by space and hairlines rather than nested
156
- * boxes. The topology follows the section's own width, so the wide-content toggle and the
157
- * sidebar layouts reflow it, not just the viewport. Mobile-first: the base rules are the
158
- * single-column layout, which is also what a browser without container queries renders.
155
+ * One stage, four beats, top to bottom: pitch (what it is, the way in, the three pillars),
156
+ * gallery (proof), closing band (get it, share it). The stage is tinted from the active theme's
157
+ * accent so it reads as a deliberate HagiCode moment against the article's neutral page, and the
158
+ * closing band bleeds to the stage edges so "get it" is the last, heaviest surface. Pillars are
159
+ * separated by accent rules rather than boxes. The topology follows the section's own width, so
160
+ * the wide-content toggle and the sidebar layouts reflow it, not just the viewport. Mobile-first:
161
+ * the base rules are the single-column layout, which is also what a browser without container
162
+ * queries renders. Every color derives from Starlight's tokens, so all themes and both modes work.
159
163
  */
160
164
  .hagilight-article-promotion {
165
+ --hagilight-promo-pad: 1.5rem;
166
+ --hagilight-promo-edge: color-mix(in srgb, var(--sl-color-text-accent) 38%, var(--sl-color-gray-5));
167
+ --hagilight-promo-tint: color-mix(in srgb, var(--sl-color-accent-low) 55%, var(--sl-color-bg-nav));
168
+ position: relative;
161
169
  container-type: inline-size;
162
170
  display: grid;
163
171
  grid-template-columns: minmax(0, 1fr);
164
- gap: 2rem;
172
+ gap: 2.25rem;
165
173
  margin: 3rem auto 2rem;
166
- padding: clamp(1rem, 3vw, 1.75rem);
167
- border: 1px solid var(--sl-color-gray-5);
174
+ padding: calc(var(--hagilight-promo-pad) + 0.25rem) var(--hagilight-promo-pad) var(--hagilight-promo-pad);
175
+ overflow: clip;
176
+ border: 1px solid var(--hagilight-promo-edge);
168
177
  border-radius: var(--sl-radius-lg, 0.75rem);
169
- background: var(--sl-color-bg-nav);
178
+ background:
179
+ radial-gradient(70% 45% at 0% 0%, color-mix(in srgb, var(--sl-color-text-accent) 16%, transparent), transparent 70%),
180
+ linear-gradient(180deg, var(--hagilight-promo-tint), var(--sl-color-bg-nav) 55%);
170
181
  color: var(--sl-color-text);
171
182
  }
172
183
 
184
+ /* The accent rule across the top edge: the stage's signature, drawn in on scroll where supported. */
185
+ .hagilight-article-promotion::before {
186
+ content: '';
187
+ position: absolute;
188
+ inset: 0 0 auto;
189
+ height: 4px;
190
+ background: linear-gradient(90deg, var(--sl-color-text-accent), color-mix(in srgb, var(--sl-color-text-accent) 20%, transparent));
191
+ transform-origin: left center;
192
+ pointer-events: none;
193
+ }
194
+
195
+ .hagilight-article-promotion ::selection {
196
+ background: color-mix(in srgb, var(--sl-color-text-accent) 38%, transparent);
197
+ color: var(--sl-color-white);
198
+ }
199
+
173
200
  .hagilight-article-promotion h2,
174
201
  .hagilight-article-promotion h3 {
175
202
  margin: 0;
176
203
  color: var(--sl-color-white);
177
- line-height: 1.25;
204
+ line-height: 1.2;
205
+ text-wrap: balance;
178
206
  }
179
207
 
180
208
  .hagilight-article-promotion h2 {
181
- font-size: var(--sl-text-2xl, 1.5rem);
209
+ font-size: clamp(2rem, 1.3rem + 3.2cqi, 3rem);
210
+ font-weight: 800;
211
+ letter-spacing: -0.02em;
182
212
  }
183
213
 
184
214
  .hagilight-article-promotion h3 {
185
- font-size: var(--sl-text-lg, 1.125rem);
215
+ font-size: var(--sl-text-xl, 1.25rem);
216
+ font-weight: 700;
186
217
  }
187
218
 
188
219
  .hagilight-article-promotion p {
189
220
  margin: 0;
190
221
  line-height: 1.6;
222
+ text-wrap: pretty;
191
223
  }
192
224
 
193
225
  .hagilight-article-promotion ul {
@@ -201,17 +233,17 @@ const gallery = copy.gallery.flatMap((item) => {
201
233
  outline-offset: 3px;
202
234
  }
203
235
 
204
- /* Pitch: headline, definition, promise, then the way in. */
236
+ /* Pitch: headline, one emphasized promise, the way in, then the pillars. */
205
237
  .hagilight-article-promotion__pitch {
206
238
  display: grid;
207
- gap: 1.75rem;
239
+ gap: 2rem;
208
240
  }
209
241
 
210
242
  .hagilight-article-promotion__intro {
211
243
  display: grid;
212
244
  align-content: start;
213
245
  justify-items: start;
214
- gap: 0.75rem;
246
+ gap: 0.875rem;
215
247
  }
216
248
 
217
249
  .hagilight-article-promotion__lead,
@@ -219,9 +251,15 @@ const gallery = copy.gallery.flatMap((item) => {
219
251
  max-inline-size: 68ch;
220
252
  }
221
253
 
254
+ .hagilight-article-promotion__lead {
255
+ color: var(--sl-color-gray-2);
256
+ }
257
+
222
258
  .hagilight-article-promotion__tagline {
223
259
  color: var(--sl-color-white);
224
- font-weight: 600;
260
+ font-size: var(--sl-text-lg, 1.125rem);
261
+ font-weight: 650;
262
+ line-height: 1.45;
225
263
  }
226
264
 
227
265
  .hagilight-article-promotion__actions {
@@ -236,32 +274,60 @@ const gallery = copy.gallery.flatMap((item) => {
236
274
  display: inline-flex;
237
275
  align-items: center;
238
276
  justify-content: center;
277
+ gap: 0.5rem;
239
278
  min-height: 2.75rem;
240
- padding: 0.5rem 1rem;
279
+ padding: 0.5rem 1.25rem;
241
280
  border: 1px solid var(--sl-color-text-accent);
242
281
  border-radius: var(--sl-radius-sm, 0.375rem);
243
282
  font-weight: 700;
244
283
  text-decoration: none;
284
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.2s cubic-bezier(0.22, 1, 0.36, 1), background-color 0.2s;
245
285
  }
246
286
 
287
+ /* The one loud control: bigger than its sibling, lifted, with an arrow that leans forward on hover. */
247
288
  .hagilight-article-promotion__cta--primary {
289
+ min-height: 3.25rem;
290
+ padding-inline: 1.625rem;
248
291
  background: var(--sl-color-text-accent);
249
292
  color: var(--sl-color-bg);
293
+ font-size: var(--sl-text-lg, 1.125rem);
294
+ box-shadow: 0 8px 20px -8px color-mix(in srgb, var(--sl-color-text-accent) 70%, transparent);
250
295
  }
251
296
 
252
297
  .hagilight-article-promotion__cta--secondary {
253
298
  background: transparent;
299
+ color: var(--sl-color-white);
300
+ border-color: var(--sl-color-gray-3);
301
+ }
302
+
303
+ .hagilight-article-promotion__arrow {
304
+ flex: none;
305
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
306
+ }
307
+
308
+ .hagilight-article-promotion__cta--primary:hover {
309
+ transform: translateY(-2px);
310
+ box-shadow: 0 12px 24px -8px color-mix(in srgb, var(--sl-color-text-accent) 80%, transparent);
311
+ }
312
+
313
+ .hagilight-article-promotion__cta--primary:active {
314
+ transform: translateY(0);
315
+ }
316
+
317
+ .hagilight-article-promotion__cta--secondary:hover {
318
+ border-color: var(--sl-color-text-accent);
254
319
  color: var(--sl-color-text-accent);
255
320
  }
256
321
 
257
- .hagilight-article-promotion__cta:hover {
258
- filter: brightness(1.08);
322
+ .hagilight-article-promotion__cta:hover .hagilight-article-promotion__arrow,
323
+ .hagilight-article-promotion__downloads:hover .hagilight-article-promotion__arrow {
324
+ transform: translateX(3px);
259
325
  }
260
326
 
261
- /* Pillars: one hairline-topped row each, icon and word on a line, description under it. */
327
+ /* Pillars: each opens with an accent rule; the pillar word is the loudest text after the title. */
262
328
  .hagilight-article-promotion__features {
263
329
  display: grid;
264
- gap: 1rem 1.5rem;
330
+ gap: 1.25rem 1.5rem;
265
331
  }
266
332
 
267
333
  .hagilight-article-promotion__features li {
@@ -269,16 +335,17 @@ const gallery = copy.gallery.flatMap((item) => {
269
335
  grid-template-columns: auto minmax(0, 1fr);
270
336
  align-content: start;
271
337
  align-items: center;
272
- gap: 0.5rem 0.625rem;
338
+ gap: 0.625rem 0.75rem;
273
339
  min-width: 0;
274
- padding-block-start: 0.875rem;
275
- border-block-start: 1px solid var(--sl-color-gray-5);
340
+ padding-block-start: 1rem;
341
+ border-block-start: 2px solid color-mix(in srgb, var(--sl-color-text-accent) 55%, transparent);
276
342
  }
277
343
 
278
344
  .hagilight-article-promotion__features strong {
279
345
  color: var(--sl-color-text-accent);
280
- font-size: var(--sl-text-lg, 1.125rem);
281
- line-height: 1.25;
346
+ font-size: var(--sl-text-xl, 1.25rem);
347
+ font-weight: 750;
348
+ line-height: 1.2;
282
349
  }
283
350
 
284
351
  .hagilight-article-promotion__feature-text {
@@ -288,6 +355,17 @@ const gallery = copy.gallery.flatMap((item) => {
288
355
  }
289
356
 
290
357
  .hagilight-article-promotion__icon {
358
+ position: relative;
359
+ display: grid;
360
+ place-items: center;
361
+ width: 2.5rem;
362
+ height: 2.5rem;
363
+ border-radius: var(--sl-radius-md, 0.5rem);
364
+ background: color-mix(in srgb, var(--sl-color-text-accent) 18%, transparent);
365
+ }
366
+
367
+ .hagilight-article-promotion__icon::after {
368
+ content: '';
291
369
  width: 1.5rem;
292
370
  height: 1.5rem;
293
371
  background: var(--sl-color-text-accent);
@@ -298,12 +376,12 @@ const gallery = copy.gallery.flatMap((item) => {
298
376
  /* Gallery: three audited screenshots, kept in the catalog's narrative order. */
299
377
  .hagilight-article-promotion__gallery-block {
300
378
  display: grid;
301
- gap: 1rem;
379
+ gap: 1.125rem;
302
380
  }
303
381
 
304
382
  .hagilight-article-promotion__gallery {
305
383
  display: grid;
306
- gap: 1.25rem 1rem;
384
+ gap: 1.5rem 1.25rem;
307
385
  }
308
386
 
309
387
  .hagilight-article-promotion__gallery li {
@@ -312,7 +390,7 @@ const gallery = copy.gallery.flatMap((item) => {
312
390
 
313
391
  .hagilight-article-promotion figure {
314
392
  display: grid;
315
- gap: 0.5rem;
393
+ gap: 0.625rem;
316
394
  margin: 0;
317
395
  }
318
396
 
@@ -324,22 +402,31 @@ const gallery = copy.gallery.flatMap((item) => {
324
402
  display: block;
325
403
  width: 100%;
326
404
  height: auto;
327
- border: 1px solid var(--sl-color-gray-5);
405
+ border: 1px solid var(--hagilight-promo-edge);
328
406
  border-radius: var(--sl-radius-md, 0.5rem);
407
+ box-shadow: 0 18px 32px -18px color-mix(in srgb, var(--sl-color-black) 80%, transparent);
408
+ transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.35s;
409
+ }
410
+
411
+ .hagilight-article-promotion figure:hover img {
412
+ transform: translateY(-3px);
413
+ border-color: var(--sl-color-text-accent);
329
414
  }
330
415
 
331
416
  .hagilight-article-promotion figcaption {
332
- color: var(--sl-color-gray-2);
417
+ color: var(--sl-color-gray-1);
333
418
  font-size: var(--sl-text-sm, 0.875rem);
334
419
  line-height: 1.5;
335
420
  }
336
421
 
337
- /* Closing: the practical next steps, as two parallel headed blocks. */
422
+ /* Closing: a full-bleed band, the heaviest surface, so "get it" is where the eye lands last. */
338
423
  .hagilight-article-promotion__closing {
339
424
  display: grid;
340
- gap: 1.5rem;
341
- padding-block-start: 1.5rem;
342
- border-block-start: 1px solid var(--sl-color-gray-5);
425
+ gap: 1.75rem;
426
+ margin: 0 calc(var(--hagilight-promo-pad) * -1) calc(var(--hagilight-promo-pad) * -1);
427
+ padding: var(--hagilight-promo-pad);
428
+ border-block-start: 1px solid var(--hagilight-promo-edge);
429
+ background: color-mix(in srgb, var(--sl-color-accent-low) 80%, var(--sl-color-bg));
343
430
  }
344
431
 
345
432
  .hagilight-article-promotion__windows,
@@ -347,7 +434,7 @@ const gallery = copy.gallery.flatMap((item) => {
347
434
  display: grid;
348
435
  align-content: start;
349
436
  justify-items: start;
350
- gap: 0.5rem;
437
+ gap: 0.625rem;
351
438
  min-width: 0;
352
439
  }
353
440
 
@@ -356,7 +443,7 @@ const gallery = copy.gallery.flatMap((item) => {
356
443
  display: flex;
357
444
  flex-wrap: wrap;
358
445
  align-items: center;
359
- gap: 0 1.25rem;
446
+ gap: 0 1.5rem;
360
447
  max-width: 100%;
361
448
  }
362
449
 
@@ -378,15 +465,18 @@ const gallery = copy.gallery.flatMap((item) => {
378
465
  .hagilight-article-promotion__downloads {
379
466
  display: inline-flex;
380
467
  align-items: center;
468
+ gap: 0.375rem;
381
469
  min-height: 2.75rem;
382
470
  color: var(--sl-color-text-accent);
383
- font-weight: 600;
471
+ font-weight: 650;
472
+ text-decoration-thickness: 1px;
473
+ text-underline-offset: 0.25em;
384
474
  }
385
475
 
386
476
  .hagilight-article-promotion__share-text {
387
477
  justify-self: stretch;
388
478
  padding: 0.75rem 1rem;
389
- border: 1px dashed var(--sl-color-gray-4);
479
+ border: 1px dashed var(--sl-color-text-accent);
390
480
  border-radius: var(--sl-radius-md, 0.5rem);
391
481
  background: var(--sl-color-bg);
392
482
  overflow-wrap: anywhere;
@@ -452,4 +542,53 @@ const gallery = copy.gallery.flatMap((item) => {
452
542
  justify-self: stretch;
453
543
  }
454
544
  }
545
+
546
+ /*
547
+ * The one authored moment: as the stage scrolls into view its accent rule draws across and the
548
+ * screenshots rise into place one after another. Scroll-driven, so there is no JS and nothing
549
+ * replays; engines without view timelines, and readers who ask for less motion, get the final
550
+ * state. The animations start at 0.35 opacity, so a tall item that never reaches its full entry
551
+ * range is still readable.
552
+ */
553
+ @media (prefers-reduced-motion: no-preference) {
554
+ @supports (animation-timeline: view()) {
555
+ .hagilight-article-promotion::before {
556
+ animation-name: hagilight-promotion-rule;
557
+ animation-timing-function: linear;
558
+ animation-fill-mode: both;
559
+ animation-timeline: view();
560
+ animation-range: entry 0% entry 80%;
561
+ }
562
+
563
+ .hagilight-article-promotion__gallery li {
564
+ animation-name: hagilight-promotion-rise;
565
+ animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
566
+ animation-fill-mode: both;
567
+ animation-timeline: view();
568
+ animation-range: entry 5% entry 45%;
569
+ }
570
+
571
+ .hagilight-article-promotion__gallery li:nth-child(2) {
572
+ animation-range: entry 12% entry 52%;
573
+ }
574
+
575
+ .hagilight-article-promotion__gallery li:nth-child(3) {
576
+ animation-range: entry 19% entry 59%;
577
+ }
578
+ }
579
+ }
580
+
581
+ @keyframes hagilight-promotion-rule {
582
+ from {
583
+ transform: scaleX(0.04);
584
+ }
585
+ }
586
+
587
+ @keyframes hagilight-promotion-rise {
588
+ from {
589
+ opacity: 0.35;
590
+ translate: 0 1.75rem;
591
+ scale: 0.97;
592
+ }
593
+ }
455
594
  </style>
package/README.md CHANGED
@@ -29,6 +29,43 @@ 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
+ ### Analytics
33
+
34
+ `hagilight()` enables PostHog without analytics configuration, an enabled flag, or a consumer project key. It uses `phc_qiVVihsL925ovhPYNdLJWz4k7aoKVMwYCevgZPE6PER5`, API host `https://us.i.posthog.com`, and SDK defaults `2026-05-30`. Google Analytics and PostHog render together in the generated global Head; 51LA stays in the Footer. PostHog covers all production pages, including localized and 404 pages, independently of Google's existing 404 exclusion. Development pages emit no PostHog loader.
35
+
36
+ PostHog options follow the Google Analytics provider convention:
37
+
38
+ | PostHog input | Behavior |
39
+ | --- | --- |
40
+ | Plugin options omitted, `analytics: {}`, or `posthog` omitted | Enabled with the supplied default key |
41
+ | `posthog: {}` | Disabled, like an empty Google Analytics provider |
42
+ | `posthog: { apiKey: 'phc_Custom123' }` | Enabled with the custom key |
43
+ | `posthog: { enabled: true, apiKey: 'phc_Custom123' }` | Enabled with the custom key |
44
+ | `posthog: { enabled: true }` | Configuration error: `apiKey` required |
45
+ | `posthog: { enabled: false }` | Disabled independently of Google Analytics and 51LA |
46
+
47
+ Keys must match `^phc_[A-Za-z0-9]+$`; enabled providers reject missing or malformed keys. Provider options must be objects and `enabled` must be boolean.
48
+
49
+ ```js
50
+ hagilight(); // shared analytics defaults
51
+ hagilight({ analytics: { posthog: { apiKey: 'phc_Custom123' } } });
52
+ hagilight({ analytics: { posthog: { enabled: false } } });
53
+ ```
54
+
55
+ A site-owned `components.Head` conflicts with enabled SEO, Google Analytics, or PostHog. To retain it, disable all three head features and explicitly compose the desired metadata and `@hagicode/hagilight-core/GoogleAnalytics` / `@hagicode/hagilight-core/PostHog` components in that Head:
56
+
57
+ ```js
58
+ hagilight({
59
+ seo: { enabled: false },
60
+ analytics: {
61
+ googleAnalytics: { enabled: false },
62
+ posthog: { enabled: false },
63
+ },
64
+ });
65
+ ```
66
+
67
+ The shared component initializes once per document; SDK defaults own PostHog navigation behavior. Choose one initialization owner and disable this provider when the site already initializes PostHog. Google Analytics and 51LA keep their configured behavior when PostHog is disabled.
68
+
32
69
  ### Theme picker
33
70
 
34
71
  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 and renders the Forest theme. First-time visitors, and visitors with an invalid saved choice, therefore see Forest; the other themes appear only after an explicit selection, which is remembered in `localStorage`.
@@ -45,6 +82,15 @@ hagilight({ themes: { enabled: false } })
45
82
 
46
83
  The plugin footer shows the same build line as the Astro package: `Branch:`, `Commit:`, and `Built:` after the attribution. It is always on and has no option of its own. The values come from `HAGILIGHT_BUILD_BRANCH` and `HAGILIGHT_BUILD_COMMIT`, then Git, then `unknown`; the core README gives the order. Detached-HEAD CI checkouts report `Branch: unknown` unless the build step sets `HAGILIGHT_BUILD_BRANCH`, as shown in the Astro README. Footer components mounted directly in a page (`@hagicode/hagilight-starlight/Footer` and `PromotoFooter`) render no build text.
47
84
 
85
+ ### Site metadata
86
+
87
+ The plugin writes `/.well-known/hagilight.metadata.json` once per build, after the sitemap, so `discovery` lists the sitemap and any RSS feed that exist. The file's schema and privacy rules are in `@hagicode/hagilight-core`'s README.
88
+
89
+ - `metadata: false` opts out. `metadata: { name, description }` sets the values; the name defaults to the Starlight `title` for the default locale, then the site hostname.
90
+ - Analytics flags report the resolved options. A provider you disable with `analytics` reads `false`. The IDs and keys are never written.
91
+ - When the plain-Astro `hagilight()` integration is also registered, this plugin writes the file, including `llms.txt` in `discovery`, whatever the order of registration. Set `metadata: false` here to let the plain-Astro integration write it instead.
92
+ - GitHub Pages needs a `.nojekyll` file in the published root, or the `.well-known` directory is dropped. The `/docs/` base path puts the file at `<site URL>/docs/.well-known/hagilight.metadata.json`.
93
+
48
94
  ### Body images
49
95
 
50
96
  `hagilight()` registers the content-image plugin from `@hagicode/hagilight-core/content-images` for the whole site. Every Markdown or MDX body image outside a `<picture>` element gets `loading="lazy"`, `decoding="async"`, and the class `hagilight-content-image`. Values the author set are kept, and logos and showcase images are not changed.
package/dist/index.d.ts CHANGED
@@ -20,6 +20,12 @@ export interface HagilightFiftyOneLaOptions {
20
20
  export interface HagilightAnalyticsOptions {
21
21
  googleAnalytics?: HagilightGoogleAnalyticsOptions;
22
22
  fiftyOneLa?: HagilightFiftyOneLaOptions;
23
+ posthog?: HagilightPostHogOptions;
24
+ }
25
+ export interface HagilightPostHogOptions {
26
+ /** Defaults to true when the provider options are omitted, otherwise to whether apiKey is set. */
27
+ enabled?: boolean;
28
+ apiKey?: string;
23
29
  }
24
30
  export interface HagilightSeoOptions {
25
31
  /** Replace Starlight's Head with Hagilight SEO metadata. Defaults to `true`; requires Astro `site`. */
@@ -49,7 +55,22 @@ export interface HagilightContentComponentsOptions {
49
55
  markdownContent?: boolean;
50
56
  }
51
57
  export type HagilightAIDisclosureOptions = Partial<AIDisclosureDefaults>;
58
+ export interface HagilightMetadataOptions {
59
+ /** Defaults to `true`. */
60
+ enabled?: boolean;
61
+ /** Site name. Defaults to the Starlight `title` for the default locale, then the site hostname. */
62
+ name?: string;
63
+ /** Site description. Defaults to the Starlight `description` when the site sets one. */
64
+ description?: string;
65
+ }
52
66
  export interface HagilightStarlightOptions {
67
+ /**
68
+ * Generate `/.well-known/hagilight.metadata.json` after the build. Defaults to `true`; `false`
69
+ * opts out, and an options object may set `name` and `description`. A site-provided file is
70
+ * never overwritten. When a plain-Astro `hagilight()` is also registered, this plugin writes
71
+ * the file and the plain-Astro integration defers to it.
72
+ */
73
+ metadata?: boolean | HagilightMetadataOptions;
53
74
  header?: HagilightToggleOptions;
54
75
  notFoundPage?: HagilightToggleOptions;
55
76
  rss?: HagilightRssOptions;
package/dist/index.js CHANGED
@@ -3,11 +3,15 @@ import { fileURLToPath } from 'node:url';
3
3
  import { resolveFaviconHeadEntry } from '@hagicode/hagilight-core/favicon';
4
4
  import { resolveBuildInfo } from '@hagicode/hagilight-core/build-info-resolver';
5
5
  import { checkContentImageRegistration, registerContentImages } from '@hagicode/hagilight-core/content-images';
6
+ import { DEFAULT_POSTHOG_API_KEY, POSTHOG_API_KEY_PATTERN } from '@hagicode/hagilight-core/posthog';
6
7
  import { resolveRssLocales } from '@hagicode/hagilight-core/rss';
7
8
  import { RSS_OWNER, registerStarlightRssOwner, } from '@hagicode/hagilight-core/rss-ownership';
8
9
  import { isValidSeoImageReference, resolveSeoImageUrl } from '@hagicode/hagilight-core/seo';
10
+ import { resolveSiteMetadataOption } from '@hagicode/hagilight-core/site-metadata';
11
+ import { registerStarlightSiteMetadataOwner } from '@hagicode/hagilight-core/site-metadata-ownership';
9
12
  import { resolveRssOptions } from './rss-utils.js';
10
13
  import { resolveSeoLocales } from './seo-utils.js';
14
+ import { createStarlightMetadataFinalizer } from './site-metadata.js';
11
15
  import { THEME_BOOTSTRAP } from './themes.js';
12
16
  const packageRoot = new URL('../', import.meta.url);
13
17
  const packageFile = (name) => fileURLToPath(new URL(name, packageRoot));
@@ -201,7 +205,7 @@ function resolveDefaultSeoLocale(defaultLocale, locales) {
201
205
  }
202
206
  return found.route;
203
207
  }
204
- function createConfiguredIntegration(instanceId, serializedOptions, rssConfig, rssLocaleFeedUrls, componentIds, getConfiguredRssFeed, generateRss, unregisterRssOwner, contentImagesEnabled) {
208
+ function createConfiguredIntegration(instanceId, serializedOptions, rssConfig, rssLocaleFeedUrls, componentIds, getConfiguredRssFeed, generateRss, unregisterRssOwner, contentImagesEnabled, siteMetadata, unregisterSiteMetadataOwner) {
205
209
  const optionsId = `virtual:hagilight-starlight/${instanceId}/options`;
206
210
  const rssConfigId = 'virtual:hagilight-starlight/rss-config';
207
211
  const headerPath = componentIds.header ? packageFile('Header.astro') : undefined;
@@ -267,11 +271,13 @@ import options from '${optionsId}';
267
271
  import DefaultHead from '@astrojs/starlight/components/Head.astro';
268
272
  import SEOHead from ${JSON.stringify(seoHeadPath)};
269
273
  import GoogleAnalytics from ${JSON.stringify(coreExport('GoogleAnalytics'))};
274
+ ${serializedOptions.posthogApiKey ? `import PostHog from ${JSON.stringify(coreExport('PostHog'))};` : ''}
270
275
  import options from '${optionsId}';
271
276
  const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
272
277
  ---
273
278
 
274
279
  {options.seoEnabled ? <SEOHead options={options} /> : <DefaultHead />}
280
+ ${serializedOptions.posthogApiKey ? '<PostHog apiKey={options.posthogApiKey} />' : ''}
275
281
  {!isNotFound && options.googleAnalyticsMeasurementId && (
276
282
  <GoogleAnalytics measurementId={options.googleAnalyticsMeasurementId} />
277
283
  )}
@@ -325,10 +331,18 @@ const isNotFound = /(?:^|\\/)404(?:\\.html)?\\/?$/u.test(Astro.url.pathname);
325
331
  prerender: true,
326
332
  });
327
333
  }
328
- updateConfig({ vite: { plugins: [vitePlugin] } });
334
+ // Starlight splices its own sitemap into the integration list before this setup runs, so
335
+ // the finalizer appended in the same update runs after the sitemap has written its index.
336
+ updateConfig({
337
+ vite: { plugins: [vitePlugin] },
338
+ ...(siteMetadata.enabled
339
+ ? { integrations: [createStarlightMetadataFinalizer(siteMetadata, () => buildInfo)] }
340
+ : {}),
341
+ });
329
342
  },
330
343
  'astro:config:done'({ config, logger }) {
331
344
  unregisterRssOwner();
345
+ unregisterSiteMetadataOwner();
332
346
  checkContentImageRegistration({ config, logger, enabled: contentImagesEnabled });
333
347
  if (config.image?.service?.entrypoint === NOOP_IMAGE_SERVICE) {
334
348
  logger.warn(NOOP_IMAGE_SERVICE_WARNING);
@@ -371,6 +385,14 @@ export default function hagilight(options = {}) {
371
385
  pattern: FIFTY_ONE_LA_ID_PATTERN,
372
386
  defaultId: DEFAULT_51LA_ID,
373
387
  });
388
+ const posthog = optionalRecord(analytics.posthog, 'PostHog analytics');
389
+ optionalBoolean(posthog, 'enabled', 'PostHog analytics enabled');
390
+ const posthogApiKey = resolveProvider(analytics.posthog, {
391
+ name: 'PostHog',
392
+ idKey: 'apiKey',
393
+ pattern: POSTHOG_API_KEY_PATTERN,
394
+ defaultId: DEFAULT_POSTHOG_API_KEY,
395
+ });
374
396
  const promoto = optionalRecord(input.promoto, 'promoto');
375
397
  const promotoEnabled = optionalBoolean(promoto, 'enabled', 'promoto enabled') !== false;
376
398
  const contentImages = optionalRecord(input.contentImages, 'contentImages');
@@ -388,7 +410,7 @@ export default function hagilight(options = {}) {
388
410
  markdownContent: markdownContentEnabled
389
411
  ? `virtual:hagilight-starlight/${instanceId}/MarkdownContent.astro`
390
412
  : undefined,
391
- head: seo.enabled || googleAnalyticsMeasurementId
413
+ head: seo.enabled || googleAnalyticsMeasurementId || posthogApiKey
392
414
  ? `virtual:hagilight-starlight/${instanceId}/Head.astro`
393
415
  : undefined,
394
416
  themeSelect: themesEnabled ? packageFile('ThemeSelect.astro') : undefined,
@@ -399,11 +421,14 @@ export default function hagilight(options = {}) {
399
421
  links: options.links ?? {},
400
422
  googleAnalyticsMeasurementId,
401
423
  fiftyOneLaId,
424
+ posthogApiKey,
402
425
  aiDisclosures,
403
426
  seoEnabled: seo.enabled,
404
427
  seo,
405
428
  };
406
429
  const unregisterRssOwner = registerStarlightRssOwner(rssEnabled);
430
+ const metadata = resolveSiteMetadataOption(input.metadata);
431
+ const unregisterSiteMetadataOwner = registerStarlightSiteMetadataOwner(metadata.enabled);
407
432
  return {
408
433
  name: '@hagicode/hagilight-starlight',
409
434
  hooks: {
@@ -428,9 +453,9 @@ export default function hagilight(options = {}) {
428
453
  }
429
454
  if (componentIds.head && config.components?.Head) {
430
455
  if (seo.enabled) {
431
- 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.');
456
+ throw new Error('Hagilight SEO cannot replace an existing Starlight Head override. Set seo: { enabled: false }, analytics.googleAnalytics: { enabled: false }, and analytics.posthog: { enabled: false } to keep your Head, or compose SEO, @hagicode/hagilight-core/GoogleAnalytics and @hagicode/hagilight-core/PostHog explicitly.');
432
457
  }
433
- throw new Error('Hagilight Google Analytics cannot replace an existing Starlight Head override. Disable Google Analytics or compose @hagicode/hagilight-core/GoogleAnalytics in your Head instead.');
458
+ throw new Error('Hagilight Google Analytics or PostHog cannot replace an existing Starlight Head override. Set analytics.googleAnalytics: { enabled: false } and analytics.posthog: { enabled: false } to keep it, or compose @hagicode/hagilight-core/GoogleAnalytics and @hagicode/hagilight-core/PostHog in your Head.');
434
459
  }
435
460
  let seoSite;
436
461
  let seoLocales = [];
@@ -483,7 +508,24 @@ export default function hagilight(options = {}) {
483
508
  new URL(`rss.${filename}.xml`, baseUrl).toString(),
484
509
  ]))
485
510
  : {};
486
- addIntegration(createConfiguredIntegration(instanceId, serializedOptions, { options: rssOptions, locales: rssLocales }, rssLocaleFeedUrls, componentIds, () => getRssFeedUrl(config) ?? rssFeedUrl, generateRss, unregisterRssOwner, contentImagesEnabled));
511
+ addIntegration(createConfiguredIntegration(instanceId, serializedOptions, { options: rssOptions, locales: rssLocales }, rssLocaleFeedUrls, componentIds, () => getRssFeedUrl(config) ?? rssFeedUrl, generateRss, unregisterRssOwner, contentImagesEnabled, {
512
+ enabled: metadata.enabled,
513
+ name: metadata.name,
514
+ description: metadata.description,
515
+ title: config.title,
516
+ siteDescription: config.description,
517
+ locales: config.locales,
518
+ defaultLocale: config.defaultLocale,
519
+ site: astroConfig.site,
520
+ base: astroConfig.base,
521
+ publicDir: astroConfig.publicDir,
522
+ analytics: {
523
+ googleAnalytics: serializedOptions.googleAnalyticsMeasurementId !== undefined,
524
+ fiftyOneLa: serializedOptions.fiftyOneLaId !== undefined,
525
+ posthog: serializedOptions.posthogApiKey !== undefined,
526
+ },
527
+ features: { seo: seo.enabled, promoto: promotoEnabled },
528
+ }, unregisterSiteMetadataOwner));
487
529
  updateConfig({
488
530
  ...(config.logo === undefined ? { logo: { src: defaultLogo, alt: 'HagiCode' } } : {}),
489
531
  customCss: [
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-starlight",
3
- "version": "0.11.0",
3
+ "version": "0.11.1-dev.133.1.338317a",
4
4
  "type": "module",
5
5
  "description": "Starlight plugin and components for HagiCode documentation",
6
6
  "repository": {
@@ -53,7 +53,7 @@
53
53
  "assets/showcase/*"
54
54
  ],
55
55
  "dependencies": {
56
- "@hagicode/hagilight-core": "0.11.0"
56
+ "@hagicode/hagilight-core": "0.11.1-dev.133.1.338317a"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@astrojs/starlight": "^0.42.4",