Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: CI

on:
push:
branches: [main]
tags: ["v*"]
pull_request:

permissions:
contents: read

jobs:
check:
name: Node ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# engines.node is ">=20": test the floor plus current releases.
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm run check
- run: npm run pack:check

astro:
name: Astro ${{ matrix.astro }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Mirrors peerDependencies.astro ("^5 || ^6 || ^7").
astro: [5, 6, 7]
defaults:
run:
working-directory: test/fixtures/astro
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm install --no-audit --no-fund
- run: npm run verify:astro${{ matrix.astro }}
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@

版本策略见 [README 的「升级与版本策略」](README.md#升级与版本策略)。每条变更都标注**调用方是否需要改配置**,升级时只读这一列即可。

## 0.1.2 - 2026-09-30

需要改配置:否。兼容性与健壮性加固,现有 `items` / `tracking` 配置原样可用。只有此前就会静默出错的输入会改为构建期报错(见最后三条)。

行为变化(无需改配置,但升级后数据或输出可能不同):

- 归因:`?from=` 的值会去掉首尾空白并忽略大小写匹配 allowlist,上报时统一使用配置里的写法。此前 `?from=GitHub`、`?from=blog%20` 会被归为 `other`。
- 归因:中键点击(“在新标签页打开”)现在也会上报。此前浏览器不为中键触发 `click`,这部分访问会漏记;右键不上报。
- 渲染:所有条目都 `enabled: false` 时 `renderPromotionLinks()` 返回空字符串,不再输出一个空的、带名字的 `<nav>` 地标。
- 渲染:`tracking` 的字符串值与 `allowedSources` 会去除首尾空白,allowlist 去重;空白的 `ariaLabel` 回落到默认的 `Project links`。

健壮性:

- 客户端增强在没有 `window` / `document` 的环境(SSR、测试、Worker)里调用不再抛 `ReferenceError`;跨 iframe 的元素按 `nodeType` 识别。
- 点击时 `one-widget:click` 与 `dataLayer` 两条上报互相隔离:页面监听器抛错、或站点把 `dataLayer` 定义成非数组,都不会影响另一条,也不会影响跳转。
- `enhancePromotionWidgets()` 中某个 widget 出错不再中断同页其它 widget 的增强。
- CMS / JSON 导出的 `null` 可选字段按“未设置”处理,不再报类型错误。
- `renderPromotionLinks()` / `normalizePromotionItems()` 接受 `options` 为 `null`;`platforms` / `icons` 除对象外也接受 `Map`。
- 平台 `hosts` 按 `URL#hostname` 的规则规范化:大小写、国际化域名(punycode)、结尾点号、误贴的 `https://…/` 都能匹配;`protocols` 可省略冒号(`"mailto"`)。自定义平台键以大小写不同的方式覆盖内建平台(如 `GitHub`)时替换而不是并存。

兼容性:

- `package.json` 增加 `main`、`types`、`typesVersions` 以及导出的 `default` 条件:TypeScript `moduleResolution: "node10"` 与只认 `default`/`require` 条件的工具也能解析入口和类型。
- 类型:函数参数接受 `readonly` 数组(`as const` 配置可直接传入),`TrackingEnvironment` 从主入口导出。
- 样式:长标签(URL、无空格的中文)自动换行,不再撑破 390px 视口;`inline` 布局的滚动容器不再裁掉焦点环;不支持 `:focus-visible` 的浏览器回落为 `:focus` 焦点环;Windows 高对比度模式下按钮保留系统色边框;读屏专用文本改用 `clip-path`。
- CI:新增 GitHub Actions,在 Node 20 / 22 / 24 上跑 `npm run check`,并用 Astro 5 / 6 / 7 实际构建 fixture。本版已在本地 Astro 5.18.2 / 6.4.8 / 7.3.5 上构建并断言通过。

新增构建期报错(仅针对此前就无法正常工作的配置):

- `mailto:` 链接没有收件人(`mailto:`、`mailto:?subject=…`)。
- `platforms` / `icons` 传入数组或非对象。此前数组会生成名为 `"0"`、`"1"` 的平台。
- `options.registry` / `options.iconRegistry` 不是 `Map`。此前会在渲染中途抛出含义不明的错误。

## 0.1.1 - 2026-09-24

需要改配置:否。全部为可选新增或修正,现有 `items` / `tracking` 配置不升级也能继续工作。
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ const items = [
2. 点击时派发 `one-widget:click` DOM 事件;
3. 若页面使用 GTM,则向 `window.dataLayer` 推送同名分析事件。

未知来源统一归入 `other`,没有参数时归入 `direct`。查询参数不会被拼到付款链接上。
未知来源统一归入 `other`,没有参数时归入 `direct`。来源值会去掉首尾空白并忽略大小写匹配(`?from=GitHub` 记为配置里的 `github`),中键“在新标签页打开”也会上报。查询参数不会被拼到付款链接上。

增强脚本是尽力而为的:没有 `window`/`document` 时不做任何事;页面监听器抛错或 `dataLayer` 被定义成非数组时,另一条上报和链接跳转都不受影响。

Eigentime 现有 `/zh/support/` 与 `/en/support/` 的具体替换方式见 [docs/eigentime-integration.md](docs/eigentime-integration.md)。

Expand Down Expand Up @@ -120,7 +122,8 @@ interface PromotionItem {

- 未显式设置 `order` 的条目会以它在数组中的下标作为 `order`,再与其它条目一起排序。因此只给部分条目写 `order` 时,未写的那条可能穿插到中间(例如 `order: 1`、下标 2、`order: 5` 会排成 `1, 2, 5`)。要精确控制顺序,建议要么全写、要么全不写。
- 相同 `order` 时,数组顺序作为稳定后备顺序。
- `enabled: false` 会保留配置但不渲染,适合临时下线某个平台。
- `enabled: false` 会保留配置但不渲染,适合临时下线某个平台。全部条目都被禁用时整个组件输出空字符串,不会留下空的 `<nav>`。
- 来自 CMS / JSON 的 `null` 可选字段等同于未填写。
- 未填 `platform` 时会根据 URL 推断;显式值始终优先。
- 未填 `kind`、`icon`、`appearance`、`emphasis` 时,才从平台注册与业务语义推导默认值。
- `appearance: "icon"` 仍保留 `aria-label` 和屏幕阅读器文本,可点击区域固定不小于 44×44px。
Expand Down Expand Up @@ -197,6 +200,6 @@ npm run verify:astro6
npm run verify:astro7
```

最近一次多版本验证:Astro 5.18.2、6.4.8、7.3.4,三个大版本均构建并断言通过。
最近一次多版本验证:Astro 5.18.2、6.4.8、7.3.5,三个大版本均构建并断言通过。GitHub Actions 会在每次推送时于 Node 20 / 22 / 24 上跑 `npm run check`,并用 Astro 5 / 6 / 7 构建 fixture。

架构与稳定 API 边界见 [docs/architecture.md](docs/architecture.md)。
11 changes: 9 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,21 @@ tracking.js:可选的来源归因与事件增强
配置错误在构建期抛出异常,而不是悄悄隐藏:

- ID 重复或格式不合法;
- URL 不是绝对地址、协议不安全或含内嵌凭据;
- URL 不是绝对地址、协议不安全、含内嵌凭据,或 `mailto:` 没有收件人;
- `kind`、`appearance`、`emphasis`、layout、theme 不属于支持值;
- 布尔字段或数值字段类型错误;
- `eventName` 为空串或纯空白;
- 自定义图标缺少 `viewBox` 或 path。
- 自定义图标缺少 `viewBox` 或 path;
- `platforms` / `icons` 不是对象或 `Map`,`registry` / `iconRegistry` 不是 `Map`。

`null` 可选字段视为未设置,因为这是 JSON / CMS 表达“没填”的常见方式,而不是配置错误。

只有两类情况使用可预期的 fallback:未知平台使用 `custom` 语义;找不到图标 key 时使用中性的 link 图标。前者保证新平台无需升级包,后者保证图标配置错误不会让链接本身消失。

新增校验只针对此前就会静默出错的输入,已能正常渲染的配置升级后不会开始报错,这是“升级不需要重新配置”的一部分。

客户端增强相反,永远不抛错:缺少 DOM 全局对象时直接跳过,`one-widget:click` 派发与 `dataLayer` 推送各自 `try/catch`,单个 widget 失败不影响同页其它 widget。它丢失的只是归因,不能影响链接本身。

## 安全边界

- 所有 label、class、URL、data attribute 与 SVG path 属性都会 HTML 转义;
Expand Down
17 changes: 14 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
{
"name": "@eigentime/one-widget",
"version": "0.1.1",
"version": "0.1.2",
"description": "Static-first, accessible promotion and sponsorship links for Eigentime and other sites.",
"type": "module",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"typesVersions": {
"*": {
"tracking": [
"./src/tracking.d.ts"
]
}
},
"sideEffects": [
"./src/styles.css"
],
"exports": {
".": {
"types": "./src/index.d.ts",
"import": "./src/index.js"
"import": "./src/index.js",
"default": "./src/index.js"
},
"./astro": "./src/astro/PromotionLinks.astro",
"./styles.css": "./src/styles.css",
"./tracking": {
"types": "./src/tracking.d.ts",
"import": "./src/tracking.js"
"import": "./src/tracking.js",
"default": "./src/tracking.js"
},
"./package.json": "./package.json"
},
Expand Down
14 changes: 12 additions & 2 deletions src/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,28 @@ export function createIconRegistry(customIcons = {}) {
// Custom keys are lowercased to stay consistent with registerIcon() and the
// lowercased item.icon lookup in model.js; otherwise a key like "Forge"
// would never match and silently fall back to the default link icon.
for (const [key, definition] of Object.entries(customIcons)) {
if (customIcons !== null && customIcons !== undefined
&& (typeof customIcons !== "object" || Array.isArray(customIcons))) {
throw new TypeError("icons must be an object keyed by icon name.");
}
const customEntries = customIcons instanceof Map ? [...customIcons] : Object.entries(customIcons ?? {});
for (const [key, definition] of customEntries) {
const normalizedKey = String(key).trim().toLowerCase();
if (!normalizedKey) throw new TypeError("Icon key cannot be empty.");
registry.set(normalizedKey, normalizeIconDefinition(normalizedKey, definition));
}
return registry;
}

export function registerIcon(registry, key, definition) {
export function assertIconRegistry(registry) {
if (!(registry instanceof Map)) {
throw new TypeError("Icon registry must be a Map created by createIconRegistry().");
}
return registry;
}

export function registerIcon(registry, key, definition) {
assertIconRegistry(registry);
const normalizedKey = String(key).trim().toLowerCase();
if (!normalizedKey) throw new TypeError("Icon key cannot be empty.");

Expand Down
29 changes: 19 additions & 10 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,16 @@ export interface RenderOptions {
tracking?: TrackingOptions;
registry?: Map<string, Required<PlatformDefinition>>;
iconRegistry?: Map<string, IconDefinition>;
platforms?: Record<string, PlatformDefinition>;
icons?: Record<string, IconDefinition>;
platforms?: Record<string, PlatformDefinition> | Map<string, PlatformDefinition>;
icons?: Record<string, IconDefinition> | Map<string, IconDefinition>;
}

export interface TrackingEnvironment {
location?: Pick<Location, "search">;
globalObject?: (Window & { dataLayer?: Array<Record<string, unknown>> }) | { dataLayer?: Array<Record<string, unknown>> };
Element?: typeof Element;
HTMLAnchorElement?: typeof HTMLAnchorElement;
CustomEvent?: typeof CustomEvent;
}

export interface NormalizedPromotionItem extends Required<Omit<PromotionItem, "shortLabel" | "eventName">> {
Expand All @@ -71,15 +79,16 @@ export const PROMOTION_KINDS: readonly PromotionKind[];
export const BUILTIN_PLATFORMS: readonly string[];
export const BUILTIN_ICONS: readonly string[];

export function createPlatformRegistry(customPlatforms?: Record<string, PlatformDefinition>): Map<string, Required<PlatformDefinition>>;
export function createPlatformRegistry(customPlatforms?: Record<string, PlatformDefinition> | Map<string, PlatformDefinition> | null): Map<string, Required<PlatformDefinition>>;
export function registerPlatform(registry: Map<string, Required<PlatformDefinition>>, key: string, definition: PlatformDefinition): Map<string, Required<PlatformDefinition>>;
export function inferPlatform(url: string | URL, registry?: Map<string, Required<PlatformDefinition>>): string | undefined;
export function createIconRegistry(customIcons?: Record<string, IconDefinition>): Map<string, IconDefinition>;
export function createIconRegistry(customIcons?: Record<string, IconDefinition> | Map<string, IconDefinition> | null): Map<string, IconDefinition>;
export function registerIcon(registry: Map<string, IconDefinition>, key: string, definition: IconDefinition): Map<string, IconDefinition>;
export function normalizeUrl(value: string, path?: string): string;
export function normalizePromotionItems(items: PromotionItem[], options?: RenderOptions): NormalizedPromotionItem[];
export function renderableItems(items: PromotionItem[], options?: RenderOptions): NormalizedPromotionItem[];
export function renderPromotionLinks(items: PromotionItem[], options?: RenderOptions): string;
export function resolveTrackingSource(search: string, config?: TrackingOptions): string;
export function enhancePromotionWidget(root: Element, environment?: Record<string, unknown>): void;
export function enhancePromotionWidgets(scope?: ParentNode): void;
export function normalizePromotionItems(items: readonly PromotionItem[], options?: RenderOptions | null): NormalizedPromotionItem[];
export function renderableItems(items: readonly PromotionItem[], options?: RenderOptions | null): NormalizedPromotionItem[];
/** Returns an empty string when no item is enabled, so no empty landmark is emitted. */
export function renderPromotionLinks(items: readonly PromotionItem[], options?: RenderOptions | null): string;
export function resolveTrackingSource(search: string | null | undefined, config?: TrackingOptions | null): string;
export function enhancePromotionWidget(root: Element, environment?: TrackingEnvironment | null): void;
export function enhancePromotionWidgets(scope?: ParentNode | null): void;
26 changes: 18 additions & 8 deletions src/model.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createPlatformRegistry, inferPlatform } from "./registry.js";
import { assertPlatformRegistry, createPlatformRegistry, inferPlatform } from "./registry.js";

export const PROMOTION_KINDS = Object.freeze([
"social",
Expand Down Expand Up @@ -36,6 +36,11 @@ export function normalizeUrl(value, path = "url") {
if ((parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.hostname) {
throw new TypeError(`${path} must include a hostname.`);
}
// "mailto:" or "mailto:?subject=hi" parses fine but opens an empty draft
// with no recipient, which is never what a promotion link intends.
if (parsed.protocol === "mailto:" && !parsed.pathname.trim()) {
throw new TypeError(`${path} must include an email address.`);
}
if (parsed.username || parsed.password) {
throw new TypeError(`${path} must not contain embedded credentials.`);
}
Expand All @@ -62,11 +67,14 @@ function defaultIcon(kind) {
return "link";
}

export function normalizePromotionItems(items, options = {}) {
export function normalizePromotionItems(items, options) {
if (!Array.isArray(items)) throw new TypeError("items must be an array.");
options ??= {};

const registry = options.registry ?? createPlatformRegistry(options.platforms);
if (options.openInNewTab !== undefined && typeof options.openInNewTab !== "boolean") {
const registry = options.registry
? assertPlatformRegistry(options.registry)
: createPlatformRegistry(options.platforms);
if (options.openInNewTab != null && typeof options.openInNewTab !== "boolean") {
throw new TypeError("options.openInNewTab must be a boolean.");
}
const defaultOpenInNewTab = options.openInNewTab ?? true;
Expand Down Expand Up @@ -104,18 +112,20 @@ export function normalizePromotionItems(items, options = {}) {
throw new TypeError(`${path}.emphasis must be primary, secondary, or quiet.`);
}

// null is what JSON / CMS exports emit for "not set", so treat it like
// undefined for every optional field instead of failing on it.
const order = item.order ?? index;
if (!Number.isFinite(order)) throw new TypeError(`${path}.order must be a finite number.`);
if (item.enabled !== undefined && typeof item.enabled !== "boolean") {
if (item.enabled != null && typeof item.enabled !== "boolean") {
throw new TypeError(`${path}.enabled must be a boolean.`);
}
if (item.openInNewTab !== undefined && typeof item.openInNewTab !== "boolean") {
if (item.openInNewTab != null && typeof item.openInNewTab !== "boolean") {
throw new TypeError(`${path}.openInNewTab must be a boolean.`);
}
if (item.icon !== undefined && item.icon !== false && (typeof item.icon !== "string" || !item.icon.trim())) {
if (item.icon != null && item.icon !== false && (typeof item.icon !== "string" || !item.icon.trim())) {
throw new TypeError(`${path}.icon must be a non-empty string or false.`);
}
if (item.eventName !== undefined && (typeof item.eventName !== "string" || !item.eventName.trim())) {
if (item.eventName != null && (typeof item.eventName !== "string" || !item.eventName.trim())) {
throw new TypeError(`${path}.eventName must be a non-empty string.`);
}

Expand Down
Loading
Loading