@hagicode/hagilight-core 0.9.1 → 0.9.2

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/Footer.astro CHANGED
@@ -3,15 +3,18 @@ import Copyright from './Copyright.astro';
3
3
  import { siteLinkGaAttributes } from './dist/analytics-events.js';
4
4
  import { resolveSiteLinks, type SiteLinksOptions } from './dist/links.js';
5
5
  import { resolveRssFooterLinks } from './dist/rss-footer-context.js';
6
+ import { buildInfoFromLocals, buildInfoItems, type BuildInfo } from './dist/build-info.js';
6
7
 
7
8
  interface Props {
8
9
  locale?: string;
9
10
  links?: SiteLinksOptions;
11
+ buildInfo?: BuildInfo;
10
12
  }
11
13
 
12
- const { locale: propLocale, links } = Astro.props;
14
+ const { locale: propLocale, links, buildInfo: propBuildInfo } = Astro.props;
13
15
  const locale = propLocale ?? Astro.currentLocale ?? 'en-US';
14
16
  const footerLinks = resolveRssFooterLinks(Astro.locals, locale, links);
17
+ const buildItems = buildInfoItems(propBuildInfo ?? buildInfoFromLocals(Astro.locals));
15
18
  const sitemapOverride = footerLinks.overrides?.sitemap;
