@plutocms/docs 0.15.0 → 0.16.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.
@@ -1,38 +1,69 @@
1
- // Join the presets, the site overrides (`plutoBlocks` in app.config.ts) and the
2
- // props found in the document into one definition for each component. Pure
3
- // functions: the caller reads the app config and passes it in.
1
+ // Join the presets, the component layer, the site overrides (`plutoBlocks` in
2
+ // app.config.ts) and the props found in the document into one definition for
3
+ // each component. Pure functions: the caller reads the app config and passes
4
+ // the sources in.
4
5
 
5
6
  import type {
6
7
  PlutoBlock,
7
8
  PlutoBlockDefinition,
8
9
  PlutoBlockOverride,
10
+ PlutoBlockPatch,
9
11
  PlutoBlockPropDefinition,
10
12
  PlutoBlocksConfig,
11
13
  PlutoBlockSlotDefinition,
14
+ PlutoBlockSources,
12
15
  PlutoComponentBlock,
13
16
  } from './types'
14
17
  import { BLOCK_GROUPS, BLOCK_PRESETS, HIDDEN_PROP_NAMES } from './presets'
15
18
 
16
- /** The definition of a block that a site declares in `app.config.ts`. It gives type help. */
17
- export function definePlutoBlock<
18
- T extends Partial<Omit<PlutoBlockDefinition, 'name'>>,
19
- >(definition: T): T {
20
- return definition
21
- }
19
+ // The typed identity helper is in its own file, so a component file that auto-imports it does not load the registry.
20
+ export { definePlutoBlock } from './define'
22
21
 
23
22
  export interface ResolveOptions {
24
- /** The `plutoBlocks` value of the app config. */
25
- overrides?: PlutoBlocksConfig
23
+ /** The layers: the component layer and the `plutoBlocks` value of the app config. */
24
+ sources?: PlutoBlockSources
26
25
  /** A block of the doc. Its props and slots fill what the definition does not list. */
27
26
  block?: PlutoComponentBlock
28
27
  }
29
28
 
30
- /** A readable name from a component name: `pluto-hero` gives `Pluto hero`. */
29
+ /** A readable name from a component name: `pluto-hero` gives `Pluto hero`, `starCount` gives `Star count`. */
31
30
  export function humanizeName(name: string): string {
32
- const text = name.replace(/^u-/, '').replace(/[-_]+/g, ' ').trim()
31
+ const text = name
32
+ .replace(/^u-/, '')
33
+ .replace(
34
+ /([a-z0-9])([A-Z])/g,
35
+ (_, before: string, letter: string) => `${before} ${letter.toLowerCase()}`
36
+ )
37
+ .replace(/[-_]+/g, ' ')
38
+ .trim()
33
39
  return text ? text[0]!.toUpperCase() + text.slice(1) : name
34
40
  }
35
41
 
