@hagicode/hagilight 0.17.1-dev.175.1.77872ad → 0.19.1-dev.184.1.830a4c6

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
@@ -112,7 +112,8 @@ The build warning reads `Hagilight SEO image <address> was kept as declared: its
112
112
  `hagilight()` writes `/llms.txt` after the build, following the [llms.txt](https://llmstxt.org/) layout: the site name as the H1, the home page description as a blockquote summary, and one H2 section of links per top-level path segment. It is generated from the built HTML, so it works for plain Astro and Starlight sites alike and needs no content collection.
113
113
 
114
114
  - Only one language is listed: the Astro `i18n.defaultLocale`, then the home page language. The home pages of the other languages go under `## Optional`.
115
- - Pages are left out when they are `noindex`, instant redirects, 404 pages, or declare a canonical URL for another page.
115
+ - Pages are left out when they are `noindex`, meta-refresh redirects, 404 pages, or declare a canonical URL for another page.
116
+ - A page that redirects as soon as it loads with a script can opt out with `<meta name="hagilight:llms" content="exclude">` in its `<head>`. Only the redirect page should carry it; a normal page keeps its entry.
116
117
  - Each link carries the page's meta description. A description repeated on three or more pages is treated as a site-wide default and left out of the links.
117
118
  - Section order is Overview first, then by page count. A section lists 12 links (Overview 36) and the file lists at most 120.
118
119
  - An `llms.txt` that the site already provides, as a public file or a page, is never overwritten. `hagilight({ enabled: false })` skips generation too.
@@ -120,8 +121,8 @@ The build warning reads `Hagilight SEO image <address> was kept as declared: its
120
121
  ```js
