@show-docs/docusaurus-theme-iframe 0.0.0-stage → 0.0.0

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
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # @show-docs/docusaurus-theme-iframe
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 把文档页整页替换成 iframe 的 Docusaurus 主题插件。frontmatter 写 `iframe:`、`scalar:`、`redoc:`
4
+ 或 `oas3:` 时,命中的页面保留站点 navbar,正文区换成一个 iframe。
5
+
6
+ `iframe:` 由本包直接处理,用来嵌外部页面;`scalar:` / `redoc:` / `oas3:` 分别由 theme-scalar、
7
+ theme-redoc、theme-stoplight 在框内渲染,本包负责外层套壳与跨 iframe 的地址、亮暗同步。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ pnpm add @show-docs/docusaurus-theme-iframe
13
+ ```
14
+
15
+ ## 使用
16
+
17
+ ### 注册插件
18
+
19
+ ```js
20
+ // docusaurus.config.mjs
21
+ export default {
22
+ plugins: ['@show-docs/docusaurus-theme-iframe']
23
+ };
24
+ ```
25
+
26
+ 使用 API 三把键时,同时注册对应的渲染包:
27
+
28
+ ```js
29
+ [
30
+ '@show-docs/docusaurus-theme-iframe',
31
+ '@show-docs/docusaurus-theme-stoplight',
32
+ '@show-docs/docusaurus-theme-redoc',
33
+ '@show-docs/docusaurus-theme-scalar'
34
+ ];
35
+ ```
36
+
37
+ ### 写 frontmatter
38
+
39
+ ```md
40
+ ---
41
+ title: 外部文档
42
+ iframe: https://example.com/handbook/
43
+ ---
44
+ ```
45
+
46
+ | 键 | 渲染方 | 值的含义 |
47
+ | -------- | --------------------------------------- | ---------------- |
48
+ | `iframe` | 本包 | 要嵌入的页面地址 |
49
+ | `scalar` | `@show-docs/docusaurus-theme-scalar` | OpenAPI 文档地址 |
50
+ | `redoc` | `@show-docs/docusaurus-theme-redoc` | OpenAPI 文档地址 |
51
+ | `oas3` | `@show-docs/docusaurus-theme-stoplight` | OpenAPI 文档地址 |
52
+
53
+ `iframe:` 的值原样使用;以 `/` 开头时按当前站点 origin 补成绝对地址。地址栏带
54
+ `?path=<相对路径>` 时,会在该地址之下再解析一层,用来切换外部站点的子页面。
55
+
56
+ ## 配置
57
+
58
+ 只有一个可选插件选项,用于覆盖本包判键的顺序和启用范围:
59
+
60
+ ```js
61
+ [
62
+ [
63
+ '@show-docs/docusaurus-theme-iframe',
64
+ {
65
+ themeConfig: {
66
+ apiFrame: {
67
+ order: [
68
+ '@show-docs/docusaurus-theme-redoc',
69
+ '@show-docs/docusaurus-theme-stoplight',
70
+ '@show-docs/docusaurus-theme-iframe'
71
+ ]
72
+ }
73
+ }
74
+ }
75
+ ]
76
+ ];
77
+ ```
78
+
79
+ | 选项 | 类型 | 默认值 | 说明 |
80
+ | ---------------------------- | ---------- | -------------------------------------- | ------------------------------------------------------------ |
81
+ | `themeConfig.apiFrame.order` | `string[]` | `scalar` → `redoc` → `oas3` → `iframe` | 判定顺序与启用范围;排在前面的层优先,未列出的层不参与判定。 |
82
+
83
+ 覆盖不会绕过「已安装」这一条:清单里列了但 `plugins` 里没装的层仍然不参与判定。同一页写了多
84
+ 把键时,按顺序取第一个命中的已安装层。开发模式下,写了键却没有层接收会在控制台给出告警。
85
+
86
+ ## 工作方式
87
+
88
+ - 命中时用 iframe 替换整个 `DocItem`:不渲染面包屑、正文、右侧目录和翻页,站点 navbar 保留。
89
+ - 页面占满 navbar 以下的一屏,并隐藏站点页脚;框的版式由四条层共用一份样式。
90
+ - 三个 API 包的框内页面(`/scalar-view`、`/redoc-view`、`/stoplight-view`)与本包通过
91
+ `postMessage` 同步地址栏 `#` 段和亮暗模式;`iframe:` 嵌外部站点时没有这条通道。
92
+ - 与 `theme-markmap` 同页使用且两把键都命中时,导图渲染在 iframe 上方。
93
+
94
+ ## 已知限制
95
+
96
+ - 外部站点设置了 `X-Frame-Options` 或 CSP `frame-ancestors` 时,iframe 区域会空白;这是浏览器
97
+ 拒绝嵌入,不是本插件报错。
98
+ - 外部 iframe 的内容与站点主题、亮暗模式、路由互不影响;跨源页面也没有可配合的代码。
99
+ - 命中页不渲染官方 `DocItem/Metadata`,浏览器标签页标题与 `<meta name="description">` 退回
100
+ 站点默认值,不读该页的 `title:` / `description:`。
101
+ - 承载 iframe 的容器会通过 `.container:has(iframe)` 去掉内边距;正文里手写的 iframe 同样命中
102
+ 这条样式。
103
+ - 对应插件未安装时,frontmatter 里的键不生效,页面按普通文档渲染。
104
+
105
+ ## 相关文档
106
+
107
+ - [维护者文档](./docs/internals.md):包内组成、编译方式、新增一条 API 层的步骤。
108
+ - [跨 iframe 协议](./docs/iframe-protocol.md):地址与亮暗同步的消息形状、两侧的监听。
109
+ - [iframe 示例](../../docs/iframe-demo.md)。
@@ -0,0 +1,38 @@
1
+ /* 「整页交给一个框」这一族四条层**共用**的那一格版式:类名由 `components/api-frame.tsx`
2
+ 同时挂在 `<iframe>` 上(`api-frame` 是这一族共用的那一个,注册表那条 `frameClass` 是各层
3
+ 专属的第二个类名,只属于一层的规则挂在那一个上)。
4
+
5
+ 定高取 `100vh - var(--ifm-navbar-height)`:滚动条长在**框内那份文档**上,那份文档没有
6
+ navbar,整屏都是它的,外层这一格给多高它就占多高,两层滚动容器各滚各的文档,互不打扰。
7
+ `display: block` 与 `border: 0` 收的是浏览器对 `<iframe>` 的两个默认(inline、带边框)。
8
+
9
+ 正文容器那一格由下面那条裸 `iframe` 规则负责(去内边距、放开最大宽度)—— 各层不再重复写
10
+ `.container:has(.X-frame)`。`max-width: 100%` 与 `unset` 在这里等价:那一格是
11
+ `.container` 自己,父链上没有别的宽度约束,两个值都落到「铺满」,所以只留一条。
12
+
13
+ 这条按「这一格里有 iframe」匹配 —— 正文里手写的 iframe 也会让承载它的容器去掉内边距,
14
+ 这是它本来就声明的全站边界,不是疏漏。
15
+
16
+ 页脚那一格同理是**这一族共有的**:框已经占满 navbar 以下那一屏,页脚留在下面只会被推出
17
+ 可视区,于是这一页多出一条只能滚到页脚的空白(配了 `themeConfig.footer` 才看得见,实测
18
+ 238px)。以前这条只写在 `theme-stoplight` 自己那份里、认的是 `stoplight-frame` 那个类名,
19
+ 所以 redoc / scalar / 外部 iframe 三条一律漏掉 —— 收进这一份、键在四把层都挂的
20
+ `api-frame` 上。Docusaurus 的 classic 布局里 `.footer` 是 `.main-wrapper` 的**紧邻后兄弟**,
21
+ 选择器就照这个形状写;认 `api-frame` 而不认裸 `iframe`,正文里手写的 iframe 不该顺带把
22
+ 整站页脚收掉。 */
23
+
24
+ .api-frame {
25
+ display: block;
26
+ border: 0;
27
+ width: 100%;
28
+ height: calc(100vh - var(--ifm-navbar-height));
29
+ }
30
+
31
+ .container:has(iframe) {
32
+ padding: 0 !important;
33
+ max-width: 100% !important;
34
+ }
35
+
36
+ .main-wrapper:has(.api-frame) + .footer {
37
+ display: none;
38
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ 外层文档页里那一个通用框:三条 API 层与 `iframe:` 那一层共用这一份渲染,形状由注册表里那一条
3
+ 决定(`lib/api-frame-registry.ts`)。
4
+
5
+ - `internal`:`src` 由 `frameViewSrc()` 拼(spec 编码进去、`hash` 不进去,服务端与客户端算出
6
+ 同一个字符串),深链那一段与亮暗靠 `useOuterFrameSync` 送(那条循环与踩过的坑在
7
+ `lib/frame-sync.ts` 与 `docs/iframe-protocol.md`)。
8
+ - `external`:框里是**外部站点**,跨源,没有那条通道;地址那三步在 `lib/frame-messages.ts` 的
9
+ `externalFrameURL()`,与框内取 spec 那条同形状,所以也在覆盖率红线里。
10
+
11
+ `<iframe>` 恒挂两个类名:共用的 `.api-frame`(尺寸与容器让位,四条层同一条式子)与注册表那条
12
+ `frameClass`(认「哪一层赢了」,只属于一层的规则挂它)。这一格只剩一个 `<iframe>` 与一个 hook,
13
+ 没有渲染器依赖,所以 `DocItem` 静态 import 它,不走 lazy/Suspense。
14
+ */
15
+
16
+ import './api-frame.css';
17
+
18
+ import { useMemo, useRef } from 'react';
19
+ import type { ReactNode } from 'react';
20
+
21
+ import { useLocation } from '@docusaurus/router';
22
+ import { useColorMode } from '@docusaurus/theme-common';
23
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
24
+ import useIsBrowser from '@docusaurus/useIsBrowser';
25
+
26
+ import { apiFrameMessages } from '../lib/api-frame-messages.ts';
27
+ import type { ApiFrameEntry } from '../lib/api-frame-registry.ts';
28
+ import {
29
+ externalFrameURL,
30
+ frameViewSrc,
31
+ type FrameColorMode,
32
+ } from '../lib/frame-messages.ts';
33
+ import { useOuterFrameSync } from '../lib/frame-sync.ts';
34
+
35
+ type Props = {
36
+ entry: ApiFrameEntry;
37
+ value: string;
38
+ /** 页面自己的 `frontMatter.title`;有它就用它当框的无障碍标题,注册表那条 `title` 只是兜底。 */
39
+ frontMatterTitle?: unknown;
40
+ };
41
+
42
+ export function ApiFrame({ entry, value, frontMatterTitle }: Props): ReactNode {
43
+ const frameClass = `api-frame ${entry.frameClass}`;
44
+ const title =
45
+ typeof frontMatterTitle === 'string' && frontMatterTitle !== ''
46
+ ? frontMatterTitle
47
+ : entry.title;
48
+
49
+ return entry.kind === 'external' ? (
50
+ <ExternalFrame frameClass={frameClass} src={value} title={title} />
51
+ ) : (
52
+ <InternalFrame
53
+ entry={entry}
54
+ frameClass={frameClass}
55
+ specUrl={value}
56
+ title={title}
57
+ />
58
+ );
59
+ }
60
+
61
+ function InternalFrame({
62
+ entry,
63
+ frameClass,
64
+ specUrl,
65
+ title,
66
+ }: {
67
+ entry: Extract<ApiFrameEntry, { kind: 'internal' }>;
68
+ frameClass: string;
69
+ specUrl: string;
70
+ title: string;
71
+ }): ReactNode {
72
+ const { siteConfig } = useDocusaurusContext();
73
+ const { colorMode } = useColorMode();
74
+ const frameRef = useRef<HTMLIFrameElement>(null);
75
+
76
+ const src = frameViewSrc({
77
+ baseUrl: siteConfig.baseUrl,
78
+ route: entry.route,
79
+ specParam: entry.specParam,
80
+ specUrl,
81
+ });
82
+
83
+ useOuterFrameSync({
84
+ colorMode: colorMode as FrameColorMode,
85
+ frameRef,
86
+ messages: apiFrameMessages(entry.namespace),
87
+ });
88
+
89
+ return (
90
+ <iframe ref={frameRef} className={frameClass} src={src} title={title} />
91
+ );
92
+ }
93
+
94
+ function ExternalFrame({
95
+ frameClass,
96
+ src,
97
+ title,
98
+ }: {
99
+ frameClass: string;
100
+ src: string;
101
+ title: string;
102
+ }): ReactNode {
103
+ const { search } = useLocation();
104
+ const isBrowser = useIsBrowser();
105
+
106
+ // 服务端渲染阶段拿不到 `window`,那时 url 就是 frontmatter 里那个原值。
107
+ const url = useMemo(
108
+ () =>
109
+ isBrowser
110
+ ? externalFrameURL({ src, search, origin: window.location.origin })
111
+ : src,
112
+ [isBrowser, search, src],
113
+ );
114
+
115
+ return <iframe className={frameClass} src={url} title={title} />;
116
+ }
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from '@docusaurus/types';
2
+ export default function theme(): Plugin;
package/dist/index.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ // 包出口是 `dist/`,所以这里的路径都相对**产物**目录算,不是相对 `src/`。
4
+ const thisDir = dirname(fileURLToPath(import.meta.url));
5
+ export default function theme() {
6
+ return {
7
+ name: '@show-docs/docusaurus-theme-iframe',
8
+ // 主题层与 `components/` 是**源码**,站在包根上,不经 `tsc`:用本包的 Docusaurus
9
+ // 项目会用自己的 babel 编译它们。本包要编译的只有这一份入口。
10
+ getThemePath() {
11
+ return resolve(thisDir, '../theme');
12
+ },
13
+ // 层里放的是 `.tsx`,这条给 `docusaurus swizzle` 用
14
+ getTypeScriptThemePath() {
15
+ return resolve(thisDir, '../theme');
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ 本次渲染该按什么顺序判这几把键 —— 注册表给数据,站点给裁决权,**只有一条规则**:
3
+
4
+ - 参与判定的层 = **装了的层**(`plugins` 里有其包名、或其包目录内入口路径的那一项);
5
+ - 顺序默认 = 注册表的清单顺序,站点可在本层插件选项
6
+ `['@show-docs/docusaurus-theme-iframe', { themeConfig: { apiFrame: { order: [...] } } }]`
7
+ 里用命名空间数组覆盖顺序与子集。**覆盖不放宽装机过滤**:列了没装的层照样不判,否则
8
+ 「指向不存在路由的框」这个失败模式会在覆盖下被重新引入。
9
+
10
+ 为什么读的是**原始 `plugins`** 而不是 `themeConfig`、那四种配置写法各怎么认,见
11
+ `docs/iframe-protocol.md` 的「注册表与分发」一节。这一格吃的是 Docusaurus 的**实现细节**
12
+ (浏览器配置序列化保留原始形状)而非公开承诺,`demo/scripts/verify-chain.mts` 第 3 节钉着它。
13
+
14
+ 落空的键(写了、层没装或被覆盖排除)进 `warnings`:装机过滤把旧的「照样命中、渲染一个坏框」
15
+ 换成「页面看着正常但键没生效」,所以手误不能静默 —— `DocItem` 在开发模式把那份清单交给
16
+ `warnFrameDistribution()` 打到控制台,构建产物不打。
17
+
18
+ 这里只回答顺序与启用;route、类名、参数名那些数据在 `api-frame-registry.ts`。
19
+ 判定与打日志都不引 React,单测与 `DocItem` 引这同一份。
20
+ */
21
+
22
+ import {
23
+ apiFrameByNamespace,
24
+ apiFrameEntriesOrdered,
25
+ type ApiFrameEntry,
26
+ } from './api-frame-registry.ts';
27
+
28
+ /** 本层的包名 = `plugins` 里那一项的名字 = 注册表里 external 条目的命名空间。 */
29
+ const IFRAME_NAMESPACE = '@show-docs/docusaurus-theme-iframe';
30
+
31
+ /** 覆盖清单的载体:插件选项那一格(`{ themeConfig: { apiFrame: { order } } }`)。 */
32
+ type ApiFramePluginOptions = {
33
+ themeConfig?: { apiFrame?: { order?: string[] } };
34
+ };
35
+
36
+ /**
37
+ 从 `siteConfig.plugins` 的一项里取「名字 + 选项」。浏览器侧拿到的是**原始配置形状**,四种写法:
38
+ - 字符串(裸包名或路径 —— 名字与层的对应关系见 `slotIsNamespace`);
39
+ - `[包名, 选项]`;
40
+ - `[路径, 选项]`;
41
+ - `{name}` 对象(归一化形状,兼容 `siteVersion` 那类元数据)。
42
+ */
43
+ function readPluginSlot(plugin: unknown): {
44
+ name: string | undefined;
45
+ options: ApiFramePluginOptions | undefined;
46
+ } {
47
+ if (typeof plugin === 'string') {
48
+ return { name: plugin, options: undefined };
49
+ }
50
+ if (Array.isArray(plugin)) {
51
+ return {
52
+ name: typeof plugin[0] === 'string' ? plugin[0] : undefined,
53
+ options: plugin[1] as ApiFramePluginOptions | undefined,
54
+ };
55
+ }
56
+ return {
57
+ name:
58
+ typeof plugin === 'object' && plugin !== null
59
+ ? (plugin as { name?: string }).name
60
+ : undefined,
61
+ options: undefined,
62
+ };
63
+ }
64
+
65
+ /**
66
+ 一个槽位的名格是不是这条层:裸包名相等,或**该包目录内任何入口**的绝对路径
67
+ (`docusaurus-theme-x` 与去前缀的 `theme-x` 两种目录段都认 —— 按包目录认,不钉具体
68
+ 入口文件名;钉文件名会把「产物改名」变成静默失效)。装机判定与覆盖槽位识别共用这一条,
69
+ 两边不存在「一层认路径、另一层只认包名」的不对称。
70
+ */
71
+ function slotIsNamespace(name: string, namespace: string): boolean {
72
+ if (name === namespace) {
73
+ return true;
74
+ }
75
+ const pathForm = name.replaceAll('\\', '/');
76
+ // 去掉 `@scope/` 前缀:没有斜杠时 lastIndexOf 给 -1,slice(0) 就是原样 —— 写成一条无分支的
77
+ // 算式,因为 `split('/').pop() ?? namespace` 那个 `??` 的兜底只有类型层面存在,运行时永远
78
+ // 走不到,是量不到的分支。
79
+ const unscoped = namespace.slice(namespace.lastIndexOf('/') + 1);
80
+ const shortForm = unscoped.replace(/^docusaurus-/, '');
81
+ return (
82
+ pathForm.includes(`/${unscoped}/`) || pathForm.includes(`/${shortForm}/`)
83
+ );
84
+ }
85
+
86
+ /** 站点给本层的那份覆盖清单;顺带认出「写了覆盖但挂的槽位认不出本层」这一种手误。 */
87
+ function findOverrideOrder(
88
+ slots: readonly {
89
+ name: string | undefined;
90
+ options: ApiFramePluginOptions | undefined;
91
+ }[],
92
+ warnings: string[],
93
+ ): string[] | undefined {
94
+ let orphanOrder: string[] | undefined;
95
+ for (const slot of slots) {
96
+ const order = slot.options?.themeConfig?.apiFrame?.order;
97
+ if (order === undefined) {
98
+ continue;
99
+ }
100
+ if (
101
+ slot.name !== undefined &&
102
+ slotIsNamespace(slot.name, IFRAME_NAMESPACE)
103
+ ) {
104
+ return order;
105
+ }
106
+ orphanOrder = order;
107
+ }
108
+ if (orphanOrder !== undefined) {
109
+ warnings.push(
110
+ `\`plugins\` 里有一项写了 \`${IFRAME_NAMESPACE}\` 的 \`themeConfig.apiFrame.order\` 覆盖,但它挂的那个插件名没被认成本层(路径写法要落在本包目录内)。覆盖未生效,仍按默认顺序判定。`,
111
+ );
112
+ }
113
+ return undefined;
114
+ }
115
+
116
+ /** 判定清单 + 需要提示给站点作者的落空原因。 */
117
+ export type FrameDistribution = {
118
+ entries: readonly ApiFrameEntry[];
119
+ /** 每条都是「配置写了但没生效」的说明;页面级那几条带页面地址(去重按文本判等,不带地址会吞掉后面同毛病的页)。由 `DocItem` 在开发模式交给 `warnFrameDistribution`。 */
120
+ warnings: readonly string[];
121
+ };
122
+
123
+ export function resolveFrameDistribution(
124
+ plugins: readonly unknown[],
125
+ frontMatter: Record<string, unknown> | undefined,
126
+ pagePath?: string,
127
+ ): FrameDistribution {
128
+ const slots = plugins.map((plugin) => readPluginSlot(plugin));
129
+ const isInstalled = (namespace: string): boolean =>
130
+ slots.some(
131
+ (slot) =>
132
+ slot.name !== undefined && slotIsNamespace(slot.name, namespace),
133
+ );
134
+
135
+ const warnings: string[] = [];
136
+ const override = findOverrideOrder(slots, warnings);
137
+
138
+ let entries: ApiFrameEntry[];
139
+ if (override === undefined) {
140
+ entries = apiFrameEntriesOrdered().filter((entry) =>
141
+ isInstalled(entry.namespace),
142
+ );
143
+ } else {
144
+ entries = [];
145
+ for (const namespace of override) {
146
+ const entry = apiFrameByNamespace(namespace);
147
+ if (entry === undefined) {
148
+ warnings.push(
149
+ `覆盖清单里的 \`${namespace}\` 不在注册表内(拼错?),这一项被丢掉。`,
150
+ );
151
+ continue;
152
+ }
153
+ if (!isInstalled(namespace)) {
154
+ warnings.push(
155
+ `覆盖清单里的 \`${namespace}\` 没装(\`plugins\` 里没有这个名字),装机过滤对覆盖同样生效,这一层不参与判定。`,
156
+ );
157
+ continue;
158
+ }
159
+ if (!entries.includes(entry)) {
160
+ entries.push(entry);
161
+ }
162
+ }
163
+ }
164
+
165
+ // 键写了、层却没参与判定 —— 交回普通文档页之前,把原因留进 warnings(为什么要带页面地址见上面)。
166
+ for (const entry of apiFrameEntriesOrdered()) {
167
+ if (entries.includes(entry)) {
168
+ continue;
169
+ }
170
+ const value = frontMatter?.[entry.frontmatterKey];
171
+ if (typeof value === 'string' && value !== '') {
172
+ warnings.push(
173
+ `${pagePath ?? '这一页'} 写了 \`${entry.frontmatterKey}:\`,但 \`${entry.namespace}\` 没参与判定(${
174
+ isInstalled(entry.namespace) ? '被站点的覆盖清单排除' : '那个插件没装'
175
+ }),交回普通文档页。`,
176
+ );
177
+ }
178
+ }
179
+
180
+ return { entries, warnings };
181
+ }
182
+
183
+ /** 每种文本只打一次:配置级那几条(覆盖拼错、挂错槽位)每翻一页都会重算出来,逐页刷屏没有增量信息。 */
184
+ const shownWarnings = new Set<string>();
185
+
186
+ /** 把落空原因打到控制台:`DocItem` 只在开发模式调这一格,构建产物里不打。 */
187
+ export function warnFrameDistribution(warnings: readonly string[]): void {
188
+ for (const warning of warnings) {
189
+ if (shownWarnings.has(warning)) {
190
+ continue;
191
+ }
192
+ shownWarnings.add(warning);
193
+ console.warn(`[theme-iframe] ${warning}`);
194
+ }
195
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ 注册表那条层的消息约定,带缓存:`createFrameMessages` 每个命名空间只造一次,
3
+ 反复取是同一个引用(`useOuterFrameSync` / `useInnerFrameSync` 把它放在 effect 依赖里,
4
+ 引用换了就多一轮拆装监听器)。
5
+
6
+ 薄薄一层为什么单独成文件:`api-frame-registry.ts` 保持**纯数据**,配置期(node-bundle,
7
+ 没有 DOM)能类型检查;这一份引 `frame-messages.ts` 的形状,只给浏览器侧用。
8
+ */
9
+
10
+ import type { FrameMessages } from './frame-messages.ts';
11
+ import { createFrameMessages } from './frame-messages.ts';
12
+
13
+ const frameMessagesCache = new Map<string, FrameMessages>();
14
+
15
+ export function apiFrameMessages(namespace: string): FrameMessages {
16
+ const cached = frameMessagesCache.get(namespace);
17
+ if (cached) {
18
+ return cached;
19
+ }
20
+ const created = createFrameMessages(namespace);
21
+ frameMessagesCache.set(namespace, created);
22
+ return created;
23
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ 「整页交给一个框」这一族层的注册表:一条层 = 一个条目,四条都在这一份里(`scalar` / `redoc`
3
+ / `stoplight` / `iframe`)。这里是层的**数据**来源(route、参数名、类名、命名空间),
4
+ 纯数据 + 纯函数、不引 React 不引浏览器:配置期(各包入口经工厂)与浏览器期(`DocItem`、框内
5
+ 那页)引同一份源码,那些名字不再像以前那样在编译边界两侧各写一份常量。带 `FrameMessages`
6
+ 的那份缓存归 `api-frame-messages.ts` —— 这一份必须能过 node-bundle(没有 DOM)的类型检查。
7
+
8
+ 判定的先后 = 这份清单的顺序(**默认值**,站点可覆盖、没装的层不判 —— 那一步在
9
+ `api-frame-entries.ts`),语义与原层栈一致:越靠外的层先判自己的键。一页同写两把键时,
10
+ 排在前面那条赢。
11
+ */
12
+
13
+ /** 条目共有的形状;两种 `kind` 差的格见下面两个类型。 */
14
+ type ApiFrameEntryBase = {
15
+ /** 包名,同时是三条 postMessage 的命名空间与插件名。 */
16
+ namespace: string;
17
+ /** md frontmatter 里那条键,值就是框要渲染的地址。 */
18
+ frontmatterKey: string;
19
+ /**
20
+ 外层 `<iframe>` 的**第二个**类名(第一个是共用的 `.api-frame`,尺寸与容器让位都在那一条上)。
21
+ 这一格现在只用来认「这一格是哪一层赢的」,挂只属于一层的规则(如 stoplight 收页脚)。
22
+ */
23
+ frameClass: string;
24
+ /**
25
+ 外层 `<iframe>` 的 `title` **兜底值**:页面写了 `frontMatter.title` 时优先用页面自己的
26
+ (`ApiFrame` 那一步),这一格是缺它时的可读名字。
27
+ */
28
+ title: string;
29
+ };
30
+
31
+ /** 框里是本包自己那条插件页面(同源),深链与亮暗靠 `frame-sync.ts` 那两条消息循环对齐。 */
32
+ export type ApiFrameEntryInternal = ApiFrameEntryBase & {
33
+ kind: 'internal';
34
+ /** 框内那条页面的路径名(不带 `baseUrl`),由本包入口经 `addRoute` 注册。 */
35
+ route: string;
36
+ /** `component` 写 `@theme/` 下的别名(如 `'@theme/ScalarPage'`),经 `getThemePath()` 挂层。 */
37
+ pageComponent: string;
38
+ /** 框内地址里带 spec 地址的那一条查询参数名。 */
39
+ specParam: string;
40
+ };
41
+
42
+ /** 框里是**外部站点**(跨源):没有那条通道,地址算法归用它的层自己管。 */
43
+ export type ApiFrameEntryExternal = ApiFrameEntryBase & {
44
+ kind: 'external';
45
+ };
46
+
47
+ export type ApiFrameEntry = ApiFrameEntryInternal | ApiFrameEntryExternal;
48
+
49
+ /** 这一族的清单(顺序 = 判定优先级,见文件头)。新增一条层的做法见 `docs/iframe-protocol.md`。 */
50
+ const apiFrameEntries: readonly ApiFrameEntry[] = [
51
+ {
52
+ kind: 'internal',
53
+ namespace: '@show-docs/docusaurus-theme-scalar',
54
+ frontmatterKey: 'scalar',
55
+ route: 'scalar-view',
56
+ pageComponent: '@theme/ScalarPage',
57
+ specParam: 'src',
58
+ frameClass: 'scalar-frame',
59
+ title: 'API 文档',
60
+ },
61
+ {
62
+ kind: 'internal',
63
+ namespace: '@show-docs/docusaurus-theme-redoc',
64
+ frontmatterKey: 'redoc',
65
+ route: 'redoc-view',
66
+ pageComponent: '@theme/RedocPage',
67
+ specParam: 'src',
68
+ frameClass: 'redoc-frame',
69
+ title: 'API 文档',
70
+ },
71
+ {
72
+ kind: 'internal',
73
+ namespace: '@show-docs/docusaurus-theme-stoplight',
74
+ frontmatterKey: 'oas3',
75
+ route: 'stoplight-view',
76
+ pageComponent: '@theme/StoplightPage',
77
+ specParam: 'src',
78
+ frameClass: 'stoplight-frame',
79
+ title: 'API 文档',
80
+ },
81
+ {
82
+ kind: 'external',
83
+ namespace: '@show-docs/docusaurus-theme-iframe',
84
+ frontmatterKey: 'iframe',
85
+ frameClass: 'iframe',
86
+ title: '其他文档',
87
+ },
88
+ ];
89
+
90
+ /** 按注册顺序遍历全部条目(`DocItem` 判键用的就是这个顺序)。 */
91
+ export function apiFrameEntriesOrdered(): readonly ApiFrameEntry[] {
92
+ return apiFrameEntries;
93
+ }
94
+
95
+ /** 按包名取那一条层。取不到说明注册表里没有这一族 —— 调用方(入口工厂)应当立刻失败,
96
+ 而不是带着一条没人注册的路由继续跑。 */
97
+ export function apiFrameByNamespace(
98
+ namespace: string,
99
+ ): ApiFrameEntry | undefined {
100
+ return apiFrameEntries.find((entry) => entry.namespace === namespace);
101
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ 用 theme-iframe 承接外层套壳的**入口工厂**:三条 API 层的 `src/index.mts` 只需要带着自己的
3
+ 包名、产物目录与 webpack 差异来调它。
4
+
5
+ 它做三件事:`getThemePath()` 挂各包自己的层目录(里面只剩框内那条页面)、
6
+ `contentLoaded` 按注册表把 `<route>` 页面注册上、其余一概不揽 —— webpack 的差异
7
+ (Redoc 的 `worker_threads` 空模块、Elements 的样式表隔离与 polyfill)由调用方经
8
+ `configureWebpack` 自己传,本工厂只负责把它接进 Plugin 形状。
9
+
10
+ **配置期代码**:跑在 Node 里(jiti 直接吃这份 `.mts` 源码,扩展名按上游 lint preset 的口径
11
+ 标 Node 环境),不引 React、不引 DOM,
12
+ 类型引 `@docusaurus/types` 用 `import type`,浏览器那一份(`api-frame-registry.ts`)不会经过这里。
13
+ */
14
+
15
+ import { existsSync } from 'node:fs';
16
+ import { dirname, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ import type { LoadContext, Plugin } from '@docusaurus/types';
20
+
21
+ import {
22
+ apiFrameByNamespace,
23
+ type ApiFrameEntryInternal,
24
+ } from './api-frame-registry.ts';
25
+
26
+ export type ApiFrameThemeOptions = {
27
+ /** 包名,同时是注册表的键;表里没有这一族就直接失败。 */
28
+ namespace: string;
29
+ /**
30
+ 入口产物所在目录(`dist/`):`getThemePath()` 要按它算 `../theme`。
31
+ 各包传自己那份 `import.meta.url`,`file:` 到路径的这一跳由工厂代做 ——
32
+ 「产物在 dist/」这个假设收敛到一处,校验(下面 `existsSync`)也才有唯一的落点。
33
+ */
34
+ selfEntryUrl: string;
35
+ /** 本包独有的 webpack 差异;没有就不传(Scalar 就是没有)。 */
36
+ configureWebpack?: Plugin['configureWebpack'];
37
+ };
38
+
39
+ export function createApiFrameTheme(
40
+ context: LoadContext,
41
+ options: ApiFrameThemeOptions,
42
+ ): Plugin {
43
+ const { namespace, selfEntryUrl, configureWebpack } = options;
44
+
45
+ const entry = apiFrameByNamespace(namespace);
46
+ if (entry === undefined || entry.kind !== 'internal') {
47
+ throw new Error(
48
+ `@show-docs/docusaurus-theme-iframe 的注册表里没有 internal 层 ${namespace}:` +
49
+ '外层套壳由该注册表承接,新增层要先在那里登记一条。',
50
+ );
51
+ }
52
+ const view = entry satisfies ApiFrameEntryInternal;
53
+
54
+ // 层目录按「产物在 dist/、theme/ 站在包根」这一格算出来。它要是静默不存在,
55
+ // 症状隔着一层:@theme/XxxPage 解析不到、报错点在建站点那一步。当场验一次,
56
+ // 报**实际解析到的路径**——构建输出目录一改,这里立刻以抛错点名,而不是留个悬空别名。
57
+ const themePath = resolve(dirname(fileURLToPath(selfEntryUrl)), '../theme');
58
+ if (!existsSync(themePath)) {
59
+ throw new Error(
60
+ `${namespace} 的主题层目录不存在:${themePath}` +
61
+ '(由入口产物所在目录算出;若构建输出目录不再是 dist/,上一句 resolve 就是要改的地方)。',
62
+ );
63
+ }
64
+
65
+ return {
66
+ name: namespace,
67
+ // 主题层与 `components/` 是源码,站在包根上,不经 tsc:用本包的站点编译它们。
68
+ getThemePath() {
69
+ return themePath;
70
+ },
71
+ ...(configureWebpack !== undefined && { configureWebpack }),
72
+ contentLoaded({ actions }) {
73
+ // 框内那条独立页面:不是文档页、没有 frontmatter 可判,走官方插件路由 API。
74
+ // `component` 写 `@theme/` 别名 —— 打包器按别名找得到各包挂进层栈的那份页面,
75
+ // 站点那边的 swizzle 也还能覆写它。
76
+ actions.addRoute({
77
+ path: `${context.siteConfig.baseUrl.replace(/\/$/, '')}/${view.route}`,
78
+ component: view.pageComponent,
79
+ exact: true,
80
+ });
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ 框内那一页的正文:三条 API 层共用这一份工厂 —— 外面那个框收成 `theme/DocItem` 一份套壳,
3
+ 框里这份收成这里。三处逐字相同的接线只留一份:按命名空间从注册表取本层那条 `internal` 条目
4
+ (取不到就立刻抛)、按包名造那三条 postMessage 的名字、在工厂外面造好「把这一份文档的 `#`
5
+ 那一段报给外层」的函数(每次渲染换引用会让那条 effect 重装监听器)。
6
+
7
+ 各家差异只剩两格,都由调用方给:
8
+
9
+ - `view`:这一层真正渲染文档的那份接线(各包 `components/` 里那份 UI),形状恒为 `url` +
10
+ `onRouteChange`。
11
+ - 亮暗落到这一份文档的方式:`modeAttribute`(Redoc / Elements 写成 `<html>` 上一条属性)或
12
+ `onMode`(Scalar 换 `<body>` 上互斥的两个类名)。两格互斥,形状就是 `useInnerFrameSync`
13
+ 那一条,本层不另设一套。
14
+
15
+ 两个方向怎么对齐、各家路由踩过的那些坑,记在 `docs/iframe-protocol.md` 与各层 `docs/internals.md`。
16
+
17
+ ## 产出的那一页是什么形状
18
+
19
+ 各包 `theme/XxxPage/index.tsx` 就是这份工厂的返回值,由本层插件路由(`addRoute` 注册的
20
+ `@theme/XxxPage`)挂上。它**不是文档页**:frontmatter、面包屑、正文、目录、翻页都没有,整份
21
+ 文档就是给 iframe 装的那一格 —— 渲染器的运行时代码、它往 `<head>` 插的那张样式表、它的
22
+ localStorage 与它自己的路由,全关在这份文档里,站点的文档页一行都碰不到。
23
+
24
+ 它也**不套** `@theme/Layout/Provider`:为什么不靠同源那条 localStorage 跟着站点开关翻,见
25
+ `docs/iframe-protocol.md` 的「为什么不用框架的那条存储同步」一节。
26
+
27
+ 渲染只在浏览器里进行(`useIsBrowser` 那一格):spec 地址在地址栏里,那份数据服务端根本没有,
28
+ 而渲染器在没有 DOM 的地方一跑就炸。各包 UI 组件因此不需要自己再挡一遍。
29
+ */
30
+
31
+ import type { ComponentType, ReactNode } from 'react';
32
+
33
+ import useIsBrowser from '@docusaurus/useIsBrowser';
34
+
35
+ import { apiFrameMessages } from './api-frame-messages.ts';
36
+ import { apiFrameByNamespace } from './api-frame-registry.ts';
37
+ import { specUrlFromSearch } from './frame-messages.ts';
38
+ import {
39
+ createRouteReporter,
40
+ useInnerFrameSync,
41
+ type InnerFrameSyncOptions,
42
+ } from './frame-sync.ts';
43
+
44
+ /** 三份渲染接线的共同形状:文档地址,加上「框内换了接口,报给外层」那一句。 */
45
+ export type ApiFrameViewProps = {
46
+ /** 选中了哪条接口,报给框外那一份文档(就是下面那份 `createRouteReporter` 造的函数)。 */
47
+ onRouteChange: (hash: string) => void;
48
+ /** 要渲染的那份 OpenAPI 文档的地址(从框内那条地址的 `src` 参数里解出来)。 */
49
+ url: string;
50
+ };
51
+
52
+ /** `useInnerFrameSync` 的选项去掉 `messages`(那一份由命名空间造,不让调用方再写一遍)。 */
53
+ type WithoutMessages<T> = T extends InnerFrameSyncOptions
54
+ ? Omit<T, 'messages'>
55
+ : never;
56
+
57
+ export type ApiFrameViewConfig = WithoutMessages<InnerFrameSyncOptions> & {
58
+ /** 本层的包名:注册表里那一条的键,同时是三条 postMessage 的命名空间。 */
59
+ namespace: string;
60
+ /** 这一层渲染文档的那份接线(各包 `components/` 里那份 UI 组件)。 */
61
+ view: ComponentType<ApiFrameViewProps>;
62
+ };
63
+
64
+ /**
65
+ 造出框内那一页:接住外层送来的 `route` 与 `mode`,按地址里的 spec 参数渲染,
66
+ 并把渲染器换过的那一段报回外层。
67
+
68
+ 地址里那一段缺着、或写成非 http(s) 的,这一格什么都不渲染 —— 那条页面是条公开地址,
69
+ 别人直接打开它时,渲染器自己那份「加载失败」摆在正文区,观感反而更像坏了。
70
+ */
71
+ export function createApiFrameView(config: ApiFrameViewConfig): ComponentType {
72
+ const { namespace, view: View, ...syncOptions } = config;
73
+
74
+ const entry = apiFrameByNamespace(namespace);
75
+ if (entry === undefined || entry.kind !== 'internal') {
76
+ throw new Error(`注册表里缺 ${namespace}`);
77
+ }
78
+
79
+ const messages = apiFrameMessages(namespace);
80
+ const reportRouteToParent = createRouteReporter(messages);
81
+
82
+ return function ApiFrameView(): ReactNode {
83
+ const isBrowser = useIsBrowser();
84
+
85
+ useInnerFrameSync({ ...syncOptions, messages });
86
+
87
+ if (!isBrowser) {
88
+ return null;
89
+ }
90
+
91
+ const url = specUrlFromSearch(
92
+ window.location.search,
93
+ window.location.origin,
94
+ entry.specParam,
95
+ );
96
+
97
+ return url === undefined ? null : (
98
+ <View onRouteChange={reportRouteToParent} url={url} />
99
+ );
100
+ };
101
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ 外层文档页与框内那一页之间的约定:iframe 的地址怎么拼、那三条 postMessage 长什么样。
3
+
4
+ 两边都是站点编译的本仓代码,跑在**同一个源**里(框内那一页是站点自己的一条插件路由),所以这套
5
+ 约定不需要跨源信任;每条消息仍要过 {@link isTrustedMessage} 那道校验 —— 为的是别人往这一页
6
+ postMessage 时不会被当成自家的,而不是为了防自己。
7
+
8
+ 这一份只有**约定与形状**,没有 React、也没有 DOM;两条消息循环在 `frame-sync.ts`,框那一格与
9
+ 它的共用版式在 `components/api-frame.tsx` 与 `components/api-frame.css`。
10
+ */
11
+
12
+ /** 亮暗那一格的取值。形状与站点的开关相同,但**不引**它的类型:那条属性由外层送进来。 */
13
+ export type FrameColorMode = 'light' | 'dark';
14
+
15
+ export type FrameRouteMessage = { type: string; hash: string };
16
+ export type FrameReadyMessage = { type: string };
17
+ export type FrameModeMessage = {
18
+ type: string;
19
+ mode: FrameColorMode;
20
+ };
21
+
22
+ /** 一个命名空间对应的一份消息约定,见 {@link createFrameMessages}。 */
23
+ export type FrameMessages = {
24
+ /** 外层 → 框内:外层地址栏里 `#` 那一段(选中了哪条接口)。 */
25
+ route(hash: string): FrameRouteMessage;
26
+ /** 框内 → 外层:框里那份文档起来了,向外层要地址与模式。 */
27
+ ready(): FrameReadyMessage;
28
+ /** 外层 → 框内:站点当前是亮色还是暗色。 */
29
+ mode(mode: FrameColorMode): FrameModeMessage;
30
+ /** 收到的数据是不是那一条接口地址(只看形状,来源由 {@link isTrustedMessage} 判)。 */
31
+ isRoute(data: unknown): data is FrameRouteMessage;
32
+ /** 收到的数据是不是「框里起来了」那一条(只看形状)。 */
33
+ isReady(data: unknown): data is FrameReadyMessage;
34
+ /** 收到的数据是不是「站点换亮暗了」那一条(只看形状)。 */
35
+ isMode(data: unknown): data is FrameModeMessage;
36
+ };
37
+
38
+ /**
39
+ 按命名空间造一份消息约定。
40
+
41
+ 命名空间取用它的主题包的包名(例如 `@show-docs/docusaurus-theme-redoc`),三条 `type`
42
+ 就是 `<命名空间>/route`、`/ready`、`/mode`。三条层各用各的命名空间,因此**不会**互相
43
+ 认到对方的消息 —— 同一站点同时挂三份文档时,这一格是必需的。
44
+ */
45
+ export function createFrameMessages(namespace: string): FrameMessages {
46
+ const routeType = `${namespace}/route`;
47
+ const readyType = `${namespace}/ready`;
48
+ const modeType = `${namespace}/mode`;
49
+
50
+ return {
51
+ route: (hash) => ({ type: routeType, hash }),
52
+ ready: () => ({ type: readyType }),
53
+ mode: (mode) => ({ type: modeType, mode }),
54
+
55
+ isReady: (data): data is FrameReadyMessage =>
56
+ typeof data === 'object' &&
57
+ data !== null &&
58
+ (data as Partial<FrameReadyMessage>).type === readyType,
59
+
60
+ // `hash` 只认空串与 `#` 开头的那些:拿到别的东西就当不是自家消息,
61
+ // 而不是照原样往地址里塞。
62
+ isRoute: (data): data is FrameRouteMessage => {
63
+ if (typeof data !== 'object' || data === null) {
64
+ return false;
65
+ }
66
+ const message = data as Partial<FrameRouteMessage>;
67
+ return (
68
+ message.type === routeType &&
69
+ typeof message.hash === 'string' &&
70
+ (message.hash === '' || message.hash.startsWith('#'))
71
+ );
72
+ },
73
+
74
+ // 亮暗只认这两个值:地址栏与第三方脚本都能往这一页送形状对上的对象,取值不在这两格
75
+ // 就不往 `<html>` 上写。
76
+ isMode: (data): data is FrameModeMessage => {
77
+ if (typeof data !== 'object' || data === null) {
78
+ return false;
79
+ }
80
+ const message = data as Partial<FrameModeMessage>;
81
+ return (
82
+ message.type === modeType &&
83
+ (message.mode === 'light' || message.mode === 'dark')
84
+ );
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ 拼框内那一页的地址。
91
+
92
+ `specUrl` 编码进去,`hash` **不**放进去:iframe 的 `src` 因此只跟 frontmatter 走,服务端与
93
+ 客户端算出的完全一样,两边不会在 `src` 这一格上对不上。深链那一段与亮暗模式都靠消息送,
94
+ 见 {@link createFrameMessages}。
95
+ */
96
+ export function frameViewSrc(options: {
97
+ baseUrl: string;
98
+ route: string;
99
+ specParam: string;
100
+ specUrl: string;
101
+ }): string {
102
+ const query = new URLSearchParams({ [options.specParam]: options.specUrl });
103
+ return `${options.baseUrl}${options.route}?${query}`;
104
+ }
105
+
106
+ /**
107
+ 从框内那一页的地址里取出要渲染的 spec 地址。
108
+
109
+ 只认能解析成 `http:` / `https:` 的:这一格来自地址栏,任何人都能手工拼一条
110
+ `/redoc-view?src=javascript:…` 那样的地址,而渲染器拿到的是「去取这个地址」的指令,
111
+ 不该由本层替它把非 URL 的东西喂进去。解析结果恒为绝对地址,`/openapi.yaml` 这种相对写法
112
+ 按 `base`(调用方给 `window.location.origin`)展开。
113
+ */
114
+ export function specUrlFromSearch(
115
+ search: string,
116
+ base: string,
117
+ specParam: string,
118
+ ): string | undefined {
119
+ const raw = new URLSearchParams(search).get(specParam);
120
+ if (raw === null) {
121
+ return undefined;
122
+ }
123
+ try {
124
+ const url = new URL(raw, base);
125
+ return url.protocol === 'http:' || url.protocol === 'https:'
126
+ ? url.href
127
+ : undefined;
128
+ } catch {
129
+ // `base` 本身不是合法 URL 时才走到这里(拼错的相对地址在 `new URL` 那一格抛)
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ /**
135
+ `iframe:` 那一层嵌**外部站点**时,frontmatter 那个值要落成的最终地址:`/` 打头补上 origin、
136
+ `?path=` 再解一层,两格都没有就原样。
137
+
138
+ `?path=` 由 `URLSearchParams` 自动解码,`src` 从 frontmatter 来、本来就是明文,所以收尾
139
+ **不再**对整串做二次解码 —— 旧写法那样会把地址里合法的 `%` 序列解坏(`a=%25` 变成 `a=%`、
140
+ `/a%2Fb` 变成 `/a/b`)。
141
+
142
+ `origin` 由调用方给(浏览器里就是 `window.location.origin`),与 {@link specUrlFromSearch}
143
+ 同形状:这一份没有 DOM,配置期那条链路也引得到它。服务端渲染阶段拿不到窗口,调用方直接用
144
+ frontmatter 里的原值,不等这一步。
145
+ */
146
+ export function externalFrameURL(options: {
147
+ src: string;
148
+ search: string;
149
+ origin: string;
150
+ }): string {
151
+ const path = new URLSearchParams(options.search).get('path') ?? '';
152
+
153
+ const resolved = options.src.startsWith('/')
154
+ ? new URL(options.src, options.origin).href
155
+ : options.src;
156
+
157
+ return path === '' ? resolved : new URL(path, resolved).href;
158
+ }
@@ -0,0 +1,219 @@
1
+ import { useEffect } from 'react';
2
+ import type { RefObject } from 'react';
3
+
4
+ import type { FrameColorMode, FrameMessages } from './frame-messages.ts';
5
+
6
+ /**
7
+ 那两条消息循环:外层那份文档里的一个 iframe,与框内那份文档里的渲染器。
8
+
9
+ 两边各装各的监听器,谁先谁后不固定,所以对齐是**两侧各推一次**的:框里起来时问一句
10
+ ({@link useInnerFrameSync} 里那句 ready),外层装好监听器也主动推一次
11
+ ({@link useOuterFrameSync})。只守其中一条就丢得掉 —— 实测丢过一次:`page.reload()` 之后
12
+ 框里那句 ready 先发出、外层的监听器还没装上,于是深链与模式都没送进去。
13
+
14
+ 这里不含 iframe 那一格本身,也不含它的尺寸:调用方自己渲染 `<iframe ref={…}>`,把同一个 ref
15
+ 交给 {@link useOuterFrameSync}。
16
+ */
17
+
18
+ /** 外层那个 iframe 的 ref,与调用方渲染时挂的是同一个。 */
19
+ export type FrameRef = RefObject<HTMLIFrameElement | null>;
20
+
21
+ /**
22
+ 这条消息是不是从 `expectedSource` 那个窗口、同一个源发来的。两个方向都过这一格:外层只收自己
23
+ 那个 iframe 的,框内只收装载它的父窗口的 —— 别人手工拼进地址栏的那一条、页面上第三方脚本发的
24
+ 消息都进不来。
25
+
26
+ 引 DOM 类型的只有这一份文件:注册表那条链不碰 `Window`,配置期与浏览器期因此能共用同一份源码。
27
+ */
28
+ export function isTrustedMessage(
29
+ event: MessageEvent,
30
+ expectedSource: Window | null,
31
+ origin: string,
32
+ ): boolean {
33
+ return event.source === expectedSource && event.origin === origin;
34
+ }
35
+
36
+ /**
37
+ 挂在**外层**那份文档里:把地址栏那一段与当前亮暗模式送进框里,并接住框里报回来的那一段。
38
+
39
+ - 外层 → 框内:`route` 与 `mode` 两条。装好监听器先推一次,`colorMode` 每次变化再推一次
40
+ (外层点了亮暗开关,框里不会来问第二次)。
41
+ - 框内 → 外层:`ready`(于是再推一次)与 `route`。后者只换外层地址里 `#` 那一段,
42
+ 而且是**替换**当前那条历史记录:框里点一条接口不该在浏览器历史里多出一格,
43
+ 那会让「后退」变成在同一页的两条接口之间来回走。
44
+ - 前进/后退改的是外层地址(深链那一段就在里面),把它送进框里,两边才还是同一条接口。
45
+ */
46
+ export function useOuterFrameSync(options: {
47
+ frameRef: FrameRef;
48
+ messages: FrameMessages;
49
+ colorMode: FrameColorMode;
50
+ }): void {
51
+ const { frameRef, messages, colorMode } = options;
52
+
53
+ useEffect(() => {
54
+ const frame = frameRef.current;
55
+ if (frame === null) {
56
+ return undefined;
57
+ }
58
+ const { origin } = window.location;
59
+
60
+ const postRoute = (target: Window): void => {
61
+ target.postMessage(messages.route(window.location.hash), origin);
62
+ };
63
+
64
+ const postMode = (target: Window): void => {
65
+ target.postMessage(messages.mode(colorMode), origin);
66
+ };
67
+
68
+ // 框里那份是不是已经站在同一个源上,是的话把它交出去。还没开始加载时它是 `about:blank`,
69
+ // 那里的 origin 是字符串 `null`,送过去只会被浏览器按目标源丢掉、白刷一条控制台报错;读
70
+ // `location` 只在同源时不抛,抛了就当还没起来。同源的判定只做一次并交出那个窗口,而不是
71
+ // 每格各判一次再 `?.` —— 「判过同源之后 contentWindow 又变空」在同步代码里不发生,
72
+ // 那条可选链就是永远量不到的分支。
73
+ const isFrameLoaded = (): Window | undefined => {
74
+ try {
75
+ return frame.contentWindow?.location.origin === origin
76
+ ? frame.contentWindow
77
+ : undefined;
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ };
82
+
83
+ const pushToFrame = (): void => {
84
+ const target = isFrameLoaded();
85
+ if (target === undefined) {
86
+ return;
87
+ }
88
+ postRoute(target);
89
+ postMode(target);
90
+ };
91
+
92
+ const onMessage = (event: MessageEvent): void => {
93
+ if (!isTrustedMessage(event, frame.contentWindow, origin)) {
94
+ return;
95
+ }
96
+ if (messages.isReady(event.data)) {
97
+ pushToFrame();
98
+ return;
99
+ }
100
+ if (
101
+ !messages.isRoute(event.data) ||
102
+ event.data.hash === window.location.hash
103
+ ) {
104
+ return;
105
+ }
106
+ window.history.replaceState(
107
+ null,
108
+ '',
109
+ `${window.location.pathname}${window.location.search}${event.data.hash}`,
110
+ );
111
+ };
112
+
113
+ // 前进/后退时框可能还没站到同源上(或已经被摘掉):走同一条同源判定,而不是赌
114
+ // contentWindow 一定在。
115
+ const onPopState = (): void => {
116
+ const target = isFrameLoaded();
117
+ if (target !== undefined) {
118
+ postRoute(target);
119
+ }
120
+ };
121
+
122
+ window.addEventListener('message', onMessage);
123
+ window.addEventListener('popstate', onPopState);
124
+ pushToFrame();
125
+ return () => {
126
+ window.removeEventListener('message', onMessage);
127
+ window.removeEventListener('popstate', onPopState);
128
+ };
129
+ }, [colorMode, frameRef, messages]);
130
+ }
131
+
132
+ /**
133
+ 挂在**框内**那份文档里:起来第一件事向外层要那两格,之后接住外层送来的两条。
134
+
135
+ - `mode`:落到这一份文档自己身上,落法由各层给(见 {@link InnerFrameSyncOptions} 那两条)。
136
+ 这一份文档不套 `ColorModeProvider`,站点那条 `data-theme` 在这里只是内联脚本写死的一份、
137
+ 没人维护,所以本层不读它。第一次送达之前是空的、也就是亮色(本站默认亮色,送达不靠谁先起来
138
+ —— 两边各推一次);站点把默认改成暗色时,这一格会先亮一下再翻过去。
139
+ - `route`:改 `window.location.hash`,这一步触发 `hashchange`,于是换到那条接口而整份文档不
140
+ 重新加载。渲染器认不认这一格是各家的事(Redoc 与 Elements 的路由按它走,Scalar 只听
141
+ `popstate`,转换写在那一层的 `scalar-ui.tsx`)。整页加载一条深链时那一段也是这么进来的。
142
+
143
+ 反方向(框内 → 外层)**不在这里**,理由见 {@link createRouteReporter}。
144
+ */
145
+ export type InnerFrameSyncOptions = { messages: FrameMessages } & (
146
+ | {
147
+ /** 写成这一份文档 `<html>` 上一条属性(属性名由调用方给):按属性判亮暗的那两家。 */
148
+ modeAttribute: string;
149
+ onMode?: undefined;
150
+ }
151
+ | {
152
+ /** 落法整个交给调用方:Scalar 换的是 `<body>` 上互斥的两个类名,属性名换不动它。 */
153
+ modeAttribute?: undefined;
154
+ onMode: (mode: FrameColorMode) => void;
155
+ }
156
+ );
157
+
158
+ export function useInnerFrameSync(options: InnerFrameSyncOptions): void {
159
+ const { messages, modeAttribute, onMode } = options;
160
+
161
+ useEffect(() => {
162
+ const { origin } = window.location;
163
+
164
+ const onMessage = (event: MessageEvent): void => {
165
+ if (!isTrustedMessage(event, window.parent, origin)) {
166
+ return;
167
+ }
168
+ if (messages.isMode(event.data)) {
169
+ if (onMode) {
170
+ onMode(event.data.mode);
171
+ return;
172
+ }
173
+ // 走到这一格就说明上面那条没给,类型上 `modeAttribute` 必在 —— 但解构出来的两个成员
174
+ // 不会跟着 `options` 一起收窄,所以要这一句转述(不是运行期的兜底)。
175
+ document.documentElement.setAttribute(
176
+ modeAttribute as string,
177
+ event.data.mode,
178
+ );
179
+ return;
180
+ }
181
+ if (
182
+ !messages.isRoute(event.data) ||
183
+ event.data.hash === window.location.hash
184
+ ) {
185
+ return;
186
+ }
187
+ // 空串这一格浏览器处理成「把 `#` 整段去掉」,不是「跳到一个空 id 的元素」
188
+ window.location.hash = event.data.hash;
189
+ };
190
+
191
+ window.addEventListener('message', onMessage);
192
+ window.parent.postMessage(messages.ready(), origin);
193
+ return () => {
194
+ window.removeEventListener('message', onMessage);
195
+ };
196
+ }, [messages, modeAttribute, onMode]);
197
+ }
198
+
199
+ /**
200
+ 造一个「把这一份文档地址里那一段报给外层」的函数(框内 → 外层那一条)。
201
+
202
+ 为什么由调用方在渲染器换接口的时候报,而不是这里监听 `hashchange`:三个渲染器(Redoc 的路由、
203
+ Elements 的路由、Scalar 自己那一条)换地址走的都是 `history.pushState`,那个方法**不触发**
204
+ `hashchange`(那个事件只由改 `location.hash`、点锚点链接、前进/后退这几条路触发)。实测都是这条:
205
+ 框里点一条接口,`location.hash` 变了、渲染器认到了,`hashchange` 一次都没进来。
206
+
207
+ **挂载那一次调用方别报**:那一次报的是初始位置 `#/`,而外层可能正带着一条深链
208
+ (`/redoc-demo#/orders/createorder`),初始那一句会先把外层地址栏里那一段擦成 `#/`,紧接着外层
209
+ 补送深链时读到的已经是被擦过的值,深链就丢了。两家 `LocationMirror` 里那格 `first` 钉的就是这一条。
210
+
211
+ 返回的函数只读 `window`,调用方可以在模块级造一份稳定的,不必每次渲染新建。
212
+ */
213
+ export function createRouteReporter(
214
+ messages: FrameMessages,
215
+ ): (hash: string) => void {
216
+ return (hash: string): void => {
217
+ window.parent.postMessage(messages.route(hash), window.location.origin);
218
+ };
219
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './frame-messages.ts';
2
+ export * from './frame-sync.ts';
3
+ export * from './api-frame-registry.ts';
4
+ export * from './api-frame-messages.ts';
5
+ export * from './api-frame-entries.ts';
6
+ export * from './api-frame-view.tsx';
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@show-docs/docusaurus-theme-iframe",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Md 的 frontmatter 写 iframe: <目标 URL> 时把该页整页换成那个页面",
5
+ "keywords": [],
6
+ "homepage": "https://github.com/show-docs/docusaurus/tree/master/theme/theme-iframe",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/show-docs/docusaurus.git",
10
+ "directory": "theme/theme-iframe"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/show-docs/docusaurus/issues"
14
+ },
15
+ "types": "dist/index.d.mts",
16
+ "main": "dist/index.mjs",
17
+ "exports": {
18
+ ".": {
19
+ "default": "./dist/index.mjs",
20
+ "types": "./dist/index.d.mts"
21
+ },
22
+ "./lib/*": "./lib/*"
23
+ },
24
+ "files": [
25
+ "components",
26
+ "dist",
27
+ "lib",
28
+ "theme"
29
+ ],
30
+ "type": "module",
31
+ "peerDependencies": {
32
+ "@docusaurus/types": "^3.10.2",
33
+ "react": "~18.3.1"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@docusaurus/types": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "engines": {
41
+ "node": "^24.0.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org/"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.build.json"
49
+ }
6
50
  }
@@ -0,0 +1,65 @@
1
+ import DocItem from '@theme-prev/DocItem';
2
+
3
+ import type { ComponentType, ReactNode } from 'react';
4
+
5
+ import { useLocation } from '@docusaurus/router';
6
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
7
+
8
+ import { ApiFrame } from '../../components/api-frame.tsx';
9
+ import {
10
+ resolveFrameDistribution,
11
+ warnFrameDistribution,
12
+ } from '../../lib/api-frame-entries.ts';
13
+
14
+ // 命中与否只看 frontmatter 里那几条已注册的键(值就是框要渲染的地址),
15
+ // 键的形状由注册表给出,本层不再逐包声明。
16
+ type MDXContent = ComponentType<Record<string, unknown>> & {
17
+ frontMatter?: Record<string, unknown>;
18
+ };
19
+
20
+ type Props = { content?: MDXContent };
21
+
22
+ /**
23
+ 本层是「整页交给一个框」这一族的**唯一外层套壳**:拿 `resolveFrameDistribution()` 给出的清单
24
+ 判 frontmatter(顺序、装机过滤与站点覆盖那一条规则都住在 `lib/api-frame-entries.ts`,这里不抄
25
+ 第二份),命中就把**整个 `DocItem`** 换成通用框 —— 与收拢前四条层同一落点、同一取舍;落空那份
26
+ `warnings` 只在开发模式交给 `warnFrameDistribution()` 打。
27
+
28
+ 代价是官方 `DocItem` 里的东西一并不要:`DocProvider`、`DocItem/Metadata`(页面标题与
29
+ 描述因此退回站点名)、`DocItem/Layout`(面包屑、正文外壳、翻页、右侧目录)。剩下的是
30
+ 文档页那层壳:navbar、footer,还有包着 iframe 的那个 `.container`。
31
+
32
+ 三条 API 层的包因此**不再有 `theme/DocItem`**:它们交出命名空间,框里那条页面仍由各自的入口
33
+ 注册(`lib/api-frame-theme.mts` 那份工厂)。
34
+ */
35
+ export default function DocItemFrame(props: Props): ReactNode {
36
+ const frontMatter = props.content?.frontMatter;
37
+ const { siteConfig } = useDocusaurusContext();
38
+ const { pathname } = useLocation();
39
+
40
+ const { entries, warnings } = resolveFrameDistribution(
41
+ siteConfig.plugins,
42
+ frontMatter,
43
+ pathname,
44
+ );
45
+
46
+ if (process.env.NODE_ENV === 'development') {
47
+ warnFrameDistribution(warnings);
48
+ }
49
+
50
+ for (const entry of entries) {
51
+ const value = frontMatter?.[entry.frontmatterKey];
52
+ if (typeof value === 'string' && value !== '') {
53
+ return (
54
+ <ApiFrame
55
+ entry={entry}
56
+ value={value}
57
+ frontMatterTitle={frontMatter?.title}
58
+ />
59
+ );
60
+ }
61
+ }
62
+
63
+ // 没命中时本层等于透明:整个交回下层实现。
64
+ return <DocItem {...props} />;
65
+ }