@plutocms/docs 0.0.0-stage → 0.15.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.
Files changed (61) hide show
  1. package/.gitattributes +1 -0
  2. package/.prettierignore +5 -0
  3. package/CHANGELOG.md +178 -0
  4. package/FEATURES.md +30 -0
  5. package/README.md +39 -2
  6. package/app/assets/css/pluto-layer.css +40 -0
  7. package/app/components/Pluto/Docs/Blocks/PlutoBlockEditor.vue +163 -0
  8. package/app/components/Pluto/Docs/Blocks/PlutoBlockInspector.vue +519 -0
  9. package/app/components/Pluto/Docs/Blocks/PlutoBlockMarkdownSplit.vue +159 -0
  10. package/app/components/Pluto/Docs/Blocks/PlutoBlockModeSwitch.vue +46 -0
  11. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutline.vue +146 -0
  12. package/app/components/Pluto/Docs/Blocks/PlutoBlockOutlineList.vue +347 -0
  13. package/app/components/Pluto/Docs/Blocks/PlutoBlockPalette.vue +85 -0
  14. package/app/components/Pluto/Docs/Blocks/PlutoBlockPreviewFrame.vue +233 -0
  15. package/app/components/Pluto/Docs/Blocks/PlutoBlockPropControl.vue +246 -0
  16. package/app/components/Pluto/Docs/Blocks/PlutoBlockRawEditor.vue +62 -0
  17. package/app/components/Pluto/Docs/Blocks/PlutoBlockTextEditor.vue +92 -0
  18. package/app/components/Pluto/Docs/Blocks/PlutoPreviewBlock.vue +80 -0
  19. package/app/components/Pluto/Docs/PlutoFieldMdc.vue +117 -0
  20. package/app/components/Pluto/Docs/PlutoMdcInsertMenu.vue +36 -0
  21. package/app/components/PlutoContentPreview.vue +35 -0
  22. package/app/composables/pluto-block-editor.ts +443 -0
  23. package/app/composables/pluto-content-edit-link.ts +19 -0
  24. package/app/composables/pluto-content-preview.ts +29 -0
  25. package/app/composables/pluto-content-runtime.ts +237 -0
  26. package/app/pages/_pluto/preview.vue +555 -0
  27. package/app/plugins/pluto-docs.ts +50 -0
  28. package/i18n/locales/en.json +253 -0
  29. package/i18n/locales/pt-BR.json +289 -0
  30. package/modules/pluto-docs/index.ts +123 -0
  31. package/modules/pluto-docs/manifest.ts +159 -0
  32. package/modules/pluto-docs/options.ts +224 -0
  33. package/nuxt.config.ts +36 -0
  34. package/package.json +55 -4
  35. package/server/handlers/navigation.get.ts +7 -0
  36. package/server/handlers/page.get.ts +7 -0
  37. package/server/handlers/surround.get.ts +7 -0
  38. package/server/plugins/pluto-docs.ts +25 -0
  39. package/server/utils/pluto-docs-runtime.ts +504 -0
  40. package/shared/types/docs.d.ts +40 -0
  41. package/shared/types/pluto-manifest.d.ts +6 -0
  42. package/shared/utils/blocks/editor.ts +357 -0
  43. package/shared/utils/blocks/history.ts +94 -0
  44. package/shared/utils/blocks/mdc-props.ts +321 -0
  45. package/shared/utils/blocks/operations.ts +692 -0
  46. package/shared/utils/blocks/parse.ts +444 -0
  47. package/shared/utils/blocks/presets.ts +383 -0
  48. package/shared/utils/blocks/preview-marks.ts +98 -0
  49. package/shared/utils/blocks/preview-protocol.ts +195 -0
  50. package/shared/utils/blocks/registry.ts +163 -0
  51. package/shared/utils/blocks/serialize.ts +107 -0
  52. package/shared/utils/blocks/tree.ts +85 -0
  53. package/shared/utils/blocks/types.ts +165 -0
  54. package/shared/utils/content-route.ts +40 -0
  55. package/shared/utils/docs-type.ts +290 -0
  56. package/shared/utils/mdc-snippets.ts +114 -0
  57. package/shared/utils/navigation-config.ts +19 -0
  58. package/shared/utils/runtime-content.ts +403 -0
  59. package/tailwind.lint.css +10 -0
  60. package/tsconfig.json +18 -0
  61. package/vitest.config.ts +7 -0
