jamdesk 1.1.128 → 1.1.129
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/dist/commands/broken-links.d.ts +2 -0
- package/dist/commands/broken-links.d.ts.map +1 -1
- package/dist/commands/broken-links.js +11 -2
- package/dist/commands/broken-links.js.map +1 -1
- package/package.json +1 -1
- package/vendored/lib/api-spec-menu-gate.ts +23 -10
- package/vendored/lib/api-spec-offer.ts +50 -0
- package/vendored/lib/api-specs-markdown-hint.ts +15 -6
- package/vendored/scripts/validate-links.cjs +92 -4
- package/vendored/workspace-package-lock.json +3 -3
|
@@ -9,6 +9,8 @@ export interface BrokenLink {
|
|
|
9
9
|
file: string;
|
|
10
10
|
line: number | null;
|
|
11
11
|
link: string;
|
|
12
|
+
/** Present for fragment-not-found warnings; carries any "Did you mean #x?" hint. */
|
|
13
|
+
message?: string;
|
|
12
14
|
suggestion?: string;
|
|
13
15
|
}
|
|
14
16
|
export interface BrokenLinksOptions {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broken-links.d.ts","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"broken-links.d.ts","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAaH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,aAAa,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAwG5E"}
|
|
@@ -66,6 +66,10 @@ export async function brokenLinks(options) {
|
|
|
66
66
|
spin.stop();
|
|
67
67
|
// Add suggestions using Levenshtein distance
|
|
68
68
|
const brokenWithSuggestions = warnings.map((w) => {
|
|
69
|
+
// Fragment-not-found warnings carry their own message (incl. any
|
|
70
|
+
// "Did you mean #anchor?" hint); a page-path Levenshtein guess would be noise.
|
|
71
|
+
if (w.message)
|
|
72
|
+
return { ...w };
|
|
69
73
|
let suggestion;
|
|
70
74
|
let minDistance = Infinity;
|
|
71
75
|
// Normalize the link for comparison
|
|
@@ -86,10 +90,15 @@ export async function brokenLinks(options) {
|
|
|
86
90
|
process.exit(0);
|
|
87
91
|
}
|
|
88
92
|
console.log('\nBroken links found:\n');
|
|
89
|
-
for (const { file, line, link, suggestion } of brokenWithSuggestions) {
|
|
93
|
+
for (const { file, line, link, message, suggestion } of brokenWithSuggestions) {
|
|
90
94
|
const location = line ? `${file}:${line}` : file;
|
|
91
95
|
console.log(`${location} - ${link}`);
|
|
92
|
-
if (
|
|
96
|
+
if (message) {
|
|
97
|
+
// Fragment-not-found: show the validator's message verbatim (it already
|
|
98
|
+
// includes "Did you mean #anchor?" when a near-miss heading exists).
|
|
99
|
+
console.log(` └─ ${message}`);
|
|
100
|
+
}
|
|
101
|
+
else if (suggestion) {
|
|
93
102
|
console.log(` └─ Did you mean: ${suggestion}`);
|
|
94
103
|
}
|
|
95
104
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"broken-links.js","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7C,OAAO,IAAI,MAAM,MAAM,CAAC;AACxB,OAAO,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExE,MAAM,UAAU,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAClD,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"broken-links.js","sourceRoot":"","sources":["../../src/commands/broken-links.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.129",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContextualOption, DocsConfig } from './docs-types';
|
|
2
2
|
import { siteHasOpenApiSpecs } from './api-specs-bundle';
|
|
3
|
+
import { shouldOfferApiSpecDownload } from './api-spec-offer';
|
|
3
4
|
|
|
4
5
|
export interface ApiSpecGateInput {
|
|
5
6
|
isIsr: boolean;
|
|
@@ -10,23 +11,35 @@ export interface ApiSpecGateInput {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
* Insert `download-api-spec`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Insert `download-api-spec` into the AI-actions menu iff the page offers the
|
|
15
|
+
* api-specs download (shared gate: shouldOfferApiSpecDownload). The offer
|
|
16
|
+
* decision is shared with the markdown footer so the two cannot drift; this
|
|
17
|
+
* function owns only the menu-specific concerns: idempotency and placement.
|
|
17
18
|
*
|
|
18
|
-
* Placement: directly below "View as Markdown"
|
|
19
|
-
* page
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* Placement: directly below "View as Markdown"; falls back to just after "Copy
|
|
20
|
+
* page", then the front, when "view" is absent (a customized options list).
|
|
21
|
+
*
|
|
22
|
+
* `siteHasOpenApiSpecs` walks the whole docs.json navigation, so it is passed as
|
|
23
|
+
* a thunk — the shared predicate evaluates it only after the cheap gates pass
|
|
24
|
+
* and only when the page has no page-level `openapi:`, preserving the original
|
|
25
|
+
* no-walk-on-guide-pages behavior.
|
|
22
26
|
*/
|
|
23
27
|
export function withApiSpecDownload(
|
|
24
28
|
options: ContextualOption[],
|
|
25
29
|
{ isIsr, isApiPage, pageHasOpenApi, config }: ApiSpecGateInput,
|
|
26
30
|
): ContextualOption[] {
|
|
27
|
-
if (!isIsr || !isApiPage || options.length === 0) return options;
|
|
28
31
|
if (options.includes('download-api-spec')) return options;
|
|
29
|
-
if (
|
|
32
|
+
if (
|
|
33
|
+
!shouldOfferApiSpecDownload({
|
|
34
|
+
isIsr,
|
|
35
|
+
menuEnabled: options.length > 0,
|
|
36
|
+
isApiPage,
|
|
37
|
+
pageHasOpenApi,
|
|
38
|
+
siteHasSpecs: () => siteHasOpenApiSpecs(config),
|
|
39
|
+
})
|
|
40
|
+
) {
|
|
41
|
+
return options;
|
|
42
|
+
}
|
|
30
43
|
const result = [...options];
|
|
31
44
|
const viewIdx = result.indexOf('view');
|
|
32
45
|
const copyIdx = result.indexOf('copy');
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single source of truth for "should this page offer the api-specs download?".
|
|
3
|
+
* Consumed by BOTH the visible menu-button gate (withApiSpecDownload,
|
|
4
|
+
* lib/api-spec-menu-gate.ts) and the agent-facing markdown footer gate
|
|
5
|
+
* (apiSpecsMarkdownFooter, lib/api-specs-markdown-hint.ts), so the two gates
|
|
6
|
+
* cannot drift on the offer condition.
|
|
7
|
+
*
|
|
8
|
+
* Pure: no I/O, no config access. Callers resolve the inputs (ISR, frontmatter,
|
|
9
|
+
* menu, spec existence) and pass primitives.
|
|
10
|
+
*
|
|
11
|
+
* Rule: ISR + the AI-actions menu is enabled + the page is an API page (`api:`
|
|
12
|
+
* or `openapi:` frontmatter) + there is a spec to bundle — a page-level
|
|
13
|
+
* `openapi:` short-circuits the site-wide spec check, exactly as the menu has
|
|
14
|
+
* always done.
|
|
15
|
+
*
|
|
16
|
+
* `siteHasSpecs` may be a thunk. The menu gate's site-spec check
|
|
17
|
+
* (siteHasOpenApiSpecs) walks the whole docs.json navigation; the original gate
|
|
18
|
+
* deliberately avoided that walk on the common non-API-page render path.
|
|
19
|
+
* Passing `() => …` preserves the laziness — the thunk runs only after the cheap
|
|
20
|
+
* gates pass and only when the page has no page-level `openapi:`.
|
|
21
|
+
*/
|
|
22
|
+
export interface ApiSpecOfferInput {
|
|
23
|
+
/** ISR runtime. Both gates are inert in local `jamdesk dev` (isIsr=false). */
|
|
24
|
+
isIsr: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The AI-actions menu is present with ≥1 option — i.e. NOT disabled via
|
|
27
|
+
* `contextual.enabled:false` / `options:[]`. This is a derived fact, not a
|
|
28
|
+
* config flag: callers compute it from the rendered options list
|
|
29
|
+
* (`options.length > 0` / `getContextualOptions(config).length > 0`), which is
|
|
30
|
+
* where menu-enablement actually lives (`lib/contextual-defaults.ts`).
|
|
31
|
+
*/
|
|
32
|
+
menuEnabled: boolean;
|
|
33
|
+
/** Page frontmatter declares `api:` or `openapi:`. */
|
|
34
|
+
isApiPage: boolean;
|
|
35
|
+
/** Page frontmatter declares its own `openapi:` — short-circuits the site-wide check. */
|
|
36
|
+
pageHasOpenApi: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Whether the site references ≥1 OpenAPI spec anywhere. May be a thunk so a
|
|
39
|
+
* caller can defer an expensive docs.json navigation walk until the cheap
|
|
40
|
+
* gates pass; see the menu-gate call site.
|
|
41
|
+
*/
|
|
42
|
+
siteHasSpecs: boolean | (() => boolean);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function shouldOfferApiSpecDownload(input: ApiSpecOfferInput): boolean {
|
|
46
|
+
const { isIsr, menuEnabled, isApiPage, pageHasOpenApi, siteHasSpecs } = input;
|
|
47
|
+
if (!(isIsr && menuEnabled && isApiPage)) return false;
|
|
48
|
+
if (pageHasOpenApi) return true;
|
|
49
|
+
return typeof siteHasSpecs === 'function' ? siteHasSpecs() : siteHasSpecs;
|
|
50
|
+
}
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* getBaseUrlFromConfig), then calls this.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { shouldOfferApiSpecDownload } from './api-spec-offer';
|
|
9
|
+
|
|
8
10
|
export interface ApiSpecsHintInput {
|
|
9
11
|
/** ISR mode only — the route + the R2 bundle only exist in prod. */
|
|
10
12
|
isIsr: boolean;
|
|
@@ -30,11 +32,18 @@ export interface ApiSpecsHintInput {
|
|
|
30
32
|
* zip route is the source of truth (it 404s if assembly is empty); this is a hint.
|
|
31
33
|
*/
|
|
32
34
|
export function apiSpecsMarkdownFooter(input: ApiSpecsHintInput): string | null {
|
|
33
|
-
const {
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
const { pageHasOpenApi, pageHasApi, zipUrl } = input;
|
|
36
|
+
if (
|
|
37
|
+
!shouldOfferApiSpecDownload({
|
|
38
|
+
isIsr: input.isIsr,
|
|
39
|
+
menuEnabled: input.menuEnabled,
|
|
40
|
+
isApiPage: pageHasOpenApi || pageHasApi,
|
|
41
|
+
pageHasOpenApi,
|
|
42
|
+
siteHasSpecs: input.siteHasSpecs,
|
|
43
|
+
})
|
|
44
|
+
) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (!zipUrl) return null; // defensive: getBaseUrlFromConfig is total, so this is contract insurance
|
|
39
48
|
return `\n\n---\n\n📦 **OpenAPI specs:** Every OpenAPI specification referenced by this documentation is available as a single download — ${zipUrl}`;
|
|
40
49
|
}
|
|
@@ -251,6 +251,89 @@ function createSlugger() {
|
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Levenshtein edit distance (iterative two-row DP). Inlined rather than pulling
|
|
256
|
+
* a dependency: validate-links.cjs is vendored into the CLI as a self-contained
|
|
257
|
+
* script (only ./github-slugger-regex.cjs ships alongside it), so it must not
|
|
258
|
+
* add runtime requires.
|
|
259
|
+
*/
|
|
260
|
+
function levenshtein(a, b) {
|
|
261
|
+
if (a === b) return 0;
|
|
262
|
+
if (a.length === 0) return b.length;
|
|
263
|
+
if (b.length === 0) return a.length;
|
|
264
|
+
let prev = new Array(b.length + 1);
|
|
265
|
+
let curr = new Array(b.length + 1);
|
|
266
|
+
for (let j = 0; j <= b.length; j++) prev[j] = j;
|
|
267
|
+
for (let i = 0; i < a.length; i++) {
|
|
268
|
+
curr[0] = i + 1;
|
|
269
|
+
for (let j = 0; j < b.length; j++) {
|
|
270
|
+
const cost = a[i] === b[j] ? 0 : 1;
|
|
271
|
+
curr[j + 1] = Math.min(curr[j] + 1, prev[j + 1] + 1, prev[j] + cost);
|
|
272
|
+
}
|
|
273
|
+
const tmp = prev;
|
|
274
|
+
prev = curr;
|
|
275
|
+
curr = tmp;
|
|
276
|
+
}
|
|
277
|
+
return prev[b.length];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Maximum edit distance for a "Did you mean #X?" suggestion. Deliberately small
|
|
282
|
+
* (precision over recall): the dominant real cases — apostrophe slips
|
|
283
|
+
* (reset-a-user-s-password → reset-a-users-password) and accent slips
|
|
284
|
+
* (pagina → página) — are distance 1. A larger cap starts suggesting
|
|
285
|
+
* real-but-wrong anchors, which is worse than no suggestion.
|
|
286
|
+
*/
|
|
287
|
+
const MAX_SUGGESTION_DISTANCE = 2;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Find the closest valid heading slug to a fragment that wasn't found, for a
|
|
291
|
+
* "Did you mean #X?" hint. Precision over recall — only returns a slug for an
|
|
292
|
+
* UNAMBIGUOUS near-miss: within MAX_SUGGESTION_DISTANCE AND strictly closer than
|
|
293
|
+
* every other candidate. So apostrophe/accent/typo slips get a suggestion, but a
|
|
294
|
+
* fragment equidistant from several similar headings (step-1/step-2/…,
|
|
295
|
+
* setup/setup-1, changelog versions) yields NO suggestion rather than an
|
|
296
|
+
* arbitrary, confidently-wrong one. Returns null when nothing qualifies.
|
|
297
|
+
*/
|
|
298
|
+
function closestSlug(fragment, slugSet) {
|
|
299
|
+
if (!fragment || !slugSet || slugSet.size === 0) return null;
|
|
300
|
+
let best = null;
|
|
301
|
+
let bestDist = Infinity;
|
|
302
|
+
let secondDist = Infinity;
|
|
303
|
+
for (const slug of slugSet) {
|
|
304
|
+
// Edit distance can't be below the length gap; skip candidates that can't
|
|
305
|
+
// beat the cap. Also bounds the Levenshtein work on very large pages, and
|
|
306
|
+
// can't hide a true tie (a distance ≤2 tie implies a length gap ≤2).
|
|
307
|
+
if (Math.abs(fragment.length - slug.length) > MAX_SUGGESTION_DISTANCE) continue;
|
|
308
|
+
const d = levenshtein(fragment, slug);
|
|
309
|
+
if (d < bestDist) {
|
|
310
|
+
secondDist = bestDist;
|
|
311
|
+
bestDist = d;
|
|
312
|
+
best = slug;
|
|
313
|
+
} else if (d < secondDist) {
|
|
314
|
+
secondDist = d;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Unambiguous near-miss: within the cap, not an exact match (≥1), and strictly
|
|
318
|
+
// closer than the runner-up (ties → no suggestion).
|
|
319
|
+
return best !== null &&
|
|
320
|
+
bestDist >= 1 &&
|
|
321
|
+
bestDist <= MAX_SUGGESTION_DISTANCE &&
|
|
322
|
+
bestDist < secondDist
|
|
323
|
+
? best
|
|
324
|
+
: null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Build the "Fragment #X not found in headings" warning message, appending a
|
|
329
|
+
* "Did you mean #Y?" hint when closestSlug found an unambiguous near-miss.
|
|
330
|
+
* Shared by both fragment-not-found sites in validateFile().
|
|
331
|
+
*/
|
|
332
|
+
function fragmentNotFoundMessage(fragment, suggestion) {
|
|
333
|
+
return 'Fragment #' + fragment + ' not found in headings' +
|
|
334
|
+
(suggestion ? '. Did you mean #' + suggestion + '?' : '');
|
|
335
|
+
}
|
|
336
|
+
|
|
254
337
|
/**
|
|
255
338
|
* Extract heading slugs from MDX content.
|
|
256
339
|
* Includes markdown headings, <Update label="..."> anchors, and
|
|
@@ -411,12 +494,13 @@ function validateFile(filePath, contentDir, results, headingMap) {
|
|
|
411
494
|
if (!linkPath && fragment) {
|
|
412
495
|
const currentSlugs = headingMap.get(currentPagePath);
|
|
413
496
|
if (currentSlugs && !currentSlugs.has(fragment)) {
|
|
497
|
+
const suggestion = closestSlug(fragment, currentSlugs);
|
|
414
498
|
results.push({
|
|
415
499
|
type: 'broken_link',
|
|
416
500
|
file: relativePath,
|
|
417
501
|
line: lineNumber,
|
|
418
502
|
link: rawHref,
|
|
419
|
-
message:
|
|
503
|
+
message: fragmentNotFoundMessage(fragment, suggestion),
|
|
420
504
|
});
|
|
421
505
|
}
|
|
422
506
|
continue;
|
|
@@ -445,12 +529,13 @@ function validateFile(filePath, contentDir, results, headingMap) {
|
|
|
445
529
|
if (fragment) {
|
|
446
530
|
const targetSlugs = getTargetHeadingSlugs(targetPath, contentDir, headingMap);
|
|
447
531
|
if (targetSlugs && !targetSlugs.has(fragment)) {
|
|
532
|
+
const suggestion = closestSlug(fragment, targetSlugs);
|
|
448
533
|
results.push({
|
|
449
534
|
type: 'broken_link',
|
|
450
535
|
file: relativePath,
|
|
451
536
|
line: lineNumber,
|
|
452
537
|
link: rawHref,
|
|
453
|
-
message:
|
|
538
|
+
message: fragmentNotFoundMessage(fragment, suggestion),
|
|
454
539
|
});
|
|
455
540
|
}
|
|
456
541
|
}
|
|
@@ -576,7 +661,10 @@ if (require.main === module) {
|
|
|
576
661
|
const location = w.line ? `${w.file}:${w.line}` : w.file;
|
|
577
662
|
console.log(` ${location}`);
|
|
578
663
|
if (w.message) {
|
|
579
|
-
|
|
664
|
+
// message is a full sentence (may end in "…?"); keep the link on its
|
|
665
|
+
// own line so the output reads cleanly rather than "…mean #y?: #x".
|
|
666
|
+
console.log(` ${w.message}`);
|
|
667
|
+
console.log(` Link: ${w.link}`);
|
|
580
668
|
} else {
|
|
581
669
|
console.log(` Missing page: ${w.link}`);
|
|
582
670
|
}
|
|
@@ -592,4 +680,4 @@ if (require.main === module) {
|
|
|
592
680
|
// Export for programmatic use
|
|
593
681
|
// generateSlug + createSlugger are exported for the drift test
|
|
594
682
|
// (validate-links-slug.test.ts), which asserts they match the live github-slugger.
|
|
595
|
-
module.exports = { validateProject, generateSlug, createSlugger };
|
|
683
|
+
module.exports = { validateProject, generateSlug, createSlugger, closestSlug, levenshtein };
|
|
@@ -2923,9 +2923,9 @@
|
|
|
2923
2923
|
}
|
|
2924
2924
|
},
|
|
2925
2925
|
"node_modules/dompurify": {
|
|
2926
|
-
"version": "3.4.
|
|
2927
|
-
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.
|
|
2928
|
-
"integrity": "sha512-
|
|
2926
|
+
"version": "3.4.8",
|
|
2927
|
+
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
|
|
2928
|
+
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
|
|
2929
2929
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
|
2930
2930
|
"optionalDependencies": {
|
|
2931
2931
|
"@types/trusted-types": "^2.0.7"
|