121
122
  hagilight({
122
123
  llms: {
123
- title: 'Example Docs', // default: the home page og:site_name
124
- summary: 'What this site is.', // default: the home page description
124
+ title: 'Example Docs', // default: the home page og:site_name; a configured value always wins
125
+ summary: 'What this site is.', // default: the home page description; a configured value always wins
125
126
  lang: 'en-US', // default: i18n.defaultLocale
126
127
  maxLinksPerSection: 60, // default: 12
127
128
  maxLinks: 200, // default: 120
@@ -164,7 +165,7 @@ Pass `llms: false` (or `{ enabled: false }`) to opt out. The file is only as goo
164
165
  "llms": "/llms.txt"
165
166
  },
166
167
  "analytics": { "googleAnalytics": true, "fiftyOneLa": false, "posthog": true },
167
- "features": { "seo": true, "promoto": true }
168
+ "features": { "seo": true, "promoto": true, "consent": true }
168
169
  }
169
170
  ```
170
171
 
@@ -178,7 +179,7 @@ Pass `llms: false` (or `{ enabled: false }`) to opt out. The file is only as goo
178
179
 
179
180
  ## PostHog
180
181
 
181
- `hagilight()` injects PostHog into the head of every page of a production build, with no analytics option, enabled flag, or project key. It uses `phc_qiVVihsL925ovhPYNdLJWz4k7aoKVMwYCevgZPE6PER5`, API host `https://us.i.posthog.com`, and SDK defaults `2026-05-30`, the same as the Starlight and Docusaurus plugins. `astro dev` pages emit no loader. Injection does not depend on the `enabled` option, which controls only the sitemap and `robots.txt`.
182
+ `hagilight()` injects PostHog into the head of every page of a production build, with no analytics option, enabled flag, or project key. It uses `phc_qiVVihsL925ovhPYNdLJWz4k7aoKVMwYCevgZPE6PER5`, API host `https://t.hagicode.com` (managed proxy), UI host `https://us.posthog.com`, person profiles `identified_only`, and SDK defaults `2026-05-30`, the same as the Starlight and Docusaurus plugins. Sites with a strict script or connect policy must allow `t.hagicode.com`. `astro dev` pages emit no loader. Injection does not depend on the `enabled` option, which controls only the sitemap and `robots.txt`.
182
183
 
183
184
  | PostHog input | Behavior |
184
185
  | --- | --- |
@@ -194,7 +195,7 @@ hagilight({ analytics: { posthog: { apiKey: 'phc_Custom123' } } });
194
195
  hagilight({ analytics: { posthog: { enabled: false } } });
195
196
  ```
196
197
 
197
- The built-in key belongs to HagiCode, so a site that omits `analytics` sends its traffic to HagiCode's project. The loader initializes once per document. SDK defaults own navigation tracking; Hagilight adds no capture calls. Choose one initialization owner: a site that already renders `@hagicode/hagilight-core/PostHog` or another PostHog snippet should remove it or set `enabled: false`. When `@astrojs/starlight` is registered, this integration injects nothing and the Starlight plugin owns PostHog. The `analytics.posthog` flag in `hagilight.metadata.json` is `true` when the built pages contain the loader.
198
+ The built-in key belongs to HagiCode, so a site that omits `analytics` sends its traffic to HagiCode's project. The loader initializes once per document. SDK defaults own navigation tracking. Hagilight also registers the site context once after initialization (`site_id` from `links.siteId` or the site URL's hostname, `site_framework` `astro`, `hagilight_version`, `locale` from `<html lang>`, and `page_type`), sets `capture_exceptions`, and sends `cta_click` for marked links through a click listener that works without Google Analytics; it adds no other capture calls. `page_type` is `other` unless a page sets it: pass the `pageType` prop to `SEOHead`, which emits `<meta name="hagilight:page-type">`, or render that meta element yourself. Choose one initialization owner: a site that already renders `@hagicode/hagilight-core/PostHog` or another PostHog snippet should remove it or set `enabled: false`. When `@astrojs/starlight` is registered, this integration injects nothing and the Starlight plugin owns PostHog. The `analytics.posthog` flag in `hagilight.metadata.json` is `true` when the built pages contain the loader.
198
199
 
199
200
  ## Body images
200
201
 
@@ -211,4 +212,8 @@ hagilight({ contentImages: { enabled: false } });
211
212
 
212
213
  Sites that do not use this integration can import the plugin from `@hagicode/hagilight-core` directly; its README describes the options and processor setup.
213
214
 
214
- See the [repository documentation](https://github.com/HagiCode-org/hagilight#readme) for RSS configuration, feed callback types, and integration behavior.
215
+ See the [repository documentation](https://github.com/HagiCode-org/hagilight#readme) for RSS configuration, feed callback types, and integration behavior.
216
+
217
+ ### Consent
218
+
219
+ `analytics.consent` (`enabled`, `privacyUrl`, `policyVersion`) applies to the PostHog this integration injects. With consent on (the default) the head carries an inert descriptor, the page mounts the popup and gate, and PostHog starts only after a grant. `enabled: false` injects PostHog immediately as before. Layouts that compose `<PostHog />`, `<GoogleAnalytics />` or `<Analytics51LA />` by hand pass `consent` to each component and render `<CookieConsent providers={...} />`.
package/SEOHead.astro CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  OG_IMAGE_WIDTH,
17
17
  classifyOgImage,
18
18
  } from '@hagicode/hagilight-core/og-image';
19
+ import { POSTHOG_PAGE_TYPES, type PostHogPageType } from '@hagicode/hagilight-core/posthog-events';
19
20
 
20
21
  interface Props {
21
22
  page?: {
@@ -37,6 +38,8 @@ interface Props {
37
38
  ogType?: string;
38
39
  organization?: DefaultOrganizationOptions;
39
40
  defaultStructuredData?: boolean;
41
+ /** The PostHog page type for this page. Emitted as `<meta name="hagilight:page-type">` when in the vocabulary. */
42
+ pageType?: PostHogPageType;
40
43
  }
41
44
 
42
45
  const {
@@ -50,6 +53,7 @@ const {
50
53
  ogType,
51
54
  organization,
52
55
  defaultStructuredData = true,
56
+ pageType,
53
57
  } = Astro.props;
54
58
  const site = configuredSite?.toString();
55
59
  const currentPageUrl = site
@@ -131,6 +135,7 @@ const defaultValues = buildDefaultStructuredData({
131
135
  ---
132
136
 
133
137
  {composedHead.map(({ tag: Tag, attrs, content }) => <Tag {...attrs} set:html={content} />)}
138
+ {(POSTHOG_PAGE_TYPES as readonly string[]).includes(pageType ?? '') && <meta name="hagilight:page-type" content={pageType} />}
134
139
  {[...defaultValues, ...dataValues].map((value) => (
135
140
  <script type="application/ld+json" set:html={serializeJsonLd(value)} />
136
141
  ))}
@@ -43,6 +43,18 @@ export interface HagilightOptions {
43
43
  }
44
44
  export interface HagilightAnalyticsOptions {
45
45
  posthog?: HagilightPostHogOptions;
46
+ /**
47
+ * Strict prior consent for the PostHog this integration injects. On by default; `enabled: false` injects PostHog
48
+ * immediately as before. Components composed by hand take their own `consent` prop.
49
+ */
50
+ consent?: HagilightConsentOptions;
51
+ }
52
+ export interface HagilightConsentOptions {
53
+ enabled?: boolean;
54
+ /** An absolute HTTP(S) URL or a site-relative path for the privacy link. */
55
+ privacyUrl?: string;
56
+ /** Defaults to `1`. 1 to 32 letters, digits, dots, underscores or hyphens. */
57
+ policyVersion?: string;
46
58
  }
47
59
  export interface HagilightPostHogOptions {
48
60
  /** Defaults to true when the provider options are omitted, otherwise to whether `apiKey` is set. */
@@ -6,6 +6,8 @@ import { resolveBuildInfo } from '@hagicode/hagilight-core/build-info-resolver';
6
6
  import { checkContentImageRegistration, registerContentImages } from '@hagicode/hagilight-core/content-images';
7
7
  import { resolveFaviconHeadEntry } from '@hagicode/hagilight-core/favicon';
8
8
  import { DEFAULT_POSTHOG_API_KEY, POSTHOG_API_KEY_PATTERN, posthogInitScript } from '@hagicode/hagilight-core/posthog';
9
+ import { consentHeadScript } from '@hagicode/hagilight-core/consent-mount';
10
+ import { validatePolicyVersion, validatePrivacyUrl } from '@hagicode/hagilight-core/consent-markup';
9
11
  import { resolveRssLocales } from '@hagicode/hagilight-core/rss';
10
12
  import { resolvePlainAstroRssOwner } from '@hagicode/hagilight-core/rss-ownership';
11
13
  import { resolveSiteMetadataOption } from '@hagicode/hagilight-core/site-metadata';
@@ -49,6 +51,33 @@ function resolvePostHogApiKey(analytics) {
49
51
  function integrationName(integration) {
50
52
  return isRecord(integration) ? integration.name : undefined;
51
53
  }
54
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
55
+ /** The plain-Astro site context: `site_id` is the configured site's hostname, when there is one. */
56
+ /** Consent for the injected PostHog: on by default, and the core validators name any invalid option. */
57
+ function resolveConsentOptions(analytics, posthogEnabled) {
58
+ const consent = isRecord(analytics) ? analytics.consent : undefined;
59
+ if (consent !== undefined && !isRecord(consent)) {
60
+ throw new TypeError('Hagilight analytics consent option must be an object.');
61
+ }
62
+ if (consent?.enabled !== undefined && typeof consent.enabled !== 'boolean') {
63
+ throw new TypeError('Hagilight analytics consent enabled option must be a boolean.');
64
+ }
65
+ return {
66
+ enabled: posthogEnabled && (consent?.enabled ?? true),
67
+ privacyUrl: validatePrivacyUrl(consent?.privacyUrl),
68
+ policyVersion: validatePolicyVersion(consent?.policyVersion),
69
+ };
70
+ }
71
+ function postHogSiteContext(site) {
72
+ let siteId;
73
+ try {
74
+ siteId = new URL(site).hostname || undefined;
75
+ }
76
+ catch {
77
+ siteId = undefined;
78
+ }
79
+ return { siteId, framework: 'astro', version: PACKAGE_VERSION };
80
+ }
52
81
  function resolveDiscoverySite(site) {
53
82
  const message = 'Hagilight sitemap and robots require an absolute HTTP(S) Astro site URL. Set `site` in astro.config.mjs.';
54
83
  let parsed;
@@ -91,6 +120,7 @@ export function hagilight({ enabled = true, rss, contentImages, llms, metadata,
91
120
  throw new TypeError('Hagilight content images enabled option must be a boolean.');
92
121
  }
93
122
  const posthogApiKey = resolvePostHogApiKey(analytics);
123
+ const consent = resolveConsentOptions(analytics, posthogApiKey !== undefined);
94
124
  const llmsOptions = resolveLlmsOptions(llms);
95
125
  const metadataOptions = resolveSiteMetadataOption(metadata);
96
126
  const contentImagesEnabled = typeof contentImages === 'boolean' ? contentImages : contentImages?.enabled !== false;
@@ -128,7 +158,23 @@ export function hagilight({ enabled = true, rss, contentImages, llms, metadata,
128
158
  // site gets PostHog from the Starlight plugin's generated Head instead.
129
159
  const hasStarlight = (config.integrations ?? []).some((integration) => integrationName(integration) === '@astrojs/starlight');
130
160
  if (posthogApiKey && command === 'build' && !hasStarlight) {
131
- injectScript('head-inline', posthogInitScript(posthogApiKey));
161
+ if (consent.enabled) {
162
+ // The loader travels inside an inert descriptor; the shared mount places the popup and starts PostHog
163
+ // and its click sink only after a grant.
164
+ const loader = posthogInitScript(posthogApiKey, postHogSiteContext(config.site));
165
+ injectScript('head-inline', consentHeadScript({
166
+ posthog: { apiKey: posthogApiKey, loader },
167
+ config: { providers: ['posthog'], privacyUrl: consent.privacyUrl, policyVersion: consent.policyVersion },
168
+ }));
169
+ const mountModule = fileURLToPath(import.meta.resolve('@hagicode/hagilight-core/consent-mount'));
170
+ injectScript('page', `import { mountConsent } from ${JSON.stringify(mountModule)};\nmountConsent();`);
171
+ }
172
+ else {
173
+ injectScript('head-inline', posthogInitScript(posthogApiKey, postHogSiteContext(config.site)));
174
+ // The click sink is independent of Google Analytics; it stays silent until window.posthog exists.
175
+ const clickSink = fileURLToPath(import.meta.resolve('@hagicode/hagilight-core/posthog-events'));
176
+ injectScript('page', `import { installPostHogClickTracking } from ${JSON.stringify(clickSink)};\ninstallPostHogClickTracking();`);
177
+ }
132
178
  }
133
179
  if (!enabled)
134
180
  return;
package/dist/llms.d.ts CHANGED
@@ -33,6 +33,9 @@ export interface BuildLlmsTxtOptions {
33
33
  }
34
34
  export declare const DEFAULT_MAX_LINKS_PER_SECTION = 12;
35
35
  export declare const DEFAULT_MAX_LINKS = 120;
36
+ /** `<meta name="hagilight:llms" content="exclude">` keeps an immediate-redirect page out of llms.txt. */
37
+ export declare const LLMS_EXCLUDE_META_NAME = "hagilight:llms";
38
+ export declare const LLMS_EXCLUDE_META_VALUE = "exclude";
36
39
  /**
37
40
  * Read the facts `llms.txt` needs from one built page. Returns `undefined` for pages that should
38
41
  * not be listed: `noindex`, instant redirects, 404 pages, and pages whose canonical URL points
package/dist/llms.js CHANGED
@@ -8,6 +8,9 @@ export const DEFAULT_MAX_LINKS_PER_SECTION = 12;
8
8
  export const DEFAULT_MAX_LINKS = 120;
9
9
  const OVERVIEW_LIMIT_FACTOR = 3;
10
10
  const GENERIC_DESCRIPTION_MIN_PAGES = 3;
11
+ /** `<meta name="hagilight:llms" content="exclude">` keeps an immediate-redirect page out of llms.txt. */
12
+ export const LLMS_EXCLUDE_META_NAME = 'hagilight:llms';
13
+ export const LLMS_EXCLUDE_META_VALUE = 'exclude';
11
14
  const LOCALE_SEGMENT = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/iu;
12
15
  const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
13
16
  function decodeEntities(value) {
@@ -74,6 +77,10 @@ export function parsePageMeta(html, pageUrl) {
74
77
  return undefined;
75
78
  if (metas.some((meta) => meta['http-equiv']?.toLowerCase() === 'refresh'))
76
79
  return undefined;
80
+ // A page that redirects as soon as it loads (for example an inline script) opts out explicitly.
81
+ if (metas.some((meta) => meta.name?.toLowerCase() === LLMS_EXCLUDE_META_NAME
82
+ && meta.content?.trim().toLowerCase() === LLMS_EXCLUDE_META_VALUE))
83
+ return undefined;
77
84
  const canonicalTag = [...head.matchAll(/<link\b[^>]*>/giu)]
78
85
  .map(([tag]) => attributes(tag))
79
86
  .find((link) => link.rel?.toLowerCase().split(/\s+/u).includes('canonical'));
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readFileSync, statSync } from 'node:fs';
7
7
  import { join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { CONSENT_DESCRIPTOR_ID } from '@hagicode/hagilight-core/consent-markup';
9
10
  import { buildSiteMetadata, detectAnalyticsLoaders, SITE_METADATA_PATH, } from '@hagicode/hagilight-core/site-metadata';
10
11
  import { findDiscoveryOutputs, writeSiteMetadata } from '@hagicode/hagilight-core/site-metadata-writer';
11
12
  import { parseHeadMeta } from './llms.js';
@@ -97,6 +98,8 @@ export function writeAstroSiteMetadata({ config, outDir, pages, build, options,
97
98
  build,
98
99
  discovery: findDiscoveryOutputs(outDir),
99
100
  analytics: detectAnalyticsLoaders(pageHtml),
101
+ // The popup is mounted exactly when a built page carries its config descriptor.
102
+ features: { consent: pageHtml.some((html) => html.includes(CONSENT_DESCRIPTOR_ID)) },
100
103
  });
101
104
  writeSiteMetadata({
102
105
  outDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight",
3
- "version": "0.17.1-dev.175.1.77872ad",
3
+ "version": "0.19.1-dev.184.1.830a4c6",
4
4
  "type": "module",
5
5
  "description": "Astro integrations and components for HagiCode sites",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@astrojs/sitemap": "^3.7.4",
43
- "@hagicode/hagilight-core": "0.17.1-dev.175.1.77872ad"
43
+ "@hagicode/hagilight-core": "0.19.1-dev.184.1.830a4c6"
44
44
  },
45
45
  "keywords": [
46
46
  "hagicode",