handzon-core 0.6.2 → 0.7.0

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": "handzon-core",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -121,6 +121,11 @@ export function tutorialsSchema({ image }: { image: () => import("astro/zod").Zo
121
121
  estimatedDuration: z.string().optional(),
122
122
  prerequisites: z.array(z.string()).default([]),
123
123
  nextTutorial: z.string().optional(),
124
+ // TODO(handzon): `cover` and `icon` are accepted by the schema for
125
+ // forward compatibility, but no page currently renders them
126
+ // (Home cards, TutorialLanding, OG meta all ignore them). Wire them
127
+ // up in TutorialCard and BaseLayout's OG tags before promoting
128
+ // cover art in author-facing docs and skills.
124
129
  cover: image().optional(),
125
130
  icon: z.union([z.string(), image()]).optional(),
126
131
  steps: z.array(z.string()).optional(),
@@ -1,7 +1,7 @@
1
1
  import { Sparkles } from "lucide-react";
2
2
  import { useState } from "react";
3
- import type { AiConfig } from "../../types/ai";
4
3
  import type { AssistantContext } from "../../lib/ai/context";
4
+ import type { AiConfig } from "../../types/ai";
5
5
  import ChatPanel from "./ChatPanel";
6
6
 
7
7
  interface Props {
@@ -3,9 +3,9 @@ import { KeyRound, Send, Settings, Sparkles, Trash2, X } from "lucide-react";
3
3
  import { useEffect, useRef, useState } from "react";
4
4
  import ReactMarkdown from "react-markdown";
5
5
  import remarkGfm from "remark-gfm";
6
- import type { AiConfig } from "../../types/ai";
7
6
  import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
8
7
  import type { AssistantContext } from "../../lib/ai/context";
8
+ import type { AiConfig } from "../../types/ai";
9
9
  import ByokSetup from "./ByokSetup";
10
10
 
11
11
  interface Props {
@@ -45,6 +45,11 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
45
45
 
46
46
  // Keep the latest message in view as chunks stream in (and on every
47
47
  // send / clear). Without this, long responses scroll out of frame.
48
+ // The deps aren't read inside the effect — they're triggers, so the
49
+ // effect re-runs when a new chunk arrives or streaming flips. Biome's
50
+ // exhaustive-deps lint would have us remove them; that would break
51
+ // the autoscroll. Keep the suppression scoped to this single effect.
52
+ // biome-ignore lint/correctness/useExhaustiveDependencies: messages + streaming are intentional triggers
48
53
  useEffect(() => {
49
54
  listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
50
55
  }, [messages, streaming]);
@@ -162,8 +167,8 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
162
167
  <KeyRound size={22} aria-hidden="true" />
163
168
  <h3>API key required</h3>
164
169
  <p>
165
- {config.name} needs an API key to answer questions. Add one to get started —
166
- it's stored in this browser only.
170
+ {config.name} needs an API key to answer questions. Add one to get started — it's
171
+ stored in this browser only.
167
172
  </p>
168
173
  <button type="button" onClick={() => setByokOpen(true)}>
169
174
  Set up key
@@ -197,7 +202,7 @@ export default function ChatPanel({ open, onOpenChange, config, context }: Props
197
202
  <div className="chat-msg chat-msg-assistant">
198
203
  <span className="chat-role">{config.name}</span>
199
204
  <div className="chat-content">
200
- <span className="chat-thinking" aria-label="Thinking">
205
+ <span className="chat-thinking" role="status" aria-label="Thinking">
201
206
  <span /> <span /> <span />
202
207
  </span>
203
208
  </div>
@@ -53,7 +53,7 @@ export default function UserMenu() {
53
53
  }
54
54
  const sess = (await sessRes.json()) as Session | null;
55
55
  const csrf = (await csrfRes.json()) as { csrfToken?: string } | null;
56
- setSession(sess && sess.user ? sess : null);
56
+ setSession(sess?.user ? sess : null);
57
57
  setCsrfToken(csrf?.csrfToken ?? null);
58
58
  } catch {
59
59
  if (!cancelled) {
@@ -79,9 +79,9 @@ export default function UserMenu() {
79
79
  // long-form context.
80
80
  const fullLabel = user?.name ?? user?.email ?? "Signed in";
81
81
  const displayName = user
82
- ? (user.name ? user.name.trim().split(/\s+/)[0] : null) ??
82
+ ? ((user.name ? user.name.trim().split(/\s+/)[0] : null) ??
83
83
  (user.email ? user.email.split("@")[0] : null) ??
84
- "Signed in"
84
+ "Signed in")
85
85
  : "";
86
86
 
87
87
  return (
@@ -95,7 +95,9 @@ export default function UserMenu() {
95
95
  {fullLabel.trim().charAt(0).toUpperCase()}
96
96
  </span>
97
97
  )}
98
- <span className="um-name" title={fullLabel}>{displayName}</span>
98
+ <span className="um-name" title={fullLabel}>
99
+ {displayName}
100
+ </span>
99
101
  <form method="post" action="/api/auth/signout">
100
102
  <input type="hidden" name="csrfToken" value={csrfToken} />
101
103
  <input type="hidden" name="callbackUrl" value={callbackUrl} />
@@ -40,6 +40,7 @@ export default function ActiveFilterChips({
40
40
  }
41
41
 
42
42
  return (
43
+ // biome-ignore lint/a11y/useSemanticElements: <fieldset> requires <legend> and carries form-control semantics; this row groups filter-removal buttons.
43
44
  <div className="active-filters" role="group" aria-label="Active filters">
44
45
  {q && (
45
46
  <button
@@ -79,11 +80,7 @@ export default function ActiveFilterChips({
79
80
  <X size={12} aria-hidden="true" />
80
81
  </button>
81
82
  ))}
82
- <button
83
- type="button"
84
- className="active-filter-clear"
85
- onClick={onClearAll}
86
- >
83
+ <button type="button" className="active-filter-clear" onClick={onClearAll}>
87
84
  Clear all
88
85
  </button>
89
86
  </div>
@@ -21,7 +21,12 @@ interface FilterState {
21
21
 
22
22
  function parseCsv(value: string | null): Set<string> {
23
23
  if (!value) return new Set();
24
- return new Set(value.split(",").map((s) => s.trim()).filter(Boolean));
24
+ return new Set(
25
+ value
26
+ .split(",")
27
+ .map((s) => s.trim())
28
+ .filter(Boolean),
29
+ );
25
30
  }
26
31
 
27
32
  function readUrlState(): FilterState {
@@ -61,11 +66,9 @@ function applyFilters(state: FilterState) {
61
66
  let visible = 0;
62
67
  cards.forEach((card) => {
63
68
  const matchesQ = !q || card.dataset.search!.includes(q);
64
- const matchesLevel =
65
- state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
69
+ const matchesLevel = state.levels.size === 0 || state.levels.has(card.dataset.difficulty ?? "");
66
70
  const cardTags = (card.dataset.tags ?? "").split(",");
67
- const matchesTag =
68
- state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
71
+ const matchesTag = state.tags.size === 0 || cardTags.some((t) => state.tags.has(t));
69
72
  const show = matchesQ && matchesLevel && matchesTag;
70
73
  if (show) {
71
74
  card.removeAttribute("data-filter-hidden");
@@ -95,12 +98,7 @@ function applyFilters(state: FilterState) {
95
98
  window.dispatchEvent(new CustomEvent("hz:filter-changed"));
96
99
  }
97
100
 
98
- export default function FilterBar({
99
- difficulties,
100
- tags,
101
- difficultyCounts,
102
- tagCounts,
103
- }: Props) {
101
+ export default function FilterBar({ difficulties, tags, difficultyCounts, tagCounts }: Props) {
104
102
  const [state, setState] = useState<FilterState>(readUrlState);
105
103
 
106
104
  useEffect(() => {
@@ -19,9 +19,7 @@ interface Props {
19
19
  */
20
20
  function applyPagination(page: number, pageSize: number): number {
21
21
  const visibleByFilter = Array.from(
22
- document.querySelectorAll<HTMLElement>(
23
- "[data-search]:not([data-filter-hidden])",
24
- ),
22
+ document.querySelectorAll<HTMLElement>("[data-search]:not([data-filter-hidden])"),
25
23
  );
26
24
  const start = (page - 1) * pageSize;
27
25
  const end = start + pageSize;
@@ -44,7 +44,9 @@ export default function ResumeRail({ tutorials }: Props) {
44
44
  <span className="rr-prefix">Continue</span>
45
45
  <span className="rr-title">{mostRecent.title}</span>
46
46
  <span className="rr-step">/ {mostRecent.step}</span>
47
- <span className="rr-arrow" aria-hidden="true">→</span>
47
+ <span className="rr-arrow" aria-hidden="true">
48
+
49
+ </span>
48
50
  </a>
49
51
  );
50
52
  }
@@ -27,12 +27,17 @@ function useRoute() {
27
27
  export default function Checkpoint({ label, id }: Props) {
28
28
  const reactId = useId();
29
29
  const checkpointId = id ?? `checkpoint:${reactId}:${label.slice(0, 40)}`;
30
- const { state, recordCheckpoint, markStepComplete } = useProgress();
30
+ const { state, recordCheckpoint, removeCheckpoint, markStepComplete, markStepIncomplete } =
31
+ useProgress();
31
32
  const route = useRoute();
32
33
  const done = !!state.checkpoints[checkpointId];
33
34
 
34
35
  function onToggle() {
35
- if (done) return;
36
+ if (done) {
37
+ removeCheckpoint(checkpointId);
38
+ if (route) markStepIncomplete(route.tutorial, route.step);
39
+ return;
40
+ }
36
41
  recordCheckpoint(checkpointId);
37
42
  if (route) markStepComplete(route.tutorial, route.step);
38
43
  }
@@ -65,21 +65,13 @@ export default function Dropdown<V extends string = string>({
65
65
  </Select.Trigger>
66
66
 
67
67
  <Select.Portal>
68
- <Select.Content
69
- className="hz-dd-content"
70
- position="popper"
71
- sideOffset={6}
72
- >
68
+ <Select.Content className="hz-dd-content" position="popper" sideOffset={6}>
73
69
  <Select.ScrollUpButton className="hz-dd-scroll">
74
70
  <ChevronUp size={14} aria-hidden="true" />
75
71
  </Select.ScrollUpButton>
76
72
  <Select.Viewport className="hz-dd-viewport">
77
73
  {options.map((opt) => (
78
- <Select.Item
79
- key={opt.value}
80
- value={opt.value}
81
- className="hz-dd-item"
82
- >
74
+ <Select.Item key={opt.value} value={opt.value} className="hz-dd-item">
83
75
  {opt.icon && <span className="hz-dd-icon">{opt.icon}</span>}
84
76
  <Select.ItemText>{opt.label}</Select.ItemText>
85
77
  <Select.ItemIndicator className="hz-dd-check">
@@ -1,13 +1,6 @@
1
1
  import * as Popover from "@radix-ui/react-popover";
2
2
  import { Check, ChevronDown, Search, X } from "lucide-react";
3
- import {
4
- type KeyboardEvent,
5
- type ReactNode,
6
- useEffect,
7
- useMemo,
8
- useRef,
9
- useState,
10
- } from "react";
3
+ import { type KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
11
4
 
12
5
  export interface MultiSelectOption {
13
6
  value: string;
@@ -64,10 +57,7 @@ export default function MultiSelect({
64
57
  // Sort by count desc so "weighty" facets bubble up. Stable
65
58
  // alphabetical secondary sort keeps neighbours predictable.
66
59
  const sorted = useMemo(
67
- () =>
68
- [...options].sort(
69
- (a, b) => b.count - a.count || a.label.localeCompare(b.label),
70
- ),
60
+ () => [...options].sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)),
71
61
  [options],
72
62
  );
73
63
 
@@ -158,11 +148,7 @@ export default function MultiSelect({
158
148
  </button>
159
149
  </Popover.Trigger>
160
150
  <Popover.Portal>
161
- <Popover.Content
162
- className="hz-ms-content"
163
- align="start"
164
- sideOffset={6}
165
- >
151
+ <Popover.Content className="hz-ms-content" align="start" sideOffset={6}>
166
152
  {searchable && (
167
153
  <label className="hz-ms-search">
168
154
  <Search size={14} aria-hidden="true" />
package/src/index.ts CHANGED
@@ -8,48 +8,43 @@
8
8
  * and types.
9
9
  */
10
10
 
11
+ // AI client (browser-side BYOK + streaming chat to handzon-ai).
12
+ export {
13
+ type ChatMessage,
14
+ clearLearnerKey,
15
+ loadLearnerKey,
16
+ saveLearnerKey,
17
+ streamChat,
18
+ } from "./lib/ai/client.ts";
19
+ export { type AssistantContext, buildContext } from "./lib/ai/context.ts";
11
20
  // Content collection helpers (built on top of astro:content).
12
21
  export {
13
- parseStepId,
14
- getTutorials,
15
- getTutorialBySlug,
16
- getStepsForTutorial,
17
22
  getStep,
23
+ getStepsForTutorial,
24
+ getTutorialBySlug,
25
+ getTutorials,
26
+ parseStepId,
27
+ type StepEntry,
18
28
  sumDurations,
19
29
  type TutorialEntry,
20
- type StepEntry,
21
30
  } from "./lib/content.ts";
22
-
23
31
  // MDX component map used by .astro pages rendering tutorial content.
24
32
  export { mdxComponents } from "./lib/mdx-components.ts";
25
-
26
- // Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
27
- export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
28
-
29
- // AI client (browser-side BYOK + streaming chat to handzon-ai).
30
- export {
31
- streamChat,
32
- loadLearnerKey,
33
- saveLearnerKey,
34
- clearLearnerKey,
35
- type ChatMessage,
36
- } from "./lib/ai/client.ts";
37
-
38
- export { buildContext, type AssistantContext } from "./lib/ai/context.ts";
39
-
40
33
  // Progress store (localStorage + optional server sync).
41
34
  export { getStore } from "./lib/progress/local.ts";
42
- export {
43
- useProgress,
44
- useProgressAfterMount,
45
- } from "./lib/progress/useProgress.ts";
46
35
  export {
47
36
  emptyState,
48
- type ProgressState,
49
- type StepKey,
50
37
  type LastVisitedEntry,
38
+ type ProgressState,
51
39
  type ProgressStore,
40
+ type StepKey,
52
41
  } from "./lib/progress/types.ts";
42
+ export {
43
+ useProgress,
44
+ useProgressAfterMount,
45
+ } from "./lib/progress/useProgress.ts";
46
+ // Rehype plugin that lets Mermaid code fences round-trip as <pre class="mermaid">.
47
+ export { default as rehypeMermaidPassthrough } from "./lib/rehype-mermaid-passthrough.ts";
53
48
 
54
49
  // AI config type (consumers provide concrete values; framework consumes shape).
55
50
  export type { AiConfig } from "./types/ai.ts";
@@ -65,6 +65,13 @@ function diffState(prev: ProgressState, next: ProgressState): ProgressEntry[] {
65
65
  out.push({ kind: "checkpoint", scope: "global", key: id, value });
66
66
  }
67
67
  }
68
+ // Emit deletions so the server tombstones unchecked checkpoints; without
69
+ // this the next snapshot fetch would resurrect them from the DB.
70
+ for (const id of Object.keys(prev.checkpoints)) {
71
+ if (!next.checkpoints[id]) {
72
+ out.push({ kind: "checkpoint", scope: "global", key: id, value: null });
73
+ }
74
+ }
68
75
  for (const [k, value] of Object.entries(next.prefs)) {
69
76
  if ((prev.prefs as Record<string, unknown>)[k] !== value) {
70
77
  out.push({ kind: "pref", scope: "global", key: k, value });
@@ -150,6 +157,7 @@ export function createRemoteStore(): ProgressStore {
150
157
  } else if (e.kind === "quiz") {
151
158
  merged.quizzes[e.key] = e.value as ProgressState["quizzes"][string];
152
159
  } else if (e.kind === "checkpoint") {
160
+ if (e.value == null) continue;
153
161
  merged.checkpoints[e.key] = e.value as ProgressState["checkpoints"][string];
154
162
  } else if (e.kind === "pref") {
155
163
  (merged.prefs as Record<string, unknown>)[e.key] = e.value;
@@ -8,6 +8,7 @@ interface ProgressApi {
8
8
  markStepIncomplete: (tutorial: string, step: string) => void;
9
9
  recordQuiz: (questionId: string, chosen: number[], correct: boolean) => void;
10
10
  recordCheckpoint: (checkpointId: string) => void;
11
+ removeCheckpoint: (checkpointId: string) => void;
11
12
  setPref: <K extends keyof ProgressState["prefs"]>(
12
13
  key: K,
13
14
  value: ProgressState["prefs"][K],
@@ -56,6 +57,13 @@ export function useProgress(): ProgressApi {
56
57
  ...s,
57
58
  checkpoints: { ...s.checkpoints, [checkpointId]: { ts: Date.now() } },
58
59
  })),
60
+ removeCheckpoint: (checkpointId: string) =>
61
+ store.set((s) => {
62
+ if (!s.checkpoints[checkpointId]) return s;
63
+ const next = { ...s.checkpoints };
64
+ delete next[checkpointId];
65
+ return { ...s, checkpoints: next };
66
+ }),
59
67
  setPref: <K extends keyof ProgressState["prefs"]>(key: K, value: ProgressState["prefs"][K]) =>
60
68
  store.set((s) => ({ ...s, prefs: { ...s.prefs, [key]: value } })),
61
69
  setLastVisited: (tutorial: string, step: string) =>
@@ -5,8 +5,9 @@
5
5
  * export const getStaticPaths = getTutorialLandingPaths;
6
6
  * export const getStaticPaths = getTutorialStepPaths;
7
7
  */
8
- import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
8
+
9
9
  import type { StepEntry, TutorialEntry } from "../lib/content.ts";
10
+ import { getStepsForTutorial, getTutorials, parseStepId } from "../lib/content.ts";
10
11
 
11
12
  export async function getTutorialLandingPaths() {
12
13
  const tutorials = await getTutorials();
@@ -10,8 +10,9 @@
10
10
  * GitHub is the only provider in 0.2; email/password and others are
11
11
  * out of scope (see plan: github-auth_d52529d5).
12
12
  */
13
- import { DrizzleAdapter } from "@auth/drizzle-adapter";
13
+
14
14
  import GitHub from "@auth/core/providers/github";
15
+ import { DrizzleAdapter } from "@auth/drizzle-adapter";
15
16
  import { defineConfig } from "auth-astro";
16
17
  import { accounts, sessions, users, verificationTokens } from "./schema.ts";
17
18
 
@@ -6,14 +6,7 @@
6
6
  * The `learners` row that maps a signed-in user to local progress lives
7
7
  * in `../db/schema.ts` — it adds a nullable `user_id` FK to `users` here.
8
8
  */
9
- import {
10
- integer,
11
- pgTable,
12
- primaryKey,
13
- text,
14
- timestamp,
15
- uuid,
16
- } from "drizzle-orm/pg-core";
9
+ import { integer, pgTable, primaryKey, text, timestamp, uuid } from "drizzle-orm/pg-core";
17
10
 
18
11
  export const users = pgTable("users", {
19
12
  id: uuid("id").primaryKey().defaultRandom(),
@@ -61,11 +61,7 @@ export async function getOrCreateLearner(
61
61
  // Anonymous path — unchanged from pre-auth behaviour.
62
62
  let deviceId = cookies.get(COOKIE)?.value;
63
63
  if (deviceId) {
64
- const found = await db
65
- .select()
66
- .from(learners)
67
- .where(eq(learners.deviceId, deviceId))
68
- .limit(1);
64
+ const found = await db.select().from(learners).where(eq(learners.deviceId, deviceId)).limit(1);
69
65
  if (found[0]) return { id: found[0].id, deviceId };
70
66
  }
71
67
  deviceId = randomDeviceId();
@@ -13,7 +13,7 @@ import { users } from "../auth/schema.ts";
13
13
 
14
14
  // Re-export the Auth.js tables so consumers (and drizzle-kit) see one
15
15
  // schema barrel.
16
- export { users, accounts, sessions, verificationTokens } from "../auth/schema.ts";
16
+ export { accounts, sessions, users, verificationTokens } from "../auth/schema.ts";
17
17
 
18
18
  export const learners = pgTable(
19
19
  "learners",
@@ -1,5 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
- import { eq, sql } from "drizzle-orm";
2
+ import { and, eq, sql } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import { getOrCreateLearner } from "../auth.ts";
5
5
  import { getDb } from "../db/client.ts";
@@ -61,30 +61,53 @@ export const POST: APIRoute = async ({ cookies, request }) => {
61
61
  const learner = await getOrCreateLearner(cookies, request);
62
62
  const db = getDb();
63
63
  const now = new Date();
64
- const rows = parsed.map((b) => ({
65
- learnerId: learner.id,
66
- kind: b.kind,
67
- scope: b.scope,
68
- key: b.key,
69
- value: b.value,
70
- updatedAt: now,
71
- }));
72
- await db
73
- .insert(progressEntries)
74
- .values(rows)
75
- .onConflictDoUpdate({
76
- target: [
77
- progressEntries.learnerId,
78
- progressEntries.kind,
79
- progressEntries.scope,
80
- progressEntries.key,
81
- ],
82
- set: {
83
- // `excluded` is the row Postgres would have inserted — without
84
- // this the SET was a no-op (`value = progress_entries.value`).
85
- value: sql`excluded.value`,
86
- updatedAt: sql`excluded.updated_at`,
87
- },
88
- });
89
- return json({ written: rows.length });
64
+
65
+ // `value: null` is the tombstone signal for "this entry was undone"
66
+ // (e.g. unchecking a checkpoint). The `value` column is NOT NULL, so
67
+ // we DELETE these rows instead of upserting them.
68
+ const deletes = parsed.filter((b) => b.value === null);
69
+ const upserts = parsed.filter((b) => b.value !== null);
70
+
71
+ for (const d of deletes) {
72
+ await db
73
+ .delete(progressEntries)
74
+ .where(
75
+ and(
76
+ eq(progressEntries.learnerId, learner.id),
77
+ eq(progressEntries.kind, d.kind),
78
+ eq(progressEntries.scope, d.scope),
79
+ eq(progressEntries.key, d.key),
80
+ ),
81
+ );
82
+ }
83
+
84
+ if (upserts.length > 0) {
85
+ const rows = upserts.map((b) => ({
86
+ learnerId: learner.id,
87
+ kind: b.kind,
88
+ scope: b.scope,
89
+ key: b.key,
90
+ value: b.value,
91
+ updatedAt: now,
92
+ }));
93
+ await db
94
+ .insert(progressEntries)
95
+ .values(rows)
96
+ .onConflictDoUpdate({
97
+ target: [
98
+ progressEntries.learnerId,
99
+ progressEntries.kind,
100
+ progressEntries.scope,
101
+ progressEntries.key,
102
+ ],
103
+ set: {
104
+ // `excluded` is the row Postgres would have inserted — without
105
+ // this the SET was a no-op (`value = progress_entries.value`).
106
+ value: sql`excluded.value`,
107
+ updatedAt: sql`excluded.updated_at`,
108
+ },
109
+ });
110
+ }
111
+
112
+ return json({ written: parsed.length });
90
113
  };
@@ -29,9 +29,12 @@ let cache: CacheEntry | null = null;
29
29
  */
30
30
  export const GET: APIRoute = async () => {
31
31
  if (!process.env.DATABASE_URL) {
32
- return json({ stats: [] satisfies TutorialStat[] }, {
33
- headers: { "Cache-Control": "public, max-age=60" },
34
- });
32
+ return json(
33
+ { stats: [] satisfies TutorialStat[] },
34
+ {
35
+ headers: { "Cache-Control": "public, max-age=60" },
36
+ },
37
+ );
35
38
  }
36
39
  const now = Date.now();
37
40
  if (cache && cache.expiresAt > now) {