@mui/internal-docs-infra 0.11.1-canary.6 → 0.11.1-canary.8
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/cli/ensureDemoClients.mjs +10 -2
- package/cli/loadNextConfig.d.mts +6 -2
- package/cli/loadNextConfig.mjs +135 -44
- package/package.json +3 -2
- package/useCode/Pre.mjs +301 -51
|
@@ -174,9 +174,13 @@ function insertClientProviderImport(source, importsAndComments) {
|
|
|
174
174
|
/**
|
|
175
175
|
* Converts a Turbopack-style glob (e.g. `./app/**\/demos/*\/index.ts`) to a
|
|
176
176
|
* RegExp that matches absolute filesystem paths. Mirrors the logic used by
|
|
177
|
-
* `withDocsInfra` for webpack rule generation.
|
|
177
|
+
* `withDocsInfra` for webpack rule generation. Pass-through when the input is
|
|
178
|
+
* already a RegExp (webpack-rule `test` regexes).
|
|
178
179
|
*/
|
|
179
180
|
function patternToRegExp(pattern) {
|
|
181
|
+
if (pattern instanceof RegExp) {
|
|
182
|
+
return pattern;
|
|
183
|
+
}
|
|
180
184
|
const SEP = '\u0000SEP\u0000';
|
|
181
185
|
const NOT_SEP = '\u0000NOT_SEP\u0000';
|
|
182
186
|
const DOUBLE_STAR = '\u0000DOUBLE_STAR\u0000';
|
|
@@ -187,9 +191,13 @@ function patternToRegExp(pattern) {
|
|
|
187
191
|
|
|
188
192
|
/**
|
|
189
193
|
* Finds the longest fixed-prefix directory in a glob pattern so we can avoid
|
|
190
|
-
* walking the entire workspace.
|
|
194
|
+
* walking the entire workspace. Webpack `test` regexes have no extractable
|
|
195
|
+
* prefix, so we fall back to walking from `baseDir`.
|
|
191
196
|
*/
|
|
192
197
|
function patternBaseDir(pattern, baseDir) {
|
|
198
|
+
if (pattern instanceof RegExp) {
|
|
199
|
+
return baseDir;
|
|
200
|
+
}
|
|
193
201
|
const stripped = pattern.replace(/^\.\//, '');
|
|
194
202
|
const segments = stripped.split('/');
|
|
195
203
|
const fixed = [];
|
package/cli/loadNextConfig.d.mts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { DescriptionReplacement } from "../pipeline/loadServerTypesMeta/format.mjs";
|
|
2
2
|
import type { OrderingConfig } from "../pipeline/loadServerTypesText/order.mjs";
|
|
3
3
|
export interface DemoClientRequirement {
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Either a Turbopack-style glob pattern (e.g. `./app/**\/demos/*\/index.ts`)
|
|
6
|
+
* or a webpack-style RegExp used as the rule's `test`. Globs are extracted
|
|
7
|
+
* from `turbopack.rules`; RegExps are extracted from `webpack` rules.
|
|
8
|
+
*/
|
|
9
|
+
pattern: string | RegExp;
|
|
6
10
|
/** Import specifier passed verbatim into the generated `client.ts`. */
|
|
7
11
|
requireClient: string;
|
|
8
12
|
}
|
package/cli/loadNextConfig.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { access } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { createJiti } from 'jiti';
|
|
4
5
|
const TYPES_LOADER = '@mui/internal-docs-infra/pipeline/loadPrecomputedTypes';
|
|
5
6
|
const CODE_HIGHLIGHTER_LOADER = '@mui/internal-docs-infra/pipeline/loadPrecomputedCodeHighlighter';
|
|
6
7
|
const TRANSFORM_METADATA_PLUGIN = '@mui/internal-docs-infra/pipeline/transformMarkdownMetadata';
|
|
@@ -83,43 +84,90 @@ function extractOptionsFromTurbopack(config) {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
87
|
+
* Builds a mock webpack config rich enough to satisfy common patterns used by
|
|
88
|
+
* real `next.config` webpack functions (e.g. `config.resolve.extensions.filter`,
|
|
89
|
+
* `config.module.rules.forEach`, `config.externals.slice`). Real webpack passes
|
|
90
|
+
* an object with these properties populated, so a too-minimal mock causes
|
|
91
|
+
* configs to throw before we can read their rules.
|
|
89
92
|
*/
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
return {};
|
|
93
|
-
}
|
|
94
|
-
const webpackConfig = {
|
|
93
|
+
function createMockWebpackConfig() {
|
|
94
|
+
return {
|
|
95
95
|
module: {
|
|
96
96
|
rules: []
|
|
97
97
|
},
|
|
98
98
|
resolve: {
|
|
99
|
-
alias: {}
|
|
99
|
+
alias: {},
|
|
100
|
+
extensions: ['.mjs', '.js', '.jsx', '.ts', '.tsx', '.json'],
|
|
101
|
+
modules: [],
|
|
102
|
+
fallback: {}
|
|
100
103
|
},
|
|
101
|
-
plugins: []
|
|
104
|
+
plugins: [],
|
|
105
|
+
externals: [],
|
|
106
|
+
optimization: {},
|
|
107
|
+
output: {},
|
|
108
|
+
experiments: {}
|
|
102
109
|
};
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Calls the webpack function with mock config + options pairs for both client
|
|
114
|
+
* and server builds, returning a merged config or `null` if both variants
|
|
115
|
+
* throw. Some Next.js configs only add loader rules when `options.isServer`
|
|
116
|
+
* is true, so we need to evaluate both branches.
|
|
117
|
+
*/
|
|
118
|
+
function callWebpackSafely(config) {
|
|
119
|
+
if (typeof config?.webpack !== 'function') {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const isServer of [false, true]) {
|
|
124
|
+
try {
|
|
125
|
+
results.push(config.webpack(createMockWebpackConfig(), {
|
|
126
|
+
defaultLoaders: {
|
|
127
|
+
babel: {}
|
|
128
|
+
},
|
|
129
|
+
isServer,
|
|
130
|
+
nextRuntime: isServer ? 'nodejs' : undefined,
|
|
131
|
+
dev: false,
|
|
132
|
+
buildId: 'docs-infra-validate',
|
|
133
|
+
config: {
|
|
134
|
+
env: {}
|
|
135
|
+
},
|
|
136
|
+
webpack: () => ({})
|
|
137
|
+
}));
|
|
138
|
+
} catch {
|
|
139
|
+
// try next variant
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (results.length === 0) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
const mergedRules = results.flatMap(result => Array.isArray(result?.module?.rules) ? result.module.rules : []);
|
|
146
|
+
return {
|
|
147
|
+
...results[0],
|
|
148
|
+
module: {
|
|
149
|
+
...(results[0]?.module ?? {}),
|
|
150
|
+
rules: mergedRules
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Calls the webpack function with a minimal config and extracts docs-infra
|
|
157
|
+
* options (ordering, descriptionReplacements, socketDir, useVisibleDescription)
|
|
158
|
+
* from the resulting rules.
|
|
159
|
+
*/
|
|
160
|
+
function extractOptionsFromWebpackResult(result) {
|
|
161
|
+
const merged = {};
|
|
162
|
+
for (const rule of result?.module?.rules ?? []) {
|
|
163
|
+
const useEntries = Array.isArray(rule?.use) ? rule.use : [];
|
|
164
|
+
const extracted = extractOptionsFromLoaderEntries(useEntries);
|
|
165
|
+
merged.ordering ??= extracted.ordering;
|
|
166
|
+
merged.descriptionReplacements ??= extracted.descriptionReplacements;
|
|
167
|
+
merged.useVisibleDescription ??= extracted.useVisibleDescription;
|
|
168
|
+
merged.socketDir ??= extracted.socketDir;
|
|
121
169
|
}
|
|
122
|
-
return
|
|
170
|
+
return merged;
|
|
123
171
|
}
|
|
124
172
|
const NEXT_CONFIG_EXTENSIONS = ['.mjs', '.js', '.ts'];
|
|
125
173
|
|
|
@@ -154,28 +202,71 @@ function extractDemoClientRequirementsFromTurbopack(config) {
|
|
|
154
202
|
}
|
|
155
203
|
return requirements;
|
|
156
204
|
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Walks webpack rules to collect demo `test` regexes that opted into automatic
|
|
208
|
+
* `client.ts` generation via the `requireClient` option. Mirrors the Turbopack
|
|
209
|
+
* extractor but uses the rule's RegExp `test` as the pattern.
|
|
210
|
+
*/
|
|
211
|
+
function extractDemoClientRequirementsFromWebpackResult(result) {
|
|
212
|
+
const requirements = [];
|
|
213
|
+
for (const rule of result?.module?.rules ?? []) {
|
|
214
|
+
if (!(rule?.test instanceof RegExp)) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const useEntries = Array.isArray(rule.use) ? rule.use : [];
|
|
218
|
+
for (const loader of useEntries) {
|
|
219
|
+
if (loader?.loader === CODE_HIGHLIGHTER_LOADER && typeof loader?.options?.requireClient === 'string') {
|
|
220
|
+
requirements.push({
|
|
221
|
+
pattern: rule.test,
|
|
222
|
+
requireClient: loader.options.requireClient
|
|
223
|
+
});
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return requirements;
|
|
229
|
+
}
|
|
157
230
|
export async function extractDocsInfraOptionsFromNextConfig(dir) {
|
|
158
231
|
const configPath = await findNextConfig(dir);
|
|
159
232
|
if (!configPath) {
|
|
160
233
|
return {};
|
|
161
234
|
}
|
|
235
|
+
let config;
|
|
162
236
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
} catch {
|
|
176
|
-
//
|
|
237
|
+
if (configPath.endsWith('.ts')) {
|
|
238
|
+
// Use jiti so TypeScript configs (and their transitive .ts imports
|
|
239
|
+
// without extensions) load the same way Next.js itself loads them.
|
|
240
|
+
const jiti = createJiti(configPath, {
|
|
241
|
+
interopDefault: true
|
|
242
|
+
});
|
|
243
|
+
const configModule = await jiti.import(configPath);
|
|
244
|
+
config = configModule?.default ?? configModule;
|
|
245
|
+
} else {
|
|
246
|
+
const configModule = await import(pathToFileURL(configPath).href);
|
|
247
|
+
config = configModule.default;
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
// Surface the failure: a silently-swallowed import error here means
|
|
251
|
+
// demoClientRequirements (and other extracted options) end up empty,
|
|
252
|
+
// which usually presents to the user as `validate` doing nothing.
|
|
253
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
254
|
+
console.warn(`[docs-infra] Failed to load ${path.relative(dir, configPath)} for option extraction: ${message}`);
|
|
255
|
+
return {};
|
|
177
256
|
}
|
|
178
|
-
|
|
257
|
+
const turbopack = extractOptionsFromTurbopack(config);
|
|
258
|
+
const webpackResult = callWebpackSafely(config);
|
|
259
|
+
const webpack = webpackResult ? extractOptionsFromWebpackResult(webpackResult) : {};
|
|
260
|
+
const turbopackDemoClientRequirements = extractDemoClientRequirementsFromTurbopack(config);
|
|
261
|
+
const webpackDemoClientRequirements = webpackResult ? extractDemoClientRequirementsFromWebpackResult(webpackResult) : [];
|
|
262
|
+
const demoClientRequirements = [...new Map([...turbopackDemoClientRequirements, ...webpackDemoClientRequirements].map(requirement => [`${typeof requirement.pattern === 'string' ? requirement.pattern : requirement.pattern.toString()}::${requirement.requireClient}`, requirement])).values()];
|
|
263
|
+
return {
|
|
264
|
+
ordering: turbopack.ordering ?? webpack.ordering,
|
|
265
|
+
descriptionReplacements: turbopack.descriptionReplacements ?? webpack.descriptionReplacements,
|
|
266
|
+
useVisibleDescription: turbopack.useVisibleDescription ?? webpack.useVisibleDescription,
|
|
267
|
+
socketDir: turbopack.socketDir ?? webpack.socketDir,
|
|
268
|
+
demoClientRequirements: demoClientRequirements.length > 0 ? demoClientRequirements : undefined
|
|
269
|
+
};
|
|
179
270
|
}
|
|
180
271
|
async function findNextConfig(dir) {
|
|
181
272
|
const checks = NEXT_CONFIG_EXTENSIONS.map(async ext => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mui/internal-docs-infra",
|
|
3
|
-
"version": "0.11.1-canary.
|
|
3
|
+
"version": "0.11.1-canary.8",
|
|
4
4
|
"author": "MUI Team",
|
|
5
5
|
"description": "MUI Infra - internal documentation creation tools.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"hast-util-to-jsx-runtime": "^2.3.6",
|
|
36
36
|
"hast-util-to-text": "^4.0.2",
|
|
37
37
|
"import-meta-resolve": "^4.2.0",
|
|
38
|
+
"jiti": "^2.7.0",
|
|
38
39
|
"jsondiffpatch": "^0.7.3",
|
|
39
40
|
"kebab-case": "^2.0.2",
|
|
40
41
|
"lz-string": "^1.5.0",
|
|
@@ -686,5 +687,5 @@
|
|
|
686
687
|
"bin": {
|
|
687
688
|
"docs-infra": "./cli/index.mjs"
|
|
688
689
|
},
|
|
689
|
-
"gitSha": "
|
|
690
|
+
"gitSha": "2d9566eb356cded76a38a33ebb312f2b3c46126c"
|
|
690
691
|
}
|
package/useCode/Pre.mjs
CHANGED
|
@@ -9,6 +9,102 @@ import { hastToJsx, decompressHast } from "../pipeline/hastUtils/index.mjs";
|
|
|
9
9
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
10
10
|
const hastChildrenCache = new WeakMap();
|
|
11
11
|
const textChildrenCache = new WeakMap();
|
|
12
|
+
|
|
13
|
+
// Document-level subscriber registry for `<details>` toggle events. Each
|
|
14
|
+
// `<Pre>` would otherwise install its own capture-phase listener; on docs
|
|
15
|
+
// pages with many code blocks that's N listeners all firing on every
|
|
16
|
+
// toggle anywhere in the document. A single shared listener fans out to
|
|
17
|
+
// the relevant subscribers instead.
|
|
18
|
+
//
|
|
19
|
+
// Subscribers register their `<pre>` element so the dispatcher can do a
|
|
20
|
+
// single `target.contains(pre)` ancestry check per subscriber and skip
|
|
21
|
+
// the nudge entirely for unrelated toggles — no JS-side work runs in
|
|
22
|
+
// `<Pre>` instances whose subtree the toggle didn't touch.
|
|
23
|
+
//
|
|
24
|
+
// The value is a Set rather than a single function so the registry
|
|
25
|
+
// tolerates the (unlikely but possible) case where two `<Pre>` instances
|
|
26
|
+
// transiently share the same DOM node — e.g. a fast unmount/remount
|
|
27
|
+
// where the next mount's setup runs before the prior mount's cleanup.
|
|
28
|
+
// Without the set the second subscribe would silently overwrite the
|
|
29
|
+
// first nudge and a single unsubscribe would orphan the other instance.
|
|
30
|
+
|
|
31
|
+
const toggleSubscribers = new Map();
|
|
32
|
+
let toggleListenerAttached = false;
|
|
33
|
+
let sharedToggleListener = null;
|
|
34
|
+
|
|
35
|
+
// Reconcile the document-level capture listener with the current
|
|
36
|
+
// subscriber set. Idempotent: callable from any code path (including
|
|
37
|
+
// test teardowns that want to defensively assert no leaked listener)
|
|
38
|
+
// without risk of leaving the document in a half-attached state.
|
|
39
|
+
function syncToggleListener() {
|
|
40
|
+
if (typeof document === 'undefined') {
|
|
41
|
+
if (toggleSubscribers.size === 0) {
|
|
42
|
+
sharedToggleListener = null;
|
|
43
|
+
toggleListenerAttached = false;
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (toggleSubscribers.size === 0) {
|
|
48
|
+
if (toggleListenerAttached && sharedToggleListener) {
|
|
49
|
+
document.removeEventListener('toggle', sharedToggleListener, true);
|
|
50
|
+
}
|
|
51
|
+
sharedToggleListener = null;
|
|
52
|
+
toggleListenerAttached = false;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!toggleListenerAttached || !sharedToggleListener) {
|
|
56
|
+
sharedToggleListener = event => {
|
|
57
|
+
const target = event.target;
|
|
58
|
+
if (!(target instanceof Node)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Snapshot before iterating: a nudge may synchronously trigger an
|
|
62
|
+
// unmount that mutates `toggleSubscribers` mid-dispatch. Iterating
|
|
63
|
+
// a snapshot keeps dispatch order independent of subscriber
|
|
64
|
+
// mutations and matches the snapshot pattern used by
|
|
65
|
+
// `sweepDetachedFrames` / `nudgeFrameObserver`.
|
|
66
|
+
Array.from(toggleSubscribers).forEach(([preNode, nudges]) => {
|
|
67
|
+
// Centralized ancestry filter: only nudge subscribers whose `<pre>`
|
|
68
|
+
// is a descendant of the toggled element. Done here (rather than
|
|
69
|
+
// in each subscriber) so unrelated toggles short-circuit before
|
|
70
|
+
// any subscriber-side work runs.
|
|
71
|
+
if (!target.contains(preNode)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
Array.from(nudges).forEach(nudge => nudge());
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
document.addEventListener('toggle', sharedToggleListener, true);
|
|
78
|
+
toggleListenerAttached = true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function subscribeToggleNudge(preNode, nudge) {
|
|
82
|
+
// Defensive SSR no-op: there is no `document` to attach a listener to,
|
|
83
|
+
// and module state in Node persists across requests — leaking a
|
|
84
|
+
// subscriber here would also leak the closure it captures. `useEffect`
|
|
85
|
+
// already won't run on the server, but make the contract explicit so
|
|
86
|
+
// any future non-effect caller can't strand entries in the registry.
|
|
87
|
+
if (typeof document === 'undefined') {
|
|
88
|
+
return () => {};
|
|
89
|
+
}
|
|
90
|
+
let nudges = toggleSubscribers.get(preNode);
|
|
91
|
+
if (!nudges) {
|
|
92
|
+
nudges = new Set();
|
|
93
|
+
toggleSubscribers.set(preNode, nudges);
|
|
94
|
+
}
|
|
95
|
+
nudges.add(nudge);
|
|
96
|
+
syncToggleListener();
|
|
97
|
+
return () => {
|
|
98
|
+
const existing = toggleSubscribers.get(preNode);
|
|
99
|
+
if (existing) {
|
|
100
|
+
existing.delete(nudge);
|
|
101
|
+
if (existing.size === 0) {
|
|
102
|
+
toggleSubscribers.delete(preNode);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
syncToggleListener();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
12
108
|
const INITIAL_VISIBLE_FRAME_TYPES = new Set(['highlighted', 'focus', 'padding-top', 'padding-bottom']);
|
|
13
109
|
function getInitialVisibleFrames(hast) {
|
|
14
110
|
if (!hast) {
|
|
@@ -281,26 +377,142 @@ export function Pre({
|
|
|
281
377
|
preParse
|
|
282
378
|
});
|
|
283
379
|
const observer = React.useRef(null);
|
|
380
|
+
const observedFrames = React.useRef(new Set());
|
|
284
381
|
const frameIndexMap = React.useRef(new WeakMap());
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
382
|
+
|
|
383
|
+
// Drop frame spans that have been detached from the DOM. Used as a
|
|
384
|
+
// defensive sweep in `nudgeFrameObserver` (and the IO effect) so the
|
|
385
|
+
// tracking sets don't grow unboundedly across re-renders, even on
|
|
386
|
+
// React 17/18 where the cleanup return value of `observeFrame` is
|
|
387
|
+
// ignored. `node.isConnected` is the cheapest available signal.
|
|
388
|
+
const sweepDetachedFrames = React.useCallback(() => {
|
|
389
|
+
const io = observer.current;
|
|
390
|
+
// Snapshot before iterating: we mutate `observedFrames` inside the
|
|
391
|
+
// loop, so iterating the live Set would rely on its (well-defined
|
|
392
|
+
// but subtle) skip-deleted-entries semantics. An array snapshot makes
|
|
393
|
+
// the intent explicit and decouples iteration order from insertion
|
|
394
|
+
// order should the storage ever change.
|
|
395
|
+
Array.from(observedFrames.current).forEach(frame => {
|
|
396
|
+
if (!frame.isConnected) {
|
|
397
|
+
observedFrames.current.delete(frame);
|
|
398
|
+
frameIndexMap.current.delete(frame);
|
|
399
|
+
io?.unobserve(frame);
|
|
290
400
|
}
|
|
291
|
-
|
|
401
|
+
});
|
|
402
|
+
}, []);
|
|
403
|
+
|
|
404
|
+
// Re-observe every tracked frame so the IntersectionObserver re-evaluates
|
|
405
|
+
// visibility without a synchronous `getBoundingClientRect()` call. Used
|
|
406
|
+
// when ancestor layout changes (CSS-driven collapse/expand, <details>
|
|
407
|
+
// toggle, tab/accordion swaps) clip or unclip frames in ways that don't
|
|
408
|
+
// themselves trigger an IntersectionObserver entry. Mirrors the
|
|
409
|
+
// `nudgeObserver` pattern in `<TypeCode>`.
|
|
410
|
+
const nudgeFrameObserver = React.useCallback(() => {
|
|
411
|
+
const io = observer.current;
|
|
412
|
+
if (!io) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
// Snapshot before iterating — see `sweepDetachedFrames` above.
|
|
416
|
+
Array.from(observedFrames.current).forEach(frame => {
|
|
417
|
+
if (!frame.isConnected) {
|
|
418
|
+
observedFrames.current.delete(frame);
|
|
419
|
+
frameIndexMap.current.delete(frame);
|
|
420
|
+
io.unobserve(frame);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
io.unobserve(frame);
|
|
424
|
+
io.observe(frame);
|
|
425
|
+
});
|
|
426
|
+
}, []);
|
|
427
|
+
|
|
428
|
+
// Holds the mounted `<pre>` element so the IO/RO/toggle setup effect can
|
|
429
|
+
// key on it. Using a state + callback-ref pair (rather than driving the
|
|
430
|
+
// setup from inside the ref callback) lets React's effect lifecycle
|
|
431
|
+
// guarantee teardown — including under StrictMode's double-invoke and
|
|
432
|
+
// for any abrupt unmount path — instead of relying on the ref callback
|
|
433
|
+
// being called with `null`.
|
|
434
|
+
const [preNode, setPreNode] = React.useState(null);
|
|
435
|
+
|
|
436
|
+
// Mirror the latest forwarded `ref` so `bindPre` can read it without
|
|
437
|
+
// depending on `ref` in its deps (which would re-create `bindPre` on
|
|
438
|
+
// every parent re-render and tear down the IO/RO/toggle setup effect
|
|
439
|
+
// below). Using `useLayoutEffect` (React 17 safe) keeps this in sync
|
|
440
|
+
// before any consumer's layout effect or imperative-handle read.
|
|
441
|
+
const forwardedRef = React.useRef(ref);
|
|
442
|
+
React.useLayoutEffect(() => {
|
|
443
|
+
const previous = forwardedRef.current;
|
|
444
|
+
forwardedRef.current = ref;
|
|
445
|
+
if (previous === ref) {
|
|
292
446
|
return;
|
|
293
447
|
}
|
|
294
|
-
|
|
295
|
-
|
|
448
|
+
// Consumer swapped to a different ref function/object on a render
|
|
449
|
+
// where the DOM node didn't change (so `bindPre` wasn't called by
|
|
450
|
+
// React). Reconcile manually: detach the old, attach the new with
|
|
451
|
+
// the current node, matching React's standard callback-ref
|
|
452
|
+
// semantics for ref swaps.
|
|
453
|
+
const current = preRef.current;
|
|
454
|
+
if (typeof previous === 'function') {
|
|
455
|
+
previous(null);
|
|
456
|
+
} else if (previous) {
|
|
457
|
+
previous.current = null;
|
|
458
|
+
}
|
|
459
|
+
if (typeof ref === 'function') {
|
|
460
|
+
ref(current);
|
|
461
|
+
} else if (ref) {
|
|
462
|
+
ref.current = current;
|
|
463
|
+
}
|
|
464
|
+
}, [ref]);
|
|
465
|
+
|
|
466
|
+
// `bindPre` is stable (empty deps): if it depended on the forwarded
|
|
467
|
+
// `ref`, a parent re-render that supplies a new ref function would
|
|
468
|
+
// recreate `bindPre`, causing React to invoke the previous callback
|
|
469
|
+
// with `null` and the new one with the same DOM node. That sequence
|
|
470
|
+
// would tear down and rebuild the IO/RO/toggle subscription on every
|
|
471
|
+
// parent render. Ref-function swaps that don't change the DOM node
|
|
472
|
+
// are reconciled by the layout effect above.
|
|
473
|
+
//
|
|
474
|
+
// Forward the consumer's ref synchronously inside the callback (not in
|
|
475
|
+
// a separate `useEffect`) so any parent `useLayoutEffect` or
|
|
476
|
+
// imperative handle that reads `ref.current` right after mount sees
|
|
477
|
+
// the `<pre>` rather than `null`.
|
|
478
|
+
const bindPre = React.useCallback(root => {
|
|
479
|
+
// React 18+ StrictMode (and some normal-update paths) can invoke a
|
|
480
|
+
// ref callback with the same node it already holds. Short-circuit
|
|
481
|
+
// so we don't trigger an extra render via `setPreNode` or a
|
|
482
|
+
// redundant ref-forward cycle for the consumer.
|
|
483
|
+
if (preRef.current === root) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
preRef.current = root;
|
|
487
|
+
const current = forwardedRef.current;
|
|
488
|
+
if (typeof current === 'function') {
|
|
489
|
+
current(root);
|
|
490
|
+
} else if (current) {
|
|
491
|
+
current.current = root;
|
|
492
|
+
}
|
|
493
|
+
setPreNode(root);
|
|
494
|
+
}, []);
|
|
495
|
+
const handleIntersection = React.useCallback(entries => {
|
|
496
|
+
setVisibleFrames(prev => {
|
|
296
497
|
const visible = [];
|
|
297
498
|
const invisible = [];
|
|
298
499
|
entries.forEach(entry => {
|
|
299
|
-
const index =
|
|
500
|
+
const index = frameIndexMap.current.get(entry.target);
|
|
300
501
|
if (index === undefined) {
|
|
301
502
|
return;
|
|
302
503
|
}
|
|
303
|
-
|
|
504
|
+
// A frame counts as visible only when it intersects the
|
|
505
|
+
// viewport AND its intersection rect has non-zero area.
|
|
506
|
+
// Frames hidden by a CSS-driven collapse (`max-height: 0;
|
|
507
|
+
// overflow: hidden;` or `visibility: hidden`) collapse to a
|
|
508
|
+
// zero-area rect; some browsers still report
|
|
509
|
+
// `isIntersecting: true` for them based on their geometric
|
|
510
|
+
// position in the document. Checking the rect dimensions
|
|
511
|
+
// matches what the user actually sees and prevents hidden
|
|
512
|
+
// frames from being upgraded to highlighted HAST.
|
|
513
|
+
const rect = entry.intersectionRect;
|
|
514
|
+
const isVisuallyVisible = entry.isIntersecting && rect.width > 0 && rect.height > 0;
|
|
515
|
+
if (isVisuallyVisible) {
|
|
304
516
|
visible.push(index);
|
|
305
517
|
} else {
|
|
306
518
|
invisible.push(index);
|
|
@@ -330,54 +542,92 @@ export function Pre({
|
|
|
330
542
|
}
|
|
331
543
|
});
|
|
332
544
|
return frames || prev;
|
|
333
|
-
}), {
|
|
334
|
-
rootMargin: hydrateMargin
|
|
335
545
|
});
|
|
546
|
+
}, []);
|
|
336
547
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
548
|
+
// Set up IntersectionObserver, ResizeObserver, and the shared
|
|
549
|
+
// <details> toggle subscription whenever the pre element changes.
|
|
550
|
+
// Running this in `useEffect` (rather than in the ref callback)
|
|
551
|
+
// delegates teardown to React's effect lifecycle, so cleanup is
|
|
552
|
+
// guaranteed even under StrictMode's double-invoke and for any
|
|
553
|
+
// unmount path.
|
|
554
|
+
React.useEffect(() => {
|
|
555
|
+
if (!preNode) {
|
|
556
|
+
return undefined;
|
|
341
557
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
345
|
-
const element = node;
|
|
346
|
-
if (!element.classList.contains('frame')) {
|
|
347
|
-
console.warn('Expected frame element in useCode <Pre>', element);
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
350
|
-
indexMap.set(element, frameIndex);
|
|
351
|
-
frameIndex += 1;
|
|
352
|
-
observer.current?.observe(element);
|
|
353
|
-
}
|
|
558
|
+
const io = new IntersectionObserver(handleIntersection, {
|
|
559
|
+
rootMargin: hydrateMargin
|
|
354
560
|
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
561
|
+
observer.current = io;
|
|
562
|
+
|
|
563
|
+
// Sweep any spans that detached between the previous IO's teardown
|
|
564
|
+
// and this one, then start observing every frame whose ref callback
|
|
565
|
+
// has registered it.
|
|
566
|
+
sweepDetachedFrames();
|
|
567
|
+
observedFrames.current.forEach(frame => io.observe(frame));
|
|
568
|
+
|
|
569
|
+
// Watch the `<pre>` itself for size changes (CSS-driven collapse
|
|
570
|
+
// animations resize ancestors, accordions/tabs swap layout). When
|
|
571
|
+
// the pre resizes, re-observe every frame so the IO re-evaluates
|
|
572
|
+
// their clipped-vs-unclipped state. Guarded so older runtimes (and
|
|
573
|
+
// JSDOM in unit tests) without ResizeObserver still work.
|
|
574
|
+
let ro = null;
|
|
575
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
576
|
+
ro = new ResizeObserver(nudgeFrameObserver);
|
|
577
|
+
ro.observe(preNode);
|
|
361
578
|
}
|
|
362
|
-
|
|
579
|
+
|
|
580
|
+
// Native <details> toggle events do not bubble, but a capture-phase
|
|
581
|
+
// listener on the document still intercepts them. A single shared
|
|
582
|
+
// listener (see `subscribeToggleNudge`) fans the event out only to
|
|
583
|
+
// mounted `<Pre>` instances whose `<pre>` is a descendant of the
|
|
584
|
+
// toggled element, so unrelated toggles elsewhere in the document
|
|
585
|
+
// don't trigger any per-instance work.
|
|
586
|
+
const unsubscribeToggle = subscribeToggleNudge(preNode, nudgeFrameObserver);
|
|
587
|
+
return () => {
|
|
588
|
+
io.disconnect();
|
|
589
|
+
observer.current = null;
|
|
590
|
+
ro?.disconnect();
|
|
591
|
+
unsubscribeToggle();
|
|
592
|
+
};
|
|
593
|
+
}, [preNode, hydrateMargin, handleIntersection, nudgeFrameObserver, sweepDetachedFrames]);
|
|
363
594
|
const observeFrame = React.useCallback(node => {
|
|
364
|
-
if (node) {
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
595
|
+
if (!node) {
|
|
596
|
+
// React 17/18 invoke ref callbacks with `null` on detach but
|
|
597
|
+
// ignore the cleanup return value below, and a single shared
|
|
598
|
+
// callback can't tell which span detached. Prune any tracked
|
|
599
|
+
// frame that's no longer in the DOM so detached nodes don't
|
|
600
|
+
// accumulate strongly-referenced inside `observedFrames` for
|
|
601
|
+
// the lifetime of the `<Pre>` instance.
|
|
602
|
+
sweepDetachedFrames();
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
// Derive frame index from DOM position among .frame siblings.
|
|
606
|
+
// This avoids putting data-frame in server-rendered HTML.
|
|
607
|
+
let index = 0;
|
|
608
|
+
let sibling = node.previousElementSibling;
|
|
609
|
+
while (sibling) {
|
|
610
|
+
if (sibling.classList.contains('frame')) {
|
|
611
|
+
index += 1;
|
|
378
612
|
}
|
|
613
|
+
sibling = sibling.previousElementSibling;
|
|
379
614
|
}
|
|
380
|
-
|
|
615
|
+
frameIndexMap.current.set(node, index);
|
|
616
|
+
observedFrames.current.add(node);
|
|
617
|
+
if (observer.current) {
|
|
618
|
+
observer.current.observe(node);
|
|
619
|
+
}
|
|
620
|
+
// React 19 ref-callback cleanup. On React 17/18 the return value is
|
|
621
|
+
// ignored; the `if (!node)` branch above + `sweepDetachedFrames`
|
|
622
|
+
// (also called from `nudgeFrameObserver` and the IO setup effect)
|
|
623
|
+
// drop entries whose `node.isConnected` is false, so the tracking
|
|
624
|
+
// sets stay bounded on those versions too.
|
|
625
|
+
return () => {
|
|
626
|
+
observedFrames.current.delete(node);
|
|
627
|
+
frameIndexMap.current.delete(node);
|
|
628
|
+
observer.current?.unobserve(node);
|
|
629
|
+
};
|
|
630
|
+
}, [sweepDetachedFrames]);
|
|
381
631
|
const frames = React.useMemo(() => {
|
|
382
632
|
let frameIndex = 0;
|
|
383
633
|
return hast?.children.map((child, index) => {
|
|
@@ -505,7 +755,7 @@ export function Pre({
|
|
|
505
755
|
// useEditable). jsx-a11y can't see that, so disable its rule here.
|
|
506
756
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
507
757
|
_jsx("pre", {
|
|
508
|
-
ref:
|
|
758
|
+
ref: bindPre,
|
|
509
759
|
className: className,
|
|
510
760
|
spellCheck: false,
|
|
511
761
|
tabIndex: isEditable ? -1 : undefined,
|