diff --git a/.changeset/cuddly-stingrays-obey.md b/.changeset/cuddly-stingrays-obey.md new file mode 100644 index 000000000..06a121203 --- /dev/null +++ b/.changeset/cuddly-stingrays-obey.md @@ -0,0 +1,5 @@ +--- +'@keystatic/core': patch +--- + +Server side bundle size improvements diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json index 9e45baa78..d4b2dd489 100644 --- a/packages/keystatic/package.json +++ b/packages/keystatic/package.json @@ -144,16 +144,14 @@ "@urql/exchange-graphcache": "^6.3.3", "@urql/exchange-persisted": "^4.1.0", "cookie": "^0.5.0", - "decimal.js": "^10.4.3", + "decimal.js-light": "^2.5.1", "emery": "^1.4.1", "escape-string-regexp": "^4.0.0", "fast-deep-equal": "^3.1.3", "graphql": "^16.6.0", "idb-keyval": "^6.2.1", "ignore": "^5.2.4", - "iron-webcrypto": "^0.10.1", "is-hotkey": "^0.2.0", - "js-base64": "^3.7.5", "js-yaml": "^4.1.0", "lib0": "^0.2.88", "lru-cache": "^10.2.0", @@ -182,12 +180,12 @@ "slate": "^0.91.4", "slate-history": "^0.86.0", "slate-react": "^0.91.9", + "superstruct": "^1.0.4", "unist-util-visit": "^5.0.0", "urql": "^4.0.0", "y-prosemirror": "^1.2.2", "y-protocols": "^1.0.6", - "yjs": "^13.6.11", - "zod": "^3.20.2" + "yjs": "^13.6.11" }, "devDependencies": { "@jest/expect": "^29.7.0", @@ -281,6 +279,8 @@ "worker": "./src/component-blocks/blank-for-react-server.tsx", "react-server": "./src/component-blocks/blank-for-react-server.tsx", "default": "./src/component-blocks/cloud-image-preview.tsx" - } + }, + "#markdoc": "./src/markdoc.js", + "#base64": "./src/base64.ts" } } diff --git a/packages/keystatic/src/api/api-node.ts b/packages/keystatic/src/api/api-node.ts index bd1059aad..b36b9e95b 100644 --- a/packages/keystatic/src/api/api-node.ts +++ b/packages/keystatic/src/api/api-node.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { z } from 'zod'; +import * as s from 'superstruct'; import fs from 'node:fs/promises'; import { Config } from '../config'; import { @@ -10,6 +10,7 @@ import { import { readToDirEntries, getAllowedDirectories } from './read-local'; import { blobSha } from '../app/trees'; import { randomBytes } from 'node:crypto'; +import { base64UrlDecode } from '#base64'; // this should be trivially dead code eliminated // it's just to ensure the types are exactly the same between this and local-noop.ts @@ -23,10 +24,10 @@ function _typeTest() { let _d: typeof b = a; } -const ghAppSchema = z.object({ - slug: z.string(), - client_id: z.string(), - client_secret: z.string(), +const ghAppSchema = s.type({ + slug: s.string(), + client_id: s.string(), + client_secret: s.string(), }); const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -55,10 +56,10 @@ export async function handleGitHubAppCreation( }; } const ghAppDataRaw = await ghAppRes.json(); - - const ghAppDataResult = ghAppSchema.safeParse(ghAppDataRaw); - - if (!ghAppDataResult.success) { + let ghAppDataResult; + try { + ghAppDataResult = s.create(ghAppDataRaw, ghAppSchema); + } catch { console.log(ghAppDataRaw); return { status: 500, @@ -66,10 +67,10 @@ export async function handleGitHubAppCreation( }; } const toAddToEnv = `# Keystatic -KEYSTATIC_GITHUB_CLIENT_ID=${ghAppDataResult.data.client_id} -KEYSTATIC_GITHUB_CLIENT_SECRET=${ghAppDataResult.data.client_secret} +KEYSTATIC_GITHUB_CLIENT_ID=${ghAppDataResult.client_id} +KEYSTATIC_GITHUB_CLIENT_SECRET=${ghAppDataResult.client_secret} KEYSTATIC_SECRET=${randomBytes(40).toString('hex')} -${slugEnvVarName ? `${slugEnvVarName}=${ghAppDataResult.data.slug}\n` : ''}`; +${slugEnvVarName ? `${slugEnvVarName}=${ghAppDataResult.slug}\n` : ''}`; let prevEnv: string | undefined; try { @@ -80,9 +81,7 @@ ${slugEnvVarName ? `${slugEnvVarName}=${ghAppDataResult.data.slug}\n` : ''}`; const newEnv = prevEnv ? `${prevEnv}\n\n${toAddToEnv}` : toAddToEnv; await fs.writeFile('.env', newEnv); await wait(200); - return redirect( - '/keystatic/created-github-app?slug=' + ghAppDataResult.data.slug - ); + return redirect('/keystatic/created-github-app?slug=' + ghAppDataResult.slug); } export function localModeApiHandler( @@ -165,6 +164,10 @@ async function blob( return { status: 200, body: contents }; } +const base64Schema = s.coerce(s.instance(Uint8Array), s.string(), val => + base64UrlDecode(val) +); + async function update( req: KeystaticRequest, config: Config, @@ -177,24 +180,26 @@ async function update( return { status: 400, body: 'Bad Request' }; } const isFilepathValid = getIsPathValid(config); + const filepath = s.refine(s.string(), 'filepath', isFilepathValid); + let updates; - const updates = z - .object({ - additions: z.array( - z.object({ - path: z.string().refine(isFilepathValid), - contents: z.string().transform(x => Buffer.from(x, 'base64')), - }) - ), - deletions: z.array( - z.object({ path: z.string().refine(isFilepathValid) }) - ), - }) - .safeParse(await req.json()); - if (!updates.success) { + try { + updates = s.create( + await req.json(), + s.object({ + additions: s.array( + s.object({ + path: filepath, + contents: base64Schema, + }) + ), + deletions: s.array(s.object({ path: filepath })), + }) + ); + } catch { return { status: 400, body: 'Bad data' }; } - for (const addition of updates.data.additions) { + for (const addition of updates.additions) { await fs.mkdir(path.dirname(path.join(baseDirectory, addition.path)), { recursive: true, }); @@ -203,7 +208,7 @@ async function update( addition.contents ); } - for (const deletion of updates.data.deletions) { + for (const deletion of updates.deletions) { await fs.rm(path.join(baseDirectory, deletion.path), { force: true }); } return { diff --git a/packages/keystatic/src/api/encryption.tsx b/packages/keystatic/src/api/encryption.tsx new file mode 100644 index 000000000..3aacc4013 --- /dev/null +++ b/packages/keystatic/src/api/encryption.tsx @@ -0,0 +1,55 @@ +import { base64UrlDecode, base64UrlEncode } from '#base64'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +async function deriveKey(secret: string, salt: Uint8Array) { + if (secret.length < 32) { + throw new Error('KEYSTATIC_SECRET must be at least 32 characters long'); + } + const encoded = encoder.encode(secret); + const key = await crypto.subtle.importKey('raw', encoded, 'HKDF', false, [ + 'deriveKey', + ]); + return crypto.subtle.deriveKey( + { name: 'HKDF', salt, hash: 'SHA-256', info: new Uint8Array(0) }, + key, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +const SALT_LENGTH = 16; +const IV_LENGTH = 12; + +export async function encryptValue(value: string, secret: string) { + const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); + const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)); + const key = await deriveKey(secret, salt); + const encoded = encoder.encode(value); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encoded + ); + const full = new Uint8Array(SALT_LENGTH + IV_LENGTH + encrypted.byteLength); + full.set(salt); + full.set(iv, SALT_LENGTH); + full.set(new Uint8Array(encrypted), SALT_LENGTH + IV_LENGTH); + return base64UrlEncode(full); +} + +export async function decryptValue(encrypted: string, secret: string) { + const decoded = base64UrlDecode(encrypted); + const salt = decoded.slice(0, SALT_LENGTH); + const key = await deriveKey(secret, salt); + const iv = decoded.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); + const value = decoded.slice(SALT_LENGTH + IV_LENGTH); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + value + ); + return decoder.decode(decrypted); +} diff --git a/packages/keystatic/src/api/generic.ts b/packages/keystatic/src/api/generic.ts index fbe266014..897a1b141 100644 --- a/packages/keystatic/src/api/generic.ts +++ b/packages/keystatic/src/api/generic.ts @@ -1,6 +1,5 @@ import cookie from 'cookie'; -import * as Iron from 'iron-webcrypto'; -import z from 'zod'; +import * as s from 'superstruct'; import { Config } from '..'; import { KeystaticResponse, @@ -10,6 +9,7 @@ import { import { handleGitHubAppCreation, localModeApiHandler } from '#api-handler'; import { webcrypto } from '#webcrypto'; import { bytesToHex } from '../hex'; +import { decryptValue, encryptValue } from './encryption'; export type APIRouteConfig = { /** @default process.env.KEYSTATIC_GITHUB_CLIENT_ID */ @@ -181,13 +181,13 @@ export function makeGenericAPIRouteHandler( }; } -const tokenDataResultType = z.object({ - access_token: z.string(), - expires_in: z.number(), - refresh_token: z.string(), - refresh_token_expires_in: z.number(), - scope: z.string(), - token_type: z.literal('bearer'), +const tokenDataResultType = s.type({ + access_token: s.string(), + expires_in: s.number(), + refresh_token: s.string(), + refresh_token_expires_in: s.number(), + scope: s.string(), + token_type: s.literal('bearer'), }); async function githubOauthCallback( @@ -231,12 +231,14 @@ async function githubOauthCallback( return { status: 401, body: 'Authorization failed' }; } const _tokenData = await tokenRes.json(); - const tokenDataParseResult = tokenDataResultType.safeParse(_tokenData); - if (!tokenDataParseResult.success) { + let tokenData; + try { + tokenData = tokenDataResultType.create(_tokenData); + } catch { return { status: 401, body: 'Authorization failed' }; } - const headers = await getTokenCookies(tokenDataParseResult.data, config); + const headers = await getTokenCookies(tokenData, config); if (state === 'close') { return { headers: [...headers, ['Content-Type', 'text/html']], @@ -248,7 +250,7 @@ async function githubOauthCallback( } async function getTokenCookies( - tokenData: z.infer, + tokenData: s.Infer, config: InnerAPIRouteConfig ) { const headers: [string, string][] = [ @@ -266,10 +268,7 @@ async function getTokenCookies( 'Set-Cookie', cookie.serialize( 'keystatic-gh-refresh-token', - await Iron.seal(webcrypto, tokenData.refresh_token, config.secret, { - ...Iron.defaults, - ttl: tokenData.refresh_token_expires_in * 1000, - }), + await encryptValue(tokenData.refresh_token, config.secret), { sameSite: 'lax', secure: process.env.NODE_ENV === 'production', @@ -295,16 +294,10 @@ async function getRefreshToken( if (!refreshTokenCookie) return; let refreshToken; try { - refreshToken = await Iron.unseal( - webcrypto, - refreshTokenCookie, - config.secret, - Iron.defaults - ); + refreshToken = await decryptValue(refreshTokenCookie, config.secret); } catch { return; } - if (typeof refreshToken !== 'string') return; return refreshToken; } @@ -341,11 +334,13 @@ async function refreshGitHubAuth( return; } const _tokenData = await tokenRes.json(); - const tokenDataParseResult = tokenDataResultType.safeParse(_tokenData); - if (!tokenDataParseResult.success) { + let tokenData; + try { + tokenData = tokenDataResultType.create(_tokenData); + } catch { return; } - return getTokenCookies(tokenDataParseResult.data, config); + return getTokenCookies(tokenData, config); } async function githubRepoNotFound( diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index 5f65292f5..20b4215d3 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -11,7 +11,7 @@ import { useState, } from 'react'; import * as Y from 'yjs'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { ActionGroup, Item } from '@keystar/ui/action-group'; import { Badge } from '@keystar/ui/badge'; @@ -95,12 +95,12 @@ type ItemPageProps = { basePath: string; }; -const storedValSchema = z.object({ - version: z.literal(1), - savedAt: z.date(), - slug: z.string(), - beforeTreeKey: z.string(), - files: z.map(z.string(), z.instanceof(Uint8Array)), +const storedValSchema = s.type({ + version: s.literal(1), + savedAt: s.date(), + slug: s.string(), + beforeTreeKey: s.string(), + files: s.map(s.string(), s.instance(Uint8Array)), }); function ItemPageInner( @@ -433,7 +433,7 @@ function LocalItemPage( state, }); const files = new Map(serialized.map(x => [x.path, x.contents])); - const data: z.infer = { + const data: s.Infer = { beforeTreeKey: localTreeKey, slug, files, @@ -837,7 +837,7 @@ function ItemPageWrapper(props: ItemPageWrapperProps) { props.itemSlug, ]); if (!raw) throw new Error('No draft found'); - const stored = storedValSchema.parse(raw); + const stored = storedValSchema.create(raw); const parsed = parseEntry( { config: props.config, diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx index 5923d4f41..2afb76c75 100644 --- a/packages/keystatic/src/app/SingletonPage.tsx +++ b/packages/keystatic/src/app/SingletonPage.tsx @@ -48,7 +48,7 @@ import { setDraft, showDraftRestoredToast, } from './persistence'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { LOADING, useData } from './useData'; import { ActionGroup, Item } from '@keystar/ui/action-group'; import { useMediaQuery, breakpointQueries } from '@keystar/ui/style'; @@ -356,7 +356,7 @@ function LocalSingletonPage( state, }); const files = new Map(serialized.map(x => [x.path, x.contents])); - const data: z.infer = { + const data: s.Infer = { beforeTreeKey: localTreeKey, files, savedAt: new Date(), @@ -488,11 +488,11 @@ function CollabSingletonPage( ); } -const storedValSchema = z.object({ - version: z.literal(1), - savedAt: z.date(), - beforeTreeKey: z.string().optional(), - files: z.map(z.string(), z.instanceof(Uint8Array)), +const storedValSchema = s.type({ + version: s.literal(1), + savedAt: s.date(), + beforeTreeKey: s.optional(s.string()), + files: s.map(s.string(), s.instance(Uint8Array)), }); function SingletonPageWrapper(props: { singleton: string; config: Config }) { @@ -516,7 +516,7 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) { useCallback(async () => { const raw = await getDraft(['singleton', props.singleton]); if (!raw) throw new Error('No draft found'); - const stored = storedValSchema.parse(raw); + const stored = storedValSchema.create(raw); const parsed = parseEntry( { config: props.config, diff --git a/packages/keystatic/src/app/auth.ts b/packages/keystatic/src/app/auth.ts index 982d00e74..7eee7e98a 100644 --- a/packages/keystatic/src/app/auth.ts +++ b/packages/keystatic/src/app/auth.ts @@ -1,11 +1,11 @@ import { parse } from 'cookie'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { Config } from '../config'; -const storedTokenSchema = z.object({ - token: z.string(), - project: z.string(), - validUntil: z.number().transform(val => new Date(val)), +const storedTokenSchema = s.object({ + token: s.string(), + project: s.string(), + validUntil: s.coerce(s.date(), s.number(), val => new Date(val)), }); export function getSyncAuth(config: Config) { @@ -33,7 +33,7 @@ export function getCloudAuth(config: Config) { ); let tokenData; try { - tokenData = storedTokenSchema.parse(JSON.parse(unparsedTokenData!)); + tokenData = storedTokenSchema.create(JSON.parse(unparsedTokenData!)); } catch (err) { return null; } @@ -47,23 +47,33 @@ export function getCloudAuth(config: Config) { return { accessToken: tokenData.token }; } +let _refreshTokenPromise: Promise<{ accessToken: string } | null> | undefined; + export async function getAuth(config: Config) { const token = getSyncAuth(config); if (config.storage.kind === 'github' && !token) { - try { - const res = await fetch('/api/keystatic/github/refresh-token', { - method: 'POST', - }); - if (res.status === 200) { - const cookies = parse(document.cookie); - const accessToken = cookies['keystatic-gh-access-token']; - if (accessToken) { - return { accessToken }; + if (!_refreshTokenPromise) { + _refreshTokenPromise = (async () => { + try { + const res = await fetch('/api/keystatic/github/refresh-token', { + method: 'POST', + }); + if (res.status === 200) { + const cookies = parse(document.cookie); + const accessToken = cookies['keystatic-gh-access-token']; + if (accessToken) { + return { accessToken }; + } + } + } catch { + } finally { + _refreshTokenPromise = undefined; } - } - } catch {} - return null; + return null; + })(); + } + return _refreshTokenPromise; } return token; } diff --git a/packages/keystatic/src/app/cloud-auth-callback.tsx b/packages/keystatic/src/app/cloud-auth-callback.tsx index e76eab8d4..cec4eae10 100644 --- a/packages/keystatic/src/app/cloud-auth-callback.tsx +++ b/packages/keystatic/src/app/cloud-auth-callback.tsx @@ -1,21 +1,21 @@ import { ProgressCircle } from '@keystar/ui/progress'; import { Text } from '@keystar/ui/typography'; import { useEffect, useMemo, useState } from 'react'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { Config } from '../config'; import { useRouter } from './router'; import { KEYSTATIC_CLOUD_API_URL, KEYSTATIC_CLOUD_HEADERS } from './utils'; import { Flex } from '@keystar/ui/layout'; -const storedStateSchema = z.object({ - state: z.string(), - from: z.string(), - code_verifier: z.string(), +const storedStateSchema = s.object({ + state: s.string(), + from: s.string(), + code_verifier: s.string(), }); -const tokenResponseSchema = z.object({ - access_token: z.string(), - token_type: z.string(), - expires_in: z.number(), +const tokenResponseSchema = s.type({ + access_token: s.string(), + token_type: s.string(), + expires_in: s.number(), }); export function KeystaticCloudAuthCallback({ config }: { config: Config }) { @@ -25,20 +25,18 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { const state = url.searchParams.get('state'); const storedState = useMemo(() => { const _storedState = localStorage.getItem('keystatic-cloud-state'); - const storedState = storedStateSchema.safeParse( - (() => { - try { - return JSON.parse(_storedState || ''); - } catch { - return null; - } - })() - ); + const storedState = (() => { + try { + return storedStateSchema.create(JSON.parse(_storedState || '')); + } catch { + return null; + } + })(); return storedState; }, []); const [error, setError] = useState(null); useEffect(() => { - if (code && state && storedState.success && config.cloud?.project) { + if (code && state && storedState && config.cloud?.project) { const { project } = config.cloud; (async () => { const res = await fetch(`${KEYSTATIC_CLOUD_API_URL}/oauth/token`, { @@ -47,7 +45,7 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { code, client_id: project, redirect_uri: `${window.location.origin}/keystatic/cloud/oauth/callback`, - code_verifier: storedState.data.code_verifier, + code_verifier: storedState.code_verifier, grant_type: 'authorization_code', }).toString(), headers: { @@ -63,7 +61,7 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { ); } const data = await res.json(); - const parsed = tokenResponseSchema.parse(data); + const parsed = tokenResponseSchema.create(data); localStorage.setItem( 'keystatic-cloud-access-token', JSON.stringify({ @@ -72,7 +70,7 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { validUntil: Date.now() + parsed.expires_in * 1000, }) ); - router.push(`/keystatic/${storedState.data.from}`); + router.push(`/keystatic/${storedState.from}`); })().catch(error => { setError(error); }); @@ -84,7 +82,7 @@ export function KeystaticCloudAuthCallback({ config }: { config: Config }) { if (!code || !state) { return Missing code or state; } - if (storedState.success === false || state !== storedState.data.state) { + if (!storedState || state !== storedState.state) { return Invalid state; } diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx index d9fb5e2c3..98d557e54 100644 --- a/packages/keystatic/src/app/create-item.tsx +++ b/packages/keystatic/src/app/create-item.tsx @@ -1,7 +1,7 @@ import { useLocalizedStringFormatter } from '@react-aria/i18n'; import { useCallback, useEffect, useMemo, useState } from 'react'; import * as Y from 'yjs'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { Button } from '@keystar/ui/button'; import { DialogContainer } from '@keystar/ui/dialog'; @@ -79,7 +79,7 @@ function CreateItemWrapper(props: { ...(duplicateSlug ? ([duplicateSlug] as const) : ([] as const)), ]); if (!raw) throw new Error('No draft found'); - const stored = storedValSchema.parse(raw); + const stored = storedValSchema.create(raw); const parsed = parseEntry( { config: props.config, @@ -271,11 +271,11 @@ function CreateItemWrapper(props: { ); } -const storedValSchema = z.object({ - version: z.literal(1), - savedAt: z.date(), - slug: z.string(), - files: z.map(z.string(), z.instanceof(Uint8Array)), +const storedValSchema = s.type({ + version: s.literal(1), + savedAt: s.date(), + slug: s.string(), + files: s.map(s.string(), s.instance(Uint8Array)), }); function CreateItemLocal(props: { @@ -353,7 +353,7 @@ function CreateItemLocal(props: { state, }); const files = new Map(serialized.map(x => [x.path, x.contents])); - const data: z.infer = { + const data: s.Infer = { slug, files, savedAt: new Date(), diff --git a/packages/keystatic/src/app/object-cache.ts b/packages/keystatic/src/app/object-cache.ts index a9aa5f8f5..a15576462 100644 --- a/packages/keystatic/src/app/object-cache.ts +++ b/packages/keystatic/src/app/object-cache.ts @@ -10,7 +10,7 @@ import { clear, } from 'idb-keyval'; import { TreeNode } from './trees'; -import { z } from 'zod'; +import * as s from 'superstruct'; type StoredTreeEntry = { path: string; @@ -48,11 +48,11 @@ export async function getBlobFromPersistedCache(sha: string) { } let _storedTreeCache: Map | undefined; -const treeSchema = z.array( - z.object({ - path: z.string(), - mode: z.string(), - sha: z.string(), +const treeSchema = s.array( + s.object({ + path: s.string(), + mode: s.string(), + sha: s.string(), }) ); @@ -63,10 +63,14 @@ function getStoredTrees() { const cache = new Map(); return entries(getTreeStore()).then(entries => { for (const [sha, tree] of entries) { - const parsed = treeSchema.safeParse(tree); - if (parsed.success && typeof sha === 'string') { - cache.set(sha, parsed.data); + if (typeof sha !== 'string') continue; + let parsed; + try { + parsed = treeSchema.create(tree); + } catch { + continue; } + cache.set(sha, parsed); } _storedTreeCache = cache; return cache; @@ -125,12 +129,18 @@ export async function garbageCollectGitObjects(roots: string[]) { const treesToDelete = new Map(); const invalidTrees: IDBValidKey[] = []; for (const [sha, tree] of await getStoredTrees()) { - const parsed = treeSchema.safeParse(tree); - if (parsed.success && typeof sha === 'string') { - treesToDelete.set(sha, parsed.data); - } else { + if (typeof sha !== 'string') { invalidTrees.push(sha); + continue; + } + let parsed; + try { + parsed = treeSchema.create(tree); + } catch { + invalidTrees.push(sha); + continue; } + treesToDelete.set(sha, parsed); } const allBlobs = (await keys(getBlobStore())) as string[]; diff --git a/packages/keystatic/src/app/shell/data.tsx b/packages/keystatic/src/app/shell/data.tsx index e8f1c16fb..2bed4f8cc 100644 --- a/packages/keystatic/src/app/shell/data.tsx +++ b/packages/keystatic/src/app/shell/data.tsx @@ -41,7 +41,7 @@ import { isDefined } from 'emery'; import { getAuth, getCloudAuth } from '../auth'; import { ViewerContext, SidebarFooter_viewer } from './viewer-data'; import { parseRepoConfig, serializeRepoConfig } from '../repo-config'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { scopeEntriesWithPathPrefix } from './path-prefix'; import { garbageCollectGitObjects, @@ -119,26 +119,26 @@ export function LocalAppShellProvider(props: { ); } -const cloudInfoSchema = z.object({ - user: z.object({ - id: z.string(), - name: z.string(), - email: z.string(), - avatarUrl: z.string().optional(), +const cloudInfoSchema = s.type({ + user: s.type({ + id: s.string(), + name: s.string(), + email: s.string(), + avatarUrl: s.optional(s.string()), }), - project: z.object({ - name: z.string(), + project: s.type({ + name: s.string(), }), - team: z.object({ - name: z.string(), - slug: z.string(), - images: z.boolean(), - multiplayer: z.boolean(), + team: s.object({ + name: s.string(), + slug: s.string(), + images: s.boolean(), + multiplayer: s.boolean(), }), }); const CloudInfo = createContext< - null | z.infer | 'unauthorized' + null | s.Infer | 'unauthorized' >(null); export function useCloudInfo() { @@ -168,7 +168,7 @@ export function CloudInfoProvider(props: { }, }); if (res.status === 401) return 'unauthorized' as const; - return cloudInfoSchema.parse(await res.json()); + return cloudInfoSchema.create(await res.json()); }, [props.config]) ); return ( diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 211ddbdf6..8e124d2be 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -5,7 +5,6 @@ import { useContext, useState } from 'react'; import { ComponentSchema, fields } from '../form/api'; import { dump } from 'js-yaml'; import { useMutation } from 'urql'; -import { fromUint8Array } from 'js-base64'; import { BranchInfoContext, fetchGitHubTreeData, @@ -29,6 +28,7 @@ import { AppSlugContext } from './onboarding/install-app'; import { createUrqlClient } from './provider'; import { serializeProps } from '../form/serialize-props'; import { scopeEntriesWithPathPrefix } from './shell/path-prefix'; +import { base64UrlEncode } from '#base64'; const textEncoder = new TextEncoder(); @@ -217,7 +217,7 @@ export function useUpsertItem(args: { fileChanges: { additions: additions.map(addition => ({ ...addition, - contents: fromUint8Array(addition.contents), + contents: base64UrlEncode(addition.contents), })), deletions, }, @@ -298,7 +298,7 @@ export function useUpsertItem(args: { body: JSON.stringify({ additions: additions.map(addition => ({ ...addition, - contents: fromUint8Array(addition.contents), + contents: base64UrlEncode(addition.contents), })), deletions, }), diff --git a/packages/keystatic/src/app/utils.ts b/packages/keystatic/src/app/utils.ts index 1860d9fc0..d1fd754b7 100644 --- a/packages/keystatic/src/app/utils.ts +++ b/packages/keystatic/src/app/utils.ts @@ -1,4 +1,4 @@ -import { fromUint8Array } from 'js-base64'; +import { base64UrlEncode } from '#base64'; import { isDefined } from 'emery'; import { Config, GitHubConfig, LocalConfig } from '../config'; @@ -195,20 +195,15 @@ export async function redirectToCloudAuth(from: string, config: Config) { if (!config.cloud?.project) { throw new Error('Not a cloud config'); } - const code_verifier = fromUint8Array( - crypto.getRandomValues(new Uint8Array(32)), - true + const code_verifier = base64UrlEncode( + crypto.getRandomValues(new Uint8Array(32)) ); - const code_challenge = fromUint8Array( + const code_challenge = base64UrlEncode( new Uint8Array( await crypto.subtle.digest('SHA-256', textEncoder.encode(code_verifier)) - ), - true - ); - const state = fromUint8Array( - crypto.getRandomValues(new Uint8Array(32)), - true + ) ); + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32))); localStorage.setItem( 'keystatic-cloud-state', JSON.stringify({ state, from, code_verifier }) diff --git a/packages/keystatic/src/base64.ts b/packages/keystatic/src/base64.ts new file mode 100644 index 000000000..3a77fa016 --- /dev/null +++ b/packages/keystatic/src/base64.ts @@ -0,0 +1,17 @@ +export function base64UrlDecode(base64: string) { + const binString = atob(base64.replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from( + binString as Iterable, + m => (m as unknown as string).codePointAt(0)! + ); +} + +export function base64UrlEncode(bytes: Uint8Array) { + const binString = Array.from(bytes, byte => String.fromCodePoint(byte)).join( + '' + ); + return btoa(binString) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} diff --git a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx index 99e94248a..90ec28770 100644 --- a/packages/keystatic/src/component-blocks/cloud-image-preview.tsx +++ b/packages/keystatic/src/component-blocks/cloud-image-preview.tsx @@ -1,7 +1,7 @@ import { useOverlayTriggerState } from '@react-stately/overlays'; import { useEffect, useState } from 'react'; import { useSelected, useSlateStatic } from 'slate-react'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { ActionButton, @@ -67,10 +67,10 @@ function slugify(input: string) { return slug; } -const imageUploadResponse = z.object({ - src: z.string(), - width: z.number(), - height: z.number(), +const imageUploadResponse = s.type({ + src: s.string(), + width: s.number(), + height: s.number(), }); function uploadImage(file: File, config: Config) { @@ -110,11 +110,14 @@ function uploadImage(file: File, config: Config) { throw new Error(`Failed to upload image: ${await res.text()}`); } const data = await res.json(); - const parsedData = imageUploadResponse.safeParse(data); - if (!parsedData.success) { + + let parsedData; + try { + parsedData = imageUploadResponse.create(data); + } catch { throw new Error('Unexpected response from cloud'); } - return parsedData.data; + return parsedData; })(); } @@ -187,11 +190,11 @@ function loadImageDimensions(url: string) { }); } -const imageDataSchema = z.object({ - src: z.string(), - alt: z.string(), - width: z.number(), - height: z.number(), +const imageDataSchema = s.type({ + src: s.string(), + alt: s.string(), + width: s.number(), + height: s.number(), }); export async function loadImageData( @@ -211,10 +214,9 @@ export async function loadImageData( ); if (res.ok) { const data = await res.json(); - const parsed = imageDataSchema.safeParse(data); - if (parsed.success) { - return parsed.data; - } + try { + return imageDataSchema.create(data); + } catch {} } } return loadImageDimensions(url).then(dimensions => ({ diff --git a/packages/keystatic/src/form/fields/document/DocumentEditor/disable-global-prism-highlighting.ts b/packages/keystatic/src/form/fields/document/DocumentEditor/disable-global-prism-highlighting.ts deleted file mode 100644 index f77257a6a..000000000 --- a/packages/keystatic/src/form/fields/document/DocumentEditor/disable-global-prism-highlighting.ts +++ /dev/null @@ -1,6 +0,0 @@ -if (!globalThis.Prism) { - globalThis.Prism = {} as any; -} -globalThis.Prism.manual = true; - -export {}; diff --git a/packages/keystatic/src/form/fields/document/DocumentEditor/pasting/index.ts b/packages/keystatic/src/form/fields/document/DocumentEditor/pasting/index.ts index 71ac9454a..813ea9197 100644 --- a/packages/keystatic/src/form/fields/document/DocumentEditor/pasting/index.ts +++ b/packages/keystatic/src/form/fields/document/DocumentEditor/pasting/index.ts @@ -4,7 +4,7 @@ import { isValidURL } from '../isValidURL'; import { insertNodesButReplaceIfSelectionIsAtEmptyParagraphOrHeading } from '../ui-utils'; import { deserializeHTML } from './html'; import { deserializeMarkdown } from './markdown'; -import { fromUint8Array, toUint8Array } from 'js-base64'; +import { base64UrlEncode, base64UrlDecode } from '#base64'; import { isBlock } from '../editor'; const urlPattern = /https?:\/\//; @@ -133,7 +133,7 @@ function setFragmentData(e: Editor, data: DataTransfer) { const string = JSON.stringify(fragment, (key, val) => { if (val instanceof Uint8Array) { return { - [bytesName]: fromUint8Array(val), + [bytesName]: base64UrlEncode(val), }; } return val; @@ -184,7 +184,7 @@ export function withPasting(editor: Editor): Editor { val !== null && bytesName in val && typeof val[bytesName] === 'string' - ? toUint8Array(val[bytesName]) + ? base64UrlDecode(val[bytesName]) : val ) as Node[]; editor.insertFragment(parsed); diff --git a/packages/keystatic/src/form/fields/document/index.tsx b/packages/keystatic/src/form/fields/document/index.tsx index 9c7456dcd..857af6b17 100644 --- a/packages/keystatic/src/form/fields/document/index.tsx +++ b/packages/keystatic/src/form/fields/document/index.tsx @@ -1,9 +1,6 @@ -import Markdoc from '@markdoc/markdoc'; - -import { Descendant, Editor } from 'slate'; +import { parse } from '#markdoc'; import { fromMarkdoc } from './markdoc/from-markdoc'; -import { toMarkdocDocument } from './markdoc/to-markdoc'; import { DocumentFeatures } from './DocumentEditor/document-features'; import { deserializeFiles } from './DocumentEditor/component-blocks/document-field'; import { collectDirectoriesUsedInSchema } from '../../../app/tree-key'; @@ -14,17 +11,18 @@ import { DocumentElement, ContentFormField, SlugFormField, - FormFieldStoredValue, } from '../../api'; import { text } from '../text'; -import { DocumentFieldInput } from '#field-ui/document'; -import { createDocumentEditorForNormalization } from './DocumentEditor/create-editor'; +import { + DocumentFieldInput, + normalizeDocumentFieldChildren, + serializeMarkdoc, +} from '#field-ui/document'; import { object } from '../object'; import { FieldDataError } from '../error'; import { basicFormFieldWithSimpleReaderParse } from '../utils'; import { fixPath } from '../../../app/path-utils'; -const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); type HeadingLevels = true | readonly (1 | 2 | 3 | 4 | 5 | 6)[]; @@ -264,35 +262,7 @@ export function document({ DocumentElement[] > { const documentFeatures = normaliseDocumentFeatures(documentFeaturesConfig); - const parse = - (mode: 'read' | 'edit') => - ( - _value: FormFieldStoredValue, - data: { - content: Uint8Array | undefined; - other: ReadonlyMap; - external: ReadonlyMap>; - slug: string | undefined; - } - ): DocumentElement[] => { - const markdoc = textDecoder.decode(data.content); - const document = fromMarkdoc(Markdoc.parse(markdoc), componentBlocks); - const editor = createDocumentEditorForNormalization( - documentFeatures, - componentBlocks - ); - editor.children = document; - Editor.normalize(editor, { force: true }); - return deserializeFiles( - editor.children, - componentBlocks, - data.other, - data.external || new Map(), - mode, - documentFeatures, - data.slug - ) as any; - }; + return { kind: 'form', formKind: 'content', @@ -311,7 +281,23 @@ export function document({ ); }, - parse: parse('edit'), + parse(_, data) { + const markdoc = textDecoder.decode(data.content); + const document = fromMarkdoc(parse(markdoc), componentBlocks); + return deserializeFiles( + normalizeDocumentFieldChildren( + documentFeatures, + componentBlocks, + document + ), + componentBlocks, + data.other, + data.external, + 'edit', + documentFeatures, + data.slug + ) as any; + }, contentExtension: '.mdoc', validate(value) { return value; @@ -333,39 +319,22 @@ export function document({ : []), ], serialize(value, opts) { - const { extraFiles, node } = toMarkdocDocument( - value as any as Descendant[], - { - componentBlocks, - documentFeatures, - slug: opts.slug, - } - ); - - const other = new Map(); - const external = new Map>(); - for (const file of extraFiles) { - if (file.parent === undefined) { - other.set(file.path, file.contents); - continue; - } - if (!external.has(file.parent)) { - external.set(file.parent, new Map()); - } - external.get(file.parent)!.set(file.path, file.contents); - } - - return { - content: textEncoder.encode( - Markdoc.format(Markdoc.parse(Markdoc.format(node))) - ), - other, - external, - value: undefined, - }; + return serializeMarkdoc(value, opts, componentBlocks, documentFeatures); }, reader: { - parse: parse('read'), + parse(value, data) { + const markdoc = textDecoder.decode(data.content); + const document = fromMarkdoc(parse(markdoc), componentBlocks); + return deserializeFiles( + document, + componentBlocks, + new Map(), + new Map(), + 'read', + documentFeatures, + undefined + ) as any; + }, }, }; } diff --git a/packages/keystatic/src/form/fields/document/markdoc/from-markdoc.ts b/packages/keystatic/src/form/fields/document/markdoc/from-markdoc.ts index 197972901..13dc6de91 100644 --- a/packages/keystatic/src/form/fields/document/markdoc/from-markdoc.ts +++ b/packages/keystatic/src/form/fields/document/markdoc/from-markdoc.ts @@ -1,5 +1,5 @@ -import { Node } from '@markdoc/markdoc'; -import { Descendant } from 'slate'; +import { Node } from '#markdoc'; +import type { Descendant } from 'slate'; import { ReadonlyPropPath } from '../DocumentEditor/component-blocks/utils'; import { getValueAtPropPath } from '../../../props-value'; import { diff --git a/packages/keystatic/src/form/fields/document/markdoc/markdoc.test.tsx b/packages/keystatic/src/form/fields/document/markdoc/markdoc.test.tsx index e34b0fe57..dd79267de 100644 --- a/packages/keystatic/src/form/fields/document/markdoc/markdoc.test.tsx +++ b/packages/keystatic/src/form/fields/document/markdoc/markdoc.test.tsx @@ -6,7 +6,7 @@ import { jsx, makeEditor } from '../DocumentEditor/tests/utils'; import { component, fields } from '../../../api'; import { fromMarkdoc as _fromMarkdoc } from './from-markdoc'; import { toMarkdoc as _toMarkdoc } from './test-utils'; -import Markdoc from '@markdoc/markdoc'; +import { parse } from '#markdoc'; import { Node } from 'slate'; const componentBlocks = { @@ -48,7 +48,7 @@ function toMarkdoc(node: Node) { function fromMarkdoc(markdoc: string) { return makeEditor( - {_fromMarkdoc(Markdoc.parse(markdoc), componentBlocks)}, + {_fromMarkdoc(parse(markdoc), componentBlocks)}, { normalization: 'normalize' } ); } diff --git a/packages/keystatic/src/form/fields/document/markdoc/test-utils.ts b/packages/keystatic/src/form/fields/document/markdoc/test-utils.ts index fc87dee73..0ebe52b05 100644 --- a/packages/keystatic/src/form/fields/document/markdoc/test-utils.ts +++ b/packages/keystatic/src/form/fields/document/markdoc/test-utils.ts @@ -1,4 +1,4 @@ -import Markdoc from '@markdoc/markdoc'; +import { format, parse } from '#markdoc'; import { Node } from 'slate'; import { defaultDocumentFeatures, @@ -17,5 +17,5 @@ export function toMarkdoc( documentFeatures: defaultDocumentFeatures, slug: undefined, }).node; - return Markdoc.format(Markdoc.parse(Markdoc.format(root))); + return format(parse(format(root))); } diff --git a/packages/keystatic/src/form/fields/document/markdoc/to-markdoc.tsx b/packages/keystatic/src/form/fields/document/markdoc/to-markdoc.tsx index ff31ac33a..9aa042953 100644 --- a/packages/keystatic/src/form/fields/document/markdoc/to-markdoc.tsx +++ b/packages/keystatic/src/form/fields/document/markdoc/to-markdoc.tsx @@ -1,4 +1,4 @@ -import Markdoc, { Node, NodeType } from '@markdoc/markdoc'; +import { Node, NodeType, Ast } from '#markdoc'; import { ReadonlyPropPath } from '../DocumentEditor/component-blocks/utils'; import { getValueAtPropPath } from '../../../props-value'; import { areArraysEqual } from '../DocumentEditor/document-features-normalization'; @@ -10,13 +10,11 @@ import { } from './find-children'; import { DocumentFeatures } from '../DocumentEditor/document-features'; import { getInitialPropsValueFromInitializer } from '../../../initial-values'; -import { Descendant } from 'slate'; +import type { Descendant } from 'slate'; import { fixPath } from '../../../../app/path-utils'; import { getSrcPrefixForImageBlock } from '../DocumentEditor/component-blocks/document-field'; import { serializeProps } from '../../../serialize-props'; -const { Ast } = Markdoc; - function toInline(nodes: Descendant[]): Node { return new Ast.Node('inline', {}, nodes.flatMap(toMarkdocInline)); } diff --git a/packages/keystatic/src/form/fields/document/ui.tsx b/packages/keystatic/src/form/fields/document/ui.tsx index 7f456e540..98cd83e20 100644 --- a/packages/keystatic/src/form/fields/document/ui.tsx +++ b/packages/keystatic/src/form/fields/document/ui.tsx @@ -7,8 +7,12 @@ import { FormFieldInputProps, } from '../../api'; import { useEntryLayoutSplitPaneContext } from '../../../app/entry-form'; -import { DocumentEditor } from './DocumentEditor'; +import { DocumentEditor, Editor } from './DocumentEditor'; import { DocumentFeatures } from './DocumentEditor/document-features'; +import { createDocumentEditorForNormalization } from './DocumentEditor/create-editor'; +import type { Descendant } from 'slate'; +import { format, parse } from '#markdoc'; +import { toMarkdocDocument } from './markdoc/to-markdoc'; let i = 0; @@ -16,6 +20,55 @@ function newKey() { return i++; } +const encoder = new TextEncoder(); + +export function serializeMarkdoc( + value: DocumentElement[], + opts: { slug: string | undefined }, + componentBlocks: Record, + documentFeatures: DocumentFeatures +) { + const { extraFiles, node } = toMarkdocDocument(value as any as Descendant[], { + componentBlocks, + documentFeatures, + slug: opts.slug, + }); + + const other = new Map(); + const external = new Map>(); + for (const file of extraFiles) { + if (file.parent === undefined) { + other.set(file.path, file.contents); + continue; + } + if (!external.has(file.parent)) { + external.set(file.parent, new Map()); + } + external.get(file.parent)!.set(file.path, file.contents); + } + + return { + content: encoder.encode(format(parse(format(node)))), + other, + external, + value: undefined, + }; +} + +export function normalizeDocumentFieldChildren( + documentFeatures: DocumentFeatures, + componentBlocks: Record, + document: Descendant[] +) { + const editor = createDocumentEditorForNormalization( + documentFeatures, + componentBlocks + ); + editor.children = document; + Editor.normalize(editor, { force: true }); + return editor.children; +} + export function DocumentFieldInput( props: FormFieldInputProps & { label: string; diff --git a/packages/keystatic/src/form/fields/empty-field-ui.tsx b/packages/keystatic/src/form/fields/empty-field-ui.tsx index 1bcfd2a9b..0289efad7 100644 --- a/packages/keystatic/src/form/fields/empty-field-ui.tsx +++ b/packages/keystatic/src/form/fields/empty-field-ui.tsx @@ -33,4 +33,7 @@ export let SlugFieldInput = empty, parseToEditorStateMDX = empty, serializeFromEditorStateMDX = empty, createEditorStateFromYJS = empty, - prosemirrorToYXmlFragment = empty; + prosemirrorToYXmlFragment = empty, + normalizeDocumentFieldChildren = empty, + slugify = empty, + serializeMarkdoc = empty; diff --git a/packages/keystatic/src/form/fields/markdoc/editor/custom-components.tsx b/packages/keystatic/src/form/fields/markdoc/editor/custom-components.tsx index c823a7285..560d74a03 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/custom-components.tsx +++ b/packages/keystatic/src/form/fields/markdoc/editor/custom-components.tsx @@ -20,7 +20,7 @@ import { toSerialized, useDeserializedValue, } from './props-serialization'; -import { fromUint8Array, toUint8Array } from 'js-base64'; +import { base64UrlEncode, base64UrlDecode } from '#base64'; function serializeProps(props: { value: unknown; @@ -35,7 +35,7 @@ function serializeProps(props: { extraFiles: props.extraFiles.map(x => ({ path: x.path, parent: x.parent, - contents: fromUint8Array(x.contents), + contents: base64UrlEncode(x.contents), })), }); } @@ -54,7 +54,7 @@ function deserializeProps( extraFiles: parsed.extraFiles.map((x: any) => ({ path: x.path, parent: x.parent, - contents: toUint8Array(x.contents), + contents: base64UrlDecode(x.contents), })), }, }; diff --git a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/clipboard.tsx b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/clipboard.tsx index 4c4cc55b1..0cd78eb6d 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/clipboard.tsx +++ b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/clipboard.tsx @@ -7,7 +7,7 @@ import { import { Plugin } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { proseMirrorToMarkdoc } from './serialize'; -import Markdoc from '@markdoc/markdoc'; +import { format, parse } from '#markdoc'; import { markdocToProseMirror } from './parse'; import { getEditorSchema } from '../schema'; @@ -21,7 +21,7 @@ export function markdocClipboard() { // you shouldn't get the block quote clipboardTextSerializer(content, view) { try { - return Markdoc.format( + return format( proseMirrorToMarkdoc( view.state.doc.type.create({}, content.content), { @@ -41,7 +41,7 @@ export function markdocClipboard() { try { return Slice.maxOpen( markdocToProseMirror( - Markdoc.parse(text), + parse(text), getEditorSchema(view.state.schema), undefined, undefined, diff --git a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts index 65bd6adcd..ef4e3eff0 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts +++ b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/parse.ts @@ -1,4 +1,4 @@ -import { Node as MarkdocNode, ValidateError } from '@markdoc/markdoc'; +import { Node as MarkdocNode, ValidateError } from '#markdoc'; import { Mark, MarkType, diff --git a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts index 12f58f895..0aebf9d2c 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts +++ b/packages/keystatic/src/form/fields/markdoc/editor/markdoc/serialize.ts @@ -1,12 +1,10 @@ -import Markdoc, { Node as MarkdocNode, NodeType } from '@markdoc/markdoc'; +import { Ast, Node as MarkdocNode, NodeType } from '#markdoc'; import { Fragment, Mark, Node as ProseMirrorNode } from 'prosemirror-model'; import { EditorSchema, getEditorSchema } from '../schema'; import { getSrcPrefixForImageBlock } from '../images'; import { fixPath } from '../../../../../app/path-utils'; import { internalToSerialized } from '../props-serialization'; -const { Ast } = Markdoc; - type DocumentSerializationState = { schema: EditorSchema; extraFiles: Map; diff --git a/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx b/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx index 6edecdbd5..7ae753a23 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx +++ b/packages/keystatic/src/form/fields/markdoc/editor/schema.tsx @@ -37,7 +37,7 @@ import { EditorConfig } from '../config'; import { toSerialized } from './props-serialization'; import { getInitialPropsValue } from '../../../initial-values'; import { getUploadedFileObject } from '../../image/ui'; -import { fromUint8Array, toUint8Array } from 'js-base64'; +import { base64UrlEncode, base64UrlDecode } from '#base64'; const blockElementSpacing = css({ marginBlock: '1em', @@ -381,7 +381,7 @@ const nodeSpecs = { node.attrs.filename.endsWith('.svg') ? 'image/svg+xml' : 'application/octet-stream' - };base64,${fromUint8Array(node.attrs.src)}`, + };base64,${base64UrlEncode(node.attrs.src)}`, alt: node.attrs.alt, title: node.attrs.title, 'data-filename': node.attrs.filename, @@ -396,7 +396,7 @@ const nodeSpecs = { const src = node.getAttribute('src'); const filename = node.getAttribute('data-filename'); if (!src?.startsWith('data:') || !filename) return false; - const srcAsUint8Array = toUint8Array( + const srcAsUint8Array = base64UrlDecode( src.replace(/^data:[a-z/-]+;base64,/, '') ); return { diff --git a/packages/keystatic/src/form/fields/markdoc/editor/tests/markdoc.test.tsx b/packages/keystatic/src/form/fields/markdoc/editor/tests/markdoc.test.tsx index 33e263a27..5c67d6e7e 100644 --- a/packages/keystatic/src/form/fields/markdoc/editor/tests/markdoc.test.tsx +++ b/packages/keystatic/src/form/fields/markdoc/editor/tests/markdoc.test.tsx @@ -5,7 +5,7 @@ import { expect, test } from '@jest/globals'; import { EditorStateDescription, jsx, toEditorState } from './utils'; import { markdocToProseMirror } from '../markdoc/parse'; import { proseMirrorToMarkdoc } from '../markdoc/serialize'; -import Markdoc from '@markdoc/markdoc'; +import { format, parse } from '#markdoc'; import { createEditorSchema } from '../schema'; import { editorOptionsToConfig } from '../../config'; import { block, inline, mark } from '../../../../../content-components'; @@ -48,9 +48,9 @@ const schema = createEditorSchema( ); function toMarkdoc(node: EditorStateDescription) { - return Markdoc.format( - Markdoc.parse( - Markdoc.format( + return format( + parse( + format( proseMirrorToMarkdoc(node.get().doc, { extraFiles: new Map(), otherFiles: new Map(), @@ -65,7 +65,7 @@ function toMarkdoc(node: EditorStateDescription) { function fromMarkdoc(markdoc: string) { return toEditorState( markdocToProseMirror( - Markdoc.parse(markdoc), + parse(markdoc), schema, new Map([['something something.png', new Uint8Array([])]]), undefined, diff --git a/packages/keystatic/src/form/fields/markdoc/index.tsx b/packages/keystatic/src/form/fields/markdoc/index.tsx index 7e0a4d1d6..c9364ca4f 100644 --- a/packages/keystatic/src/form/fields/markdoc/index.tsx +++ b/packages/keystatic/src/form/fields/markdoc/index.tsx @@ -1,4 +1,4 @@ -import Markdoc, { Node as MarkdocNode } from '@markdoc/markdoc'; +import { Node as MarkdocNode, parse } from '#markdoc'; import { ContentFormField } from '../../api'; import { @@ -97,7 +97,7 @@ export function markdoc({ reader: { parse: (_, { content }) => { const text = textDecoder.decode(content); - return { node: Markdoc.parse(text) }; + return { node: parse(text) }; }, }, collaboration: { diff --git a/packages/keystatic/src/form/fields/markdoc/markdoc-config.ts b/packages/keystatic/src/form/fields/markdoc/markdoc-config.ts index de6b1b6f9..0bb37eb74 100644 --- a/packages/keystatic/src/form/fields/markdoc/markdoc-config.ts +++ b/packages/keystatic/src/form/fields/markdoc/markdoc-config.ts @@ -1,4 +1,4 @@ -import Markdoc, { Config, NodeType, SchemaAttribute } from '@markdoc/markdoc'; +import { nodes, Config, NodeType, SchemaAttribute } from '#markdoc'; import { MarkdocEditorOptions, editorOptionsToConfig } from './config'; import { ComponentSchema } from '../../api'; import { ContentComponent } from '../../../content-components'; @@ -82,8 +82,6 @@ function fieldsToMarkdocAttributes( ); } -const { nodes } = Markdoc; - export function createMarkdocConfig< Components extends Record, >(opts: { diff --git a/packages/keystatic/src/form/fields/markdoc/ui.tsx b/packages/keystatic/src/form/fields/markdoc/ui.tsx index d50128f84..750330f8b 100644 --- a/packages/keystatic/src/form/fields/markdoc/ui.tsx +++ b/packages/keystatic/src/form/fields/markdoc/ui.tsx @@ -5,7 +5,7 @@ import { Editor } from './editor'; import { createEditorState } from './editor/editor-state'; import { EditorSchema, getEditorSchema } from './editor/schema'; import { markdocToProseMirror } from './editor/markdoc/parse'; -import Markdoc from '@markdoc/markdoc'; +import { format, parse } from '#markdoc'; import { proseMirrorToMarkdoc } from './editor/markdoc/serialize'; import { useEntryLayoutSplitPaneContext } from '../../../app/entry-form'; import { fromMarkdown } from 'mdast-util-from-markdown'; @@ -38,7 +38,7 @@ export function parseToEditorState( ) { const markdoc = textDecoder.decode(content); const doc = markdocToProseMirror( - Markdoc.parse(markdoc), + parse(markdoc), schema, files, otherFiles, @@ -59,9 +59,9 @@ export function serializeFromEditorState( schema: getEditorSchema(value.schema), slug, }); - const markdoc = Markdoc.format(markdocNode); + const markdoc = format(markdocNode); return { - content: textEncoder.encode(Markdoc.format(Markdoc.parse(markdoc))), + content: textEncoder.encode(format(parse(markdoc))), other, external, }; diff --git a/packages/keystatic/src/form/fields/number/validateNumber.tsx b/packages/keystatic/src/form/fields/number/validateNumber.tsx index b951f7909..09afd036b 100644 --- a/packages/keystatic/src/form/fields/number/validateNumber.tsx +++ b/packages/keystatic/src/form/fields/number/validateNumber.tsx @@ -1,4 +1,4 @@ -import Decimal from 'decimal.js'; +import Decimal from 'decimal.js-light'; export function validateNumber( validation: diff --git a/packages/keystatic/src/form/fields/slug/index.tsx b/packages/keystatic/src/form/fields/slug/index.tsx index 98370a0c3..d5da1c3f4 100644 --- a/packages/keystatic/src/form/fields/slug/index.tsx +++ b/packages/keystatic/src/form/fields/slug/index.tsx @@ -1,7 +1,6 @@ import { FormFieldStoredValue, SlugFormField } from '../../api'; -import slugify from '@sindresorhus/slugify'; import { validateText } from '../text/validateText'; -import { SlugFieldInput } from '#field-ui/slug'; +import { SlugFieldInput, slugify } from '#field-ui/slug'; import { Glob } from '../../..'; import { FieldDataError } from '../error'; @@ -84,10 +83,17 @@ export function slug(_args: { }; const naiveGenerateSlug: (name: string) => string = args.slug?.generate || slugify; - const defaultValue = { - name: args.name.defaultValue ?? '', - slug: naiveGenerateSlug(args.name.defaultValue ?? ''), - }; + let _defaultValue: { name: string; slug: string } | undefined; + + function defaultValue() { + if (!_defaultValue) { + _defaultValue = { + name: args.name.defaultValue ?? '', + slug: naiveGenerateSlug(args.name.defaultValue ?? ''), + }; + } + return _defaultValue; + } function validate( value: { name: string; slug: string }, @@ -131,14 +137,12 @@ export function slug(_args: { ); }, - defaultValue() { - return defaultValue; - }, + defaultValue, parse(value, args) { if (args?.slug !== undefined) { return parseAsSlugField(value, args.slug); diff --git a/packages/keystatic/src/form/fields/slug/ui.tsx b/packages/keystatic/src/form/fields/slug/ui.tsx index 1eca42850..4f576f524 100644 --- a/packages/keystatic/src/form/fields/slug/ui.tsx +++ b/packages/keystatic/src/form/fields/slug/ui.tsx @@ -13,6 +13,8 @@ import { validateText } from '../text/validateText'; const emptySet = new Set(); +export { default as slugify } from '@sindresorhus/slugify'; + export function SlugFieldInput( props: FormFieldInputProps<{ name: string; slug: string }> & { defaultValue: { name: string; slug: string }; diff --git a/packages/keystatic/src/markdoc.d.ts b/packages/keystatic/src/markdoc.d.ts new file mode 100644 index 000000000..643aed25a --- /dev/null +++ b/packages/keystatic/src/markdoc.d.ts @@ -0,0 +1 @@ +export * from '@markdoc/markdoc'; diff --git a/packages/keystatic/src/markdoc.js b/packages/keystatic/src/markdoc.js new file mode 100644 index 000000000..4d101c2ca --- /dev/null +++ b/packages/keystatic/src/markdoc.js @@ -0,0 +1 @@ +export * from '@markdoc/markdoc/dist/index.mjs'; diff --git a/packages/keystatic/test/reader.test.tsx b/packages/keystatic/test/reader.test.tsx index aeb539743..c5a674ddf 100644 --- a/packages/keystatic/test/reader.test.tsx +++ b/packages/keystatic/test/reader.test.tsx @@ -121,6 +121,7 @@ test('read', async () => { "text": "Cool, and things are direct to GitHub?", }, ], + "textAlign": undefined, "type": "paragraph", }, { @@ -162,6 +163,7 @@ test('read deep', async () => { "text": "Dan Cousens", }, ], + "textAlign": undefined, "type": "paragraph", }, ], @@ -175,6 +177,7 @@ test('read deep', async () => { "text": "Cool, and things are direct to GitHub?", }, ], + "textAlign": undefined, "type": "paragraph", }, { @@ -246,6 +249,7 @@ test('read all deep', async () => { "text": "Dan Cousens", }, ], + "textAlign": undefined, "type": "paragraph", }, ], @@ -259,6 +263,7 @@ test('read all deep', async () => { "text": "Cool, and things are direct to GitHub?", }, ], + "textAlign": undefined, "type": "paragraph", }, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b56e82334..e2451d17e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1207,9 +1207,9 @@ importers: cookie: specifier: ^0.5.0 version: 0.5.0 - decimal.js: - specifier: ^10.4.3 - version: 10.4.3 + decimal.js-light: + specifier: ^2.5.1 + version: 2.5.1 emery: specifier: ^1.4.1 version: 1.4.2 @@ -1228,15 +1228,9 @@ importers: ignore: specifier: ^5.2.4 version: 5.2.4 - iron-webcrypto: - specifier: ^0.10.1 - version: 0.10.1 is-hotkey: specifier: ^0.2.0 version: 0.2.0 - js-base64: - specifier: ^3.7.5 - version: 3.7.5 js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -1321,6 +1315,9 @@ importers: slate-react: specifier: ^0.91.9 version: 0.91.11(react-dom@18.2.0)(react@18.2.0)(slate@0.91.4) + superstruct: + specifier: ^1.0.4 + version: 1.0.4 unist-util-visit: specifier: ^5.0.0 version: 5.0.0 @@ -1336,9 +1333,6 @@ importers: yjs: specifier: ^13.6.11 version: 13.6.11 - zod: - specifier: ^3.20.2 - version: 3.22.2 devDependencies: '@jest/expect': specifier: ^29.7.0 @@ -13934,6 +13928,10 @@ packages: engines: {node: '>=0.10.0'} dev: false + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + /decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} dev: false @@ -15425,7 +15423,7 @@ packages: eslint: 8.48.0 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 2.7.1(eslint-plugin-import@2.28.1)(eslint@8.48.0) - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.48.0) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.48.0) eslint-plugin-jsx-a11y: 6.7.1(eslint@8.48.0) eslint-plugin-react: 7.33.2(eslint@8.48.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.48.0) @@ -15470,7 +15468,7 @@ packages: dependencies: debug: 4.3.4 eslint: 8.48.0 - eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.48.0) + eslint-plugin-import: 2.28.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.48.0) glob: 7.2.3 is-glob: 4.0.3 resolve: 1.22.4 @@ -15529,6 +15527,35 @@ packages: transitivePeerDependencies: - supports-color + /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1)(eslint@8.48.0): + resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + dependencies: + '@typescript-eslint/parser': 5.62.0(eslint@8.48.0)(typescript@5.4.5) + debug: 3.2.7 + eslint: 8.48.0 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 2.7.1(eslint-plugin-import@2.28.1)(eslint@8.48.0) + transitivePeerDependencies: + - supports-color + /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.48.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} @@ -15569,6 +15596,40 @@ packages: regexpp: 3.2.0 dev: true + /eslint-plugin-import@2.28.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.48.0): + resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + '@typescript-eslint/parser': 5.62.0(eslint@8.48.0)(typescript@5.4.5) + array-includes: 3.1.7 + array.prototype.findlastindex: 1.2.3 + array.prototype.flat: 1.3.1 + array.prototype.flatmap: 1.3.1 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.48.0 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1)(eslint@8.48.0) + has: 1.0.3 + is-core-module: 2.13.0 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.7 + object.groupby: 1.0.1 + object.values: 1.1.7 + semver: 6.3.1 + tsconfig-paths: 3.14.2 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + /eslint-plugin-import@2.28.1(@typescript-eslint/parser@5.62.0)(eslint-import-resolver-typescript@3.6.1)(eslint@8.48.0): resolution: {integrity: sha512-9I9hFlITvOV55alzoKBI+K9q74kv0iKMeY6av5+umsNwayt59fz692daGyjR+oStBQgx6nwR9rXldDev3Clw+A==} engines: {node: '>=4'} @@ -17554,10 +17615,6 @@ packages: engines: {node: '>= 10'} dev: true - /iron-webcrypto@0.10.1: - resolution: {integrity: sha512-QGOS8MRMnj/UiOa+aMIgfyHcvkhqNUsUxb1XzskENvbo+rEfp6TOwqd1KPuDzXC4OnGHcMSVxDGRoilqB8ViqA==} - dev: false - /is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -18588,10 +18645,6 @@ packages: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} dev: false - /js-base64@3.7.5: - resolution: {integrity: sha512-3MEt5DTINKqfScXKfJFrRbxkrnk2AxPWGBL/ycjz4dK8iqiSJ06UxD8jh8xuh6p10TX4t2+7FsBYVxxQbMg+qA==} - dev: false - /js-cookie@2.2.1: resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} dev: true @@ -24209,6 +24262,11 @@ packages: resolution: {integrity: sha512-W4SitSZ9MOyMPbHreoZVEneSZyPEeNGbdfJo/7FkJyRs/M3wQRFzq+t3S/NBwlrFSWdx1ONLjLb9pB+UKe4IqQ==} dev: true + /superstruct@1.0.4: + resolution: {integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==} + engines: {node: '>=14.0.0'} + dev: false + /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -26180,10 +26238,6 @@ packages: stacktracey: 2.1.8 dev: false - /zod@3.22.2: - resolution: {integrity: sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==} - dev: false - /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}