clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / apps / editor / src / details-document.js
details-document.js 5.6K
1import { APPEARANCES } from './client-svg.js'2import { normalizeMetadata, normalizePresentation } from './state.js'34export const DETAILS_MODEL_URI = 'inmemory://diagram.zip/details.json'5export const DETAILS_SCHEMA_URI = 'https://diagram.zip/schemas/details.v1.json'67export const DETAILS_DOCUMENT_SCHEMA = {8 $schema: 'https://json-schema.org/draft/2020-12/schema',9 $id: DETAILS_SCHEMA_URI,10 title: 'Diagram details',11 description: 'Metadata, renderer options, and presentation settings for the current diagram.',12 type: 'object',13 additionalProperties: false,14 required: ['title', 'description', 'presentation'],15 properties: {16 title: {17 type: 'string',18 maxLength: 200,19 description: 'Document title. The browser tab and shared embeds use this value.',20 },21 description: {22 type: 'string',23 maxLength: 2000,24 description: 'A plain-text description of what the diagram shows.',25 },26 options: {27 type: 'object',28 maxProperties: 64,29 propertyNames: { pattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },30 additionalProperties: { type: ['string', 'number', 'boolean'] },31 description: 'Renderer-specific options.',32 },33 presentation: {34 type: 'object',35 additionalProperties: false,36 required: ['appearance', 'background', 'padding', 'frame'],37 properties: {38 appearance: {39 type: 'string',40 enum: APPEARANCES,41 description: 'raw preserves renderer paint; themed appearances use the shared Diagram.zip palette.',42 },43 background: {44 type: 'string',45 pattern: '^(?:|#[0-9a-fA-F]{6})$',46 description: 'Renderer-default canvas color as an empty string or a six-digit hex color. Used by raw appearance.',47 },48 padding: {49 type: 'integer',50 minimum: 0,51 maximum: 256,52 description: 'Renderer-default canvas padding in pixels. Used by raw appearance.',53 },54 frame: {55 type: 'boolean',56 description: 'Add a bounded renderer-default canvas. Used by raw appearance.',57 },58 },59 },60 },61}6263export function detailsDocumentFor(state = {}) {64 const meta = normalizeMetadata(state.meta)65 const presentation = normalizePresentation(state.presentation)66 return {67 title: meta.title,68 description: meta.description,69 options: normalizeRendererOptions(state.options),70 presentation,71 }72}7374export function serializeDetailsDocument(state) {75 return `${JSON.stringify(detailsDocumentFor(state), null, 2)}\n`76}7778export function detailsStateWithTitle(state, title) {79 if (typeof title !== 'string' || title.length > 200) {80 throw new Error('title must be a string with at most 200 characters.')81 }82 return {83 meta: normalizeMetadata({ ...state.meta, title }),84 options: normalizeRendererOptions(state.options),85 presentation: normalizePresentation(state.presentation),86 }87}8889export function parseDetailsDocument(source) {90 let value91 try {92 value = JSON.parse(source)93 } catch (error) {94 throw new Error(`Details must be valid JSON. ${error instanceof Error ? error.message : ''}`.trim())95 }96 assertObject(value, 'Details must be a JSON object.')97 assertKeys(value, ['title', 'description', 'options', 'presentation'], 'details')98 assertRequired(value, ['title', 'description', 'presentation'], 'details')99 if (typeof value.title !== 'string' || value.title.length > 200) {100 throw new Error('title must be a string with at most 200 characters.')101 }102 if (typeof value.description !== 'string' || value.description.length > 2000) {103 throw new Error('description must be a string with at most 2000 characters.')104 }105106 assertObject(value.presentation, 'presentation must be a JSON object.')107 assertKeys(value.presentation, ['appearance', 'background', 'padding', 'frame'], 'presentation')108 assertRequired(value.presentation, ['appearance', 'background', 'padding', 'frame'], 'presentation')109 let presentation110 try {111 presentation = normalizePresentation(value.presentation)112 } catch {113 throw new Error('presentation has an invalid appearance, background, padding, or frame value.')114 }115 return {116 meta: normalizeMetadata({ title: value.title, description: value.description }),117 options: normalizeRendererOptions(value.options),118 presentation,119 }120}121122function normalizeRendererOptions(options = {}) {123 assertObject(options, 'options must be a JSON object.')124 const entries = Object.entries(options)125 if (entries.length > 64) throw new Error('options cannot contain more than 64 properties.')126 const normalized = {}127 for (const [name, value] of entries) {128 if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(name)) throw new Error(`options contains invalid property "${name}".`)129 if (!['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value))) {130 throw new Error(`option "${name}" must be a string, number, or boolean.`)131 }132 if (String(value).length > 4_096) throw new Error(`option "${name}" is too large.`)133 normalized[name] = value134 }135 return normalized136}137138function assertObject(value, message) {139 if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(message)140}141142function assertKeys(value, allowed, label) {143 const unknown = Object.keys(value).find(key => !allowed.includes(key))144 if (unknown) throw new Error(`${label} contains unknown property "${unknown}".`)145}146147function assertRequired(value, required, label) {148 const missing = required.find(key => !Object.hasOwn(value, key))149 if (missing) throw new Error(`${label} is missing required property "${missing}".`)150}