jamdesk 1.1.213 → 1.1.214
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/lib/deps.js +1 -1
- package/package.json +3 -3
- package/vendored/app/api/markdown-export/[project]/[...slug]/route.ts +31 -1
- package/vendored/components/mdx/Tooltip.tsx +27 -1
- package/vendored/lib/email-notifier.ts +6 -8
- package/vendored/lib/layout-helpers.tsx +1 -1
- package/vendored/lib/mailfully.ts +86 -0
- package/vendored/lib/markdown-absolute-links.ts +197 -0
- package/vendored/lib/middleware-helpers.ts +24 -0
- package/vendored/lib/screenshot-capture.ts +88 -16
- package/vendored/lib/static-artifacts.ts +10 -3
- package/vendored/lib/static-file-route.ts +17 -1
- package/vendored/themes/dusk/variables.css +58 -8
- package/vendored/workspace-package-lock.json +184 -161
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
import { chromium, Browser } from 'playwright';
|
|
15
15
|
import { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } from '@aws-sdk/client-s3';
|
|
16
16
|
import { getFirestore } from 'firebase-admin/firestore';
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { resolveAuth, type ResolvedAuth } from './auth-resolver';
|
|
19
|
+
import { signAuthCookie, signAuthCookieV2 } from '../shared/auth-cookie';
|
|
17
20
|
|
|
18
21
|
interface CaptureOptions {
|
|
19
22
|
slug: string;
|
|
@@ -97,20 +100,64 @@ export async function launchChromiumWithRetry(
|
|
|
97
100
|
throw lastError;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
// Long enough to cover the 45s capture race, short enough that a leaked
|
|
104
|
+
// cookie is worthless almost immediately (visitor unlocks last 30 days).
|
|
105
|
+
const CAPTURE_COOKIE_TTL_MS = 5 * 60_000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mint the jd_auth cookie a successful unlock would set, so a password- or
|
|
109
|
+
* JWT-gated site is captured as an unlocked visitor sees it instead of as the
|
|
110
|
+
* unlock form. Uses the gate's own resolveAuth + signing, so the cookie is
|
|
111
|
+
* exactly what the proxy verifies. Cloud Run already holds EDGE_AUTH_SECRET
|
|
112
|
+
* and R2 read access to every tenant's content, so this grants the capture
|
|
113
|
+
* nothing new. JWT mode gets no groups (least privilege): a group-restricted
|
|
114
|
+
* homepage captures as the gate's 404.
|
|
115
|
+
*
|
|
116
|
+
* Returns null when the site is ungated, or when the cookie can't be minted —
|
|
117
|
+
* the capture then proceeds unauthenticated, as it did before this existed.
|
|
118
|
+
* Never rejects: the capture starts it before launching Chromium and may
|
|
119
|
+
* abandon it on timeout.
|
|
120
|
+
*/
|
|
121
|
+
export async function mintCaptureAuthCookie(
|
|
122
|
+
slug: string,
|
|
123
|
+
host: string,
|
|
124
|
+
): Promise<{ name: string; value: string } | null> {
|
|
125
|
+
let auth: ResolvedAuth | null;
|
|
126
|
+
try {
|
|
127
|
+
auth = await resolveAuth(slug);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.warn(`Screenshot auth lookup failed for ${slug}; capturing without unlock cookie:`, error);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (!auth) return null;
|
|
133
|
+
const secret = process.env.EDGE_AUTH_SECRET;
|
|
134
|
+
if (!secret) {
|
|
135
|
+
console.warn(`Screenshot: ${slug} is gated but EDGE_AUTH_SECRET is unset; capture will hit the password wall`);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = { slug, host, version: auth.version, expiresAt: Date.now() + CAPTURE_COOKIE_TTL_MS };
|
|
140
|
+
const value = auth.authType === 'jwt'
|
|
141
|
+
? await signAuthCookieV2(payload, {}, secret)
|
|
142
|
+
: await signAuthCookie(payload, secret);
|
|
143
|
+
return { name: `jd_auth_${slug}`, value };
|
|
144
|
+
}
|
|
145
|
+
|
|
100
146
|
/**
|
|
101
|
-
* Clean up old screenshots for a project, keeping only the
|
|
102
|
-
* This prevents R2 storage from accumulating old screenshots.
|
|
147
|
+
* Clean up old screenshots for a project, keeping only the one just uploaded.
|
|
148
|
+
* This prevents R2 storage from accumulating old screenshots. Exact key, not
|
|
149
|
+
* the build's prefix: a regenerate reuses the buildId under a new file name,
|
|
150
|
+
* and the superseded image must not stay reachable.
|
|
103
151
|
*/
|
|
104
152
|
async function cleanupOldScreenshots(
|
|
105
153
|
r2Client: S3Client,
|
|
106
154
|
r2Bucket: string,
|
|
107
155
|
slug: string,
|
|
108
|
-
|
|
156
|
+
currentKey: string
|
|
109
157
|
): Promise<void> {
|
|
110
158
|
try {
|
|
111
159
|
// R2 key pattern matches assets API: {slug}/assets/_screenshots/{buildId}/
|
|
112
160
|
const prefix = `${slug}/assets/_screenshots/`;
|
|
113
|
-
const currentPrefix = `${slug}/assets/_screenshots/${currentBuildId}/`;
|
|
114
161
|
|
|
115
162
|
// List all screenshot objects for this project
|
|
116
163
|
const listResponse = await r2Client.send(
|
|
@@ -124,9 +171,8 @@ async function cleanupOldScreenshots(
|
|
|
124
171
|
return;
|
|
125
172
|
}
|
|
126
173
|
|
|
127
|
-
// Filter out objects from the current build
|
|
128
174
|
const objectsToDelete = listResponse.Contents.filter(
|
|
129
|
-
(obj) => obj.Key &&
|
|
175
|
+
(obj) => obj.Key && obj.Key !== currentKey
|
|
130
176
|
).map((obj) => ({ Key: obj.Key! }));
|
|
131
177
|
|
|
132
178
|
if (objectsToDelete.length === 0) {
|
|
@@ -191,6 +237,11 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
191
237
|
});
|
|
192
238
|
|
|
193
239
|
const capturePromise = async (): Promise<CaptureResult> => {
|
|
240
|
+
// Gated site: without this the capture screenshots the unlock form.
|
|
241
|
+
// Started before launch so its Redis reads overlap Chromium startup
|
|
242
|
+
// instead of spending the 45s budget sequentially.
|
|
243
|
+
const authCookiePromise = mintCaptureAuthCookie(slug, new URL(siteUrl).host);
|
|
244
|
+
|
|
194
245
|
// Launch browser with system Chromium (retry transient launch flakes)
|
|
195
246
|
browser = await launchChromiumWithRetry();
|
|
196
247
|
|
|
@@ -207,6 +258,13 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
207
258
|
const page = await browser.newPage();
|
|
208
259
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
209
260
|
|
|
261
|
+
const authCookie = await authCookiePromise;
|
|
262
|
+
if (authCookie) {
|
|
263
|
+
await page.context().addCookies([
|
|
264
|
+
{ ...authCookie, url: siteUrl, httpOnly: true, secure: true, sameSite: 'Lax' },
|
|
265
|
+
]);
|
|
266
|
+
}
|
|
267
|
+
|
|
210
268
|
// Cold render after a project-wide revalidation can transiently 404 —
|
|
211
269
|
// retry once on 4xx/5xx, then capture whatever the page shows so the
|
|
212
270
|
// dashboard preview reflects the real state of the site.
|
|
@@ -218,14 +276,22 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
218
276
|
// (jamdesk-docs) NEVER goes network-idle. 'networkidle' timed out at 15s
|
|
219
277
|
// and every capture/Retry failed. 'load' fires before the prefetch storm.
|
|
220
278
|
const gotoOpts = { waitUntil: 'load', timeout: 15000 } as const;
|
|
221
|
-
console.log(`Capturing screenshot from: ${homepageUrl}`);
|
|
222
|
-
|
|
223
|
-
const firstStatus =
|
|
279
|
+
console.log(`Capturing screenshot from: ${homepageUrl}${authCookie ? ' (unlock cookie attached)' : ''}`);
|
|
280
|
+
let response = await page.goto(homepageUrl, gotoOpts);
|
|
281
|
+
const firstStatus = response?.status();
|
|
224
282
|
|
|
225
283
|
if (firstStatus !== undefined && firstStatus >= 400) {
|
|
226
284
|
console.log(`Screenshot got ${firstStatus} on first attempt, retrying once after 3s`);
|
|
227
285
|
await page.waitForTimeout(3000);
|
|
228
|
-
await page.goto(homepageUrl, gotoOpts);
|
|
286
|
+
response = await page.goto(homepageUrl, gotoOpts);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Still behind the password/JWT wall (cookie not minted or rejected):
|
|
290
|
+
// the page is the unlock form, which must never be stored as the site
|
|
291
|
+
// preview. Fail so the dashboard offers Retry instead.
|
|
292
|
+
const finalStatus = response?.status();
|
|
293
|
+
if (finalStatus === 401 || finalStatus === 403) {
|
|
294
|
+
return { screenshotUrl: null, error: `Screenshot blocked by site auth (HTTP ${finalStatus})` };
|
|
229
295
|
}
|
|
230
296
|
|
|
231
297
|
// Short best-effort settle for late images/fonts — the page above already
|
|
@@ -256,10 +322,17 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
256
322
|
}
|
|
257
323
|
|
|
258
324
|
// Upload to R2 with timeout via AbortController
|
|
259
|
-
// R2 key follows ISR assets pattern: {slug}/assets/_screenshots/{buildId}/
|
|
325
|
+
// R2 key follows ISR assets pattern: {slug}/assets/_screenshots/{buildId}/{name}.jpg
|
|
260
326
|
// ISR middleware routes /_screenshots/*.jpeg → /api/assets/_screenshots/*.jpeg
|
|
261
327
|
// Assets API builds R2 key: {project}/assets/{assetPath}
|
|
262
|
-
|
|
328
|
+
//
|
|
329
|
+
// The random name is what keeps a password-protected site's preview
|
|
330
|
+
// private: the proxy serves this path without the unlock cookie, and the
|
|
331
|
+
// buildId alone is NOT secret (lastBuildId sits on the projects doc, which
|
|
332
|
+
// any signed-in user can read). The full URL is stored only on the
|
|
333
|
+
// members-only build doc. See isSiteScreenshotPath.
|
|
334
|
+
const screenshotPath = `_screenshots/${buildId}/${randomBytes(16).toString('hex')}.jpg`;
|
|
335
|
+
const screenshotKey = `${slug}/assets/${screenshotPath}`;
|
|
263
336
|
const abortController = new AbortController();
|
|
264
337
|
const uploadTimeout = setTimeout(() => abortController.abort(), 5000);
|
|
265
338
|
|
|
@@ -279,11 +352,10 @@ export async function captureHomepageScreenshot(options: CaptureOptions): Promis
|
|
|
279
352
|
}
|
|
280
353
|
|
|
281
354
|
// Clean up old screenshots after successful upload
|
|
282
|
-
await cleanupOldScreenshots(r2Client, r2Bucket, slug,
|
|
355
|
+
await cleanupOldScreenshots(r2Client, r2Bucket, slug, screenshotKey);
|
|
283
356
|
|
|
284
|
-
// Return the project subdomain URL (
|
|
285
|
-
|
|
286
|
-
const screenshotUrl = `https://${slug}.jamdesk.app/_screenshots/${buildId}/homepage.jpg`;
|
|
357
|
+
// Return the project subdomain URL (a new name per capture busts caches)
|
|
358
|
+
const screenshotUrl = `https://${slug}.jamdesk.app/${screenshotPath}`;
|
|
287
359
|
|
|
288
360
|
return { screenshotUrl };
|
|
289
361
|
};
|
|
@@ -364,8 +364,9 @@ function bucketPagesByLocale<P extends { path: string }>(
|
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
/**
|
|
367
|
-
* Render the root llms.txt (default locale) plus one file per
|
|
368
|
-
*
|
|
367
|
+
* Render the root llms.txt (default locale) plus one file per active language
|
|
368
|
+
* (see bucketPagesByLocale for membership). The default language's file is a
|
|
369
|
+
* copy of the root, written only when another language is active too.
|
|
369
370
|
* Cross-links between locale files render under the trailing "## Optional"
|
|
370
371
|
* section.
|
|
371
372
|
*/
|
|
@@ -400,7 +401,13 @@ export function generateLlmsTxtFiles(
|
|
|
400
401
|
|
|
401
402
|
const byLocale: Record<string, string> = {};
|
|
402
403
|
for (const code of activeCodes) {
|
|
403
|
-
if (code === defaultCode)
|
|
404
|
+
if (code === defaultCode) {
|
|
405
|
+
// The default locale's index IS the root file. Also writing it under
|
|
406
|
+
// /{default}/ keeps its URL in step with /{default}/llms-full.txt, which
|
|
407
|
+
// generateLlmsFullTxtFiles writes under the same two-active-locales rule.
|
|
408
|
+
if (activeCodes.length > 1) byLocale[code] = root;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
404
411
|
byLocale[code] = generateLlmsTxt({
|
|
405
412
|
...rest, baseUrl, hostAtDocs, docsPrefix, sections,
|
|
406
413
|
pages: pagesFor(code),
|
|
@@ -11,6 +11,7 @@ import path from 'path';
|
|
|
11
11
|
|
|
12
12
|
import { NextRequest, NextResponse } from 'next/server';
|
|
13
13
|
|
|
14
|
+
import { getLanguageRouting } from '@/lib/language-matcher';
|
|
14
15
|
import { isKnownLanguageCode } from '@/lib/language-utils';
|
|
15
16
|
import { log } from '@/lib/logger';
|
|
16
17
|
import { isIsrMode } from '@/lib/page-isr-helpers';
|
|
@@ -173,6 +174,13 @@ export function createCorsStaticFileHandler(
|
|
|
173
174
|
* Like llms.txt, locale files are intentionally NOT suppressed on
|
|
174
175
|
* `x-jd-noindex` — AI agents fetch them directly and don't honor robots
|
|
175
176
|
* semantics.
|
|
177
|
+
*
|
|
178
|
+
* Builds since 2026-09-23 write `/{default}/llms.txt` as a copy of the root.
|
|
179
|
+
* For sites not rebuilt since, a miss on the default language falls back to
|
|
180
|
+
* the root file, so `/en/llms.txt` answers next to `/en/llms-full.txt` without
|
|
181
|
+
* waiting for a rebuild. The default comes from `_languages.json`, which is
|
|
182
|
+
* null when fewer than two languages are routable, when a site predates the
|
|
183
|
+
* file, or on a read error — all of those keep the plain 404.
|
|
176
184
|
*/
|
|
177
185
|
export function createLocaleLlmsTxtHandler(file: 'llms.txt' | 'llms-full.txt' = 'llms.txt'): (
|
|
178
186
|
request: NextRequest,
|
|
@@ -205,7 +213,15 @@ export function createLocaleLlmsTxtHandler(file: 'llms.txt' | 'llms-full.txt' =
|
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
try {
|
|
208
|
-
|
|
216
|
+
let content = await fetchStaticFile(projectSlug, filename);
|
|
217
|
+
if (content === null && file === 'llms.txt') {
|
|
218
|
+
// Fallback for sites built before the default-locale copy existed (see
|
|
219
|
+
// the doc comment for when routing is null).
|
|
220
|
+
const routing = await getLanguageRouting(projectSlug);
|
|
221
|
+
if (routing?.defaultLanguage.toLowerCase() === locale.toLowerCase()) {
|
|
222
|
+
content = await fetchStaticFile(projectSlug, 'llms.txt');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
209
225
|
if (content === null) {
|
|
210
226
|
return new NextResponse('Not found', { status: 404 });
|
|
211
227
|
}
|
|
@@ -40,9 +40,10 @@
|
|
|
40
40
|
|
|
41
41
|
--color-border: #D4D7DD;
|
|
42
42
|
--color-border-hover: #C6CBD3;
|
|
43
|
-
--border-width:
|
|
43
|
+
--border-width: 0.5px; /* hairline dividers, jam's weight */
|
|
44
44
|
|
|
45
45
|
--color-text-primary: #1A1D22; /* 15.1:1 */
|
|
46
|
+
--color-heading: #2B3038; /* 11.8:1 — a step softer than text-primary */
|
|
46
47
|
--color-text-secondary: #3C424C; /* 9.0:1 */
|
|
47
48
|
--color-text-tertiary: #515A67; /* 6.2:1 */
|
|
48
49
|
--color-text-muted: #616B7B; /* 4.8:1 */
|
|
@@ -104,7 +105,7 @@
|
|
|
104
105
|
|
|
105
106
|
/* Weights only — sizes come from jam's ladder. Body is 400, not jam's 300:
|
|
106
107
|
a thin weight undoes the contrast the grays above are tuned for. */
|
|
107
|
-
--prose-h1-weight:
|
|
108
|
+
--prose-h1-weight: 500;
|
|
108
109
|
--prose-h2-weight: 600;
|
|
109
110
|
--prose-h3-weight: 600;
|
|
110
111
|
--prose-h4-weight: 600;
|
|
@@ -123,6 +124,7 @@
|
|
|
123
124
|
--color-border-hover: #3E444E;
|
|
124
125
|
|
|
125
126
|
--color-text-primary: #E4E7EA; /* 15.0:1 */
|
|
127
|
+
--color-heading: var(--color-text-primary);
|
|
126
128
|
--color-text-secondary: #B8BFC7; /* 10.0:1 */
|
|
127
129
|
--color-text-tertiary: #909AA7; /* 6.5:1 */
|
|
128
130
|
--color-text-muted: #768394; /* 4.8:1 */
|
|
@@ -192,7 +194,24 @@ body[data-theme="dusk"] h1 {
|
|
|
192
194
|
line-height: var(--prose-h1-line-height);
|
|
193
195
|
font-weight: var(--prose-h1-weight);
|
|
194
196
|
letter-spacing: -0.01em;
|
|
195
|
-
color: var(--color-
|
|
197
|
+
color: var(--color-heading);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* A bigger H1 than jam's 30/27/24 ladder. Set on body so it beats jam's
|
|
201
|
+
:root media queries regardless of stylesheet order. */
|
|
202
|
+
body[data-theme="dusk"] {
|
|
203
|
+
--prose-h1-size: 34px;
|
|
204
|
+
}
|
|
205
|
+
@media (min-width: 640px) and (max-width: 1023px) {
|
|
206
|
+
body[data-theme="dusk"] { --prose-h1-size: 30px; }
|
|
207
|
+
}
|
|
208
|
+
@media (max-width: 639px) {
|
|
209
|
+
body[data-theme="dusk"] { --prose-h1-size: 26px; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* More air around the page title (breadcrumb above, lead text below). */
|
|
213
|
+
body[data-theme="dusk"] header > div > h1 {
|
|
214
|
+
margin-block: 12px;
|
|
196
215
|
}
|
|
197
216
|
|
|
198
217
|
body[data-theme="dusk"] .prose h2 {
|
|
@@ -201,7 +220,7 @@ body[data-theme="dusk"] .prose h2 {
|
|
|
201
220
|
line-height: var(--prose-h2-line-height);
|
|
202
221
|
font-weight: var(--prose-h2-weight);
|
|
203
222
|
letter-spacing: var(--prose-h2-tracking);
|
|
204
|
-
color: var(--color-
|
|
223
|
+
color: var(--color-heading);
|
|
205
224
|
}
|
|
206
225
|
|
|
207
226
|
body[data-theme="dusk"] .prose h3 {
|
|
@@ -210,7 +229,7 @@ body[data-theme="dusk"] .prose h3 {
|
|
|
210
229
|
line-height: var(--prose-h3-line-height);
|
|
211
230
|
font-weight: var(--prose-h3-weight);
|
|
212
231
|
letter-spacing: var(--prose-h3-tracking);
|
|
213
|
-
color: var(--color-
|
|
232
|
+
color: var(--color-heading);
|
|
214
233
|
}
|
|
215
234
|
|
|
216
235
|
body[data-theme="dusk"] .prose h4 {
|
|
@@ -219,7 +238,7 @@ body[data-theme="dusk"] .prose h4 {
|
|
|
219
238
|
line-height: var(--prose-h4-line-height);
|
|
220
239
|
font-weight: var(--prose-h4-weight);
|
|
221
240
|
letter-spacing: var(--prose-h4-tracking);
|
|
222
|
-
color: var(--color-
|
|
241
|
+
color: var(--color-heading);
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
/* Inline code — jam's padding, Dusk's surface. */
|
|
@@ -250,14 +269,14 @@ body[data-theme="dusk"] hr {
|
|
|
250
269
|
|
|
251
270
|
/* ===== HEADER ===== */
|
|
252
271
|
/* The header-logo <header> has no border of its own. Same construction as
|
|
253
|
-
nebula's divider (see its comment for the inset math), at
|
|
272
|
+
nebula's divider (see its comment for the inset math), at --border-width. */
|
|
254
273
|
body[data-theme="dusk"] header[data-has-tabs]::after {
|
|
255
274
|
content: '';
|
|
256
275
|
position: absolute;
|
|
257
276
|
left: -1rem;
|
|
258
277
|
right: -1rem;
|
|
259
278
|
bottom: 0;
|
|
260
|
-
height:
|
|
279
|
+
height: var(--border-width);
|
|
261
280
|
background-color: var(--color-border);
|
|
262
281
|
pointer-events: none;
|
|
263
282
|
}
|
|
@@ -269,10 +288,41 @@ body[data-theme="dusk"] header[data-has-tabs]::after {
|
|
|
269
288
|
}
|
|
270
289
|
}
|
|
271
290
|
|
|
291
|
+
/* Tailwind's `border*` utilities are a hard 1px. Route them through
|
|
292
|
+
--border-width so every divider (sidebar edge, cards, panels) is the same
|
|
293
|
+
hairline — jam's rule, re-scoped. */
|
|
294
|
+
body[data-theme="dusk"] .border {
|
|
295
|
+
border-width: var(--border-width) !important;
|
|
296
|
+
}
|
|
297
|
+
body[data-theme="dusk"] .border-t {
|
|
298
|
+
border-top-width: var(--border-width) !important;
|
|
299
|
+
}
|
|
300
|
+
body[data-theme="dusk"] .border-b {
|
|
301
|
+
border-bottom-width: var(--border-width) !important;
|
|
302
|
+
}
|
|
303
|
+
body[data-theme="dusk"] .border-l {
|
|
304
|
+
border-left-width: var(--border-width) !important;
|
|
305
|
+
}
|
|
306
|
+
body[data-theme="dusk"] .border-r {
|
|
307
|
+
border-right-width: var(--border-width) !important;
|
|
308
|
+
}
|
|
309
|
+
|
|
272
310
|
/* ===== SIDEBAR (jam's spacing, re-scoped) ===== */
|
|
273
311
|
/* The sidebar keeps base's border-r — Dusk separates with borders, so unlike
|
|
274
312
|
jam and halo it does not remove it. */
|
|
275
313
|
|
|
314
|
+
/* The shared desktop <aside> is `lg:self-start`, so it is only as tall as its
|
|
315
|
+
nav and the border-r stopped short of the column bottom on short pages.
|
|
316
|
+
Stretch it to the full layout row so the edge always runs the whole height;
|
|
317
|
+
`lg:max-h-full` still caps it and .sidebar-scroll (flex: 1, below) keeps
|
|
318
|
+
scrolling inside it. Matched via .sidebar-scroll so the TOC <aside> is
|
|
319
|
+
untouched; desktop only, since below lg this element is the drawer. */
|
|
320
|
+
@media (min-width: 1024px) {
|
|
321
|
+
body[data-theme="dusk"] aside:has(.sidebar-scroll) {
|
|
322
|
+
align-self: stretch;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
276
326
|
body[data-theme="dusk"] [data-nav-anchor] {
|
|
277
327
|
padding: 0 0 16px 0;
|
|
278
328
|
position: relative;
|