jamdesk 1.1.157 → 1.1.158

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jamdesk",
3
- "version": "1.1.157",
3
+ "version": "1.1.158",
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",
@@ -134,6 +134,10 @@ export const Card = memo(function Card({ title, icon, img, href, children, horiz
134
134
  .group:hover .cta-text {
135
135
  color: var(--color-primary) !important;
136
136
  }
137
+ /* Card body typography. The height-affecting subset below (line-height
138
+ + margins on > p and first/last-child) is mirrored in themes/base.css
139
+ so it also applies at first paint — Next.js dev injects styled-jsx
140
+ only after first paint. Keep both copies in sync. */
137
141
  .card-content > p {
138
142
  margin: 0 0 0.75rem 0;
139
143
  font-weight: 300;
@@ -15,30 +15,38 @@ const NEXT_THEME: Record<string, (typeof THEMES)[number]['name']> = {
15
15
  system: 'light',
16
16
  };
17
17
 
18
- interface ThemeToggleProps {
19
- size?: 'default' | 'large';
20
- }
21
-
22
- export function ThemeToggle({ size = 'default' }: ThemeToggleProps) {
18
+ export function ThemeToggle() {
23
19
  const [mounted, setMounted] = useState(false);
24
20
  const { theme, setTheme } = useTheme();
25
21
 
26
- const isLarge = size === 'large';
27
- const containerClass = isLarge
28
- ? 'flex items-center gap-1 bg-[var(--color-bg-secondary)] rounded-xl px-1.5 py-1.5 border border-[var(--color-border)]'
29
- : 'flex items-center gap-1 bg-[var(--color-bg-secondary)]/50 rounded-md px-0.5 py-0.5 border border-[var(--color-border)]/50';
30
- const buttonClass = isLarge ? 'px-5 py-3 rounded-lg cursor-pointer' : 'px-1.5 py-0.5 rounded cursor-pointer';
31
- const iconClass = isLarge ? 'text-[20px]' : 'text-[12px]';
22
+ const containerClass =
23
+ 'flex items-center gap-1 bg-[var(--color-bg-secondary)]/50 rounded-md px-0.5 py-0.5 border border-[var(--color-border)]/50';
24
+ const buttonClass = 'px-1.5 py-0.5 rounded cursor-pointer';
25
+ const iconClass = 'text-[12px]';
26
+ // Fixed-size icon slot shared by the pre-mount placeholder and the mounted
27
+ // buttons. Without it the control changes width when the FontAwesome icon font
28
+ // swaps in (font-display: swap), reflowing the header nav horizontally (CLS).
29
+ // overflow-hidden caps a wide fallback glyph.
30
+ const iconSlot = 'inline-flex items-center justify-center w-4 h-4 overflow-hidden';
32
31
 
33
32
  useEffect(() => {
34
33
  setMounted(true);
35
34
  }, []);
36
35
 
36
+ // The placeholder MUST mirror the mounted markup exactly — same [data-theme-toggle]
37
+ // attribute AND <button> tag — because themes style the toggle through both
38
+ // selectors: Pulsar HIDES the header copy via `header [data-theme-toggle]` and
39
+ // shrinks the sidebar copy's cells via `[data-theme-toggle] button { width:16px }`.
40
+ // If the placeholder used a <div>, that button rule wouldn't match it, so the
41
+ // cells would render wide then collapse to 16px when the real buttons mount —
42
+ // shifting the toggle (and its neighbors) sideways on load.
37
43
  if (!mounted) {
38
44
  return (
39
- <div className={containerClass}>
45
+ <div className={containerClass} data-theme-toggle aria-hidden="true">
40
46
  {THEMES.map((t) => (
41
- <div key={t.name} className={isLarge ? 'w-8 h-8' : 'w-5 h-4'} />
47
+ <button key={t.name} type="button" disabled className={buttonClass}>
48
+ <span className={iconSlot} />
49
+ </button>
42
50
  ))}
43
51
  </div>
44
52
  );
@@ -66,7 +74,9 @@ export function ThemeToggle({ size = 'default' }: ThemeToggleProps) {
66
74
  aria-pressed={active}
67
75
  title={title}
68
76
  >
69
- <i className={`fa-solid ${icon} ${iconClass}`} aria-hidden="true" />
77
+ <span className={iconSlot}>
78
+ <i className={`fa-solid ${icon} ${iconClass}`} aria-hidden="true" />
79
+ </span>
70
80
  </button>
71
81
  );
72
82
  })}
@@ -104,3 +104,39 @@ export async function resolveAuth(
104
104
  // for backwards compatibility. The split keeps the matcher (a pure
105
105
  // function with no Redis dependency) importable from build-time modules
106
106
  // like public-paths-resolver without dragging in the Redis client.
107
+
108
+ /**
109
+ * Quick boolean check: is this project password-gated right now?
110
+ * Used by R2 read paths to CHOOSE WHICH FETCH MECHANISM to use:
111
+ * - true → S3 SDK (HMAC-authed, slower)
112
+ * - false → CF public domain (fast, cached)
113
+ *
114
+ * NOT an access-control function. Real password gating is upstream in
115
+ * proxy.ts via resolveAuth + the unlock cookie. By the time r2-content.ts
116
+ * calls this, access has already been authorized; we're only picking the
117
+ * fetch mechanism. Therefore: fail OPEN (return false) on Redis error so
118
+ * brownouts don't nullify the CDN-cache latency win.
119
+ *
120
+ * Reads go through cached-redis (getProjectAuthSecret/getProjectAuthPublic) —
121
+ * the same wrappers resolveAuth uses — so the two share per-instance cache
122
+ * entries for these keys instead of each holding its own copy. cached-redis
123
+ * does NOT cache fetch errors, so the fail-open path below still retries on
124
+ * the next call rather than pinning `false`.
125
+ */
126
+ export async function isProjectGated(slug: string): Promise<boolean> {
127
+ if (!redis) return false;
128
+
129
+ try {
130
+ const [secret, pub] = await Promise.all([
131
+ getProjectAuthSecret(slug),
132
+ getProjectAuthPublic(slug),
133
+ ]);
134
+ return !!secret && typeof secret.hash === 'string' && pub?.enabled === true;
135
+ } catch {
136
+ // Fail OPEN: this is a read-mechanism choice, not access control.
137
+ // Gating already happened upstream in proxy.ts. Failing closed here
138
+ // would route every read through S3 SDK during Redis brownouts,
139
+ // nullifying the CDN-cache latency win precisely when it matters most.
140
+ return false;
141
+ }
142
+ }
@@ -39,6 +39,8 @@ export async function runR2Upload(options: R2UploadOptions): Promise<R2UploadRes
39
39
  buildId,
40
40
  '--commitSha',
41
41
  commitSha || 'unknown',
42
+ '--deployId',
43
+ buildId,
42
44
  ];
43
45
  if (fullRebuild) {
44
46
  uploadArgs.push('--full');
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Per-project deployId pointer with in-process LRU cache.
3
+ *
4
+ * Reads/writes `projectDeployId:<slug>` in Redis. The deployId is the prefix
5
+ * we use under each project's R2 bucket path (`<slug>/<deployId>/<rest>`) so
6
+ * we can swap to a new build atomically without purging CF cache.
7
+ *
8
+ * Cache rules:
9
+ * - Positive entries (string deployId) cached for 30s.
10
+ * - Negative entries (no deployId in Redis) cached for 5s — short enough
11
+ * that a brand-new project's first build appears promptly, long enough
12
+ * to prevent thundering-herd Redis reads on cold slugs.
13
+ * - Transient Redis errors are NOT cached. Returns null for the current
14
+ * call but lets the next call retry.
15
+ * - LRU evicts oldest at 500 entries; a successful get reorders to MRU.
16
+ */
17
+ import type { Redis } from '@upstash/redis';
18
+ import { redis } from './redis';
19
+ import { logger } from '../shared/logger';
20
+
21
+ const REDIS_KEY = (slug: string): string => `projectDeployId:${slug}`;
22
+ const PROJECT_DEPLOY_HISTORY_PREFIX = 'projectDeployHistory:';
23
+ const DEPLOY_HISTORY_RETENTION = 10; // GC protects the most recent N deployIds
24
+ const LRU_MAX = 500;
25
+ const LRU_TTL_MS = 30_000;
26
+ const LRU_NULL_TTL_MS = 5_000;
27
+
28
+ interface CacheEntry {
29
+ value: string | null;
30
+ expires: number;
31
+ }
32
+
33
+ const lru = new Map<string, CacheEntry>();
34
+
35
+ function lruGet(slug: string): CacheEntry | undefined {
36
+ const entry = lru.get(slug);
37
+ if (!entry) return undefined;
38
+ if (entry.expires < Date.now()) {
39
+ lru.delete(slug);
40
+ return undefined;
41
+ }
42
+ // Recency refresh: reorder to most-recently-used.
43
+ lru.delete(slug);
44
+ lru.set(slug, entry);
45
+ return entry;
46
+ }
47
+
48
+ function lruSet(slug: string, value: string | null): void {
49
+ if (lru.size >= LRU_MAX) {
50
+ const oldest = lru.keys().next().value;
51
+ if (oldest !== undefined) lru.delete(oldest);
52
+ }
53
+ const ttl = value === null ? LRU_NULL_TTL_MS : LRU_TTL_MS;
54
+ lru.set(slug, { value, expires: Date.now() + ttl });
55
+ }
56
+
57
+ /**
58
+ * Clear the in-process LRU. With a slug, evicts just that project's entry —
59
+ * whole-project revalidation calls this so the next read re-resolves the
60
+ * pointer from Redis instead of serving a ≤30s-stale deployId into freshly
61
+ * purged Data Cache entries. Without a slug, clears everything (tests,
62
+ * bare `{all: true}` revalidation).
63
+ */
64
+ export function clearProjectDeployIdCache(slug?: string): void {
65
+ if (slug === undefined) {
66
+ lru.clear();
67
+ return;
68
+ }
69
+ lru.delete(slug);
70
+ }
71
+
72
+ /**
73
+ * Read the current deployId for a project. Returns null when the slug has
74
+ * no pointer yet (brand-new project, or pre-rollout) or when Redis read
75
+ * throws (we fail open to the legacy non-prefixed path; see r2-content.ts
76
+ * branch for the migration story).
77
+ */
78
+ export async function getProjectDeployId(slug: string): Promise<string | null> {
79
+ const cached = lruGet(slug);
80
+ if (cached !== undefined) return cached.value;
81
+
82
+ if (!redis) {
83
+ lruSet(slug, null);
84
+ return null;
85
+ }
86
+
87
+ try {
88
+ const value = await redis.get(REDIS_KEY(slug));
89
+ if (typeof value === 'string' && value.length > 0) {
90
+ lruSet(slug, value);
91
+ return value;
92
+ }
93
+ lruSet(slug, null);
94
+ return null;
95
+ } catch (err) {
96
+ // Do NOT cache failures — next call must retry Redis. Caching a
97
+ // transient error would extend a 5s blip into a 30s outage for
98
+ // every slug that happened to miss during the window.
99
+ logger.warn('[project-deploy-id] redis read failed', {
100
+ slug,
101
+ error: (err as Error).message,
102
+ });
103
+ return null;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Write the current deployId pointer. Called by upload-to-r2 after the new
109
+ * deployId's content is fully uploaded — atomic swap to the new prefix.
110
+ *
111
+ * Throws on empty/non-string deployId (caller bug — better to fail loudly
112
+ * than silently corrupt the pointer) and when Redis isn't configured
113
+ * (shouldn't happen in prod; throwing surfaces the misconfig fast).
114
+ */
115
+ export async function setProjectDeployId(
116
+ slug: string,
117
+ deployId: string,
118
+ // Injectable so recordProjectDeploy's history writes and pointer write
119
+ // go through the SAME client its caller provided; defaults to the
120
+ // module singleton for direct callers.
121
+ client: Redis | null = redis,
122
+ ): Promise<void> {
123
+ if (typeof deployId !== 'string' || deployId.length === 0) {
124
+ throw new Error('deployId must be a non-empty string');
125
+ }
126
+ if (!client) {
127
+ throw new Error('Redis not configured');
128
+ }
129
+ await client.set(REDIS_KEY(slug), deployId);
130
+ lruSet(slug, deployId);
131
+ }
132
+
133
+ /**
134
+ * Publish a new deployId: records into history FIRST, then writes the
135
+ * current pointer.
136
+ *
137
+ * Ordering invariant — load-bearing for `r2-deploy-gc`:
138
+ * `history MUST contain the deployId before the pointer references it`
139
+ *
140
+ * Otherwise GC could observe `pointer = X` while `X ∉ history`, classify
141
+ * X's R2 prefix as orphaned, and delete the live deploy. Concretely:
142
+ * - LPUSH first. If it throws, we re-throw without ever setting the
143
+ * pointer — readers keep serving the previous deploy.
144
+ * - SET pointer + LTRIM history then fan out in parallel. They're
145
+ * independent (different keys, no causal dependency on each other).
146
+ * - SET failure after LPUSH succeeded: the new deploy's prefix is
147
+ * protected (it's in history) but the pointer didn't flip. Caller
148
+ * sees the throw and surfaces a build warning.
149
+ * - LTRIM failure is best-effort — history grows by 1 entry, GC still
150
+ * walks the LRANGE correctly. Logged at WARN, NOT re-thrown.
151
+ *
152
+ * Net cost vs the prior parallel-everything design: one extra Redis
153
+ * round-trip on the happy path. Worth it — the alternative is silent
154
+ * data loss when GC lands in Phase 6.
155
+ */
156
+ export async function recordProjectDeploy(
157
+ slug: string,
158
+ deployId: string,
159
+ redisClient: Redis,
160
+ ): Promise<void> {
161
+ const historyKey = `${PROJECT_DEPLOY_HISTORY_PREFIX}${slug}`;
162
+
163
+ await redisClient.lpush(historyKey, deployId);
164
+
165
+ await Promise.all([
166
+ setProjectDeployId(slug, deployId, redisClient),
167
+ redisClient
168
+ .ltrim(historyKey, 0, DEPLOY_HISTORY_RETENTION - 1)
169
+ .catch((err: unknown) => {
170
+ logger.warn('[project-deploy-id] ltrim failed (non-fatal)', {
171
+ slug,
172
+ error: err instanceof Error ? err.message : String(err),
173
+ });
174
+ }),
175
+ ]);
176
+ }
177
+
178
+ /**
179
+ * Delete a project's deployId pointer AND history. Called when a project
180
+ * becomes password-gated (legacy-only writes): after its versioned prefixes are
181
+ * purged there is no versioned deploy to reference, and leaving a stale pointer
182
+ * would make r2-deploy-gc eternally "protect" a deployId whose prefix no longer
183
+ * exists. Idempotent — a no-op for a project that never published a pointer.
184
+ */
185
+ export async function clearProjectDeploy(
186
+ slug: string,
187
+ client: Redis | null = redis,
188
+ ): Promise<void> {
189
+ if (!client) {
190
+ throw new Error('Redis not configured');
191
+ }
192
+ await Promise.all([
193
+ client.del(REDIS_KEY(slug)),
194
+ client.del(`${PROJECT_DEPLOY_HISTORY_PREFIX}${slug}`),
195
+ ]);
196
+ clearProjectDeployIdCache(slug);
197
+ }
@@ -0,0 +1,95 @@
1
+ import type { Redis } from '@upstash/redis';
2
+ import { logger } from '../shared/logger.js';
3
+ import { recordProjectDeploy } from './project-deploy-id.js';
4
+ import { isPublicFetchEnabled } from './r2-feature-flags.js';
5
+ import { DEPLOY_FLIP_TAG } from './revalidation-trigger.js';
6
+
7
+ const PUBLISH_ATTEMPTS = 3;
8
+ const PUBLISH_RETRY_BASE_MS = 250;
9
+
10
+ const sleep = (ms: number): Promise<void> =>
11
+ new Promise((resolve) => setTimeout(resolve, ms));
12
+
13
+ interface PublishArgs {
14
+ slug: string;
15
+ deployId: string;
16
+ redisClient: Redis | null;
17
+ recordProjectDeploy?: typeof recordProjectDeploy;
18
+ }
19
+
20
+ /**
21
+ * Publish the deployId pointer with retries. Fatality is flag-aware:
22
+ *
23
+ * - USE_R2_PUBLIC_FETCH off (current prod): no reader resolves the
24
+ * pointer — every read goes to legacy keys — so a Redis outage here
25
+ * must NOT fail customer builds for a pointer nothing consumes yet.
26
+ * Log at error (greppable for the rollout preflight) and continue.
27
+ * - USE_R2_PUBLIC_FETCH on: a pointer that doesn't flip means readers
28
+ * keep serving the previous deploy even though new content uploaded,
29
+ * with no fallback exposing the fresh writes. That's fatal — the
30
+ * build must not report success.
31
+ *
32
+ * Transient Upstash blips were the failure mode that motivated retries:
33
+ * a single 503 during the one-shot publish would have failed an
34
+ * otherwise-complete build.
35
+ */
36
+ export async function publishProjectDeployIdOrThrow({
37
+ slug,
38
+ deployId,
39
+ redisClient,
40
+ recordProjectDeploy: record = recordProjectDeploy,
41
+ }: PublishArgs): Promise<void> {
42
+ if (!redisClient) {
43
+ const message =
44
+ '[r2-upload] KV not configured; cannot publish projectDeployId pointer';
45
+ if (isPublicFetchEnabled()) {
46
+ throw new Error(message);
47
+ }
48
+ logger.error(
49
+ `${message} (non-fatal: USE_R2_PUBLIC_FETCH off, readers use legacy keys)`,
50
+ { slug, deployId },
51
+ );
52
+ return;
53
+ }
54
+
55
+ let lastErr: unknown;
56
+ for (let attempt = 1; attempt <= PUBLISH_ATTEMPTS; attempt++) {
57
+ try {
58
+ await record(slug, deployId, redisClient);
59
+ logger.info(
60
+ `${DEPLOY_FLIP_TAG} projectDeployId set, revalidation should follow`,
61
+ { projectName: slug, deployId, ...(attempt > 1 ? { attempt } : {}) },
62
+ );
63
+ return;
64
+ } catch (err) {
65
+ lastErr = err;
66
+ if (attempt < PUBLISH_ATTEMPTS) {
67
+ const delayMs = PUBLISH_RETRY_BASE_MS * attempt;
68
+ logger.warn('[r2-upload] projectDeployId publish failed, retrying', {
69
+ slug,
70
+ deployId,
71
+ attempt,
72
+ delayMs,
73
+ error: err instanceof Error ? err.message : String(err),
74
+ });
75
+ await sleep(delayMs);
76
+ }
77
+ }
78
+ }
79
+
80
+ if (isPublicFetchEnabled()) {
81
+ throw new Error(
82
+ `Failed to publish projectDeployId pointer for ${slug}/${deployId}`,
83
+ { cause: lastErr },
84
+ );
85
+ }
86
+ logger.error(
87
+ '[r2-upload] projectDeployId publish failed after retries (non-fatal: USE_R2_PUBLIC_FETCH off, readers use legacy keys)',
88
+ {
89
+ slug,
90
+ deployId,
91
+ attempts: PUBLISH_ATTEMPTS,
92
+ error: lastErr instanceof Error ? lastErr.message : String(lastErr),
93
+ },
94
+ );
95
+ }