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.
Files changed (33) hide show
  1. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +2 -0
  2. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +112 -0
  4. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +1 -0
  5. package/dist/__tests__/unit/language-filter.test.d.ts +2 -0
  6. package/dist/__tests__/unit/language-filter.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/language-filter.test.js +166 -0
  8. package/dist/__tests__/unit/language-filter.test.js.map +1 -0
  9. package/dist/lib/deprecated-components.d.ts +1 -0
  10. package/dist/lib/deprecated-components.d.ts.map +1 -1
  11. package/dist/lib/deprecated-components.js +1 -0
  12. package/dist/lib/deprecated-components.js.map +1 -1
  13. package/dist/lib/language-filter.d.ts +31 -0
  14. package/dist/lib/language-filter.d.ts.map +1 -0
  15. package/dist/lib/language-filter.js +14 -0
  16. package/dist/lib/language-filter.js.map +1 -0
  17. package/package.json +1 -1
  18. package/vendored/components/chat/ChatMessage.tsx +49 -15
  19. package/vendored/components/layout/EmbedLinkInterceptor.tsx +2 -4
  20. package/vendored/components/mdx/MermaidInner.tsx +96 -5
  21. package/vendored/components/search/SearchModal.tsx +19 -1
  22. package/vendored/lib/deprecated-components.ts +1 -0
  23. package/vendored/lib/firestore-helpers.ts +20 -0
  24. package/vendored/lib/is-modified-click.ts +11 -0
  25. package/vendored/lib/middleware-helpers.ts +4 -0
  26. package/vendored/lib/preprocess-mdx.ts +20 -12
  27. package/vendored/lib/redis.ts +32 -0
  28. package/vendored/lib/render-doc-page.tsx +35 -23
  29. package/vendored/lib/scanner-blocklist.ts +26 -1
  30. package/vendored/lib/sitemap-xsl.ts +121 -0
  31. package/vendored/lib/static-file-route.ts +7 -2
  32. package/vendored/lib/vector-store.ts +153 -69
  33. 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 (!rect.closest('.cluster') && !rect.closest('.node') && !rect.classList.contains('actor')) {
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
- styleElements(svgEl, 'text, .nodeLabel, .edgeLabel, .label, tspan', { fill: palette.text });
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 { checkForDeprecatedComponents } from './deprecated-components';
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
 
@@ -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
- const content = preprocessMdx(rawContent, { assetVersion: config.assetVersion });
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 = null;
500
+ let mdxError: string | null = preprocessError;
491
501
  let mdxCompileMs: number | null = null;
492
502
  const mdxCompileStart = performance.now();
493
- try {
494
- await compileMDX({
495
- source: effectiveSource,
496
- components: AllComponentsWithInline,
497
- options: {
498
- ...mdxSecurityOptions,
499
- mdxOptions: {
500
- ...getCommonMdxOptions(config, highlighter),
501
- recmaPlugins: [
502
- recmaCompoundComponents,
503
- recmaCollectMissingRefs(missingRefCollector),
504
- recmaGuardExpressions,
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
- } catch (err) {
510
- // Syntax/compile failure the only path that can 500 around MDXRemote.
511
- // Degrade to an inline placeholder; the chosen branch renders it instead
512
- // of calling MDXRemote with source known to throw at compile.
513
- mdxError = err instanceof Error ? err.message : String(err);
514
- logger.warn(`[MDX] compile failed for ${pagePath}: ${mdxError}`);
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'; // Django settings.py, webpack-stats.json, Laravel/Rails logs, Spring Boot /actuator|/management
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,