42
+ /**
43
+ * The key of a component in the sources: the kebab name that `pascalCase` turns back into the
44
+ * component name. `PlutoHero` gives `pluto-hero`, and `UPageCTA` gives `u-page-c-t-a`. A name that
45
+ * is already kebab case stays as it is.
46
+ */
47
+ export function blockKey(name: string): string {
48
+ return name.replace(/(?<!^|-)[A-Z]/g, (letter) => `-${letter}`).toLowerCase()
49
+ }
50
+
51
+ /** `star-count` gives `starCount`. */
52
+ export function camelKey(name: string): string {
53
+ return name.replace(/-+([a-z0-9])/gi, (_, letter: string) =>
54
+ letter.toUpperCase()
55
+ )
56
+ }
57
+
58
+ /** The key of `map` that means `key`: the same key, else the same name in kebab or camel case. */
59
+ function sameKey(map: object, key: string): string {
60
+ if (key in map) {
61
+ return key
62
+ }
63
+ const camel = camelKey(key)
64
+ return Object.keys(map).find((other) => camelKey(other) === camel) ?? key
65
+ }
66
+
36
67
  function automaticDefinition(name: string): PlutoBlockDefinition {
37
68
  return {
38
69
  name,
@@ -57,31 +88,113 @@ function propFromValue(value: unknown): PlutoBlockPropDefinition {
57
88
  }
58
89
  }
59
90
 
60
- function isOverrideObject(
61
- override: PlutoBlockOverride | undefined
62
- ): override is Exclude<PlutoBlockOverride, false> {
63
- return typeof override === 'object' && override !== null
91
+ /** The layer of one block in a config. The key can be the kebab name or any form of it. */
92
+ function layerFor(
93
+ config: PlutoBlocksConfig | undefined,
94
+ key: string
95
+ ): PlutoBlockOverride | undefined {
96
+ if (!config) {
97
+ return undefined
98
+ }
99
+ if (key in config) {
100
+ return config[key]
101
+ }
102
+ const other = Object.keys(config).find((name) => blockKey(name) === key)
103
+ return other === undefined ? undefined : config[other]
104
+ }
105
+
106
+ /**
107
+ * Join the entries of one map (props or slots) one by one. An entry of the patch changes the
108
+ * entry of the base key by key, and `false` removes it. An entry with no base starts from `fill`.
109
+ */
110
+ function mergeEntries<T extends object>(
111
+ base: Record<string, T> | undefined,
112
+ patch: Record<string, Partial<T> | false | undefined> | undefined,
113
+ fill: Partial<T>
114
+ ): Record<string, T> {
115
+ const out = { ...base }
116
+ for (const [key, value] of Object.entries(patch ?? {})) {
117
+ if (value === undefined) {
118
+ continue
119
+ }
120
+ const target = sameKey(out, key)
121
+ if (value === false) {
122
+ delete out[target]
123
+ } else {
124
+ out[target] = { ...fill, ...out[target], ...value } as T
125
+ }
126
+ }
127
+ return out
64
128
  }
65
129
 
130
+ /** The same join for two patches: `false` stays in the result, so a layer above can still remove. */
131
+ function mergePatchEntries<T extends object>(
132
+ base: Record<string, Partial<T> | false | undefined> | undefined,
133
+ patch: Record<string, Partial<T> | false | undefined> | undefined
134
+ ): Record<string, Partial<T> | false | undefined> {
135
+ const out = { ...base }
136
+ for (const [key, value] of Object.entries(patch ?? {})) {
137
+ if (value === undefined) {
138
+ continue
139
+ }
140
+ const target = sameKey(out, key)
141
+ const current = out[target]
142
+ out[target] =
143
+ value === false || current === undefined || current === false
144
+ ? value
145
+ : { ...current, ...value }
146
+ }
147
+ return out
148
+ }
149
+
150
+ /**
151
+ * Put a layer over a definition. The fields of the layer replace the fields of the base. Props and
152
+ * slots join key by key (shallow). `false` removes a prop or a slot. `false` for the whole block
153
+ * hides it.
154
+ */
66
155
  function mergeDefinition(
67
156
  base: PlutoBlockDefinition,
68
- override: PlutoBlockOverride | undefined
157
+ layer: PlutoBlockOverride | undefined
69
158
  ): PlutoBlockDefinition {
70
- if (override === false) {
159
+ if (layer === false) {
71
160
  return { ...base, hidden: true }
72
161
  }
73
- if (!isOverrideObject(override)) {
162
+ if (typeof layer !== 'object' || layer === null) {
74
163
  return base
75
164
  }
76
165
  return {
77
166
  ...base,
78
- ...override,
167
+ ...layer,
79
168
  name: base.name,
80
- props: { ...base.props, ...override.props },
81
- slots: { ...base.slots, ...override.slots },
169
+ props: mergeEntries(base.props, layer.props, { type: 'string' }),
170
+ slots: mergeEntries(base.slots, layer.slots, {}),
82
171
  }
83
172
  }
84
173
 
174
+ /**
175
+ * Put one patch over another, for example the in-file macro over the block made from the
176
+ * component meta. The result is still a patch: `false` for a prop or a slot stays in it.
177
+ */
178
+ export function mergeBlockPatch(
179
+ base: PlutoBlockPatch,
180
+ over: PlutoBlockPatch | false | undefined
181
+ ): PlutoBlockPatch {
182
+ if (over === undefined) {
183
+ return base
184
+ }
185
+ if (over === false) {
186
+ return { ...base, hidden: true }
187
+ }
188
+ const props = mergePatchEntries(base.props, over.props)
189
+ const slots = mergePatchEntries(base.slots, over.slots)
190
+ return {
191
+ ...base,
192
+ ...over,
193
+ ...(Object.keys(props).length ? { props } : {}),
194
+ ...(Object.keys(slots).length ? { slots } : {}),
195
+ } as PlutoBlockPatch
196
+ }
197
+
85
198
  /** The slot content kind for a slot that no definition lists. */
86
199
  function slotFromChildren(children: PlutoBlock[]): PlutoBlockSlotDefinition {
87
200
  const onlyText = children.every((child) => child.kind === 'text')
@@ -89,33 +202,50 @@ function slotFromChildren(children: PlutoBlock[]): PlutoBlockSlotDefinition {
89
202
  }
90
203
 
91
204
  /**
92
- * The definition of a component: the preset (or an automatic one), the site
93
- * override over it, and the props and slots of `options.block` that neither
94
- * lists. Found props go to the Advanced section.
205
+ * The definition of a component: the preset (or an automatic one), the
206
+ * component layer over it, the site override over that, and the props and
207
+ * slots of `options.block` that none of them lists. Found props go to the
208
+ * Advanced section.
209
+ *
210
+ * The name can be kebab case (`pluto-hero`) or the Pascal form that a page
211
+ * can use (`PlutoHero`). The definition has the kebab name. A prop of the
212
+ * definition takes the spelling that the block uses: when the page has
213
+ * `star-count` and the meta has `starCount`, the key is `star-count`, so an
214
+ * edit changes the prop of the page and adds no second one.
95
215
  */
96
216
  export function resolveBlockDefinition(
97
217
  name: string,
98
218
  options: ResolveOptions = {}
99
219
  ): PlutoBlockDefinition {
100
- const base = BLOCK_PRESETS[name] ?? automaticDefinition(name)
101
- const merged = mergeDefinition(base, options.overrides?.[name])
220
+ const key = blockKey(name)
221
+ const { components, overrides } = options.sources ?? {}
222
+ const merged = mergeDefinition(
223
+ mergeDefinition(
224
+ BLOCK_PRESETS[key] ?? automaticDefinition(key),
225
+ layerFor(components, key)
226
+ ),
227
+ layerFor(overrides, key)
228
+ )
102
229
  const found = options.block?.kind === 'component' ? options.block : undefined
103
230
  if (!found) {
104
231
  return merged
105
232
  }
106
- const props = { ...merged.props }
107
- for (const [key, value] of Object.entries(found.props)) {
233
+ const props: Record<string, PlutoBlockPropDefinition> = {}
234
+ for (const [prop, definition] of Object.entries(merged.props ?? {})) {
235
+ props[sameKey(found.props, prop)] = definition
236
+ }
237
+ for (const [prop, value] of Object.entries(found.props)) {
108
238
  if (
109
- !(key in props) &&
110
- !(HIDDEN_PROP_NAMES as readonly string[]).includes(key)
239
+ !(prop in props) &&
240
+ !(HIDDEN_PROP_NAMES as readonly string[]).includes(prop)
111
241
  ) {
112
- props[key] = propFromValue(value)
242
+ props[prop] = propFromValue(value)
113
243
  }
114
244
  }
115
245
  const slots = { ...merged.slots }
116
- for (const [key, children] of Object.entries(found.slots)) {
117
- if (!(key in slots)) {
118
- slots[key] = slotFromChildren(children)
246
+ for (const [slot, children] of Object.entries(found.slots)) {
247
+ if (!(slot in slots)) {
248
+ slots[slot] = slotFromChildren(children)
119
249
  }
120
250
  }
121
251
  return { ...merged, props, slots }
@@ -133,19 +263,21 @@ export interface PaletteGroup {
133
263
 
134
264
  /**
135
265
  * The blocks of the palette, in groups. The presets come first, then the
136
- * components that only the site config declares. A hidden block, or a block set
137
- * to `false`, is not in the list.
266
+ * components of the component layer (a component shows only when it is in
267
+ * that layer), then the components that only the site config declares. A
268
+ * hidden block, or a block set to `false`, is not in the list.
138
269
  */
139
270
  export function listPaletteBlocks(
140
- overrides: PlutoBlocksConfig = {}
271
+ sources: PlutoBlockSources = {}
141
272
  ): PaletteGroup[] {
142
- const names = [
273
+ const names = new Set([
143
274
  ...Object.keys(BLOCK_PRESETS),
144
- ...Object.keys(overrides).filter((name) => !(name in BLOCK_PRESETS)),
145
- ]
275
+ ...Object.keys(sources.components ?? {}).map(blockKey),
276
+ ...Object.keys(sources.overrides ?? {}).map(blockKey),
277
+ ])
146
278
  const groups = new Map<string, PlutoBlockDefinition[]>()
147
279
  for (const name of names) {
148
- const definition = resolveBlockDefinition(name, { overrides })
280
+ const definition = resolveBlockDefinition(name, { sources })
149
281
  if (definition.hidden) {
150
282
  continue
151
283
  }
@@ -128,6 +128,11 @@ export interface PlutoBlockPropDefinition {
128
128
  * on the site), else the prop. The slot does not show in the Content section.
129
129
  */
130
130
  slot?: string
131
+ /**
132
+ * The value the component uses when the prop is missing. The control shows it as the
133
+ * placeholder (or in the "Default" item of a choice). It never goes into the Markdown.
134
+ */
135
+ default?: string | number | boolean
131
136
  }
132
137
 
133
138
  /** The editor for the content of a slot. */
@@ -158,8 +163,33 @@ export interface PlutoBlockDefinition {
158
163
  hidden?: boolean
159
164
  }
160
165
 
161
- /** A site override: a partial definition, or `false` to hide the block from the palette. */
162
- export type PlutoBlockOverride =
163
- Partial<Omit<PlutoBlockDefinition, 'name'>> | false
166
+ /** A change to one prop: a partial prop definition. A prop with no `type` is a `string`. */
167
+ export type PlutoBlockPropPatch = Partial<PlutoBlockPropDefinition>
168
+
169
+ /**
170
+ * A partial block definition: one layer of the sources (the component layer, or the site
171
+ * override). `Props` is the prop interface of a component, so `definePlutoBlock<Props>()` can
172
+ * check the keys of `props`. A prop or a slot set to `false` is removed from the layers below.
173
+ */
174
+ export interface PlutoBlockPatch<
175
+ Props extends object = Record<string, unknown>,
176
+ > extends Partial<Omit<PlutoBlockDefinition, 'name' | 'props' | 'slots'>> {
177
+ props?: { [Key in keyof Props & string]?: PlutoBlockPropPatch | false }
178
+ slots?: Record<string, PlutoBlockSlotDefinition | false>
179
+ }
180
+
181
+ /** One layer for one block: a partial definition, or `false` to hide the block from the palette. */
182
+ export type PlutoBlockOverride = PlutoBlockPatch | false
164
183
 
184
+ /** The blocks of one layer, by component name (kebab case). */
165
185
  export type PlutoBlocksConfig = Record<string, PlutoBlockOverride>
186
+
187
+ /**
188
+ * The layers that join the preset into a block definition. The component layer comes from the
189
+ * component files (it is empty until the build step fills it). The overrides are `plutoBlocks`
190
+ * in `app.config.ts`, and they win.
191
+ */
192
+ export interface PlutoBlockSources {
193
+ components?: PlutoBlocksConfig
194
+ overrides?: PlutoBlocksConfig
195
+ }