feat(schema-form): schema-driven React form renderer package
@deepseek-ai/dsh-client-schema-form rehydrates the wire's serialized
schemastery envelope (new Schema(json)) and edits a draft user section
against it: presence-in-draft marks a field overridden with a per-field
reset, inherited values render as placeholders, role('secret') slots are
write-only with configured-state placeholders from the wire's secrets
list, dict adds take a union-typed sKey as their vocabulary, and any
node the renderer cannot faithfully edit falls back to a read-only view
instead of silently disappearing. renderField(context) is the role hook
the Models page will use for the credential-ref control; validateDraft
runs the same rehydrated validator the host uses, so the browser and
host judge one schema.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field.group {
|
||||
border: 1px solid var(--border, #e2e2e2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.labelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #555);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border, #d9d9d9);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
background: var(--surface, #fff);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.control:focus {
|
||||
outline: 2px solid var(--accent, #3964fe);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.resetButton {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--accent, #3964fe);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dictKey {
|
||||
min-width: 96px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unsupported {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #888);
|
||||
}
|
||||
|
||||
.unsupported pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-sunken, #f5f5f5);
|
||||
overflow-x: auto;
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Schema-driven React form renderer for settings sections. `SchemaForm`
|
||||
* rehydrates the wire's serialized schemastery envelope and edits a draft
|
||||
* user section against it; the model helpers expose the same introspection
|
||||
* and immutable path editing for page-level composition.
|
||||
* @module @deepseek-ai/dsh-client-schema-form
|
||||
*/
|
||||
|
||||
export { SchemaForm } from './SchemaForm.tsx'
|
||||
export type {
|
||||
SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
|
||||
} from './SchemaForm.tsx'
|
||||
export {
|
||||
deletePath, getPath, hasPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
|
||||
} from './model.ts'
|
||||
export type { NodeKind, SchemaNode } from './model.ts'
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-schema-form`.
|
||||
* @module @deepseek-ai/dsh-client-schema-form/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-schema-form-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure React rendering library — it emits no cordis
|
||||
* events and owns no cross-plugin mutable relation; draft immutability,
|
||||
* schema rehydration, and control/edit round trips are asserted directly by
|
||||
* this package's component and model specs.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Schema introspection and draft-editing helpers behind the form renderer.
|
||||
* The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
|
||||
* live validator whose node relations (`dict`/`inner`/`list`) the renderer
|
||||
* walks; drafts are edited immutably by path.
|
||||
* @module @deepseek-ai/dsh-client-schema-form/model
|
||||
*/
|
||||
|
||||
import Schema from 'schemastery'
|
||||
|
||||
/** Live schemastery node; the renderer reads only its structural relations. */
|
||||
export type SchemaNode = Schema
|
||||
|
||||
/**
|
||||
* Rehydrate a serialized schema envelope into a live validator/node tree.
|
||||
* @param serialized - `schema.toJSON()` output received over the wire.
|
||||
* @returns the root schema node.
|
||||
*/
|
||||
export function rehydrateSchema(serialized: unknown): SchemaNode {
|
||||
return new Schema(serialized as Schema)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a draft against a rehydrated schema.
|
||||
* @param schema - rehydrated root node.
|
||||
* @param draft - candidate value.
|
||||
* @returns the validation failure message, or `undefined` when the draft passes.
|
||||
*/
|
||||
export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined {
|
||||
try {
|
||||
;(schema as unknown as (value: unknown) => unknown)(draft)
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderable classification of one schema node. */
|
||||
export type NodeKind =
|
||||
| 'object'
|
||||
| 'dict'
|
||||
| 'array'
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'union-const'
|
||||
| 'unsupported'
|
||||
|
||||
/**
|
||||
* Classify one node into the renderer's vocabulary. A union renders as a
|
||||
* select only when every branch is a literal; everything else the renderer
|
||||
* cannot faithfully edit is `unsupported` and falls back to a read-only view
|
||||
* (never silently dropped).
|
||||
* @param node - live schema node.
|
||||
* @returns the control family for this node.
|
||||
*/
|
||||
export function nodeKind(node: SchemaNode): NodeKind {
|
||||
switch (node.type) {
|
||||
case 'object': return 'object'
|
||||
case 'dict': return 'dict'
|
||||
case 'array': return 'array'
|
||||
case 'string': return 'string'
|
||||
case 'number': return 'number'
|
||||
case 'boolean': return 'boolean'
|
||||
case 'union':
|
||||
return (node.list ?? []).every(branch => branch.type === 'const') ? 'union-const' : 'unsupported'
|
||||
default:
|
||||
return 'unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal choices of a `union-const` node, in declaration order.
|
||||
* @param node - a node classified `union-const`.
|
||||
* @returns each branch's literal value.
|
||||
*/
|
||||
export function unionChoices(node: SchemaNode): unknown[] {
|
||||
return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a nested value by path.
|
||||
* @param value - root value (draft or fallback layer).
|
||||
* @param path - key path from the root; array indexes as strings.
|
||||
* @returns the value at the path, or `undefined` along a missing branch.
|
||||
*/
|
||||
export function getPath(value: unknown, path: readonly string[]): unknown {
|
||||
let current: unknown = value
|
||||
for (const key of path) {
|
||||
if (Array.isArray(current)) {
|
||||
current = current[Number(key)]
|
||||
continue
|
||||
}
|
||||
if (typeof current !== 'object' || current === null) return undefined
|
||||
current = (current as Record<string, unknown>)[key]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** Whether a draft explicitly carries the path (its presence marks a user override). */
|
||||
export function hasPath(value: unknown, path: readonly string[]): boolean {
|
||||
if (path.length === 0) return value !== undefined
|
||||
const parent = getPath(value, path.slice(0, -1))
|
||||
const key = path[path.length - 1] as string
|
||||
if (Array.isArray(parent)) return Number(key) < parent.length
|
||||
if (typeof parent !== 'object' || parent === null) return false
|
||||
return key in parent
|
||||
}
|
||||
|
||||
function cloneContainer(container: unknown, key: string): Record<string, unknown> | unknown[] {
|
||||
if (Array.isArray(container)) return [...container as unknown[]]
|
||||
if (typeof container === 'object' && container !== null) return { ...container as Record<string, unknown> }
|
||||
// A missing intermediate materializes as the container the next key needs.
|
||||
return /^\d+$/.test(key) ? [] : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably set a nested value, materializing missing intermediate containers.
|
||||
* @param root - draft root (never mutated).
|
||||
* @param path - non-empty key path.
|
||||
* @param value - value to store at the path.
|
||||
* @returns the new draft root.
|
||||
*/
|
||||
export function setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
|
||||
if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path')
|
||||
const result = { ...root }
|
||||
let target: Record<string, unknown> | unknown[] = result
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i] as string
|
||||
const child = cloneContainer(
|
||||
Array.isArray(target) ? target[Number(key)] : (target)[key],
|
||||
path[i + 1] as string,
|
||||
)
|
||||
if (Array.isArray(target)) target[Number(key)] = child
|
||||
else (target)[key] = child
|
||||
target = child
|
||||
}
|
||||
const leaf = path[path.length - 1] as string
|
||||
if (Array.isArray(target)) target[Number(leaf)] = value
|
||||
else (target)[leaf] = value
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutably remove a nested key (the per-field reset: the resolved value
|
||||
* falls back to the composition base and schema defaults). Removing along a
|
||||
* missing branch returns the root unchanged.
|
||||
* @param root - draft root (never mutated).
|
||||
* @param path - non-empty key path.
|
||||
* @returns the new draft root.
|
||||
*/
|
||||
export function deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
|
||||
if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path')
|
||||
if (!hasPath(root, path)) return root
|
||||
const result = { ...root }
|
||||
let target: Record<string, unknown> | unknown[] = result
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i] as string
|
||||
const child = cloneContainer(
|
||||
Array.isArray(target) ? target[Number(key)] : (target)[key],
|
||||
path[i + 1] as string,
|
||||
)
|
||||
if (Array.isArray(target)) target[Number(key)] = child
|
||||
else (target)[key] = child
|
||||
target = child
|
||||
}
|
||||
const leaf = path[path.length - 1] as string
|
||||
if (Array.isArray(target)) target.splice(Number(leaf), 1)
|
||||
else Reflect.deleteProperty(target, leaf)
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user