@crombolo/schema 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reader.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The strict TOML reader both schema parsers use.
3
+ *
4
+ * Strict by design: an unknown key is an error, because a misspelt key is the
5
+ * most common mistake a non-developer makes and silently ignoring it would hide
6
+ * the thing they were trying to do. Every problem carries a dotted path, so a
7
+ * message points at a line rather than at a file.
8
+ */
9
+ import type { SchemaProblem } from './model.ts'
10
+
11
+ export type Toml = Record<string, unknown>
12
+
13
+ export interface ParseResult<T> {
14
+ value: T | undefined
15
+ problems: SchemaProblem[]
16
+ }
17
+
18
+ export class Reader {
19
+ readonly problems: SchemaProblem[] = []
20
+ constructor(readonly file: string) {}
21
+
22
+ problem(path: string, message: string): void {
23
+ this.problems.push({ file: this.file, path, message })
24
+ }
25
+
26
+ table(value: unknown, path: string): Toml | undefined {
27
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value as Toml
28
+ this.problem(path, 'expected a table')
29
+ return undefined
30
+ }
31
+
32
+ tables(value: unknown, path: string): Toml[] {
33
+ if (value === undefined) return []
34
+ if (!Array.isArray(value)) {
35
+ this.problem(path, 'expected an array of tables ([[...]])')
36
+ return []
37
+ }
38
+ return value.filter((v, i) => this.table(v, `${path}[${i}]`) !== undefined) as Toml[]
39
+ }
40
+
41
+ str(t: Toml, key: string, path: string, required = false): string | undefined {
42
+ const v = t[key]
43
+ if (v === undefined) {
44
+ if (required) this.problem(`${path}.${key}`, 'is required')
45
+ return undefined
46
+ }
47
+ if (typeof v !== 'string') {
48
+ this.problem(`${path}.${key}`, 'expected a string')
49
+ return undefined
50
+ }
51
+ return v
52
+ }
53
+
54
+ bool(t: Toml, key: string, path: string, fallback: boolean): boolean {
55
+ const v = t[key]
56
+ if (v === undefined) return fallback
57
+ if (typeof v !== 'boolean') {
58
+ this.problem(`${path}.${key}`, 'expected true or false')
59
+ return fallback
60
+ }
61
+ return v
62
+ }
63
+
64
+ num(t: Toml, key: string, path: string): number | undefined {
65
+ const v = t[key]
66
+ if (v === undefined) return undefined
67
+ if (typeof v !== 'number') {
68
+ this.problem(`${path}.${key}`, 'expected a number')
69
+ return undefined
70
+ }
71
+ return v
72
+ }
73
+
74
+ strs(t: Toml, key: string, path: string): string[] {
75
+ const v = t[key]
76
+ if (v === undefined) return []
77
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) {
78
+ this.problem(`${path}.${key}`, 'expected an array of strings')
79
+ return []
80
+ }
81
+ return v as string[]
82
+ }
83
+
84
+ unknown(t: Toml, allowed: readonly string[], path: string): void {
85
+ for (const key of Object.keys(t)) {
86
+ if (!allowed.includes(key))
87
+ this.problem(`${path}.${key}`, `unknown key; expected one of ${allowed.join(', ')}`)
88
+ }
89
+ }
90
+ }
91
+
92
+ export function toml(source: string, r: Reader): Toml | undefined {
93
+ try {
94
+ return Bun.TOML.parse(source) as Toml
95
+ } catch (error) {
96
+ r.problem('', `invalid TOML: ${(error as Error).message}`)
97
+ return undefined
98
+ }
99
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The first file, and the repetitive edit.
3
+ *
4
+ * Schema-as-code means a type is a TOML file somebody writes — which leaves a
5
+ * new site with nothing to copy from, and leaves adding a property a matter of
6
+ * remembering the vocabulary and inventing a uuid. These build the model; the
7
+ * canonical writer turns it into the file, so what the CLI writes is byte for
8
+ * byte what `schema rewrite` would.
9
+ */
10
+ import type { SchemaContentType, SchemaProperty, SchemaTab, SchemaTypeKind } from './model.ts'
11
+ import { allProperties } from './model.ts'
12
+
13
+ /** `articlePage` → `Article Page`, which is what the backoffice shows. */
14
+ export function nameFor(alias: string): string {
15
+ const spaced = alias
16
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
17
+ .replace(/[-_]+/g, ' ')
18
+ .trim()
19
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
20
+ }
21
+
22
+ export interface NewTypeOptions {
23
+ alias: string
24
+ kind?: SchemaTypeKind
25
+ name?: string
26
+ description?: string
27
+ icon?: string
28
+ /** Content types only: may a page of this type sit at the root of the tree. */
29
+ allowAtRoot?: boolean
30
+ /** An element type: content with no URL, created in the Library. */
31
+ element?: boolean
32
+ /** Content types only: declare `components`/`default-component` for a view of its own. */
33
+ template?: boolean
34
+ /** The tab the scaffolded `title` goes in; absent means no properties at all. */
35
+ tab?: string
36
+ }
37
+
38
+ const DEFAULT_ICONS: Record<SchemaTypeKind, string> = {
39
+ document: 'icon-document',
40
+ media: 'icon-picture',
41
+ member: 'icon-user',
42
+ }
43
+
44
+ /** A type as a new site would write it: keys filled in, nothing else assumed. */
45
+ export function newType(
46
+ options: NewTypeOptions,
47
+ key: () => string = () => crypto.randomUUID(),
48
+ ): SchemaContentType {
49
+ const kind = options.kind ?? 'document'
50
+ const element = options.element === true
51
+ // An element has no URL, so a template would never be used; neither has a
52
+ // media or member type, which is why the vocabulary has no room for one.
53
+ const templated = kind === 'document' && !element && options.template !== false
54
+ const tabs: SchemaTab[] =
55
+ options.tab === undefined
56
+ ? []
57
+ : [
58
+ {
59
+ name: options.tab,
60
+ properties: [
61
+ {
62
+ key: key(),
63
+ alias: 'title',
64
+ name: 'Title',
65
+ // An element has no page, so it has no heading either.
66
+ description: element ? undefined : 'Shown as the page heading',
67
+ type: 'textstring',
68
+ mandatory: true,
69
+ variesByCulture: false,
70
+ variesBySegment: false,
71
+ labelOnTop: false,
72
+ },
73
+ ],
74
+ groups: [],
75
+ },
76
+ ]
77
+ return {
78
+ key: key(),
79
+ alias: options.alias,
80
+ name: options.name ?? nameFor(options.alias),
81
+ description: options.description,
82
+ icon: options.icon ?? DEFAULT_ICONS[kind],
83
+ allowAtRoot: kind === 'document' ? options.allowAtRoot === true : false,
84
+ isElement: element,
85
+ allowInLibrary: element,
86
+ variesByCulture: false,
87
+ variesBySegment: false,
88
+ compositions: [],
89
+ allowChildren: [],
90
+ components: templated ? [options.alias] : [],
91
+ defaultComponent: templated ? options.alias : undefined,
92
+ cleanup: { prevent: false },
93
+ properties: [],
94
+ tabs,
95
+ }
96
+ }
97
+
98
+ export interface AddPropertyOptions {
99
+ alias: string
100
+ /** A data type alias: built in, or one of `schema/data-types/`. */
101
+ type: string
102
+ name?: string
103
+ description?: string
104
+ mandatory?: boolean
105
+ /** Which tab to put it in; the first existing one, else `Content`. */
106
+ tab?: string
107
+ }
108
+
109
+ export type AddPropertyResult =
110
+ | { ok: true; type: SchemaContentType; tab: string; property: SchemaProperty }
111
+ | { ok: false; reason: string }
112
+
113
+ /**
114
+ * Appends a property, at the end of a tab, because order in the file is order
115
+ * in the editor. Nothing is removed and nothing is reordered: this is an edit a
116
+ * person should be able to read in a diff.
117
+ */
118
+ export function addProperty(
119
+ type: SchemaContentType,
120
+ options: AddPropertyOptions,
121
+ key: () => string = () => crypto.randomUUID(),
122
+ ): AddPropertyResult {
123
+ if (allProperties(type).some((p) => p.alias === options.alias))
124
+ return { ok: false, reason: `"${type.alias}" already has a property "${options.alias}"` }
125
+
126
+ const property: SchemaProperty = {
127
+ key: key(),
128
+ alias: options.alias,
129
+ name: options.name ?? nameFor(options.alias),
130
+ description: options.description,
131
+ type: options.type,
132
+ mandatory: options.mandatory === true,
133
+ variesByCulture: false,
134
+ variesBySegment: false,
135
+ labelOnTop: false,
136
+ }
137
+
138
+ const wanted = options.tab ?? type.tabs[0]?.name ?? 'Content'
139
+ const existing = type.tabs.find((tab) => tab.name === wanted)
140
+ const tabs = existing
141
+ ? type.tabs.map((tab) =>
142
+ tab === existing ? { ...tab, properties: [...tab.properties, property] } : tab,
143
+ )
144
+ : [...type.tabs, { name: wanted, properties: [property], groups: [] }]
145
+ return { ok: true, type: { ...type, tabs }, tab: wanted, property }
146
+ }
147
+
148
+ /** The view a scaffolded content type points at, if it has not got one yet. */
149
+ export function viewScaffold(type: SchemaContentType): string {
150
+ const rendered = allProperties(type)
151
+ .filter((property) => property.type === 'textstring' || property.type === 'textarea')
152
+ .slice(0, 2)
153
+ const body = rendered
154
+ .map((property) =>
155
+ property.alias === 'title'
156
+ ? ` <h1>{model.text('title')}</h1>`
157
+ : ` <p>{model.text('${property.alias}')}</p>`,
158
+ )
159
+ .join('\n')
160
+ return `import type { PageProps } from '@crombolo/crombolo'
161
+
162
+ export default function ${nameFor(type.alias).replace(/\s+/g, '')}({ model }: PageProps) {
163
+ return (
164
+ <html lang="en">
165
+ <body>
166
+ ${body || ` <h1>{model.name}</h1>`}
167
+ </body>
168
+ </html>
169
+ )
170
+ }
171
+ `
172
+ }