16
19
  const resolvedLinks = resolveSiteLinks(locale, {
17
20
  ...footerLinks,
@@ -96,6 +99,17 @@ const resolvedLinks = resolveSiteLinks(locale, {
96
99
  <span class="hagilight-footer__attribution"><slot name="attribution" /></span>
97
100
  </>
98
101
  )}
102
+ {buildItems.map((item) => (
103
+ <>
104
+ {' | '}
105
+ <span
106
+ class="hagilight-footer__build"
107
+ data-build-field={item.field}
108
+ title={item.title}
109
+ data-build-commit={item.title}
110
+ >{item.label}: {item.datetime ? <time datetime={item.datetime}>{item.text}</time> : item.text}</span>
111
+ </>
112
+ ))}
99
113
  </p>
100
114
  </footer>
101
115
 
@@ -168,6 +182,10 @@ const resolvedLinks = resolveSiteLinks(locale, {
168
182
  overflow-wrap: anywhere;
169
183
  }
170
184
 
185
+ .hagilight-footer__build {
186
+ overflow-wrap: anywhere;
187
+ }
188
+
171
189
  @media (max-width: 40rem) {
172
190
  .hagilight-footer__sections {
173
191
  grid-template-columns: 1fr;
@@ -98,7 +98,8 @@ const { fallback, locale = Astro.currentLocale ?? 'en' } = Astro.props;
98
98
  transition: opacity 0.24s ease, visibility 0s linear;
99
99
  }
100
100
 
101
- .hagilight-promoto[data-state='footer-hidden'] .hagilight-promoto__shell {
101
+ .hagilight-promoto[data-state='footer-hidden'] .hagilight-promoto__shell,
102
+ .hagilight-promoto[data-state='top-hidden'] .hagilight-promoto__shell {
102
103
  opacity: 0;
103
104
  visibility: hidden;
104
105
  pointer-events: none;
package/README.md CHANGED
@@ -35,6 +35,22 @@ import { resolveSiteLinks } from '@hagicode/hagilight-core/links';
35
35
  const links = resolveSiteLinks('en-US');
36
36
  ```
37
37
 
38
+ ## Build info
39
+
40
+ `@hagicode/hagilight-core/build-info` is browser-safe. It exports the `BuildInfo` type, `normalizeBuildInfo`, `shortCommit`, and `buildInfoItems`, which turn a resolved value into the footer's Branch, Commit, and Built items. `@hagicode/hagilight-core/build-info-resolver` is Node-only: `resolveBuildInfo({ root, env, now })` reads the value for one project. Keep Node-only code out of any module that a browser bundle imports.
41
+
42
+ Each field resolves on its own, in this order:
43
+
44
+ | Field | Order | Fallback |
45
+ | --- | --- | --- |
46
+ | Branch | `HAGILIGHT_BUILD_BRANCH`, then `git symbolic-ref --short -q HEAD` | `unknown` |
47
+ | Commit | `HAGILIGHT_BUILD_COMMIT`, then `git rev-parse HEAD` | `unknown` |
48
+ | Built | `now`, in UTC, truncated to whole seconds | none; always set |
49
+
50
+ After trimming, `HAGILIGHT_BUILD_BRANCH` must be nonblank and free of control characters. `HAGILIGHT_BUILD_COMMIT` must be 7 to 64 hexadecimal characters and is stored lowercased. An invalid explicit value falls through to Git. Git runs with a 5-second timeout and its stderr is discarded. A detached HEAD, an unborn branch, a missing `git`, a directory outside any repository, or a timeout yields `unknown` for that field only. The resolver never throws.
51
+
52
+ The footer shows the commit as its first seven characters, with the full hash in the `title` attribute. The core `Footer` renders this line only when it receives a `buildInfo` prop or the `hagilight()` integration from `@hagicode/hagilight/integration` is registered; otherwise it renders no build text.
53
+
38
54
  ## Content images
39
55
 
40
56
  `@hagicode/hagilight-core/content-images` normalizes the `<img>` elements in Markdown and MDX body content at build time. A body image gets the marker class `hagilight-content-image`, `loading="lazy"`, and `decoding="async"`. Values the author already set are kept, existing classes are kept, and the marker is added once.
@@ -71,6 +87,8 @@ With the unified processor, pass `rehypeContentImages()` in `rehypePlugins` inst
71
87
 
72
88
  The shared banner (`PromotoBanner`, `loadActivePromotions`) displays only promotions whose `platforms` array in Index's `/promote.json` includes `web`, in addition to being enabled, inside their activation window, and matched with `/promote_content.json`. This follows the `platforms` flag documented in the Index README. An entry whose `platforms` is missing, empty, or not an array of strings is not displayed, other values such as `hagicode` are ignored, and matching is exact (`Web` does not match). When no promotion remains, the banner shows its site fallback or stays hidden, as before.
73
89
 
90
+ The banner stays hidden while the page is at the top during a domain's first 30 visits, and while the Footer is in view. Every page load, refresh, and page-to-page navigation counts as a visit; scrolling and hash changes do not. The per-domain counter is stored in the visitor's browser under `hagilight:promoto-banner:visit-count:<hostname>` and is never sent anywhere.
91
+
74
92
  A promotion's `image` may also carry `sources`, the alternative image files that Index publishes in the order AVIF, WebP, then the original. The banner renders them as a `<picture>` in that order, with the original as its `<img>`, and skips any entry whose `type` is not an `image/*` media type or whose URL is unsafe. Each `src` is resolved against `https://index.hagicode.com` when the promotion documents load. If the browser fails to load a tier, the banner removes it and the next tier is used. A card without `sources` renders the plain `<img>` as before, and `sources` never affect the dismissal signature, so a dismissed banner stays dismissed when only its image chain changes.
75
93
 
76
94
  ## Google Analytics click events
@@ -0,0 +1,8 @@
1
+ import { type BuildInfo } from './build-info.js';
2
+ export interface ResolveBuildInfoOptions {
3
+ /** Site project root. Git runs here, so a site inside a larger repository still finds it. */
4
+ root: string;
5
+ env?: Readonly<Record<string, string | undefined>>;
6
+ now?: Date;
7
+ }
8
+ export declare function resolveBuildInfo({ root, env, now }: ResolveBuildInfoOptions): BuildInfo;
@@ -0,0 +1,29 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { normalizeBranch, normalizeCommit, UNKNOWN } from './build-info.js';
3
+ const GIT_TIMEOUT_MS = 5000;
4
+ function readGit(root, args) {
5
+ try {
6
+ return execFileSync('git', args, {
7
+ cwd: root,
8
+ encoding: 'utf8',
9
+ timeout: GIT_TIMEOUT_MS,
10
+ stdio: ['ignore', 'pipe', 'ignore'],
11
+ windowsHide: true,
12
+ });
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ function formatBuiltAt(now) {
19
+ return now.toISOString().replace(/\.\d{3}Z$/u, 'Z');
20
+ }
21
+ export function resolveBuildInfo({ root, env = process.env, now = new Date() }) {
22
+ const branch = normalizeBranch(env.HAGILIGHT_BUILD_BRANCH)
23
+ ?? normalizeBranch(readGit(root, ['symbolic-ref', '--short', '-q', 'HEAD']))
24
+ ?? UNKNOWN;
25
+ const commit = normalizeCommit(env.HAGILIGHT_BUILD_COMMIT)
26
+ ?? normalizeCommit(readGit(root, ['rev-parse', 'HEAD']))
27
+ ?? UNKNOWN;
28
+ return Object.freeze({ branch, commit, builtAt: formatBuiltAt(now) });
29
+ }
@@ -0,0 +1,19 @@
1
+ export declare const UNKNOWN = "unknown";
2
+ export interface BuildInfo {
3
+ readonly branch: string;
4
+ readonly commit: string;
5
+ readonly builtAt: string;
6
+ }
7
+ export interface BuildInfoItem {
8
+ readonly field: 'branch' | 'commit' | 'builtAt';
9
+ readonly label: string;
10
+ readonly text: string;
11
+ readonly title?: string;
12
+ readonly datetime?: string;
13
+ }
14
+ export declare function normalizeBranch(value: unknown): string | undefined;
15
+ export declare function normalizeCommit(value: unknown): string | undefined;
16
+ export declare function normalizeBuildInfo(input: unknown): BuildInfo;
17
+ export declare function shortCommit(commit: string): string;
18
+ export declare function buildInfoFromLocals(locals: unknown): BuildInfo | undefined;
19
+ export declare function buildInfoItems(info: BuildInfo | null | undefined): readonly BuildInfoItem[];
@@ -0,0 +1,60 @@
1
+ export const UNKNOWN = 'unknown';
2
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
3
+ const COMMIT_PATTERN = /^[0-9a-f]{7,64}$/iu;
4
+ const BUILT_AT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u;
5
+ function isRecord(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+ export function normalizeBranch(value) {
9
+ if (typeof value !== 'string')
10
+ return undefined;
11
+ const branch = value.trim();
12
+ return branch && !CONTROL_CHARACTERS.test(branch) ? branch : undefined;
13
+ }
14
+ export function normalizeCommit(value) {
15
+ if (typeof value !== 'string')
16
+ return undefined;
17
+ const commit = value.trim();
18
+ return COMMIT_PATTERN.test(commit) ? commit.toLowerCase() : undefined;
19
+ }
20
+ function normalizeBuiltAt(value) {
21
+ if (typeof value !== 'string' || !BUILT_AT_PATTERN.test(value))
22
+ return undefined;
23
+ return Number.isNaN(Date.parse(value)) ? undefined : value;
24
+ }
25
+ export function normalizeBuildInfo(input) {
26
+ const record = isRecord(input) ? input : {};
27
+ return Object.freeze({
28
+ branch: normalizeBranch(record.branch) ?? UNKNOWN,
29
+ commit: normalizeCommit(record.commit) ?? UNKNOWN,
30
+ builtAt: normalizeBuiltAt(record.builtAt) ?? UNKNOWN,
31
+ });
32
+ }
33
+ export function shortCommit(commit) {
34
+ return normalizeCommit(commit)?.slice(0, 7) ?? UNKNOWN;
35
+ }
36
+ export function buildInfoFromLocals(locals) {
37
+ if (!isRecord(locals) || locals.hagilightBuild === undefined)
38
+ return undefined;
39
+ return normalizeBuildInfo(locals.hagilightBuild);
40
+ }
41
+ export function buildInfoItems(info) {
42
+ if (info === undefined || info === null)
43
+ return [];
44
+ const { branch, commit, builtAt } = normalizeBuildInfo(info);
45
+ return [
46
+ { field: 'branch', label: 'Branch', text: branch },
47
+ {
48
+ field: 'commit',
49
+ label: 'Commit',
50
+ text: shortCommit(commit),
51
+ ...(commit === UNKNOWN ? {} : { title: commit }),
52
+ },
53
+ {
54
+ field: 'builtAt',
55
+ label: 'Built',
56
+ text: builtAt,
57
+ ...(builtAt === UNKNOWN ? {} : { datetime: builtAt }),
58
+ },
59
+ ];
60
+ }
@@ -1,9 +1,14 @@
1
1
  import { type PromotionCard, type PromotionImage, type PromotionImageSource } from './promotions.js';
2
- export type PromotionVisibility = 'dismissed' | 'footer-hidden' | 'hidden' | 'ready';
2
+ export type PromotionVisibility = 'dismissed' | 'footer-hidden' | 'hidden' | 'ready' | 'top-hidden';
3
3
  export declare function selectPromotionCards(remote: PromotionCard[], fallback?: PromotionCard | null): PromotionCard[];
4
4
  export declare function getPromotionImageSources(image: PromotionImage | undefined): PromotionImageSource[];
5
5
  export declare function getPromotionSetSignature(cards: PromotionCard[]): string | null;
6
- export declare function getPromotionVisibility(cards: PromotionCard[], signature: string | null, dismissedSignature: string | null, footerVisible: boolean): PromotionVisibility;
6
+ export declare function isScrolledToTop(scrollY: number): boolean;
7
+ export declare function isPageTopRuleActive(visitCount: number): boolean;
8
+ export declare function getVisitCountKey(hostname: string): string;
9
+ export declare function parseVisitCount(raw: string | null): number;
10
+ export declare function getPromotionVisibility(cards: PromotionCard[], signature: string | null, dismissedSignature: string | null, footerVisible: boolean, atPageTop?: boolean): PromotionVisibility;
7
11
  export declare function getNextPromotionIndex(index: number, count: number, direction: -1 | 1): number;
8
12
  export declare function shouldAutoRotate(count: number, paused: boolean, reducedMotion: boolean, documentVisible: boolean, visibility: PromotionVisibility): boolean;
13
+ export declare function recordSiteVisit(): number;
9
14
  export declare function definePromotoBannerElement(): void;
@@ -2,8 +2,13 @@ import { gaEventAttributes } from './analytics-events.js';
2
2
  import { loadActivePromotions, } from './promotions.js';
3
3
  const ELEMENT_NAME = 'hagilight-promoto-banner';
4
4
  const DISMISSED_SIGNATURE_KEY = 'hagilight:promoto-banner:dismissed-signature';
5
+ const VISIT_COUNT_KEY_PREFIX = 'hagilight:promoto-banner:visit-count:';
6
+ const VISIT_EVENT = 'hagilight:promoto-visit';
7
+ const PAGE_TOP_VISIT_LIMIT = 30;
5
8
  const ROTATION_INTERVAL_MS = 8000;
6
9
  const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
10
+ let lastCountedPage = null;
11
+ let siteVisitCount = 0;
7
12
  function isRecord(value) {
8
13
  return typeof value === 'object' && value !== null && !Array.isArray(value);
9
14
  }
@@ -67,12 +72,29 @@ export function getPromotionSetSignature(cards) {
67
72
  image?.variant ?? '', image?.width ?? 0, image?.height ?? 0,
68
73
  ]));
69
74
  }
70
- export function getPromotionVisibility(cards, signature, dismissedSignature, footerVisible) {
75
+ export function isScrolledToTop(scrollY) {
76
+ return scrollY <= 1;
77
+ }
78
+ export function isPageTopRuleActive(visitCount) {
79
+ return visitCount <= PAGE_TOP_VISIT_LIMIT;
80
+ }
81
+ export function getVisitCountKey(hostname) {
82
+ return `${VISIT_COUNT_KEY_PREFIX}${hostname}`;
83
+ }
84
+ export function parseVisitCount(raw) {
85
+ if (raw === null || !/^\d+$/u.test(raw))
86
+ return 0;
87
+ const count = Number(raw);
88
+ return Number.isSafeInteger(count) ? count : 0;
89
+ }
90
+ export function getPromotionVisibility(cards, signature, dismissedSignature, footerVisible, atPageTop = false) {
71
91
  if (!cards.length)
72
92
  return 'hidden';
73
93
  if (signature && signature === dismissedSignature)
74
94
  return 'dismissed';
75
- return footerVisible ? 'footer-hidden' : 'ready';
95
+ if (footerVisible)
96
+ return 'footer-hidden';
97
+ return atPageTop ? 'top-hidden' : 'ready';
76
98
  }
77
99
  export function getNextPromotionIndex(index, count, direction) {
78
100
  return count > 0 ? (index + direction + count) % count : 0;
@@ -96,6 +118,48 @@ function saveDismissedSignature(signature) {
96
118
  console.warn('Unable to save the dismissed promotion preference.', error);
97
119
  }
98
120
  }
121
+ function getLocalStorage() {
122
+ try {
123
+ return window.localStorage || null;
124
+ }
125
+ catch {
126
+ return null;
127
+ }
128
+ }
129
+ function countVisit(storage, key) {
130
+ let count;
131
+ try {
132
+ count = parseVisitCount(storage.getItem(key));
133
+ }
134
+ catch {
135
+ return 0;
136
+ }
137
+ if (!isPageTopRuleActive(count))
138
+ return count;
139
+ try {
140
+ storage.setItem(key, String(count + 1));
141
+ return count + 1;
142
+ }
143
+ catch {
144
+ return count;
145
+ }
146
+ }
147
+ export function recordSiteVisit() {
148
+ if (typeof window === 'undefined' || !window.location)
149
+ return siteVisitCount;
150
+ const page = `${window.location.pathname}${window.location.search}`;
151
+ if (page === lastCountedPage)
152
+ return siteVisitCount;
153
+ const storage = getLocalStorage();
154
+ if (!storage)
155
+ return siteVisitCount;
156
+ lastCountedPage = page;
157
+ siteVisitCount = countVisit(storage, getVisitCountKey(window.location.hostname));
158
+ if (typeof document !== 'undefined' && typeof document.dispatchEvent === 'function') {
159
+ document.dispatchEvent(new Event(VISIT_EVENT));
160
+ }
161
+ return siteVisitCount;
162
+ }
99
163
  export function definePromotoBannerElement() {
100
164
  if (typeof customElements === 'undefined' || typeof HTMLElement === 'undefined'
101
165
  || customElements.get(ELEMENT_NAME))
@@ -152,6 +216,7 @@ export function definePromotoBannerElement() {
152
216
  this.syncRotation();
153
217
  };
154
218
  onPageLoad = () => {
219
+ recordSiteVisit();
155
220
  // Re-fetch in the current page locale after a Starlight view-transition
156
221
  // navigation. The element may persist across a soft language switch, so
157
222
  // its previously loaded locale can differ from the new page's data-locale.
@@ -159,6 +224,7 @@ export function definePromotoBannerElement() {
159
224
  void this.reload();
160
225
  };
161
226
  connectedCallback() {
227
+ recordSiteVisit();
162
228
  this.footer = document.querySelector('footer');
163
229
  this.motionQuery = window.matchMedia?.('(prefers-reduced-motion: reduce)');
164
230
  this.reducedMotion = this.motionQuery?.matches ?? false;
@@ -171,6 +237,7 @@ export function definePromotoBannerElement() {
171
237
  document.addEventListener('visibilitychange', this.onVisibilityChange);
172
238
  this.motionQuery?.addEventListener('change', this.onMotionChange);
173
239
  document.addEventListener('astro:page-load', this.onPageLoad);
240
+ document.addEventListener(VISIT_EVENT, this.onLayoutChange);
174
241
  if (typeof ResizeObserver !== 'undefined') {
175
242
  this.resizeObserver = new ResizeObserver(this.onLayoutChange);
176
243
  if (this.footer)
@@ -192,6 +259,7 @@ export function definePromotoBannerElement() {
192
259
  document.removeEventListener('visibilitychange', this.onVisibilityChange);
193
260
  this.motionQuery?.removeEventListener('change', this.onMotionChange);
194
261
  document.removeEventListener('astro:page-load', this.onPageLoad);
262
+ document.removeEventListener(VISIT_EVENT, this.onLayoutChange);
195
263
  this.resizeObserver?.disconnect();
196
264
  this.stopRotation();
197
265
  this.stopRefresh();
@@ -341,13 +409,15 @@ export function definePromotoBannerElement() {
341
409
  }
342
410
  updateVisibility() {
343
411
  const signature = getPromotionSetSignature(this.cards);
344
- const state = getPromotionVisibility(this.cards, signature, this.dismissedSignature, this.isFooterVisible());
412
+ const atPageTop = isScrolledToTop(window.scrollY ?? document.documentElement.scrollTop)
413
+ && isPageTopRuleActive(siteVisitCount);
414
+ const state = getPromotionVisibility(this.cards, signature, this.dismissedSignature, this.isFooterVisible(), atPageTop);
345
415
  const hidden = state === 'hidden' || state === 'dismissed';
346
416
  const shell = this.querySelector('[data-promoto-shell]');
347
417
  this.hidden = hidden;
348
418
  if (shell) {
349
419
  shell.hidden = hidden;
350
- shell.inert = hidden || state === 'footer-hidden';
420
+ shell.inert = state !== 'ready';
351
421
  }
352
422
  this.dataset.state = state;
353
423
  }
@@ -360,7 +430,7 @@ export function definePromotoBannerElement() {
360
430
  syncRotation() {
361
431
  this.stopRotation();
362
432
  const state = this.dataset.state;
363
- if (state !== 'ready' && state !== 'footer-hidden' && state !== 'hidden' && state !== 'dismissed')
433
+ if (state !== 'ready' && state !== 'footer-hidden' && state !== 'top-hidden' && state !== 'hidden' && state !== 'dismissed')
364
434
  return;
365
435
  if (!shouldAutoRotate(this.cards.length, this.paused, this.reducedMotion, document.visibilityState !== 'hidden', state))
366
436
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hagicode/hagilight-core",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "description": "Shared TypeScript utilities, components, and assets for HagiCode Astro and Starlight sites",
6
6
  "repository": {
@@ -58,6 +58,14 @@
58
58
  "types": "./dist/content-images.d.ts",
59
59
  "default": "./dist/content-images.js"
60
60
  },
61
+ "./build-info": {
62
+ "types": "./dist/build-info.d.ts",
63
+ "default": "./dist/build-info.js"
64
+ },
65
+ "./build-info-resolver": {
66
+ "types": "./dist/build-info-resolver.d.ts",
67
+ "default": "./dist/build-info-resolver.js"
68
+ },
61
69
  "./Copyright": "./Copyright.astro",
62
70
  "./PromotoBanner": "./PromotoBanner.astro",
63
71
  "./GoogleAnalytics": "./GoogleAnalytics.astro",