@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,163 @@
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.
4
+
5
+ import type {
6
+ PlutoBlock,
7
+ PlutoBlockDefinition,
8
+ PlutoBlockOverride,
9
+ PlutoBlockPropDefinition,
10
+ PlutoBlocksConfig,
11
+ PlutoBlockSlotDefinition,
12
+ PlutoComponentBlock,
13
+ } from './types'
14
+ import { BLOCK_GROUPS, BLOCK_PRESETS, HIDDEN_PROP_NAMES } from './presets'
15
+
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
+ }
22
+
23
+ export interface ResolveOptions {
24
+ /** The `plutoBlocks` value of the app config. */
25
+ overrides?: PlutoBlocksConfig
26
+ /** A block of the doc. Its props and slots fill what the definition does not list. */
27
+ block?: PlutoComponentBlock
28
+ }
29
+
30
+ /** A readable name from a component name: `pluto-hero` gives `Pluto hero`. */
31
+ export function humanizeName(name: string): string {
32
+ const text = name.replace(/^u-/, '').replace(/[-_]+/g, ' ').trim()
33
+ return text ? text[0]!.toUpperCase() + text.slice(1) : name
34
+ }
35
+
36
+ function automaticDefinition(name: string): PlutoBlockDefinition {
37
+ return {
38
+ name,
39
+ label: humanizeName(name),
40
+ icon: 'i-lucide-puzzle',
41
+ group: 'site',
42
+ slots: { default: { content: 'blocks' } },
43
+ }
44
+ }
45
+
46
+ function propFromValue(value: unknown): PlutoBlockPropDefinition {
47
+ if (typeof value === 'boolean') {
48
+ return { type: 'boolean', advanced: true }
49
+ }
50
+ if (typeof value === 'number') {
51
+ return { type: 'number', advanced: true }
52
+ }
53
+ const text = typeof value === 'string' ? value : ''
54
+ return {
55
+ type: typeof value === 'string' && !text.includes('\n') ? 'string' : 'text',
56
+ advanced: true,
57
+ }
58
+ }
59
+
60
+ function isOverrideObject(
61
+ override: PlutoBlockOverride | undefined
62
+ ): override is Exclude<PlutoBlockOverride, false> {
63
+ return typeof override === 'object' && override !== null
64
+ }
65
+
66
+ function mergeDefinition(
67
+ base: PlutoBlockDefinition,
68
+ override: PlutoBlockOverride | undefined
69
+ ): PlutoBlockDefinition {
70
+ if (override === false) {
71
+ return { ...base, hidden: true }
72
+ }
73
+ if (!isOverrideObject(override)) {
74
+ return base
75
+ }
76
+ return {
77
+ ...base,
78
+ ...override,
79
+ name: base.name,
80
+ props: { ...base.props, ...override.props },
81
+ slots: { ...base.slots, ...override.slots },
82
+ }
83
+ }
84
+
85
+ /** The slot content kind for a slot that no definition lists. */
86
+ function slotFromChildren(children: PlutoBlock[]): PlutoBlockSlotDefinition {
87
+ const onlyText = children.every((child) => child.kind === 'text')
88
+ return { content: onlyText ? 'rich' : 'blocks' }
89
+ }
90
+
91
+ /**
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.
95
+ */
96
+ export function resolveBlockDefinition(
97
+ name: string,
98
+ options: ResolveOptions = {}
99
+ ): PlutoBlockDefinition {
100
+ const base = BLOCK_PRESETS[name] ?? automaticDefinition(name)
101
+ const merged = mergeDefinition(base, options.overrides?.[name])
102
+ const found = options.block?.kind === 'component' ? options.block : undefined
103
+ if (!found) {
104
+ return merged
105
+ }
106
+ const props = { ...merged.props }
107
+ for (const [key, value] of Object.entries(found.props)) {
108
+ if (
109
+ !(key in props) &&
110
+ !(HIDDEN_PROP_NAMES as readonly string[]).includes(key)
111
+ ) {
112
+ props[key] = propFromValue(value)
113
+ }
114
+ }
115
+ const slots = { ...merged.slots }
116
+ for (const [key, children] of Object.entries(found.slots)) {
117
+ if (!(key in slots)) {
118
+ slots[key] = slotFromChildren(children)
119
+ }
120
+ }
121
+ return { ...merged, props, slots }
122
+ }
123
+
124
+ /** The text a palette inserts for a component. */
125
+ export function insertTextFor(definition: PlutoBlockDefinition): string {
126
+ return definition.insert ?? `::${definition.name}\n::`
127
+ }
128
+
129
+ export interface PaletteGroup {
130
+ group: string
131
+ items: PlutoBlockDefinition[]
132
+ }
133
+
134
+ /**
135
+ * 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.
138
+ */
139
+ export function listPaletteBlocks(
140
+ overrides: PlutoBlocksConfig = {}
141
+ ): PaletteGroup[] {
142
+ const names = [
143
+ ...Object.keys(BLOCK_PRESETS),
144
+ ...Object.keys(overrides).filter((name) => !(name in BLOCK_PRESETS)),
145
+ ]
146
+ const groups = new Map<string, PlutoBlockDefinition[]>()
147
+ for (const name of names) {
148
+ const definition = resolveBlockDefinition(name, { overrides })
149
+ if (definition.hidden) {
150
+ continue
151
+ }
152
+ groups.set(definition.group, [
153
+ ...(groups.get(definition.group) ?? []),
154
+ definition,
155
+ ])
156
+ }
157
+ const known = BLOCK_GROUPS as readonly string[]
158
+ const order = [
159
+ ...known.filter((group) => groups.has(group)),
160
+ ...[...groups.keys()].filter((group) => !known.includes(group)),
161
+ ]
162
+ return order.map((group) => ({ group, items: groups.get(group)! }))
163
+ }
@@ -0,0 +1,107 @@
1
+ // Write a block doc back to text. A block with `original` writes its source
2
+ // bytes. The gaps between blocks are always the source text. Only an edited
3
+ // block is built from the model, so one edit changes only the lines of that block.
4
+
5
+ import type {
6
+ PlutoBlock,
7
+ PlutoBlockDoc,
8
+ PlutoCodeBlock,
9
+ PlutoComponentBlock,
10
+ } from './types'
11
+ import {
12
+ completeOrder,
13
+ isSameProps,
14
+ renderInlineProps,
15
+ renderYamlProps,
16
+ } from './mdc-props'
17
+ import { flatChildren } from './tree'
18
+
19
+ /** Puts the indent before each non-blank line but the first. */
20
+ export function indentLines(text: string, indent: string): string {
21
+ if (!indent) {
22
+ return text
23
+ }
24
+ return text
25
+ .split('\n')
26
+ .map((line, index) => (index > 0 && line !== '' ? indent + line : line))
27
+ .join('\n')
28
+ }
29
+
30
+ function renderCode(block: PlutoCodeBlock): string {
31
+ const char = block.fence[0] ?? '`'
32
+ let fence = block.fence.length >= 3 ? block.fence : char.repeat(3)
33
+ // A line of the code that looks like the fence needs a longer fence.
34
+ for (const line of block.code.split('\n')) {
35
+ const match = /^\s*([`~]{3,})\s*$/.exec(line)
36
+ if (match && match[1]![0] === char && match[1]!.length >= fence.length) {
37
+ fence = char.repeat(match[1]!.length + 1)
38
+ }
39
+ }
40
+ const info = [
41
+ block.lang,
42
+ block.filename === undefined ? '' : `[${block.filename}]`,
43
+ block.meta ?? '',
44
+ ]
45
+ .filter(Boolean)
46
+ .join(' ')
47
+ const body = block.code === '' ? '' : `\n${block.code}`
48
+ return indentLines(`${fence}${info}${body}\n${fence}`, block.indent ?? '')
49
+ }
50
+
51
+ /** The opening line of a component, with its props. Unchanged props give the source text. */
52
+ export function renderOpener(block: PlutoComponentBlock): string {
53
+ if (isSameProps(block.props, block.propOrder, block.propSource)) {
54
+ return block.openerText
55
+ }
56
+ const order = completeOrder(block.props, block.propOrder)
57
+ const head = `${':'.repeat(block.colons)}${block.name}`
58
+ if (block.propsStyle === 'yaml' && block.yamlHead !== undefined) {
59
+ const entries = renderYamlProps(block.props, order, block.propSource)
60
+ if (entries.length === 0) {
61
+ return head
62
+ }
63
+ const fenceIndent = block.yamlHead.slice(
64
+ block.yamlHead.lastIndexOf('\n') + 1
65
+ )
66
+ const yamlIndent = block.yamlIndent ?? ''
67
+ const inner = entries
68
+ .map((entry) => yamlIndent + indentLines(entry, yamlIndent))
69
+ .join('\n')
70
+ return `${head}${block.yamlHead}---\n${inner}\n${fenceIndent}---`
71
+ }
72
+ const body = renderInlineProps(block.props, order, block.propSource)
73
+ return body ? `${head}{${body}}` : head
74
+ }
75
+
76
+ function renderComponent(block: PlutoComponentBlock): string {
77
+ const children = flatChildren(block)
78
+ let out = renderOpener(block)
79
+ for (const [index, child] of children.entries()) {
80
+ out += (block.gaps[index] ?? '') + renderBlock(child)
81
+ }
82
+ return `${out}${block.gaps[children.length] ?? ''}${':'.repeat(block.colons)}`
83
+ }
84
+
85
+ /** The text of one block, without the white space before its first line. */
86
+ export function renderBlock(block: PlutoBlock): string {
87
+ if (block.original) {
88
+ return block.original.text
89
+ }
90
+ switch (block.kind) {
91
+ case 'text':
92
+ case 'raw':
93
+ return indentLines(block.markdown, block.indent ?? '')
94
+ case 'code':
95
+ return renderCode(block)
96
+ case 'component':
97
+ return renderComponent(block)
98
+ }
99
+ }
100
+
101
+ export function serializeBlocks(doc: PlutoBlockDoc): string {
102
+ let out = doc.gaps[0] ?? ''
103
+ for (const [index, block] of doc.blocks.entries()) {
104
+ out += renderBlock(block) + (doc.gaps[index + 1] ?? '')
105
+ }
106
+ return out
107
+ }
@@ -0,0 +1,85 @@
1
+ // Helpers to walk the block tree. No Nuxt import.
2
+
3
+ import type {
4
+ PlutoBlock,
5
+ PlutoBlockDoc,
6
+ PlutoBlockLocation,
7
+ PlutoComponentBlock,
8
+ } from './types'
9
+
10
+ /** The children of a component in source order, over all slots. */
11
+ export function flatChildren(component: PlutoComponentBlock): PlutoBlock[] {
12
+ return Object.values(component.slots).flat()
13
+ }
14
+
15
+ /** Calls `visit` for each block, parents first. */
16
+ export function walkBlocks(
17
+ blocks: PlutoBlock[],
18
+ visit: (block: PlutoBlock, parent?: PlutoComponentBlock) => void,
19
+ parent?: PlutoComponentBlock
20
+ ): void {
21
+ for (const block of blocks) {
22
+ visit(block, parent)
23
+ if (block.kind === 'component') {
24
+ walkBlocks(flatChildren(block), visit, block)
25
+ }
26
+ }
27
+ }
28
+
29
+ export function findBlock(
30
+ doc: PlutoBlockDoc,
31
+ id: string
32
+ ): PlutoBlockLocation | undefined {
33
+ const search = (
34
+ blocks: PlutoBlock[],
35
+ parent?: PlutoComponentBlock,
36
+ slot?: string
37
+ ): PlutoBlockLocation | undefined => {
38
+ for (const [index, block] of blocks.entries()) {
39
+ if (block.id === id) {
40
+ return { block, parent, slot, index }
41
+ }
42
+ if (block.kind === 'component') {
43
+ for (const [name, children] of Object.entries(block.slots)) {
44
+ const found = search(children, block, name)
45
+ if (found) {
46
+ return found
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return undefined
52
+ }
53
+ return search(doc.blocks)
54
+ }
55
+
56
+ /** The ids from the top-level block down to the block. Empty when the id is unknown. */
57
+ export function pathOf(doc: PlutoBlockDoc, id: string): string[] {
58
+ const search = (
59
+ blocks: PlutoBlock[],
60
+ trail: string[]
61
+ ): string[] | undefined => {
62
+ for (const block of blocks) {
63
+ const next = [...trail, block.id]
64
+ if (block.id === id) {
65
+ return next
66
+ }
67
+ if (block.kind === 'component') {
68
+ const found = search(flatChildren(block), next)
69
+ if (found) {
70
+ return found
71
+ }
72
+ }
73
+ }
74
+ return undefined
75
+ }
76
+ return search(doc.blocks, []) ?? []
77
+ }
78
+
79
+ let counter = 0
80
+
81
+ /** A new id for a block that is not from a parse. */
82
+ export function newBlockId(): string {
83
+ counter++
84
+ return `n${counter}`
85
+ }
@@ -0,0 +1,165 @@
1
+ // Types of the block model. The model has no Nuxt import, so a package can
2
+ // own it later. The truth stays the Markdown string: a block with `original`
3
+ // is written back byte for byte.
4
+
5
+ /** A part of the source text that a block came from. */
6
+ export interface PlutoSlice {
7
+ start: number
8
+ end: number
9
+ text: string
10
+ }
11
+
12
+ interface PlutoBlockBase {
13
+ id: string
14
+ /** The source text of the block. An edit removes it from the block and its ancestors. */
15
+ original?: PlutoSlice
16
+ /** The white space before the first line of the block, inside a component. */
17
+ indent?: string
18
+ }
19
+
20
+ export interface PlutoTextBlock extends PlutoBlockBase {
21
+ kind: 'text'
22
+ /** Without the indent of the parent component. */
23
+ markdown: string
24
+ }
25
+
26
+ export interface PlutoCodeBlock extends PlutoBlockBase {
27
+ kind: 'code'
28
+ lang: string
29
+ filename?: string
30
+ /** The rest of the info string after the language and the file name. */
31
+ meta?: string
32
+ /** The fence characters, for example three backticks. */
33
+ fence: string
34
+ /** Without the indent of the parent component. */
35
+ code: string
36
+ }
37
+
38
+ export type PlutoPropValue = unknown
39
+
40
+ export type PlutoPropsStyle = 'inline' | 'yaml'
41
+
42
+ export interface PlutoComponentBlock extends PlutoBlockBase {
43
+ kind: 'component'
44
+ name: string
45
+ props: Record<string, PlutoPropValue>
46
+ /** Where the props are written: after the name `{a="b"}` or in a YAML block. */
47
+ propsStyle: PlutoPropsStyle
48
+ /** The order of the props in the source. New props go at the end. */
49
+ propOrder: string[]
50
+ /**
51
+ * The source of each prop, for a prop that has not changed. The key is a
52
+ * prop name. The value is the text of the token, or of the YAML entry.
53
+ */
54
+ propSource: Record<string, { value: PlutoPropValue; text: string }>
55
+ /** The colons of the opening line. */
56
+ colons: number
57
+ /** The slots in the order of the source. `default` is the content before the first `#name`. */
58
+ slots: Record<string, PlutoBlock[]>
59
+ /**
60
+ * The text between the children, in source order, over all slots. It has the
61
+ * `#name` lines of the slots. Its length is the number of children plus one.
62
+ */
63
+ gaps: string[]
64
+ /** For `yaml` style: the text between the opening line and the YAML block. */
65
+ yamlHead?: string
66
+ /** The opening line as it was in the source, for the check "did the props change". */
67
+ openerText: string
68
+ /** For `yaml` style: the indent of the YAML entries. */
69
+ yamlIndent?: string
70
+ }
71
+
72
+ export type PlutoRawReason =
73
+ 'table' | 'html' | 'inline-mdc' | 'footnote' | 'task-list' | 'unknown'
74
+
75
+ export interface PlutoRawBlock extends PlutoBlockBase {
76
+ kind: 'raw'
77
+ markdown: string
78
+ reason: PlutoRawReason
79
+ }
80
+
81
+ export type PlutoBlock =
82
+ PlutoTextBlock | PlutoCodeBlock | PlutoComponentBlock | PlutoRawBlock
83
+
84
+ export interface PlutoBlockDoc {
85
+ blocks: PlutoBlock[]
86
+ /** The original text before, between and after the blocks. Its length is the number of blocks plus one. */
87
+ gaps: string[]
88
+ }
89
+
90
+ /** Where a block is in the tree. `parent` is undefined for a top-level block. */
91
+ export interface PlutoBlockLocation {
92
+ block: PlutoBlock
93
+ parent?: PlutoComponentBlock
94
+ slot?: string
95
+ /** The index in the list of the document, or in the slot. */
96
+ index: number
97
+ }
98
+
99
+ // Block definitions: what the editor shows for a component.
100
+
101
+ export type PlutoBlockPropType =
102
+ | 'string'
103
+ | 'text'
104
+ | 'boolean'
105
+ | 'number'
106
+ | 'select'
107
+ | 'icon'
108
+ | 'color'
109
+ | 'link'
110
+
111
+ export interface PlutoBlockPropOption {
112
+ value: string
113
+ /** A plain text or an i18n key. */
114
+ label?: string
115
+ }
116
+
117
+ export interface PlutoBlockPropDefinition {
118
+ type: PlutoBlockPropType
119
+ /** A plain text or an i18n key. */
120
+ label?: string
121
+ description?: string
122
+ options?: Array<string | PlutoBlockPropOption>
123
+ /** A prop in the Advanced section of the panel. */
124
+ advanced?: boolean
125
+ /**
126
+ * A slot with the same text, for example the `default` slot of a button and its `label`. The
127
+ * panel shows one field for both: it edits the slot when the block has text in it (the slot wins
128
+ * on the site), else the prop. The slot does not show in the Content section.
129
+ */
130
+ slot?: string
131
+ }
132
+
133
+ /** The editor for the content of a slot. */
134
+ export type PlutoBlockSlotContent = 'rich' | 'inline' | 'blocks'
135
+
136
+ export interface PlutoBlockSlotDefinition {
137
+ label?: string
138
+ content?: PlutoBlockSlotContent
139
+ /** For `blocks`: the component names that the slot accepts. */
140
+ accepts?: string[]
141
+ }
142
+
143
+ export interface PlutoBlockDefinition {
144
+ name: string
145
+ /** A plain text or an i18n key. */
146
+ label: string
147
+ /** A plain text or an i18n key. */
148
+ description?: string
149
+ icon: string
150
+ group: string
151
+ props?: Record<string, PlutoBlockPropDefinition>
152
+ slots?: Record<string, PlutoBlockSlotDefinition>
153
+ /** The Markdown that the palette inserts. */
154
+ insert?: string
155
+ /** `false` shows a placeholder card instead of the component in the preview. */
156
+ preview?: boolean
157
+ /** Not in the palette. The block still shows with its label. */
158
+ hidden?: boolean
159
+ }
160
+
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
164
+
165
+ export type PlutoBlocksConfig = Record<string, PlutoBlockOverride>
@@ -0,0 +1,40 @@
1
+ const NUMBER_PREFIX = /^\d+\./
2
+
3
+ /**
4
+ * The public route of a Nuxt Content page, from its content id. Nuxt Content
5
+ * drops the number prefixes of the folders and files, and a final `index`
6
+ * segment. For example `1.getting-started/2.installation` gives
7
+ * `/getting-started/installation`, and `index` gives `/`. `prefix` is added in
8
+ * front of the path (for example `/docs`).
9
+ */
10
+ export function contentRoutePath(id: string, prefix = ''): string {
11
+ const segments = String(id)
12
+ .split('/')
13
+ .filter(Boolean)
14
+ .map((segment) => segment.replace(NUMBER_PREFIX, ''))
15
+
16
+ if (segments.at(-1) === 'index') {
17
+ segments.pop()
18
+ }
19
+
20
+ const base = prefix.split('/').filter(Boolean)
21
+ const path = [...base, ...segments].join('/')
22
+ return `/${path}`
23
+ }
24
+
25
+ /**
26
+ * The admin form of a content item: `/admin/content/<type>/<id>`. Each segment
27
+ * of the id is URL-encoded, and the slashes stay (like `contentIdPath` in core).
28
+ */
29
+ export function contentEditPath(type: string, id: string): string {
30
+ const path = String(id)
31
+ .split('/')
32
+ .map((segment) => encodeURIComponent(segment))
33
+ .join('/')
34
+ return `/admin/content/${encodeURIComponent(type)}/${path}`
35
+ }
36
+
37
+ /** The admin form that creates an item of a type: `/admin/content/<type>/new`. */
38
+ export function contentNewPath(type: string): string {
39
+ return `/admin/content/${encodeURIComponent(type)}/new`
40
+ }