diff --git a/.changeset/curvy-pianos-bow.md b/.changeset/curvy-pianos-bow.md new file mode 100644 index 00000000..b9792a9c --- /dev/null +++ b/.changeset/curvy-pianos-bow.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes JSON fields in the content editor so existing values load into a formatted textarea and valid edits save back as structured JSON. diff --git a/docs/src/content/docs/reference/field-types.mdx b/docs/src/content/docs/reference/field-types.mdx index cfab88fc..da5a01f4 100644 --- a/docs/src/content/docs/reference/field-types.mdx +++ b/docs/src/content/docs/reference/field-types.mdx @@ -367,6 +367,8 @@ Arbitrary JSON data. Use for complex nested structures, third-party integrations } ``` +In the admin UI, JSON fields render in a syntax-highlighted editor with JSON validity checking. Existing values are pretty-printed on load, and invalid JSON blocks saving until it is fixed. + Stored as-is in SQLite JSON column. diff --git a/packages/admin/package.json b/packages/admin/package.json index 3a1ab4b8..010aa0b9 100644 --- a/packages/admin/package.json +++ b/packages/admin/package.json @@ -22,8 +22,12 @@ "test": "vitest", "typecheck": "tsgo --noEmit" }, - "dependencies": { - "@cloudflare/kumo": "^1.16.0", + "dependencies": { + "@cloudflare/kumo": "^1.16.0", + "@codemirror/lang-json": "^6.0.1", + "@codemirror/language": "^6.10.8", + "@codemirror/state": "^6.5.2", + "@codemirror/view": "^6.36.3", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -94,4 +98,4 @@ ], "author": "Matt Kane", "license": "MIT" -} \ No newline at end of file +} diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index d55d82e7..bb4d2f12 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -38,8 +38,14 @@ import { usePluginAdmins } from "../lib/plugin-context.js"; import { cn, slugify } from "../lib/utils"; import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js"; import { DocumentOutline } from "./editor/DocumentOutline"; +import type { JsonCodeEditorProps } from "./JsonCodeEditor.js"; import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js"; +const LazyJsonCodeEditor = React.lazy(async () => { + const module = await import("./JsonCodeEditor.js"); + return { default: module.JsonCodeEditor }; +}); + /** Autosave debounce delay in milliseconds */ const AUTOSAVE_DELAY = 2000; @@ -212,6 +218,11 @@ export function ContentEditor({ const [slug, setSlug] = React.useState(item?.slug || ""); const [slugTouched, setSlugTouched] = React.useState(!!item?.slug); const [status, setStatus] = React.useState(item?.status || "draft"); + const [fieldErrors, setFieldErrors] = React.useState>({}); + const fieldErrorsRef = React.useRef(fieldErrors); + fieldErrorsRef.current = fieldErrors; + const [jsonEditorEpoch, setJsonEditorEpoch] = React.useState(0); + const previousItemIdRef = React.useRef(item?.id); const [internalBylines, setInternalBylines] = React.useState( item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ?? [], @@ -254,7 +265,21 @@ export function ContentEditor({ const itemDataString = React.useMemo(() => (item ? JSON.stringify(item.data) : ""), [item?.data]); React.useEffect(() => { if (item) { + const didItemIdChange = item.id !== previousItemIdRef.current; + const hasActiveFieldErrors = Object.values(fieldErrorsRef.current).some( + (error) => typeof error === "string" && error.length > 0, + ); + + if (!didItemIdChange && hasActiveFieldErrors) { + return; + } + setFormData(item.data); + if (didItemIdChange) { + setFieldErrors({}); + setJsonEditorEpoch((epoch) => epoch + 1); + } + previousItemIdRef.current = item.id; setSlug(item.slug || ""); setSlugTouched(!!item.slug); setStatus(item.status); @@ -301,6 +326,16 @@ export function ContentEditor({ [formData, slug, activeBylines], ); const isDirty = isNew || currentData !== lastSavedData; + const hasFieldErrors = React.useMemo( + () => Object.values(fieldErrors).some((error) => typeof error === "string" && error.length > 0), + [fieldErrors], + ); + const handleFieldValidationChange = React.useCallback((name: string, error: string | null) => { + setFieldErrors((prev) => { + if ((prev[name] ?? null) === error) return prev; + return { ...prev, [name]: error }; + }); + }, []); // Autosave with debounce // Track pending autosave to cancel on manual save @@ -316,8 +351,8 @@ export function ContentEditor({ return; } - // Don't autosave if not dirty or already saving - if (!isDirty || isSaving || isAutosaving) { + // Don't autosave if not dirty, invalid, or already saving + if (!isDirty || hasFieldErrors || isSaving || isAutosaving) { return; } @@ -340,7 +375,17 @@ export function ContentEditor({ clearTimeout(autosaveTimeoutRef.current); } }; - }, [currentData, isNew, onAutosave, item?.id, isDirty, isSaving, isAutosaving, activeBylines]); + }, [ + currentData, + isNew, + onAutosave, + item?.id, + isDirty, + hasFieldErrors, + isSaving, + isAutosaving, + activeBylines, + ]); // Cancel pending autosave on manual save const handleSubmit = (e: React.FormEvent) => { @@ -534,7 +579,12 @@ export function ContentEditor({ {hasPendingChanges ? "Preview draft" : "Preview"} )} - + {!isNew && ( <> {supportsDrafts && hasPendingChanges && onDiscardDraft && ( @@ -612,7 +662,7 @@ export function ContentEditor({
{Object.entries(fields).map(([name, field]) => ( ))}
@@ -922,6 +973,7 @@ interface FieldRendererProps { field: FieldDescriptor; value: unknown; onChange: (name: string, value: unknown) => void; + onValidationChange?: (name: string, error: string | null) => void; /** Callback when a portableText editor is ready */ onEditorReady?: (editor: Editor) => void; /** Minimal chrome - hides toolbar, fades labels, removes borders (distraction-free mode) */ @@ -936,6 +988,26 @@ interface FieldRendererProps { manifest?: import("../lib/api/client.js").AdminManifest | null; } +function JsonEditorFallback({ + id, + label, + labelClass, +}: Pick) { + return ( +
+
+ {label} +
+
+ Loading editor... +
+
+ ); +} + /** * Render field based on type */ @@ -950,6 +1022,7 @@ function FieldRenderer({ onBlockSidebarOpen, onBlockSidebarClose, manifest, + onValidationChange, }: FieldRendererProps) { const pluginAdmins = usePluginAdmins(); const label = field.label || name.charAt(0).toUpperCase() + name.slice(1); @@ -957,6 +1030,10 @@ function FieldRenderer({ const labelClass = minimal ? "text-kumo-subtle/50 text-xs font-normal" : undefined; const handleChange = React.useCallback((v: unknown) => onChange(name, v), [onChange, name]); + const handleValidationChange = React.useCallback( + (error: string | null) => onValidationChange?.(name, error), + [name, onValidationChange], + ); // Check for plugin field widget override if (field.widget) { @@ -1095,6 +1172,22 @@ function FieldRenderer({ /> ); + case "json": + return ( + } + > + + + ); + case "select": { const selectItems: Record = {}; for (const opt of field.options ?? []) { diff --git a/packages/admin/src/components/JsonCodeEditor.tsx b/packages/admin/src/components/JsonCodeEditor.tsx new file mode 100644 index 00000000..885cd862 --- /dev/null +++ b/packages/admin/src/components/JsonCodeEditor.tsx @@ -0,0 +1,124 @@ +import { json } from "@codemirror/lang-json"; +import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { EditorView, type ViewUpdate, placeholder } from "@codemirror/view"; +import * as React from "react"; + +import { cn } from "../lib/utils"; + +function formatJsonValue(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return JSON.stringify(value, null, 2); + } + } + try { + return JSON.stringify(value, null, 2); + } catch { + return ""; + } +} + +const jsonEditorTheme = EditorView.theme({ + "&": { + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: "13px", + }, + ".cm-scroller": { overflow: "auto", maxHeight: "20rem", minHeight: "10rem" }, + ".cm-content": { padding: "0.625rem 0.75rem", caretColor: "currentColor" }, + ".cm-line": { padding: "0" }, + "&.cm-focused": { outline: "none" }, + ".cm-gutters": { display: "none" }, +}); + +export interface JsonCodeEditorProps { + id: string; + label: string; + labelClass?: string; + value: unknown; + onChange: (value: unknown) => void; + onValidationChange?: (error: string | null) => void; +} + +export function JsonCodeEditor({ + id, + label, + labelClass, + value, + onChange, + onValidationChange, +}: JsonCodeEditorProps) { + const containerRef = React.useRef(null); + const viewRef = React.useRef(null); + const onChangeRef = React.useRef(onChange); + const onValidationRef = React.useRef(onValidationChange); + onChangeRef.current = onChange; + onValidationRef.current = onValidationChange; + + React.useEffect(() => { + if (!containerRef.current) return; + + const updateListener = EditorView.updateListener.of((update: ViewUpdate) => { + if (!update.docChanged) return; + const content = update.state.doc.toString(); + if (!content.trim()) { + onChangeRef.current(null); + onValidationRef.current?.(null); + return; + } + try { + onChangeRef.current(JSON.parse(content)); + onValidationRef.current?.(null); + } catch (e) { + onValidationRef.current?.(e instanceof Error ? e.message : "Invalid JSON"); + } + }); + + const view = new EditorView({ + state: EditorState.create({ + doc: formatJsonValue(value), + extensions: [ + json(), + syntaxHighlighting(defaultHighlightStyle), + EditorView.lineWrapping, + EditorView.contentAttributes.of({ "aria-labelledby": `${id}-label` }), + placeholder('{\n "key": "value"\n}'), + jsonEditorTheme, + updateListener, + ], + }), + parent: containerRef.current, + }); + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = null; + }; + // Only runs on mount. The component is intentionally uncontrolled. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {label} +
+
+
+
+
+ ); +} diff --git a/packages/admin/tests/components/ContentEditor.test.tsx b/packages/admin/tests/components/ContentEditor.test.tsx index 57359ca2..75f23faf 100644 --- a/packages/admin/tests/components/ContentEditor.test.tsx +++ b/packages/admin/tests/components/ContentEditor.test.tsx @@ -143,6 +143,111 @@ describe("ContentEditor", () => { const input = screen.getByLabelText("Order"); await expect.element(input).toHaveAttribute("type", "number"); }); + + it("loads json fields into a syntax-highlighted editor for existing items", async () => { + const metadata = { theme: "dark", nested: { enabled: true } }; + const screen = await renderEditor({ + fields: { metadata: { kind: "json", label: "Metadata" } }, + isNew: false, + item: makeItem({ data: { metadata } }), + }); + + // The CodeMirror editor has role="textbox" and is labelled by the + // "Metadata" heading via aria-labelledby. + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await expect.element(editor).toBeInTheDocument(); + // CodeMirror renders each line in a separate div, so DOM textContent + // has no newlines between lines. Check key strings are present. + await expect.element(editor).toHaveTextContent('"theme"'); + await expect.element(editor).toHaveTextContent('"dark"'); + await expect.element(editor).toHaveTextContent('"nested"'); + }); + + it("loads json string values as valid JSON string scalars", async () => { + const screen = await renderEditor({ + fields: { metadata: { kind: "json", label: "Metadata" } }, + isNew: false, + item: makeItem({ data: { metadata: "dark" } }), + }); + + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await expect.element(editor).toHaveTextContent('"dark"'); + }); + + it("keeps in-progress json text when same item refreshes", async () => { + const fields = { metadata: { kind: "json", label: "Metadata" } } satisfies Record< + string, + FieldDescriptor + >; + const item = makeItem({ + id: "item-1", + data: { metadata: { theme: "dark" } }, + updatedAt: "2025-01-15T10:30:00Z", + }); + const props: ContentEditorProps = { + collection: "posts", + collectionLabel: "Post", + fields, + isNew: false, + item, + onSave: vi.fn(), + }; + + const screen = await render(); + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await editor.fill('{"theme":"light","draft":true}'); + + await screen.rerender( + , + ); + + await expect + .element(screen.getByRole("textbox", { name: "Metadata" })) + .toHaveTextContent('"light"'); + await expect + .element(screen.getByRole("textbox", { name: "Metadata" })) + .toHaveTextContent('"draft"'); + }); + + it("resets json editor when switching to another item", async () => { + const fields = { metadata: { kind: "json", label: "Metadata" } } satisfies Record< + string, + FieldDescriptor + >; + const props: ContentEditorProps = { + collection: "posts", + collectionLabel: "Post", + fields, + isNew: false, + item: makeItem({ id: "item-1", data: { metadata: { theme: "dark" } } }), + onSave: vi.fn(), + }; + + const screen = await render(); + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await editor.fill('{"theme":"light"}'); + + await screen.rerender( + , + ); + + await expect + .element(screen.getByRole("textbox", { name: "Metadata" })) + .toHaveTextContent('"other"'); + await expect + .element(screen.getByRole("textbox", { name: "Metadata" })) + .not.toHaveTextContent('"light"'); + }); }); describe("saving", () => { @@ -165,6 +270,114 @@ describe("ContentEditor", () => { ); }); + it("save form parses json editor content back into structured data", async () => { + const onSave = vi.fn(); + const screen = await renderEditor({ + isNew: false, + onSave, + fields: { metadata: { kind: "json", label: "Metadata" } }, + item: makeItem({ data: { metadata: { enabled: false } } }), + }); + + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await editor.fill('{"enabled":true,"count":2}'); + + const saveBtn = screen.getByRole("button", { name: "Save" }); + await saveBtn.click(); + + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + metadata: { enabled: true, count: 2 }, + }), + }), + ); + }); + + it("disables save when json editor content is invalid", async () => { + const onSave = vi.fn(); + const screen = await renderEditor({ + isNew: false, + onSave, + fields: { metadata: { kind: "json", label: "Metadata" } }, + item: makeItem({ data: { metadata: { enabled: false } } }), + }); + + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await editor.fill('{"enabled": true'); + + const saveBtn = screen.getByRole("button", { name: "Save" }); + await expect.element(saveBtn).toBeDisabled(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("keeps json error state when same item refreshes", async () => { + const fields = { metadata: { kind: "json", label: "Metadata" } } satisfies Record< + string, + FieldDescriptor + >; + const props: ContentEditorProps = { + collection: "posts", + collectionLabel: "Post", + fields, + isNew: false, + item: makeItem({ + id: "item-1", + data: { metadata: { theme: "dark" } }, + updatedAt: "2025-01-15T10:30:00Z", + }), + onSave: vi.fn(), + }; + + const screen = await render(); + const editor = screen.getByRole("textbox", { name: "Metadata" }); + await editor.fill('{"theme": '); + + let saveBtn = screen.getByRole("button", { name: "Save" }); + await expect.element(saveBtn).toBeDisabled(); + + await screen.rerender( + , + ); + + saveBtn = screen.getByRole("button", { name: "Save" }); + await expect.element(saveBtn).toBeDisabled(); + await expect + .element(screen.getByRole("textbox", { name: "Metadata" })) + .toHaveTextContent('{"theme":'); + }); + + it("does not autosave while json field has validation errors", async () => { + vi.useFakeTimers(); + try { + const onAutosave = vi.fn(); + const screen = await renderEditor({ + isNew: false, + onAutosave, + fields: { + title: { kind: "string", label: "Title" }, + metadata: { kind: "json", label: "Metadata" }, + }, + item: makeItem({ data: { title: "Initial", metadata: { theme: "dark" } } }), + }); + + await screen.getByLabelText("Title").fill("Changed title"); + await screen.getByRole("textbox", { name: "Metadata" }).fill('{"theme": '); + + await vi.advanceTimersByTimeAsync(2100); + expect(onAutosave).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("SaveButton shows correct dirty state for new items", async () => { const screen = await renderEditor({ isNew: true }); // New items are always dirty diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3a8a5f..b56fcdef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -473,6 +473,18 @@ importers: '@cloudflare/kumo': specifier: ^1.16.0 version: 1.16.0(@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@types/react@19.2.14)(echarts@6.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6) + '@codemirror/lang-json': + specifier: ^6.0.1 + version: 6.0.2 + '@codemirror/language': + specifier: ^6.10.8 + version: 6.12.3 + '@codemirror/state': + specifier: ^6.5.2 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.36.3 + version: 6.41.0 '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2053,6 +2065,18 @@ packages: '@cloudflare/workers-types@4.20260305.1': resolution: {integrity: sha512-835BZaIcgjuYIUqgOWJSpwQxFSJ8g/X1OCZFLO7bmirM6TGmVgIGwiGItBgkjUXXCPrYzJEldsJkuFuK7ePuMw==} + '@codemirror/lang-json@6.0.2': + resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + + '@codemirror/language@6.12.3': + resolution: {integrity: sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==} + + '@codemirror/state@6.6.0': + resolution: {integrity: sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==} + + '@codemirror/view@6.41.0': + resolution: {integrity: sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -2675,6 +2699,18 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/json@1.0.3': + resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + '@libsql/client@0.8.1': resolution: {integrity: sha512-xGg0F4iTDFpeBZ0r4pA6icGsYa5rG6RAG+i/iLDnpCAnSuTqEWMDdPlVseiq4Z/91lWI9jvvKKiKpovqJ1kZWA==} @@ -2740,6 +2776,9 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -7514,6 +7553,9 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -8527,7 +8569,7 @@ snapshots: dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0) + '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1)) astro: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -8553,7 +8595,7 @@ snapshots: dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0) + '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1)) astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -9308,7 +9350,7 @@ snapshots: optionalDependencies: workerd: 1.20260401.1 - '@cloudflare/vite-plugin@1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0)': + '@cloudflare/vite-plugin@1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1))': dependencies: '@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260401.1) miniflare: 4.20260301.1 @@ -9353,6 +9395,31 @@ snapshots: '@cloudflare/workers-types@4.20260305.1': {} + '@codemirror/lang-json@6.0.2': + dependencies: + '@codemirror/language': 6.12.3 + '@lezer/json': 1.0.3 + + '@codemirror/language@6.12.3': + dependencies: + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.41.0 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/state@6.6.0': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.41.0': + dependencies: + '@codemirror/state': 6.6.0 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@colors/colors@1.5.0': optional: true @@ -9791,6 +9858,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lezer/common@1.5.1': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/json@1.0.3': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + '@libsql/client@0.8.1': dependencies: '@libsql/core': 0.8.1 @@ -9881,6 +9964,8 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@marijn/find-cluster-break@1.0.2': {} + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -15589,6 +15674,8 @@ snapshots: strip-json-comments@5.0.3: {} + style-mod@4.1.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14