jamdesk 1.1.140 → 1.1.142
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/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
- package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
- package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
- package/dist/__tests__/unit/language-filter.test.js +166 -0
- package/dist/__tests__/unit/language-filter.test.js.map +1 -0
- package/dist/lib/deprecated-components.d.ts +1 -0
- package/dist/lib/deprecated-components.d.ts.map +1 -1
- package/dist/lib/deprecated-components.js +1 -0
- package/dist/lib/deprecated-components.js.map +1 -1
- package/dist/lib/language-filter.d.ts +31 -0
- package/dist/lib/language-filter.d.ts.map +1 -0
- package/dist/lib/language-filter.js +14 -0
- package/dist/lib/language-filter.js.map +1 -0
- package/package.json +1 -1
- package/vendored/components/chat/ChatMessage.tsx +49 -15
- package/vendored/components/layout/EmbedLinkInterceptor.tsx +2 -4
- package/vendored/components/mdx/MermaidInner.tsx +96 -5
- package/vendored/components/search/SearchModal.tsx +19 -1
- package/vendored/lib/deprecated-components.ts +1 -0
- package/vendored/lib/firestore-helpers.ts +20 -0
- package/vendored/lib/is-modified-click.ts +11 -0
- package/vendored/lib/middleware-helpers.ts +4 -0
- package/vendored/lib/preprocess-mdx.ts +20 -12
- package/vendored/lib/redis.ts +32 -0
- package/vendored/lib/render-doc-page.tsx +35 -23
- package/vendored/lib/scanner-blocklist.ts +26 -1
- package/vendored/lib/sitemap-xsl.ts +121 -0
- package/vendored/lib/static-file-route.ts +7 -2
- package/vendored/lib/vector-store.ts +153 -69
- package/vendored/workspace-package-lock.json +90 -90
|
@@ -47,8 +47,29 @@ interface ColorPalette {
|
|
|
47
47
|
ganttSections: string[];
|
|
48
48
|
ganttGridLine: string;
|
|
49
49
|
gitBranchColors: string[];
|
|
50
|
+
pieSliceStroke: string;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// Mermaid's built-in palettes (including 'neutral') color pie slices with
|
|
54
|
+
// washed-out, near-white grays: low-contrast on a light background and wiped to
|
|
55
|
+
// transparent by the dark-mode path-fill inversion below — so slices render with
|
|
56
|
+
// no visible color in either mode. We override slice + legend fills with a
|
|
57
|
+
// distinct, mid-tone palette that reads on both backgrounds, mirroring the
|
|
58
|
+
// curated colors already used for gantt bars and git branches. Every entry has at
|
|
59
|
+
// least one channel < 200, so isLightColor() never flags them.
|
|
60
|
+
const PIE_SLICE_COLORS = [
|
|
61
|
+
'#3b82f6', // blue
|
|
62
|
+
'#ef4444', // red
|
|
63
|
+
'#22c55e', // green
|
|
64
|
+
'#f59e0b', // amber
|
|
65
|
+
'#a855f7', // purple
|
|
66
|
+
'#14b8a6', // teal
|
|
67
|
+
'#ec4899', // pink
|
|
68
|
+
'#f97316', // orange
|
|
69
|
+
'#64748b', // slate
|
|
70
|
+
'#84cc16', // lime
|
|
71
|
+
];
|
|
72
|
+
|
|
52
73
|
const darkPalette: ColorPalette = {
|
|
53
74
|
text: '#e5e7eb',
|
|
54
75
|
line: '#9ca3af',
|
|
@@ -59,6 +80,7 @@ const darkPalette: ColorPalette = {
|
|
|
59
80
|
ganttSections: ['#1e1e3f', '#2d2d1e', '#1e2d1e'],
|
|
60
81
|
ganttGridLine: '#374151',
|
|
61
82
|
gitBranchColors: ['#3b82f6', '#eab308', '#22c55e', '#f97316', '#ec4899', '#a78bfa'],
|
|
83
|
+
pieSliceStroke: '#1f2937', // dark slate — crisp separation between slices on a dark bg
|
|
62
84
|
};
|
|
63
85
|
|
|
64
86
|
const lightPalette: ColorPalette = {
|
|
@@ -71,6 +93,7 @@ const lightPalette: ColorPalette = {
|
|
|
71
93
|
ganttSections: ['#f0f0ff', '#fff8e6', '#f0fff0'],
|
|
72
94
|
ganttGridLine: '#e5e7eb',
|
|
73
95
|
gitBranchColors: ['#3b82f6', '#eab308', '#22c55e', '#f97316', '#ec4899', '#8b5cf6'],
|
|
96
|
+
pieSliceStroke: '#ffffff', // white — crisp separation between slices on a light bg
|
|
74
97
|
};
|
|
75
98
|
|
|
76
99
|
// Styling helper utilities
|
|
@@ -129,7 +152,46 @@ function applyClassDiagramStyles(svgEl: SVGElement, palette: ColorPalette): void
|
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
function applyPieChartStyles(svgEl: SVGElement, palette: ColorPalette): void {
|
|
155
|
+
// Title and legend text follow the theme text color.
|
|
132
156
|
styleElements(svgEl, '.pieLabel, .legend text, .pieTitleText', { fill: palette.text });
|
|
157
|
+
|
|
158
|
+
const slices = svgEl.querySelectorAll<SVGPathElement>('path.pieCircle');
|
|
159
|
+
if (slices.length === 0) return; // not a pie chart
|
|
160
|
+
|
|
161
|
+
// Re-color slices and legend swatches from PIE_SLICE_COLORS. Each distinct
|
|
162
|
+
// original (washed-out) color maps to one vivid color, recorded as the slices
|
|
163
|
+
// are walked, then reused for the legend so a slice and its legend entry —
|
|
164
|
+
// which share mermaid's original ordinal color — get the same override.
|
|
165
|
+
const colorMap = new Map<string, string>();
|
|
166
|
+
let nextColor = 0;
|
|
167
|
+
const overrideFor = (rawFill: string | null | undefined): string => {
|
|
168
|
+
const key = normalizeColor(rawFill) ?? `pos-${nextColor}`;
|
|
169
|
+
let vivid = colorMap.get(key);
|
|
170
|
+
if (!vivid) {
|
|
171
|
+
vivid = PIE_SLICE_COLORS[nextColor % PIE_SLICE_COLORS.length];
|
|
172
|
+
nextColor += 1;
|
|
173
|
+
colorMap.set(key, vivid);
|
|
174
|
+
}
|
|
175
|
+
return vivid;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
slices.forEach((slice) => {
|
|
179
|
+
const vivid = overrideFor(slice.style.fill || slice.getAttribute('fill'));
|
|
180
|
+
slice.style.fill = vivid;
|
|
181
|
+
slice.style.stroke = palette.pieSliceStroke;
|
|
182
|
+
slice.style.strokeWidth = '2';
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Legend swatches: <g class="legend"> > rect, fill set via inline style.
|
|
186
|
+
svgEl.querySelectorAll<SVGRectElement>('.legend rect').forEach((swatch) => {
|
|
187
|
+
const vivid = overrideFor(swatch.style.fill || swatch.getAttribute('fill'));
|
|
188
|
+
swatch.style.fill = vivid;
|
|
189
|
+
swatch.style.stroke = vivid;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Percentage labels sit on top of the slices — white reads on every palette
|
|
193
|
+
// tone. The dark-mode text inversion spares text.slice so this survives there.
|
|
194
|
+
styleElements(svgEl, 'text.slice', { fill: '#ffffff' });
|
|
133
195
|
}
|
|
134
196
|
|
|
135
197
|
// Check if an RGB color is light (r,g,b > threshold)
|
|
@@ -147,6 +209,26 @@ function isDarkColor(rgbString: string, threshold = 150): boolean {
|
|
|
147
209
|
return r < threshold && g < threshold && b < threshold;
|
|
148
210
|
}
|
|
149
211
|
|
|
212
|
+
// Normalize a CSS color (hex or rgb()) to a canonical "r,g,b" string. A pie
|
|
213
|
+
// slice's fill arrives as a hex attribute (e.g. "#ECECFF") while its matching
|
|
214
|
+
// legend swatch's fill is an inline style the browser reports as "rgb(...)" —
|
|
215
|
+
// normalizing lets the same underlying color compare equal across both formats,
|
|
216
|
+
// so a slice and its legend entry get the same override color. Returns null when
|
|
217
|
+
// the value can't be parsed (caller falls back to a positional key).
|
|
218
|
+
function normalizeColor(value: string | null | undefined): string | null {
|
|
219
|
+
if (!value) return null;
|
|
220
|
+
const v = value.trim();
|
|
221
|
+
const rgb = v.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
|
|
222
|
+
if (rgb) return `${+rgb[1]},${+rgb[2]},${+rgb[3]}`;
|
|
223
|
+
let hex = v.replace(/^#/, '');
|
|
224
|
+
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
|
225
|
+
if (hex.length === 6 && /^[0-9a-f]{6}$/i.test(hex)) {
|
|
226
|
+
const n = parseInt(hex, 16);
|
|
227
|
+
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
150
232
|
// Shared styles applied to both light and dark modes
|
|
151
233
|
function applyCommonStyles(svgEl: SVGElement, palette: ColorPalette): void {
|
|
152
234
|
// Cluster/subgraph backgrounds
|
|
@@ -174,15 +256,22 @@ function applyCommonStyles(svgEl: SVGElement, palette: ColorPalette): void {
|
|
|
174
256
|
|
|
175
257
|
// Dark mode requires additional color inversions
|
|
176
258
|
function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void {
|
|
177
|
-
// Make background rects transparent
|
|
259
|
+
// Make background rects transparent. Pie legend swatches (.legend rect) are
|
|
260
|
+
// re-colored in applyPieChartStyles — leave their fills alone.
|
|
178
261
|
svgEl.querySelectorAll('rect').forEach((rect) => {
|
|
179
|
-
if (
|
|
262
|
+
if (
|
|
263
|
+
!rect.closest('.cluster') &&
|
|
264
|
+
!rect.closest('.node') &&
|
|
265
|
+
!rect.classList.contains('actor') &&
|
|
266
|
+
!rect.closest('.legend')
|
|
267
|
+
) {
|
|
180
268
|
(rect as SVGElement).style.fill = 'transparent';
|
|
181
269
|
}
|
|
182
270
|
});
|
|
183
271
|
|
|
184
|
-
// Invert text colors
|
|
185
|
-
|
|
272
|
+
// Invert text colors. text.slice (pie percentage labels) is excluded so the
|
|
273
|
+
// white set in applyPieChartStyles stays legible on the colored slices.
|
|
274
|
+
styleElements(svgEl, 'text:not(.slice), .nodeLabel, .edgeLabel, .label, tspan', { fill: palette.text });
|
|
186
275
|
styleElements(svgEl, 'text.actor, .messageText, .labelText, .loopText, .noteText', { fill: palette.text });
|
|
187
276
|
|
|
188
277
|
// Invert foreignObject text and clear light backgrounds
|
|
@@ -198,8 +287,10 @@ function applyDarkModeInversions(svgEl: SVGElement, palette: ColorPalette): void
|
|
|
198
287
|
}
|
|
199
288
|
});
|
|
200
289
|
|
|
201
|
-
// Invert lines and paths
|
|
290
|
+
// Invert lines and paths. Pie slices (path.pieCircle) carry their own colors
|
|
291
|
+
// from applyPieChartStyles — skip them so the fill isn't wiped to transparent.
|
|
202
292
|
svgEl.querySelectorAll('path, line').forEach((el) => {
|
|
293
|
+
if ((el as Element).classList.contains('pieCircle')) return;
|
|
203
294
|
const computed = window.getComputedStyle(el);
|
|
204
295
|
if (computed.stroke && computed.stroke !== 'none') {
|
|
205
296
|
(el as SVGElement).style.stroke = palette.line;
|
|
@@ -157,6 +157,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
157
157
|
const router = useRouter();
|
|
158
158
|
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
|
159
159
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
160
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
160
161
|
// Track if user clicked a result (to avoid double-tracking on modal close)
|
|
161
162
|
const hasTrackedRef = useRef(false);
|
|
162
163
|
// Store last search state for tracking on modal close
|
|
@@ -519,17 +520,34 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
519
520
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
|
|
520
521
|
<i className="fa-solid fa-magnifying-glass h-4 w-4 text-[var(--color-text-muted)] flex-shrink-0" aria-hidden="true" />
|
|
521
522
|
<input
|
|
523
|
+
ref={inputRef}
|
|
522
524
|
type="search"
|
|
523
525
|
placeholder="Search documentation…"
|
|
524
526
|
value={query}
|
|
525
527
|
onChange={(e) => setQuery(e.target.value)}
|
|
526
|
-
className="flex-1 bg-transparent text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none text-base"
|
|
528
|
+
className="flex-1 bg-transparent text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none text-base [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
|
|
527
529
|
autoComplete="off"
|
|
528
530
|
autoFocus
|
|
529
531
|
aria-label="Search documentation"
|
|
530
532
|
aria-controls="search-results"
|
|
531
533
|
aria-activedescendant={rows[selectedIndex] ? `search-result-${selectedIndex}` : undefined}
|
|
532
534
|
/>
|
|
535
|
+
{query && (
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() => {
|
|
539
|
+
setQuery('');
|
|
540
|
+
inputRef.current?.focus();
|
|
541
|
+
}}
|
|
542
|
+
className="flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-[var(--color-text-muted)] hover:bg-[var(--color-text-secondary)] transition-colors flex-shrink-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
|
|
543
|
+
aria-label="Clear search"
|
|
544
|
+
>
|
|
545
|
+
<i
|
|
546
|
+
className="fa-solid fa-xmark text-[9px] leading-none text-[var(--color-bg-primary)]"
|
|
547
|
+
aria-hidden="true"
|
|
548
|
+
/>
|
|
549
|
+
</button>
|
|
550
|
+
)}
|
|
533
551
|
<button
|
|
534
552
|
onClick={onClose}
|
|
535
553
|
className="p-1.5 cursor-pointer hover:bg-[var(--color-bg-tertiary)] rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
|
|
@@ -24,6 +24,7 @@ export interface DeprecatedComponentInfo {
|
|
|
24
24
|
/**
|
|
25
25
|
* Map of deprecated components to their replacements.
|
|
26
26
|
* Add new deprecated components here as they are removed.
|
|
27
|
+
* NEVER remove entries: render-time auto-migrate (preprocess-mdx.ts) rewrites stale R2 content with them forever.
|
|
27
28
|
*/
|
|
28
29
|
export const DEPRECATED_COMPONENTS: Record<string, DeprecatedComponentInfo> = {
|
|
29
30
|
CardGroup: {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import admin from 'firebase-admin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when a Firestore project claims this slug (the canonical slug lives at
|
|
5
|
+
* projects/{id}/github/config.slug — collection-group query avoids enumerating
|
|
6
|
+
* every project). Used as the server-side guard for expectOrphan deletes:
|
|
7
|
+
* orphan-purge tooling must never be able to wipe a live tenant, regardless of
|
|
8
|
+
* what a client-side script computed. The collection-group single-field index
|
|
9
|
+
* exemption for `github.slug` (COLLECTION_GROUP ASCENDING) already exists in
|
|
10
|
+
* dashboard/firestore.indexes.json, so this query needs no new index.
|
|
11
|
+
*/
|
|
12
|
+
export async function isSlugActive(slug: string): Promise<boolean> {
|
|
13
|
+
const snap = await admin
|
|
14
|
+
.firestore()
|
|
15
|
+
.collectionGroup('github')
|
|
16
|
+
.where('slug', '==', slug)
|
|
17
|
+
.limit(1)
|
|
18
|
+
.get();
|
|
19
|
+
return !snap.empty;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MouseEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when the user has asked the browser for special handling — new tab
|
|
5
|
+
* (cmd/ctrl), new window (shift), background tab (middle/non-primary button) —
|
|
6
|
+
* or another handler already claimed the event. Click interceptors must defer
|
|
7
|
+
* to native behavior in these cases instead of hijacking the click.
|
|
8
|
+
*/
|
|
9
|
+
export function isModifiedClick(e: MouseEvent): boolean {
|
|
10
|
+
return e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
|
11
|
+
}
|
|
@@ -431,6 +431,10 @@ const SKIP_EXTENSIONS = [
|
|
|
431
431
|
'.ttf',
|
|
432
432
|
'.eot',
|
|
433
433
|
'.map',
|
|
434
|
+
// sitemap.xsl is constant content (no project context needed); skipping
|
|
435
|
+
// avoids the resolution round-trip AND the password gate, which special-
|
|
436
|
+
// cases sitemap.xml but would otherwise intercept the stylesheet fetch.
|
|
437
|
+
'.xsl',
|
|
434
438
|
];
|
|
435
439
|
|
|
436
440
|
/**
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { ASSET_PREFIX, appendAssetVersion } from './docs-types';
|
|
10
|
-
import {
|
|
10
|
+
import { convertDeprecatedComponents } from './deprecated-components';
|
|
11
11
|
import { filterVisibility } from './visibility-filter';
|
|
12
|
+
import { logger } from '../shared/logger';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* JSX components that contain markdown content requiring special preprocessing.
|
|
@@ -1083,6 +1084,24 @@ export function preprocessMdx(content: string, options?: { assetVersion?: string
|
|
|
1083
1084
|
// that flow through preprocessMdx independently.
|
|
1084
1085
|
processed = filterVisibility(processed, 'humans');
|
|
1085
1086
|
|
|
1087
|
+
// Auto-migrate deprecated components instead of failing. Builds hard-fail
|
|
1088
|
+
// on these separately (build.ts — now the SOLE author-facing gate) so
|
|
1089
|
+
// authors are still told — but content uploaded to R2 BEFORE a component
|
|
1090
|
+
// was deprecated never rebuilds, and throwing here turned every render of
|
|
1091
|
+
// such a page into a 500 (2026-06-12 mintlfiy incident). Render what the
|
|
1092
|
+
// author meant. Runs EARLY so converted tags (e.g. <Columns>) get the same
|
|
1093
|
+
// JSX_CONTENT_COMPONENTS indentation/blank-line treatment as authored ones.
|
|
1094
|
+
// Called unconditionally so self-closing forms (e.g. <CardGroup/>) are not
|
|
1095
|
+
// silently skipped — conversion gates on changes.length > 0, not on a
|
|
1096
|
+
// separate pre-check whose pattern was narrower than convertDeprecatedComponents.
|
|
1097
|
+
const { content: converted, changes } = convertDeprecatedComponents(processed);
|
|
1098
|
+
if (changes.length > 0) {
|
|
1099
|
+
processed = converted;
|
|
1100
|
+
logger.warn(
|
|
1101
|
+
`[MDX] auto-migrated deprecated component(s) at render time: ${changes.join('; ')} — run \`jamdesk migrate\` to update the source`,
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1086
1105
|
// Strip snippet imports
|
|
1087
1106
|
processed = stripSnippetImports(processed);
|
|
1088
1107
|
|
|
@@ -1136,17 +1155,6 @@ export function preprocessMdx(content: string, options?: { assetVersion?: string
|
|
|
1136
1155
|
// it and bleed an :HL hint into prefetched RSC payloads.
|
|
1137
1156
|
processed = addLazyLoadingToRawImg(processed);
|
|
1138
1157
|
|
|
1139
|
-
// Check for deprecated components (defense-in-depth - builds also check earlier)
|
|
1140
|
-
const deprecatedErrors = checkForDeprecatedComponents(processed, 'content');
|
|
1141
|
-
if (deprecatedErrors.length > 0) {
|
|
1142
|
-
const err = deprecatedErrors[0];
|
|
1143
|
-
throw new Error(
|
|
1144
|
-
`<${err.component}> has been removed. Use <${err.replacement}> instead.\n\n` +
|
|
1145
|
-
`${err.migrationHint}\n\n` +
|
|
1146
|
-
`Run \`jamdesk migrate\` to convert automatically.`
|
|
1147
|
-
);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
1158
|
// Clean up any resulting empty lines at the start
|
|
1151
1159
|
processed = processed.replace(/^\n+/, '');
|
|
1152
1160
|
|
package/vendored/lib/redis.ts
CHANGED
|
@@ -116,6 +116,38 @@ export async function deleteDomainAuthSecret(
|
|
|
116
116
|
await upstashCommand(kvUrl, kvToken, 'DEL', `domainAuthSecret:${hostname}`);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Purge ALL slug-scoped Redis keys for a deleted project.
|
|
121
|
+
*
|
|
122
|
+
* Project deletion historically removed only the domain-scoped keys
|
|
123
|
+
* (domain:/domainCfg:/domainVerify:/domainStatus:, via removeDomain) and left
|
|
124
|
+
* these six behind — which kept deleted tenants resolvable on *.jamdesk.app
|
|
125
|
+
* and serving stale R2 content (2026-06-12 mintlfiy incident). Called from
|
|
126
|
+
* the build-service /delete-project-assets handler during the delete cascade.
|
|
127
|
+
*
|
|
128
|
+
* Returns the number of keys that actually existed and were deleted.
|
|
129
|
+
*/
|
|
130
|
+
export async function purgeProjectRedisKeys(
|
|
131
|
+
slug: string,
|
|
132
|
+
kvUrl?: string,
|
|
133
|
+
kvToken?: string
|
|
134
|
+
): Promise<number> {
|
|
135
|
+
if (!kvUrl || !kvToken) return 0;
|
|
136
|
+
const keys = [
|
|
137
|
+
`projectCfg:${slug}`,
|
|
138
|
+
`projectInactive:${slug}`,
|
|
139
|
+
`projectMeta:${slug}`,
|
|
140
|
+
`projectAuthSecret:${slug}`,
|
|
141
|
+
`projectAuthPublic:${slug}`,
|
|
142
|
+
`domainAuthSecret:${slug}.jamdesk.app`,
|
|
143
|
+
];
|
|
144
|
+
// Single variadic DEL: one round trip, and atomic on the Redis side — a
|
|
145
|
+
// transient failure can't purge some keys and orphan the rest (the exact
|
|
146
|
+
// partial-purge incident class this function exists to close).
|
|
147
|
+
const result = await upstashCommand(kvUrl, kvToken, 'DEL', ...keys);
|
|
148
|
+
return typeof result === 'number' ? result : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
119
151
|
// ---------------------------------------------------------------------------
|
|
120
152
|
// Project inactive flag — write side only.
|
|
121
153
|
// Set when the project owner's subscription is canceled, past_due, or
|
|
@@ -338,7 +338,17 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
338
338
|
? import('@/lib/openapi-isr')
|
|
339
339
|
: null;
|
|
340
340
|
|
|
341
|
-
|
|
341
|
+
// Preprocess can throw on malformed stale R2 content. A throw here escapes
|
|
342
|
+
// every guard below and 500s via error.tsx — catch it and route to the same
|
|
343
|
+
// inline-placeholder path as a compile failure (never-500 invariant).
|
|
344
|
+
let content = '';
|
|
345
|
+
let preprocessError: string | null = null;
|
|
346
|
+
try {
|
|
347
|
+
content = preprocessMdx(rawContent, { assetVersion: config.assetVersion });
|
|
348
|
+
} catch (err) {
|
|
349
|
+
preprocessError = err instanceof Error ? err.message : String(err);
|
|
350
|
+
logger.warn(`[MDX] preprocess failed for ${pagePath}: ${preprocessError}`);
|
|
351
|
+
}
|
|
342
352
|
|
|
343
353
|
const stepEntries: StepSlugEntry[] = extractHeadings(content)
|
|
344
354
|
.filter(h => typeof h.stepNumber === 'number')
|
|
@@ -487,31 +497,33 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
487
497
|
? content.replace(/<ResponseExample>[\s\S]*?<\/ResponseExample>/g, '')
|
|
488
498
|
: content;
|
|
489
499
|
const missingRefCollector: MissingRefCollector = { names: [] };
|
|
490
|
-
let mdxError: string | null =
|
|
500
|
+
let mdxError: string | null = preprocessError;
|
|
491
501
|
let mdxCompileMs: number | null = null;
|
|
492
502
|
const mdxCompileStart = performance.now();
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
503
|
+
if (!mdxError) {
|
|
504
|
+
try {
|
|
505
|
+
await compileMDX({
|
|
506
|
+
source: effectiveSource,
|
|
507
|
+
components: AllComponentsWithInline,
|
|
508
|
+
options: {
|
|
509
|
+
...mdxSecurityOptions,
|
|
510
|
+
mdxOptions: {
|
|
511
|
+
...getCommonMdxOptions(config, highlighter),
|
|
512
|
+
recmaPlugins: [
|
|
513
|
+
recmaCompoundComponents,
|
|
514
|
+
recmaCollectMissingRefs(missingRefCollector),
|
|
515
|
+
recmaGuardExpressions,
|
|
516
|
+
],
|
|
517
|
+
},
|
|
506
518
|
},
|
|
507
|
-
}
|
|
508
|
-
})
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
519
|
+
});
|
|
520
|
+
} catch (err) {
|
|
521
|
+
// Syntax/compile failure — the only path that can 500 around MDXRemote.
|
|
522
|
+
// Degrade to an inline placeholder; the chosen branch renders it instead
|
|
523
|
+
// of calling MDXRemote with source known to throw at compile.
|
|
524
|
+
mdxError = err instanceof Error ? err.message : String(err);
|
|
525
|
+
logger.warn(`[MDX] compile failed for ${pagePath}: ${mdxError}`);
|
|
526
|
+
}
|
|
515
527
|
}
|
|
516
528
|
mdxCompileMs = Math.round(performance.now() - mdxCompileStart);
|
|
517
529
|
|
|
@@ -55,7 +55,8 @@ export type ScannerCategory =
|
|
|
55
55
|
| 'secrets-config' // /secrets.yml, /application.yml, /vault.env, /elasticsearch.yml, etc.
|
|
56
56
|
| 'cms-exploit' // Atlassian .action/.jspa + Confluence /exportword + /rest/tinymce/*
|
|
57
57
|
| 'backup-archive' // /backup.sql, /*.zip, /*.tar.gz, /*.bak, /*.swp, /*.orig, etc.
|
|
58
|
-
| 'framework-config'
|
|
58
|
+
| 'framework-config' // Django settings.py, webpack-stats.json, Laravel/Rails logs, Spring Boot /actuator|/management
|
|
59
|
+
| 'legacy-content'; // Confluence-legacy /download/(attachments|thumbnails|export) — stale-index re-crawls of dead migrated URLs, NOT exploit probes
|
|
59
60
|
|
|
60
61
|
// Exact-match → category (lowercase keys).
|
|
61
62
|
const SCANNER_EXACT: ReadonlyMap<string, ScannerCategory> = new Map([
|
|
@@ -63,6 +64,10 @@ const SCANNER_EXACT: ReadonlyMap<string, ScannerCategory> = new Map([
|
|
|
63
64
|
['/wp-config.php', 'wordpress' as const],
|
|
64
65
|
['/wp-config.php.bak', 'wordpress' as const],
|
|
65
66
|
['/xmlrpc.php', 'wordpress' as const],
|
|
67
|
+
// Bare WP install-dir probe (40 hits / 72h, 2026-06-12). Exact match
|
|
68
|
+
// ONLY — a customer docs site about WordPress may legitimately own
|
|
69
|
+
// /wordpress/<page>; only the bare root probe is scanner-distinctive.
|
|
70
|
+
['/wordpress', 'wordpress' as const],
|
|
66
71
|
['/adminer.php', 'php-config' as const],
|
|
67
72
|
['/config.php', 'php-config' as const],
|
|
68
73
|
['/configuration.php', 'php-config' as const],
|
|
@@ -141,6 +146,24 @@ const CMS_EXPLOIT_EXTENSION_REGEX = /\.(action|jspa)$/;
|
|
|
141
146
|
const CMS_EXPLOIT_TINYMCE_PREFIX = '/rest/tinymce/';
|
|
142
147
|
const CMS_EXPLOIT_EXPORTWORD = '/exportword';
|
|
143
148
|
|
|
149
|
+
// Confluence-legacy download URL space: /download/attachments/<id>/…,
|
|
150
|
+
// /download/thumbnails/<id>/…, /download/export/pdfexport-<jobid>/….
|
|
151
|
+
// Stale crawlers replay these from pre-migration indexes (photofinale's
|
|
152
|
+
// old Confluence site); each otherwise costs an assets-route pass +
|
|
153
|
+
// ~300ms R2 GET before 404ing. The numeric content-ID segment /
|
|
154
|
+
// pdfexport- job prefix are the Confluence fingerprints —
|
|
155
|
+
// precision-over-recall: bare /download, non-numeric IDs, and other
|
|
156
|
+
// /download/export/* names intentionally do NOT match.
|
|
157
|
+
//
|
|
158
|
+
// NOTE: this check runs BEFORE tenant docs.json redirect resolution
|
|
159
|
+
// (proxy.ts step order), so matching paths can never be tenant redirect
|
|
160
|
+
// SOURCES. If a future Confluence-migration customer needs 301s over
|
|
161
|
+
// this URL space to preserve backlinks, this pattern must be revisited.
|
|
162
|
+
// Own category (not cms-exploit): these are benign stale re-crawls,
|
|
163
|
+
// and cms-exploit volume feeds the digest's WAF-promotion ranking.
|
|
164
|
+
const LEGACY_CONTENT_CONFLUENCE_DOWNLOAD_REGEX =
|
|
165
|
+
/^\/download\/(?:(?:attachments|thumbnails)\/\d+(?:\/|$)|export\/pdfexport-)/;
|
|
166
|
+
|
|
144
167
|
// Prefix-match → category (lowercase). Matches the exact prefix OR `${prefix}/...`.
|
|
145
168
|
// /actuator and /management cover Spring Boot management endpoints —
|
|
146
169
|
// /management is the default-overridable base path (management.endpoints.web.base-path).
|
|
@@ -232,6 +255,8 @@ export function classifyScannerProbe(pathname: string): ScannerCategory | null {
|
|
|
232
255
|
if (lower === CMS_EXPLOIT_EXPORTWORD) return 'cms-exploit';
|
|
233
256
|
if (lower.startsWith(CMS_EXPLOIT_TINYMCE_PREFIX)) return 'cms-exploit';
|
|
234
257
|
|
|
258
|
+
if (LEGACY_CONTENT_CONFLUENCE_DOWNLOAD_REGEX.test(lower)) return 'legacy-content';
|
|
259
|
+
|
|
235
260
|
if (BACKUP_ARCHIVE_EXTENSION_REGEX.test(lower)) return 'backup-archive';
|
|
236
261
|
|
|
237
262
|
if (FRAMEWORK_BASENAMES.has(basename)) return 'framework-config';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap XSL stylesheet — purely cosmetic, for humans who open sitemap.xml
|
|
3
|
+
* in a browser. Crawlers ignore the xml-stylesheet processing instruction.
|
|
4
|
+
*
|
|
5
|
+
* Served at /sitemap.xsl and /docs/sitemap.xsl by constant-content routes
|
|
6
|
+
* (no R2, identical for every project). The PI injected into sitemap.xml
|
|
7
|
+
* uses a RELATIVE href ("sitemap.xsl") so the same XML body resolves the
|
|
8
|
+
* stylesheet correctly from both /sitemap.xml and /docs/sitemap.xml —
|
|
9
|
+
* browsers require the XSL to be same-origin anyway.
|
|
10
|
+
*
|
|
11
|
+
* XSLT 1.0 only: that's all browser XSLT processors implement.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { NextResponse } from 'next/server';
|
|
15
|
+
|
|
16
|
+
/** Processing instruction injected into served sitemap.xml bodies. */
|
|
17
|
+
export const SITEMAP_STYLESHEET_PI = '<?xml-stylesheet type="text/xsl" href="sitemap.xsl"?>';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inject the stylesheet PI immediately after the XML declaration (the PI is
|
|
21
|
+
* only valid in the prolog, before the root element). Sitemaps without a
|
|
22
|
+
* declaration get it prepended. Idempotent: if a build-time generator ever
|
|
23
|
+
* starts baking a stylesheet PI into the R2 artifact, serve-time injection
|
|
24
|
+
* must not double it.
|
|
25
|
+
*/
|
|
26
|
+
export function injectSitemapStylesheet(xml: string): string {
|
|
27
|
+
if (xml.includes('<?xml-stylesheet')) return xml;
|
|
28
|
+
const declMatch = xml.match(/^\uFEFF?<\?xml[^?]*\?>\r?\n?/);
|
|
29
|
+
if (declMatch) {
|
|
30
|
+
return declMatch[0] + SITEMAP_STYLESHEET_PI + '\n' + xml.slice(declMatch[0].length);
|
|
31
|
+
}
|
|
32
|
+
return SITEMAP_STYLESHEET_PI + '\n' + xml;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shared GET handler for /sitemap.xsl and /docs/sitemap.xsl (same pattern as
|
|
37
|
+
* lib/api-specs-route.ts) - one place for the headers, two thin route files.
|
|
38
|
+
*/
|
|
39
|
+
export function sitemapXslRouteHandler(): NextResponse {
|
|
40
|
+
return new NextResponse(SITEMAP_XSL, {
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'text/xsl; charset=utf-8',
|
|
43
|
+
'Cache-Control': 'public, max-age=86400, s-maxage=86400',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const SITEMAP_XSL = `<?xml version="1.0" encoding="UTF-8"?>
|
|
49
|
+
<xsl:stylesheet version="1.0"
|
|
50
|
+
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
|
|
51
|
+
xmlns:sm="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
52
|
+
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
53
|
+
<xsl:output method="html" encoding="UTF-8" indent="yes"/>
|
|
54
|
+
<xsl:template match="/">
|
|
55
|
+
<html>
|
|
56
|
+
<head>
|
|
57
|
+
<title>XML Sitemap</title>
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
59
|
+
<style>
|
|
60
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
61
|
+
margin: 0; padding: 2rem; color: #1a1a2e; background: #fafafa; }
|
|
62
|
+
h1 { font-size: 1.4rem; margin: 0 0 0.25rem; }
|
|
63
|
+
p.meta { color: #6b7280; margin: 0 0 1.5rem; font-size: 0.875rem; }
|
|
64
|
+
table { border-collapse: collapse; width: 100%; background: #fff;
|
|
65
|
+
border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden;
|
|
66
|
+
font-size: 0.875rem; }
|
|
67
|
+
th { text-align: left; padding: 0.6rem 0.9rem; background: #f3f4f6;
|
|
68
|
+
font-weight: 600; border-bottom: 1px solid #e5e7eb; white-space: nowrap; }
|
|
69
|
+
td { padding: 0.5rem 0.9rem; border-bottom: 1px solid #f3f4f6;
|
|
70
|
+
vertical-align: top; }
|
|
71
|
+
tr:last-child td { border-bottom: none; }
|
|
72
|
+
a { color: #4f46e5; text-decoration: none; word-break: break-all; }
|
|
73
|
+
a:hover { text-decoration: underline; }
|
|
74
|
+
.num { color: #9ca3af; text-align: right; }
|
|
75
|
+
.alt { color: #6b7280; white-space: nowrap; }
|
|
76
|
+
.alt a { color: #6b7280; }
|
|
77
|
+
.nowrap { white-space: nowrap; }
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<h1>XML Sitemap</h1>
|
|
82
|
+
<p class="meta">
|
|
83
|
+
<xsl:value-of select="count(sm:urlset/sm:url)"/> URLs.
|
|
84
|
+
This is how the sitemap looks to humans; search engines read the raw XML.
|
|
85
|
+
</p>
|
|
86
|
+
<table>
|
|
87
|
+
<tr>
|
|
88
|
+
<th class="num">#</th>
|
|
89
|
+
<th>URL</th>
|
|
90
|
+
<xsl:if test="sm:urlset/sm:url/xhtml:link">
|
|
91
|
+
<th>Languages</th>
|
|
92
|
+
</xsl:if>
|
|
93
|
+
<th>Last modified</th>
|
|
94
|
+
<th>Change freq.</th>
|
|
95
|
+
<th>Priority</th>
|
|
96
|
+
</tr>
|
|
97
|
+
<xsl:for-each select="sm:urlset/sm:url">
|
|
98
|
+
<tr>
|
|
99
|
+
<td class="num"><xsl:value-of select="position()"/></td>
|
|
100
|
+
<td>
|
|
101
|
+
<a href="{sm:loc}"><xsl:value-of select="sm:loc"/></a>
|
|
102
|
+
</td>
|
|
103
|
+
<xsl:if test="/sm:urlset/sm:url/xhtml:link">
|
|
104
|
+
<td class="alt">
|
|
105
|
+
<xsl:for-each select="xhtml:link[@hreflang != 'x-default']">
|
|
106
|
+
<a href="{@href}"><xsl:value-of select="@hreflang"/></a>
|
|
107
|
+
<xsl:if test="position() != last()"><xsl:text> </xsl:text></xsl:if>
|
|
108
|
+
</xsl:for-each>
|
|
109
|
+
</td>
|
|
110
|
+
</xsl:if>
|
|
111
|
+
<td class="nowrap"><xsl:value-of select="sm:lastmod"/></td>
|
|
112
|
+
<td><xsl:value-of select="sm:changefreq"/></td>
|
|
113
|
+
<td><xsl:value-of select="sm:priority"/></td>
|
|
114
|
+
</tr>
|
|
115
|
+
</xsl:for-each>
|
|
116
|
+
</table>
|
|
117
|
+
</body>
|
|
118
|
+
</html>
|
|
119
|
+
</xsl:template>
|
|
120
|
+
</xsl:stylesheet>
|
|
121
|
+
`;
|
|
@@ -15,6 +15,7 @@ import { isKnownLanguageCode } from '@/lib/language-utils';
|
|
|
15
15
|
import { log } from '@/lib/logger';
|
|
16
16
|
import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
17
17
|
import { fetchStaticFile } from '@/lib/r2-content';
|
|
18
|
+
import { injectSitemapStylesheet } from '@/lib/sitemap-xsl';
|
|
18
19
|
|
|
19
20
|
/** Filenames served via createStaticFileHandler (7 at root + 7 at /docs). */
|
|
20
21
|
export const STATIC_FILE_NAMES = [
|
|
@@ -207,6 +208,10 @@ export function createStaticFileHandler(
|
|
|
207
208
|
const contentType = contentTypeOverride || getContentType(filename);
|
|
208
209
|
const cacheControl = cacheControlOverride || 'public, max-age=3600, s-maxage=3600';
|
|
209
210
|
const synthesizeNoindexResponse = buildNoindexResponse(filename);
|
|
211
|
+
// Cosmetic browser rendering only — crawlers ignore the PI. Injected at
|
|
212
|
+
// serve time (not into the R2 artifact) so it ships with an ISR deploy
|
|
213
|
+
// alone: no Cloud Run change, no per-project rebuilds.
|
|
214
|
+
const postProcess = filename === 'sitemap.xml' ? injectSitemapStylesheet : (s: string) => s;
|
|
210
215
|
|
|
211
216
|
return async function GET(request: NextRequest): Promise<NextResponse> {
|
|
212
217
|
if (!isIsrMode()) {
|
|
@@ -216,7 +221,7 @@ export function createStaticFileHandler(
|
|
|
216
221
|
// 404s on these paths and breaks the SearchModal init.
|
|
217
222
|
const localPath = path.join(process.cwd(), 'public', filename);
|
|
218
223
|
if (fs.existsSync(localPath)) {
|
|
219
|
-
return new NextResponse(fs.readFileSync(localPath), {
|
|
224
|
+
return new NextResponse(postProcess(fs.readFileSync(localPath, 'utf-8')), {
|
|
220
225
|
headers: { 'Content-Type': contentType, 'Cache-Control': 'no-cache' },
|
|
221
226
|
});
|
|
222
227
|
}
|
|
@@ -245,7 +250,7 @@ export function createStaticFileHandler(
|
|
|
245
250
|
return new NextResponse(`${label} not found`, { status: 404 });
|
|
246
251
|
}
|
|
247
252
|
|
|
248
|
-
return new NextResponse(content, {
|
|
253
|
+
return new NextResponse(postProcess(content), {
|
|
249
254
|
headers: {
|
|
250
255
|
'Content-Type': contentType,
|
|
251
256
|
'Cache-Control': cacheControl,
|