@@ -0,0 +1,443 @@
1
+ import type { ComputedRef, InjectionKey, Ref } from 'vue'
2
+ import type { PlutoPaletteEntry } from '../../shared/utils/blocks/editor'
3
+ import type { PlutoHistory } from '../../shared/utils/blocks/history'
4
+ import type { PlutoBlockTarget } from '../../shared/utils/blocks/operations'
5
+ import type {
6
+ PlutoBlock,
7
+ PlutoBlockDefinition,
8
+ PlutoBlockDoc,
9
+ PlutoBlockLocation,
10
+ PlutoBlocksConfig,
11
+ PlutoCodeBlock,
12
+ PlutoPropValue,
13
+ } from '../../shared/utils/blocks/types'
14
+ import { computed, inject, ref, shallowRef, watch } from 'vue'
15
+ import { acceptedNames, describeBlock } from '../../shared/utils/blocks/editor'
16
+ import {
17
+ canRedo,
18
+ canUndo,
19
+ createHistory,
20
+ pushHistory,
21
+ redo,
22
+ resetHistory,
23
+ undo,
24
+ } from '../../shared/utils/blocks/history'
25
+ import {
26
+ blocksFromMarkdown,
27
+ duplicateBlock,
28
+ insertBlocks,
29
+ moveBlock,
30
+ removeBlock,
31
+ replaceWithMarkdown,
32
+ setProp,
33
+ setSlot,
34
+ updateCode,
35
+ updateText,
36
+ } from '../../shared/utils/blocks/operations'
37
+ import { parseBlocks } from '../../shared/utils/blocks/parse'
38
+ import {
39
+ humanizeName,
40
+ resolveBlockDefinition,
41
+ } from '../../shared/utils/blocks/registry'
42
+ import { serializeBlocks } from '../../shared/utils/blocks/serialize'
43
+ import { findBlock, flatChildren, pathOf } from '../../shared/utils/blocks/tree'
44
+
45
+ export interface PlutoBlockSelection {
46
+ id: string
47
+ /** `preview` when a click in the preview selected the block, so the preview needs no scroll. */
48
+ source: 'ui' | 'preview'
49
+ }
50
+
51
+ export interface PlutoBlockEditorOptions {
52
+ /** The Markdown text of the field. */
53
+ source: () => string
54
+ /** Called with the new text after each edit, undo and redo. Never called at the start. */
55
+ emit: (value: string) => void
56
+ /** The `plutoBlocks` value of the app config. */
57
+ overrides?: () => PlutoBlocksConfig | undefined
58
+ now?: () => number
59
+ }
60
+
61
+ export interface PlutoBlockEditor {
62
+ doc: ComputedRef<PlutoBlockDoc>
63
+ /** The message of a parse error. The editor then offers the Markdown mode only. */
64
+ error: Ref<string | null>
65
+ selection: Ref<PlutoBlockSelection | null>
66
+ selectedId: ComputedRef<string | null>
67
+ selected: ComputedRef<PlutoBlockLocation | undefined>
68
+ hoverId: Ref<string | null>
69
+ canUndo: ComputedRef<boolean>
70
+ canRedo: ComputedRef<boolean>
71
+ /** True once a block was edited since the editor opened. */
72
+ edited: ComputedRef<boolean>
73
+ /** The ids of the blocks that changed since the editor opened. A dot marks them in the outline. */
74
+ editedIds: ComputedRef<Set<string>>
75
+ palette: Ref<{ open: boolean; target: PlutoBlockTarget | null }>
76
+ /** The components whose children the outline hides. A selection inside one opens it. */
77
+ collapsedIds: Ref<Set<string>>
78
+ toggleCollapsed: (id: string) => void
79
+ select: (id: string | null, source?: 'ui' | 'preview') => void
80
+ openPalette: (target?: PlutoBlockTarget | null) => void
81
+ undo: () => void
82
+ redo: () => void
83
+ insert: (
84
+ entry: PlutoPaletteEntry,
85
+ target?: PlutoBlockTarget | null
86
+ ) => string[]
87
+ remove: (id: string) => void
88
+ duplicate: (id: string) => void
89
+ /** Moves a block by `delta` places in its own list. */
90
+ move: (id: string, delta: number) => void
91
+ moveTo: (id: string, target: PlutoBlockTarget) => void
92
+ setProp: (id: string, key: string, value: PlutoPropValue) => void
93
+ setSlot: (id: string, slot: string, markdown: string) => void
94
+ updateText: (id: string, markdown: string) => void
95
+ updateCode: (
96
+ id: string,
97
+ patch: Partial<Pick<PlutoCodeBlock, 'lang' | 'filename' | 'meta' | 'code'>>
98
+ ) => void
99
+ /** Replaces a block with the blocks parsed from Markdown, and selects the first one. */
100
+ replaceMarkdown: (id: string, markdown: string) => void
101
+ definitionOf: (block: PlutoBlock) => PlutoBlockDefinition | undefined
102
+ describe: (block: PlutoBlock) => ReturnType<typeof describeBlock>
103
+ /** The list that holds a block: the doc, or one slot. */
104
+ siblings: (id: string) => PlutoBlock[]
105
+ }
106
+
107
+ export const plutoBlockEditorKey: InjectionKey<PlutoBlockEditor> =
108
+ Symbol('plutoBlockEditor')
109
+
110
+ /** The block editor that the calling component sits in. Throws outside a `PlutoBlockEditor`. */
111
+ export function usePlutoBlockEditor(): PlutoBlockEditor {
112
+ const editor = inject(plutoBlockEditorKey, null)
113
+ if (!editor) {
114
+ throw new Error('usePlutoBlockEditor() needs a PlutoBlockEditor above it.')
115
+ }
116
+ return editor
117
+ }
118
+
119
+ function tryParse(source: string): {
120
+ doc: PlutoBlockDoc
121
+ error: string | null
122
+ } {
123
+ try {
124
+ return { doc: parseBlocks(source), error: null }
125
+ } catch (error) {
126
+ return {
127
+ doc: { blocks: [], gaps: [source] },
128
+ error: error instanceof Error ? error.message : String(error),
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Ids of the blocks whose `original` is gone, over the whole tree. */
134
+ function editedBlockIds(
135
+ blocks: PlutoBlock[],
136
+ out = new Set<string>()
137
+ ): Set<string> {
138
+ for (const block of blocks) {
139
+ if (!block.original) {
140
+ out.add(block.id)
141
+ }
142
+ if (block.kind === 'component') {
143
+ editedBlockIds(flatChildren(block), out)
144
+ }
145
+ }
146
+ return out
147
+ }
148
+
149
+ /**
150
+ * The state of one block editor: the doc, the selection, the undo history and
151
+ * the edit actions. Each edit builds a new doc, writes the text and calls
152
+ * `options.emit`. A block that no edit touched keeps its source bytes, so
153
+ * opening a page and leaving it changes nothing.
154
+ */
155
+ export function createPlutoBlockEditor(
156
+ options: PlutoBlockEditorOptions
157
+ ): PlutoBlockEditor {
158
+ const now = options.now ?? Date.now
159
+ const overrides = () => options.overrides?.() ?? {}
160
+
161
+ const initial = tryParse(options.source())
162
+ const error = ref<string | null>(initial.error)
163
+ const history = shallowRef<PlutoHistory<PlutoBlockDoc>>(
164
+ createHistory(initial.doc)
165
+ )
166
+ const doc = computed(() => history.value.present)
167
+ const selection = ref<PlutoBlockSelection | null>(null)
168
+ const hoverId = ref<string | null>(null)
169
+ const palette = ref<{ open: boolean; target: PlutoBlockTarget | null }>({
170
+ open: false,
171
+ target: null,
172
+ })
173
+ const collapsedIds = ref(new Set<string>())
174
+ let lastEmitted = options.source()
175
+
176
+ const selected = computed(() =>
177
+ selection.value ? findBlock(doc.value, selection.value.id) : undefined
178
+ )
179
+ const selectedId = computed(() => selected.value?.block.id ?? null)
180
+
181
+ // The text changed outside the editor (a reload, a switch from the Markdown mode).
182
+ watch(options.source, (value) => {
183
+ if (value === lastEmitted) {
184
+ return
185
+ }
186
+ const parsed = tryParse(value)
187
+ error.value = parsed.error
188
+ history.value = resetHistory(parsed.doc)
189
+ lastEmitted = value
190
+ selection.value = null
191
+ })
192
+
193
+ const editedIds = computed(() => editedBlockIds(doc.value.blocks))
194
+
195
+ function toggleCollapsed(id: string): void {
196
+ const next = new Set(collapsedIds.value)
197
+ if (!next.delete(id)) {
198
+ next.add(id)
199
+ }
200
+ collapsedIds.value = next
201
+ }
202
+
203
+ // A block selected in the preview or in the panel must show in the outline: open its parents.
204
+ watch(selection, (current) => {
205
+ if (!current) {
206
+ return
207
+ }
208
+ const parents = pathOf(doc.value, current.id).slice(0, -1)
209
+ if (parents.some((id) => collapsedIds.value.has(id))) {
210
+ collapsedIds.value = new Set(
211
+ [...collapsedIds.value].filter((id) => !parents.includes(id))
212
+ )
213
+ }
214
+ })
215
+
216
+ function write(next: PlutoBlockDoc): void {
217
+ const text = serializeBlocks(next)
218
+ lastEmitted = text
219
+ options.emit(text)
220
+ }
221
+
222
+ function commit(next: PlutoBlockDoc, key?: string): boolean {
223
+ if (next === doc.value) {
224
+ return false
225
+ }
226
+ history.value = pushHistory(history.value, next, now(), key)
227
+ write(next)
228
+ return true
229
+ }
230
+
231
+ function select(id: string | null, source: 'ui' | 'preview' = 'ui'): void {
232
+ selection.value = id ? { id, source } : null
233
+ }
234
+
235
+ function siblings(id: string): PlutoBlock[] {
236
+ const found = findBlock(doc.value, id)
237
+ if (!found) {
238
+ return []
239
+ }
240
+ return found.parent
241
+ ? (found.parent.slots[found.slot ?? 'default'] ?? [])
242
+ : doc.value.blocks
243
+ }
244
+
245
+ function definitionOf(block: PlutoBlock): PlutoBlockDefinition | undefined {
246
+ return block.kind === 'component'
247
+ ? resolveBlockDefinition(block.name, { overrides: overrides(), block })
248
+ : undefined
249
+ }
250
+
251
+ /** After the selected block, or into a slot of the selected block that accepts the new component. */
252
+ function defaultTarget(entry: PlutoPaletteEntry): PlutoBlockTarget {
253
+ const current = selected.value
254
+ if (!current) {
255
+ return { index: doc.value.blocks.length }
256
+ }
257
+ if (entry.kind === 'component' && current.block.kind === 'component') {
258
+ const definition = definitionOf(current.block)
259
+ for (const slot of Object.keys(definition?.slots ?? {})) {
260
+ const accepts = definition ? acceptedNames(definition, slot) : undefined
261
+ if (accepts?.includes(entry.name)) {
262
+ return {
263
+ parentId: current.block.id,
264
+ slot,
265
+ index: (current.block.slots[slot] ?? []).length,
266
+ }
267
+ }
268
+ }
269
+ }
270
+ return {
271
+ parentId: current.parent?.id,
272
+ slot: current.slot,
273
+ index: current.index + 1,
274
+ }
275
+ }
276
+
277
+ function insert(
278
+ entry: PlutoPaletteEntry,
279
+ target?: PlutoBlockTarget | null
280
+ ): string[] {
281
+ const parsed = blocksFromMarkdown(entry.markdown)
282
+ const result = insertBlocks(
283
+ doc.value,
284
+ parsed.blocks,
285
+ target ?? defaultTarget(entry),
286
+ parsed.gaps
287
+ )
288
+ if (!result) {
289
+ return []
290
+ }
291
+ commit(result.doc)
292
+ if (result.ids[0]) {
293
+ select(result.ids[0])
294
+ }
295
+ return result.ids
296
+ }
297
+
298
+ function remove(id: string): void {
299
+ const found = findBlock(doc.value, id)
300
+ if (!found) {
301
+ return
302
+ }
303
+ const list = siblings(id)
304
+ const neighbour =
305
+ list[found.index + 1] ?? list[found.index - 1] ?? found.parent
306
+ commit(removeBlock(doc.value, id))
307
+ select(neighbour?.id ?? null)
308
+ }
309
+
310
+ function duplicate(id: string): void {
311
+ const result = duplicateBlock(doc.value, id)
312
+ if (result) {
313
+ commit(result.doc)
314
+ select(result.ids[0] ?? null)
315
+ }
316
+ }
317
+
318
+ function moveTo(id: string, target: PlutoBlockTarget): void {
319
+ commit(moveBlock(doc.value, id, target))
320
+ }
321
+
322
+ function move(id: string, delta: number): void {
323
+ const found = findBlock(doc.value, id)
324
+ if (!found) {
325
+ return
326
+ }
327
+ const length = siblings(id).length
328
+ const index = Math.max(0, Math.min(found.index + delta, length - 1))
329
+ if (index !== found.index) {
330
+ moveTo(id, { parentId: found.parent?.id, slot: found.slot, index })
331
+ }
332
+ }
333
+
334
+ return {
335
+ doc,
336
+ error,
337
+ selection,
338
+ selectedId,
339
+ selected,
340
+ hoverId,
341
+ canUndo: computed(() => canUndo(history.value)),
342
+ canRedo: computed(() => canRedo(history.value)),
343
+ edited: computed(() => editedIds.value.size > 0),
344
+ editedIds,
345
+ palette,
346
+ collapsedIds,
347
+ toggleCollapsed,
348
+ select,
349
+ openPalette(target = null) {
350
+ palette.value = { open: true, target }
351
+ },
352
+ undo() {
353
+ history.value = undo(history.value)
354
+ write(history.value.present)
355
+ },
356
+ redo() {
357
+ history.value = redo(history.value)
358
+ write(history.value.present)
359
+ },
360
+ insert,
361
+ remove,
362
+ duplicate,
363
+ move,
364
+ moveTo,
365
+ setProp: (id, key, value) =>
366
+ void commit(setProp(doc.value, id, key, value), `prop:${id}:${key}`),
367
+ setSlot: (id, slot, markdown) =>
368
+ void commit(setSlot(doc.value, id, slot, markdown), `slot:${id}:${slot}`),
369
+ updateText: (id, markdown) =>
370
+ void commit(updateText(doc.value, id, markdown), `text:${id}`),
371
+ updateCode: (id, patch) =>
372
+ void commit(updateCode(doc.value, id, patch), `code:${id}`),
373
+ replaceMarkdown(id, markdown) {
374
+ const result = replaceWithMarkdown(doc.value, id, markdown)
375
+ if (result) {
376
+ commit(result.doc)
377
+ select(result.ids[0] ?? null)
378
+ }
379
+ },
380
+ definitionOf,
381
+ describe: (block) => describeBlock(block, overrides()),
382
+ siblings,
383
+ }
384
+ }
385
+
386
+ /** The id of the top-level block that holds a block. The preview selects at this level. */
387
+ export function topLevelId(
388
+ doc: PlutoBlockDoc,
389
+ id: string | null
390
+ ): string | null {
391
+ return id ? (pathOf(doc, id)[0] ?? null) : null
392
+ }
393
+
394
+ /**
395
+ * The `plutoBlocks` value of the app config, as `PlutoBlocksConfig`. Nuxt types
396
+ * `useAppConfig()` from the literal config of the site, where `'inline'` is a
397
+ * `string`, so read the value through this function, not from `useAppConfig()`.
398
+ */
399
+ export function usePlutoBlocksConfig(): () => PlutoBlocksConfig | undefined {
400
+ const appConfig = useAppConfig()
401
+ return () => appConfig.plutoBlocks as unknown as PlutoBlocksConfig | undefined
402
+ }
403
+
404
+ /** The texts of the slots of a component: the title of a slot, and the label of its "Add" button. */
405
+ export function usePlutoBlockLabels() {
406
+ const { t, tx } = usePlutoI18n()
407
+ const blocksConfig = usePlutoBlocksConfig()
408
+
409
+ function slotLabel(
410
+ definition: PlutoBlockDefinition | undefined,
411
+ slot: string
412
+ ): string {
413
+ const label = definition?.slots?.[slot]?.label
414
+ if (label) {
415
+ return tx(label)
416
+ }
417
+ return slot === 'default'
418
+ ? t('docs.blocks.slots.default')
419
+ : humanizeName(slot)
420
+ }
421
+
422
+ /** "Add Card" when the slot takes one kind of block, "Add a block" for the main slot, else "Add to Buttons". */
423
+ function addLabel(
424
+ definition: PlutoBlockDefinition | undefined,
425
+ slot: string
426
+ ): string {
427
+ const accepts = definition?.slots?.[slot]?.accepts
428
+ if (accepts?.length === 1) {
429
+ return t('docs.editor.addItem', {
430
+ name: tx(
431
+ resolveBlockDefinition(accepts[0]!, { overrides: blocksConfig() })
432
+ .label
433
+ ),
434
+ })
435
+ }
436
+ if (slot === 'default') {
437
+ return t('docs.editor.addBlock')
438
+ }
439
+ return t('docs.editor.addTo', { name: slotLabel(definition, slot) })
440
+ }
441
+
442
+ return { slotLabel, addLabel }
443
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The admin edit link of a Nuxt Content page, for the signed-in staff only.
3
+ * Returns `null` for a visitor, so a public page shows no link. The link is
4
+ * `/admin/content/<type>/<id>`. The id is the page stem (`2.core/03.content-model`).
5
+ * The staff check runs in the browser, so the link appears after hydration,
6
+ * never in the server HTML.
7
+ *
8
+ * It also tells the admin bar which page is on screen, so the bar shows
9
+ * "Edit page" for it. It is a wrapper of core's `usePlutoEditLink`.
10
+ *
11
+ * @param page A Nuxt Content page. Only `stem` is read.
12
+ * @param type The content type name. Default `doc`.
13
+ */
14
+ export function usePlutoContentEditLink(
15
+ page: MaybeRefOrGetter<{ stem?: string } | null | undefined>,
16
+ type = 'doc'
17
+ ) {
18
+ return usePlutoEditLink(type, () => toValue(page)?.stem)
19
+ }
@@ -0,0 +1,29 @@
1
+ import type { InjectionKey, MaybeRefOrGetter } from 'vue'
2
+
3
+ /** How the blocks render in the preview: the same options as the `ContentRenderer` of the site page. */
4
+ export interface PlutoPreviewRenderOptions {
5
+ /** `false` renders the HTML tags without the prose components, like `<ContentRenderer :prose="false">`. */
6
+ prose?: boolean
7
+ /** A map of tag to component, like the `components` prop of `ContentRenderer`. */
8
+ components?: Record<string, string>
9
+ }
10
+
11
+ export const plutoPreviewRenderKey: InjectionKey<
12
+ MaybeRefOrGetter<PlutoPreviewRenderOptions>
13
+ > = Symbol('pluto-preview-render')
14
+
15
+ /**
16
+ * Sets how the blocks render in the preview of the visual editor. Call it in
17
+ * the site's own `PlutoContentPreview`, when a page renders with other
18
+ * `ContentRenderer` options than the default. For example, a landing page
19
+ * with `:prose="false"`:
20
+ *
21
+ * ```ts
22
+ * usePlutoPreviewRender(() => (props.frontmatter.id === 'index' ? { prose: false } : {}))
23
+ * ```
24
+ */
25
+ export function usePlutoPreviewRender(
26
+ options: MaybeRefOrGetter<PlutoPreviewRenderOptions>
27
+ ) {
28
+ provide(plutoPreviewRenderKey, options)
29
+ }