From 11e0c6bcd47c82066f003e77307ef7d2dc7b07e5 Mon Sep 17 00:00:00 2001 From: carl chen Date: Mon, 7 Sep 2026 15:25:05 +0800 Subject: [PATCH 1/2] feat(mirror-redirect): extract cn-mirror channel logic into createMirrorRedirect antdv-next site and x site each maintained their own geo-redirect logic and diverged after only one side was hardened. Add a configurable single implementation: local signal scoring, GeoIP fallback chain, cn-site reachability probe, preference memory with TTL, debug/opt-out switches, plus isMirrorHost for mirror-host matching (ICP footer). --- README.md | 44 ++++ src/index.ts | 1 + src/mirror-redirect.ts | 532 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 577 insertions(+) create mode 100644 src/mirror-redirect.ts diff --git a/README.md b/README.md index c98a1ee..028bc31 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,49 @@ resolve: { /// ``` +## 国内镜像通道(主站 → `.cn` 镜像站) + +antdv-next 主站(www.antdv-next.com ⇄ www.antdv-next.cn)和 x 站 +(x.antdv-next.com ⇄ x.antdv-next.cn)之前各自维护过一份"检测到国内访问, +就引导到镜像站"的逻辑。这份逻辑(语言 / 时区本地信号、GeoIP 回退、可达性 +探测、偏好记忆、调试与豁免开关)现在已经收敛成一份按站点配置实例化的实现; +弹窗和文案仍然由站点自己负责,因为两个站点的弹窗方式不同。 + +```ts +import { createMirrorRedirect } from '@antdv-next/docs-plugins' + +const mirror = createMirrorRedirect({ + mainHosts: ['antdv-next.com', 'www.antdv-next.com'], + mirrorOrigin: 'https://www.antdv-next.cn', + // 跳转前先探测镜像站能不能访问(用图片请求,跨域不需要 CORS); + // 不需要探测的站点可以省略这行 + probeUrl: 'https://www.antdv-next.cn/antdv-next.png', +}) +``` + +```ts +// 在组件 mounted 之后调用一次 +const decision = await mirror.getDecision() + +if (decision === 'redirect') { + mirror.redirect() // location.replace,保留 pathname/search/hash + return +} +if (decision === 'prompt') { + // 用站点自己的弹窗 + 文案询问;用户确认后 setPreference('accepted') 再 redirect() +} +``` + +- 只有 `mainHosts` 里的权威主站域名会触发,localhost 和 preview 部署不受影响 +- 用户选过 `accepted` → 镜像站可达就自动跳转;选过 `rejected` → 跳过 + (拒绝默认记住 30 天,可用 `rejectedTtlMs` 调整) +- 没有偏好时,先按语言 / 时区 / `-cn` 路径打分(港/澳/台直接排除),分数不足 + 再查 GeoIP(`geoApis`,默认 boce 单接口);没配 `probeUrl` 就不探测 +- 调试:`localStorage.DEBUG = debugValue` 可强制走完整流程(含 localhost); + 本次豁免:`?cn-redirect=off`(参数名可用 `disableSearchParam` 改) +- `mirror.isMirrorHost(hostname)`:判断主机是否属于镜像站部署 + (比如只在 `.cn` 镜像站展示 ICP 备案) + ## 默认行为与选项 | 选项 | 默认 | 说明 | @@ -117,6 +160,7 @@ resolve: { - `createMarkdown` / `useMarkdown` / `loadBaseMd` / `loadShiki`(`CreateMarkdownOptions`) - markdown-it 插件:`container` / `demo` / `github-alerts` / `image` / `link` / `pre-wrapper` / `stackblitz` / `table` - `postcssIsolateStyles`:markdown 样式隔离 PostCSS 插件 +- 镜像通道:`createMirrorRedirect`(类型:`MirrorRedirectOptions` / `MirrorRedirect` / `MirrorRedirectDecision`) - `tsToJs` + `createOxfmtJsFormatter`:demo 源码 TS → JS 转换与格式化 - 组件:`CodeDemo` + `provideDemoContext` / `useDemoContext`(类型:`DemoModule` / `DemoSourceData` 等) - 工具:`getDemoId` / `shortHash` diff --git a/src/index.ts b/src/index.ts index 2575702..e5228ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from './isolate-styles' export * from './markdown' export * from './md2vue' export * from './md-plugin' +export * from './mirror-redirect' export * from './plugins/container' export * from './plugins/demo' export * from './plugins/github-alerts' diff --git a/src/mirror-redirect.ts b/src/mirror-redirect.ts new file mode 100644 index 0000000..8ab9199 --- /dev/null +++ b/src/mirror-redirect.ts @@ -0,0 +1,532 @@ +/** + * 主站与国内镜像站之间的访问判定与跳转。 + * + * 文档站点通常同时部署在国际主站与国内镜像站上:镜像站面向中国大陆, + * 访问更快,也可能需要展示 ICP 备案。主站需要识别来自中国大陆的访客, + * 引导他们切换到镜像站继续浏览。各站点此前常各自复制一份"判定 + 跳转" + * 实现,后续修补难以同步;这里把它收敛为按站点配置实例化的单一实现: + * 站点只需要声明自己的差异 —— 权威域名、镜像站 origin、路径映射方式、 + * 探测资源等 —— 判断流程完全复用。 + * + * 选项默认值按一份实践中打磨过的配置设定: + * - 只有权威主站域名会触发跳转,preview 部署和 localhost 不受影响; + * - 先用浏览器语言、时区、国内文档路径做本地信号打分,港/澳/台直接排除; + * - 信号不足以判断时,再走 GeoIP 接口链二次确认(接口可达但判定非国内即停); + * - 提示或跳转前先探测镜像站能否访问(用图片请求避开 CORS),可关闭; + * - 用户的通过/拒绝偏好写入 localStorage,拒绝会带 TTL,过期后重新询问。 + * + * 本模块只包含纯浏览器逻辑,不依赖 Vue / antd;弹窗与文案由站点负责。 + */ + +export type MirrorRedirectPreference = 'accepted' | 'rejected' +export type MirrorRedirectDecision = 'redirect' | 'prompt' | 'skip' + +/** 主机匹配规则:`equals` 精确匹配;`subdomainsOf` 匹配域名本身及其所有子域。 */ +export interface HostnameMatcher { + /** 精确相等的主机名(小写、可带尾点,自动规范化)。 */ + equals?: string[] + /** 命中该域名本身或以 `.${domain}` 结尾的任意子域。 */ + subdomainsOf?: string[] +} + +export interface GeoIpApi { + url: string + /** + * 解析响应文本并判定是否为国内(中国大陆)访问。 + * 返回 false 表示接口可达但判定非国内 —— 判定链立即停止,不再尝试后续接口; + * 网络 / CORS / HTTP 非 2xx 失败则继续下一个接口。 + */ + parse: (text: string) => boolean +} + +export interface MirrorRedirectOptions { + /** + * 权威主站域名(跳转发起方),如 `['example.com', 'www.example.com']`。 + * 只有这些域名会触发跳转/提示,preview 部署和 localhost 不受影响。 + */ + mainHosts: string[] + + /** + * 国内镜像站 origin,如 `'https://www.example.cn'`。 + * 跳转时保留当前 pathname/search/hash,只替换 origin。 + */ + mirrorOrigin: string + + /** + * 判断"当前站点是否部署在镜像域"的匹配规则,`isMirrorHost()` 按它判断 + * (例如只在镜像站展示 ICP 备案)。 + * @default 由 mirrorOrigin 推导:去掉 `www.` 前缀,按该域名及其子域匹配 + */ + mirrorHosts?: HostnameMatcher + + /** + * 国际主站路径 → 镜像站路径 的映射。 + * - `'cn-suffix'`:追加 `-cn`(`/` → `/index-cn`,已带 `-cn` 或 `/~demos` 保持不变); + * - `'same'`:两站路由一致,直接保留原路径; + * - 自定义函数:返回目标路径。 + * @default 'cn-suffix' + */ + pathname?: 'cn-suffix' | 'same' | ((pathname: string) => string) + + /** + * 是否启用本地信号(浏览器语言 / 时区 / `-cn` 路径)打分。 + * @default true + */ + localSignals?: boolean + + /** + * GeoIP 回退链:本地信号不足以判定时按序尝试,首个可达接口出结论。 + * @default v4_dx.boce.com 单接口 + */ + geoApis?: GeoIpApi[] + + /** 单个 GeoIP 接口超时。@default 1500 */ + geoTimeoutMs?: number + + /** + * 镜像站可达性探测地址(图片请求,跨域也不需要 CORS)。 + * 提示或跳转之前会先确认镜像站确实能访问;传 `null` 关闭探测, + * 直接按可达处理。 + * @default null + */ + probeUrl?: string | null + + /** 可达性探测超时。@default 1500 */ + probeTimeoutMs?: number + + /** 偏好 localStorage key。@default 'cn-site-redirect-preference' */ + storageKey?: string + + /** + * 用户拒绝后需要等待多久才重新询问(ms)。带过期时间是为了照顾 + * 经常往返两地的用户;传 `Infinity` 表示永久记住。 + * @default 30 天 + */ + rejectedTtlMs?: number + + /** + * 本地信号得分达到该值才直接提示,不再请求 GeoIP。 + * @default 3 + */ + confidentScore?: number + + /** + * `localStorage.DEBUG` 的强制触发值(含 localhost):设 `'site-a'` + * 后 `localStorage.DEBUG = 'site-a'` 强制走完整流程。省略则禁用该开关。 + */ + debugValue?: string + + /** + * 单次访问豁免的 query 参数名:`?${param}=off` 跳过本次判定 + * (用于有意引导到国际站时)。 + * @default 'cn-redirect' + */ + disableSearchParam?: string +} + +export interface MirrorRedirect { + /** 当前用户偏好;无记录或已过期返回 null。兼容旧版写入的纯字符串值。 */ + getPreference: () => MirrorRedirectPreference | null + + /** 写入偏好(带时间戳)。 */ + setPreference: (preference: MirrorRedirectPreference) => void + + /** 构造跳转 URL;不在主站权威域名、位于 `/~demos` 或与当前地址相同返回 null。 */ + buildRedirectUrl: () => string | null + + /** 跳转到镜像站对应页面(`location.replace`,不产生历史记录)。 */ + redirect: () => void + + /** + * 返回本次访问应该怎么处理:自动跳转 / 询问用户 / 静默跳过。 + * 判断会读取 window 的 location、navigator、localStorage,并可能用到 + * fetch 与 Image,所以要等进入浏览器环境之后再调用 + * (比如组件 mounted 之后)。 + */ + getDecision: () => Promise + + /** 主机名是否命中镜像站部署规则(可与 localhost 判断并用,如备案展示)。 */ + isMirrorHost: (hostname: string) => boolean +} + +interface IpAddrResponse { + code?: number + message?: string + data?: { + from?: string + ip?: string + } +} + +interface StoredPreference { + value: MirrorRedirectPreference + time: number +} + +const DEFAULT_STORAGE_KEY = 'cn-site-redirect-preference' +const DEFAULT_REJECTED_TTL_MS = 30 * 24 * 60 * 60 * 1000 +const DEFAULT_GEO_TIMEOUT_MS = 1500 +const DEFAULT_PROBE_TIMEOUT_MS = 1500 +const DEFAULT_CONFIDENT_SCORE = 3 +const DEFAULT_DISABLE_SEARCH_PARAM = 'cn-redirect' + +// 仅中国大陆(不含港澳台)的 IANA 时区;港澳台走国际站,不进国内通道。 +const MAINLAND_TIME_ZONES = [ + 'Asia/Shanghai', + 'Asia/Chongqing', + 'Asia/Chungking', + 'Asia/Harbin', + 'Asia/Urumqi', + 'Asia/Kashgar', + 'PRC', +] +const NON_MAINLAND_TIME_ZONES = ['Asia/Hong_Kong', 'Asia/Macau', 'Asia/Macao', 'Asia/Taipei'] +const NON_MAINLAND_LANGUAGE_REGIONS = ['hk', 'mo', 'tw', 'sg', 'hant'] +const NON_MAINLAND_IP_REGIONS = ['香港', '澳门', '澳門', '台湾', '台灣'] + +const DEFAULT_GEO_APIS: GeoIpApi[] = [ + { + url: 'https://v4_dx.boce.com:44433/ipaddr', + parse: (text) => { + try { + const result = JSON.parse(text) as IpAddrResponse + return isChinaMainlandVisit(result.data?.from) + } + catch { + return false + } + }, + }, +] + +function normalizeHostname(hostname: string): string { + return hostname.trim().toLowerCase().replace(/\.$/, '') +} + +function hostMatches(hostname: string, matcher: HostnameMatcher): boolean { + const host = normalizeHostname(hostname) + + if (!host) + return false + + if (matcher.equals?.some(item => normalizeHostname(item) === host)) + return true + + return matcher.subdomainsOf?.some((domain) => { + const d = normalizeHostname(domain) + return host === d || host.endsWith(`.${d}`) + }) ?? false +} + +function toCnSuffixPathname(pathname: string): string { + if (pathname === '/' || pathname === '') + return '/index-cn' + if (pathname.startsWith('/~demos') || pathname.endsWith('-cn')) + return pathname + return `${pathname}-cn` +} + +function readStorage(key: string): string | null { + try { + return window.localStorage.getItem(key) + } + catch { + // 禁用 cookie、隐私模式等场景下 localStorage 可能抛错,不能因此打断页面。 + return null + } +} + +function writeStorage(key: string, value: string): void { + try { + window.localStorage.setItem(key, value) + } + catch { + // 配额不足或隐私模式写不进去时直接忽略,下次访问会再问一次。 + } +} + +/** + * 低成本的本地信号打分:单条弱信号不足以单独触发提示,港/澳/台(时区或 + * 语言)直接否决。语言 zh-CN/zh-Hans +2、其他 zh-* +1;大陆时区 +2; + * 命中 `-cn` 页面 +1。 + */ +function getLocalSignalScore(pathname: string): number { + // navigator.languages 可能为空,退回到单个 language。 + const { languages, language } = window.navigator + const languageTags = (languages?.length ? [...languages] : [language]) + .filter(Boolean) + .map(tag => tag.toLowerCase()) + + let timeZone = '' + + try { + timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone ?? '' + } + catch { + // 个别环境 Intl 不可用,时区信号缺失不阻塞其余信号。 + } + + const vetoed = NON_MAINLAND_TIME_ZONES.includes(timeZone) + || languageTags.some(tag => tag.startsWith('zh-') + && NON_MAINLAND_LANGUAGE_REGIONS.some(region => tag.includes(region))) + + if (vetoed) + return 0 + + let score = 0 + + if (languageTags.some(tag => tag === 'zh-cn' || tag.startsWith('zh-hans'))) + score += 2 + else if (languageTags.some(tag => tag === 'zh' || tag.startsWith('zh-'))) + score += 1 + + if (MAINLAND_TIME_ZONES.includes(timeZone)) + score += 2 + + // 已在国内文档页(`-cn` 路径,斜杠结尾也命中)本身就是强信号。 + if (/-cn\/?$/.test(pathname)) + score += 1 + + return score +} + +/** boce 接口的返回文本以"中国 福建 福州"等表述来源,港澳台需单独排除。 */ +function isChinaMainlandVisit(from: string | undefined): boolean { + if (!from) + return false + + if (NON_MAINLAND_IP_REGIONS.some(region => from.includes(region))) + return false + + return from === '中国' || from.startsWith('中国/') +} + +export function createMirrorRedirect(options: MirrorRedirectOptions): MirrorRedirect { + const { + mainHosts, + mirrorOrigin, + mirrorHosts: mirrorHostsOption, + pathname = 'cn-suffix', + localSignals = true, + geoApis = DEFAULT_GEO_APIS, + geoTimeoutMs = DEFAULT_GEO_TIMEOUT_MS, + probeUrl = null, + probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS, + storageKey = DEFAULT_STORAGE_KEY, + rejectedTtlMs = DEFAULT_REJECTED_TTL_MS, + confidentScore = DEFAULT_CONFIDENT_SCORE, + debugValue, + disableSearchParam = DEFAULT_DISABLE_SEARCH_PARAM, + } = options + + const mapPathname = (pathnameValue: string): string => { + if (pathname === 'cn-suffix') + return toCnSuffixPathname(pathnameValue) + if (pathname === 'same') + return pathnameValue + return pathname(pathnameValue) + } + + const mirrorHosts = mirrorHostsOption ?? deriveHostsFromOrigin(mirrorOrigin) + + const isRedirectableHost = (hostname: string): boolean => { + // 只有权威主站域名参与跳转,preview 部署和 localhost 不受影响; + // 调试开关(localStorage.DEBUG === debugValue)也会放行,和 getDecision 里的判断保持一致。 + const normalized = normalizeHostname(hostname) + return mainHosts.some(host => normalizeHostname(host) === normalized) + || readStorage('DEBUG') === debugValue + } + + // 镜像站是否可达只需要探测一次;图片请求不需要 CORS,跨域也能用。 + let cnSiteReachablePromise: Promise | null = null + const probeReachable = (): Promise => { + if (!probeUrl) + return Promise.resolve(true) + + cnSiteReachablePromise ??= new Promise((resolve) => { + const image = new Image() + let settled = false + + const settle = (reachable: boolean) => { + if (!settled) { + settled = true + resolve(reachable) + } + } + + image.onload = () => settle(true) + image.onerror = () => settle(false) + image.src = probeUrl + + window.setTimeout(() => settle(false), probeTimeoutMs) + }) + + return cnSiteReachablePromise + } + + const getPreference = (): MirrorRedirectPreference | null => { + if (typeof window === 'undefined') + return null + + const raw = readStorage(storageKey) + + if (!raw) + return null + + // 旧版站点写入的是纯字符串,继续兼容。 + if (raw === 'accepted' || raw === 'rejected') + return raw + + let stored: StoredPreference | null = null + + try { + stored = JSON.parse(raw) as StoredPreference + } + catch { + return null + } + + if (stored?.value !== 'accepted' && stored?.value !== 'rejected') + return null + + if (stored.value === 'rejected' + && rejectedTtlMs < Number.POSITIVE_INFINITY + && Date.now() - stored.time > rejectedTtlMs) { + return null + } + + return stored.value + } + + const setPreference = (preference: MirrorRedirectPreference): void => { + if (typeof window === 'undefined') + return + + writeStorage( + storageKey, + JSON.stringify({ value: preference, time: Date.now() } satisfies StoredPreference), + ) + } + + const buildRedirectUrl = (): string | null => { + if (typeof window === 'undefined') + return null + + const { location } = window + + if (!isRedirectableHost(location.hostname) || location.pathname.startsWith('/~demos')) + return null + + const targetUrl = new URL(mirrorOrigin) + targetUrl.pathname = mapPathname(location.pathname) + targetUrl.search = location.search + targetUrl.hash = location.hash + + return targetUrl.href === location.href ? null : targetUrl.href + } + + const redirect = (): void => { + const targetUrl = buildRedirectUrl() + + if (targetUrl) + window.location.replace(targetUrl) + } + + /** + * GeoIP 接口链二次确认,只在本地信号不足以判断时使用:某个接口一旦返回 + * 成功就立刻下结论(判定非国内即停止);只有网络错误、CORS 失败或非 2xx + * 响应才继续尝试下一个接口。 + */ + const lookupChinaMainland = async (): Promise => { + for (const api of geoApis) { + const controller = new AbortController() + const timeoutId = window.setTimeout(() => controller.abort(), geoTimeoutMs) + + try { + const response = await fetch(api.url, { signal: controller.signal }) + + if (!response.ok) + continue + + const text = await response.text() + let mainland = false + + try { + mainland = api.parse(text) + } + catch { + // 解析失败按"接口可达但非国内"处理,停止探测。 + } + + return mainland + } + catch { + // 忽略网络与 CORS 失败,尝试下一个接口。 + } + finally { + window.clearTimeout(timeoutId) + } + } + + return false + } + + const getDecision = async (): Promise => { + if (typeof window === 'undefined') + return 'skip' + + const { location } = window + + // `?${disableSearchParam}=off` 表示本次访问不参与判定,方便刻意留在国际站。 + if (new URLSearchParams(location.search).get(disableSearchParam) === 'off' || !buildRedirectUrl()) + return 'skip' + + const preference = getPreference() + + if (preference === 'rejected') + return 'skip' + + if (preference === 'accepted') + return await probeReachable() ? 'redirect' : 'skip' + + // 调试开关(localStorage.DEBUG === debugValue,含 localhost)直接按高分处理, + // 和 isRedirectableHost 里的判断保持一致。 + const forced = readStorage('DEBUG') === debugValue + const score = forced + ? confidentScore + : (localSignals ? getLocalSignalScore(location.pathname) : 0) + + // 没有任何本地信号时直接跳过,不为 GeoIP 额外发请求。 + if (score <= 0) + return 'skip' + + if (score < confidentScore && !await lookupChinaMainland()) + return 'skip' + + return await probeReachable() ? 'prompt' : 'skip' + } + + return { + getPreference, + setPreference, + buildRedirectUrl, + redirect, + getDecision, + isMirrorHost: hostname => hostMatches(hostname, mirrorHosts), + } +} + +function deriveHostsFromOrigin(origin: string): HostnameMatcher { + let host = origin + + try { + host = new URL(origin).hostname + } + catch { + // origin 非法时按原始字符串匹配,由调用方保证配置正确。 + } + + const domain = normalizeHostname(host).replace(/^www\./, '') + + return domain ? { subdomainsOf: [domain] } : {} +} From 715c3885aa422f0d7c0bcb523fe1be5be95f9491 Mon Sep 17 00:00:00 2001 From: carl chen Date: Mon, 7 Sep 2026 15:39:13 +0800 Subject: [PATCH 2/2] docs: keep root README as summary, move mirror channel docs into module folder --- README.md | 48 ++-------- src/mirror-redirect/README.md | 89 +++++++++++++++++++ .../index.ts} | 0 3 files changed, 96 insertions(+), 41 deletions(-) create mode 100644 src/mirror-redirect/README.md rename src/{mirror-redirect.ts => mirror-redirect/index.ts} (100%) diff --git a/README.md b/README.md index 028bc31..ec58e46 100644 --- a/README.md +++ b/README.md @@ -98,48 +98,14 @@ resolve: { /// ``` -## 国内镜像通道(主站 → `.cn` 镜像站) +## 模块汇总 -antdv-next 主站(www.antdv-next.com ⇄ www.antdv-next.cn)和 x 站 -(x.antdv-next.com ⇄ x.antdv-next.cn)之前各自维护过一份"检测到国内访问, -就引导到镜像站"的逻辑。这份逻辑(语言 / 时区本地信号、GeoIP 回退、可达性 -探测、偏好记忆、调试与豁免开关)现在已经收敛成一份按站点配置实例化的实现; -弹窗和文案仍然由站点自己负责,因为两个站点的弹窗方式不同。 +各功能模块的详细文档放在对应模块目录下的 `README.md`,这里只做入口汇总; +其余插件与管线用法见上方「快速开始」与「API 总览」。 -```ts -import { createMirrorRedirect } from '@antdv-next/docs-plugins' - -const mirror = createMirrorRedirect({ - mainHosts: ['antdv-next.com', 'www.antdv-next.com'], - mirrorOrigin: 'https://www.antdv-next.cn', - // 跳转前先探测镜像站能不能访问(用图片请求,跨域不需要 CORS); - // 不需要探测的站点可以省略这行 - probeUrl: 'https://www.antdv-next.cn/antdv-next.png', -}) -``` - -```ts -// 在组件 mounted 之后调用一次 -const decision = await mirror.getDecision() - -if (decision === 'redirect') { - mirror.redirect() // location.replace,保留 pathname/search/hash - return -} -if (decision === 'prompt') { - // 用站点自己的弹窗 + 文案询问;用户确认后 setPreference('accepted') 再 redirect() -} -``` - -- 只有 `mainHosts` 里的权威主站域名会触发,localhost 和 preview 部署不受影响 -- 用户选过 `accepted` → 镜像站可达就自动跳转;选过 `rejected` → 跳过 - (拒绝默认记住 30 天,可用 `rejectedTtlMs` 调整) -- 没有偏好时,先按语言 / 时区 / `-cn` 路径打分(港/澳/台直接排除),分数不足 - 再查 GeoIP(`geoApis`,默认 boce 单接口);没配 `probeUrl` 就不探测 -- 调试:`localStorage.DEBUG = debugValue` 可强制走完整流程(含 localhost); - 本次豁免:`?cn-redirect=off`(参数名可用 `disableSearchParam` 改) -- `mirror.isMirrorHost(hostname)`:判断主机是否属于镜像站部署 - (比如只在 `.cn` 镜像站展示 ICP 备案) +| 模块 | 用途 | 详细文档 | +| --- | --- | --- | +| 镜像通道 `createMirrorRedirect` | 判定大陆访客并引导其切换到国内镜像站 | [`src/mirror-redirect/README.md`](src/mirror-redirect/README.md) | ## 默认行为与选项 @@ -160,7 +126,7 @@ if (decision === 'prompt') { - `createMarkdown` / `useMarkdown` / `loadBaseMd` / `loadShiki`(`CreateMarkdownOptions`) - markdown-it 插件:`container` / `demo` / `github-alerts` / `image` / `link` / `pre-wrapper` / `stackblitz` / `table` - `postcssIsolateStyles`:markdown 样式隔离 PostCSS 插件 -- 镜像通道:`createMirrorRedirect`(类型:`MirrorRedirectOptions` / `MirrorRedirect` / `MirrorRedirectDecision`) +- 镜像通道:`createMirrorRedirect`(类型:`MirrorRedirectOptions` / `MirrorRedirect` / `MirrorRedirectDecision`;详见[模块 README](src/mirror-redirect/README.md)) - `tsToJs` + `createOxfmtJsFormatter`:demo 源码 TS → JS 转换与格式化 - 组件:`CodeDemo` + `provideDemoContext` / `useDemoContext`(类型:`DemoModule` / `DemoSourceData` 等) - 工具:`getDemoId` / `shortHash` diff --git a/src/mirror-redirect/README.md b/src/mirror-redirect/README.md new file mode 100644 index 0000000..f0396a4 --- /dev/null +++ b/src/mirror-redirect/README.md @@ -0,0 +1,89 @@ +# 镜像通道跳转(`createMirrorRedirect`) + +主站与国内镜像站之间的访问判定与跳转。 + +文档站点通常同时部署在国际主站与国内镜像站上:镜像站面向中国大陆,访问 +更快,也可能需要展示 ICP 备案。主站需要识别来自中国大陆的访客,引导他们 +切换到镜像站继续浏览。各站点此前常各自复制一份实现,后续修补难以同步; +本模块把"判定 + 跳转"收敛为按站点配置实例化的单一实现,站点只需声明 +自身差异,判断流程完全复用。 + +模块只包含纯浏览器逻辑,不依赖 Vue / antd;弹窗与文案由站点负责。 + +## 使用 + +```ts +import { createMirrorRedirect } from '@antdv-next/docs-plugins' + +const mirror = createMirrorRedirect({ + mainHosts: ['example.com', 'www.example.com'], + mirrorOrigin: 'https://www.example.cn', + // 跳转前先探测镜像站能不能访问(用图片请求,跨域不需要 CORS); + // 不需要探测的站点可以省略这行 + probeUrl: 'https://www.example.cn/probe.png', +}) +``` + +在组件 mounted 之后调用一次: + +```ts +const decision = await mirror.getDecision() + +if (decision === 'redirect') { + mirror.redirect() // location.replace,保留 pathname/search/hash + return +} +if (decision === 'prompt') { + // 用站点自己的弹窗 + 文案询问;用户确认后 setPreference('accepted') 再 redirect() +} +``` + +## 决策流程 + +1. 当前域名不在 `mainHosts` 内,或 URL 带 `?cn-redirect=off` → 跳过 +2. 用户之前选过: + - `accepted` → 镜像站可达则直接跳转 + - `rejected` → 跳过(默认记住 30 天,可用 `rejectedTtlMs` 调整) +3. 没有偏好时按本地信号打分: + - 浏览器语言:`zh-CN` / `zh-Hans` +2,其他 `zh-*` +1 + - 时区命中大陆时区 +2;港 / 澳 / 台时区或语言直接否决 + - 命中国内文档路径(如 `-cn` 后缀)+1 + - 分数 ≥ `confidentScore`(默认 3)才直接提示;分数不足时再查 GeoIP +4. GeoIP 接口链(`geoApis`,默认 v4_dx.boce.com 单接口)按序尝试,某个接口 + 一旦成功返回就下结论(判定非国内即停止),只有网络错误 / CORS 失败 / + 非 2xx 响应才换下一个 +5. 提示或跳转前,若配置了 `probeUrl`,先确认镜像站确实可访问 + +## 选项 + +| 选项 | 默认 | 说明 | +| --- | --- | --- | +| `mainHosts` | 必填 | 权威主站域名(跳转发起方),只有这些域名会触发;preview 部署和 localhost 不受影响 | +| `mirrorOrigin` | 必填 | 国内镜像站 origin;跳转保留当前 pathname/search/hash,只替换 origin | +| `mirrorHosts` | 由 `mirrorOrigin` 推导 | 判断"当前是否部署在镜像域"的匹配规则,`isMirrorHost()` 使用(如只在镜像站展示备案) | +| `pathname` | `'cn-suffix'` | 路径映射:`'cn-suffix'` 追加 `-cn`(`/` → `/index-cn`);`'same'` 两站路由一致原样保留;或自定义函数 | +| `localSignals` | `true` | 是否启用语言 / 时区 / 路径本地信号打分 | +| `geoApis` | boce 单接口 | GeoIP 回退链,本地信号不足时二次确认 | +| `geoTimeoutMs` | `1500` | 单个 GeoIP 接口超时 | +| `probeUrl` | `null` | 镜像站可达性探测地址;传 `null` 不探测,按可达处理 | +| `probeTimeoutMs` | `1500` | 可达性探测超时 | +| `storageKey` | `'cn-site-redirect-preference'` | 偏好存储 key(兼容旧版写入的纯字符串值) | +| `rejectedTtlMs` | 30 天 | 用户拒绝后多久重新询问;`Infinity` 永久记住 | +| `confidentScore` | `3` | 本地信号达到该分数才直接提示,不再请求 GeoIP | +| `debugValue` | 无 | 设 `localStorage.DEBUG = debugValue` 强制走完整流程(含 localhost);省略禁用 | +| `disableSearchParam` | `'cn-redirect'` | URL 带 `?{param}=off` 时本次访问不参与判定 | + +## API + +`createMirrorRedirect(options)` 返回: + +- `getDecision(): Promise<'redirect' | 'prompt' | 'skip'>` +- `redirect(): void` +- `buildRedirectUrl(): string | null` +- `getPreference(): 'accepted' | 'rejected' | null` +- `setPreference(preference): void` +- `isMirrorHost(hostname): boolean` —— 主机是否属于镜像站部署(可配合 + localhost 判断用于备案展示) + +类型:`MirrorRedirectOptions` / `MirrorRedirect` / `MirrorRedirectDecision` / +`MirrorRedirectPreference` / `HostnameMatcher` / `GeoIpApi` diff --git a/src/mirror-redirect.ts b/src/mirror-redirect/index.ts similarity index 100% rename from src/mirror-redirect.ts rename to src/mirror-redirect/index.ts