@architecture-model/editable-metadata 0.2.0 → 0.4.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/Derived.astro +17 -0
- package/Editor.jsx +113 -34
- package/Metadata.astro +25 -9
- package/People.astro +33 -0
- package/PeopleTable.astro +37 -0
- package/Property.astro +38 -0
- package/README.md +93 -4
- package/editor.css +32 -0
- package/editor.js +44 -13
- package/fields.js +20 -0
- package/index.js +6 -2
- package/package.json +13 -3
- package/references.js +9 -0
package/Derived.astro
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { LikeC4Model } from 'likec4/model'
|
|
3
|
+
import { model } from './model.js'
|
|
4
|
+
|
|
5
|
+
type Value = string | number | boolean | null | undefined | Array<string | number>
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
of: string
|
|
9
|
+
value: (element: LikeC4Model.Element, model: LikeC4Model) => Value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { of, value } = Astro.props
|
|
13
|
+
const result = value(model.element(of), model)
|
|
14
|
+
const text = typeof result === 'boolean' ? (result ? 'Yes' : 'No') : [result ?? []].flat().join(', ')
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{text}
|
package/Editor.jsx
CHANGED
|
@@ -4,17 +4,25 @@ import { Command } from 'cmdk'
|
|
|
4
4
|
import { useRef, useState } from 'react'
|
|
5
5
|
import './editor.css'
|
|
6
6
|
|
|
7
|
-
const post = async (
|
|
8
|
-
const response = await fetch('/__model/
|
|
7
|
+
const post = async (change) => {
|
|
8
|
+
const response = await fetch('/__model/element', { method: 'POST', body: JSON.stringify(change) })
|
|
9
9
|
if (!response.ok) throw new Error(await response.text())
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const listOf = (value) => [value].flat().filter(Boolean)
|
|
13
|
-
const
|
|
13
|
+
const linksOf = (links) =>
|
|
14
|
+
links
|
|
15
|
+
.map(({ url, label }) => ({ url: url.trim(), label: label?.trim() }))
|
|
16
|
+
.filter((link) => link.url)
|
|
17
|
+
.map(({ url, label }) => (label ? { url, label } : { url }))
|
|
18
|
+
const keyOf = (list) => JSON.stringify(list.map((item) => JSON.stringify(item)).sort())
|
|
19
|
+
const shown = (type, values, labelOf) =>
|
|
20
|
+
type === 'links' ? values.map((link) => link.label || link.url).join(', ') : values.map(labelOf).join(', ')
|
|
14
21
|
|
|
15
22
|
function Options({ type, options, chosen, onChoose, onClear }) {
|
|
16
23
|
const [query, setQuery] = useState('')
|
|
17
24
|
const typed = query.trim()
|
|
25
|
+
const known = options.some((option) => option.value === typed || option.label === typed)
|
|
18
26
|
|
|
19
27
|
return (
|
|
20
28
|
<Command className="em-command" loop>
|
|
@@ -27,12 +35,18 @@ function Options({ type, options, chosen, onChoose, onClear }) {
|
|
|
27
35
|
<Command.List className="em-list">
|
|
28
36
|
<Command.Empty className="em-empty">No options</Command.Empty>
|
|
29
37
|
{options.map((option) => (
|
|
30
|
-
<Command.Item
|
|
31
|
-
|
|
32
|
-
{option}
|
|
38
|
+
<Command.Item
|
|
39
|
+
key={option.value}
|
|
40
|
+
value={option.value}
|
|
41
|
+
keywords={[option.label]}
|
|
42
|
+
className="em-item"
|
|
43
|
+
onSelect={() => onChoose(option.value)}
|
|
44
|
+
>
|
|
45
|
+
<span className="em-check">{chosen.includes(option.value) ? '✓' : ''}</span>
|
|
46
|
+
{option.label}
|
|
33
47
|
</Command.Item>
|
|
34
48
|
))}
|
|
35
|
-
{type === 'text' && typed && !
|
|
49
|
+
{type === 'text' && typed && !known && (
|
|
36
50
|
<Command.Item value={typed} className="em-item" onSelect={() => (setQuery(''), onChoose(typed))}>
|
|
37
51
|
<span className="em-check">+</span>
|
|
38
52
|
Add “{typed}”
|
|
@@ -49,37 +63,85 @@ function Options({ type, options, chosen, onChoose, onClear }) {
|
|
|
49
63
|
)
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
|
|
66
|
+
function Links({ links, onChange, onSave }) {
|
|
67
|
+
const set = (index, field) => (event) =>
|
|
68
|
+
onChange(links.map((link, at) => (at === index ? { ...link, [field]: event.target.value } : link)))
|
|
69
|
+
const onKeyDown = (event) => event.key === 'Enter' && (event.preventDefault(), onSave())
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="em-command">
|
|
73
|
+
{links.map((link, index) => (
|
|
74
|
+
<div className="em-link" key={index}>
|
|
75
|
+
<input
|
|
76
|
+
className="em-input"
|
|
77
|
+
type="url"
|
|
78
|
+
placeholder="https://…"
|
|
79
|
+
aria-label="URL"
|
|
80
|
+
value={link.url}
|
|
81
|
+
onChange={set(index, 'url')}
|
|
82
|
+
onKeyDown={onKeyDown}
|
|
83
|
+
autoFocus={index === links.length - 1}
|
|
84
|
+
/>
|
|
85
|
+
<input
|
|
86
|
+
className="em-input"
|
|
87
|
+
placeholder="Label"
|
|
88
|
+
aria-label="Label"
|
|
89
|
+
value={link.label ?? ''}
|
|
90
|
+
onChange={set(index, 'label')}
|
|
91
|
+
onKeyDown={onKeyDown}
|
|
92
|
+
/>
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
className="em-remove"
|
|
96
|
+
aria-label={`Remove ${link.url || 'link'}`}
|
|
97
|
+
onClick={() => onChange(links.filter((_, at) => at !== index))}
|
|
98
|
+
>
|
|
99
|
+
×
|
|
100
|
+
</button>
|
|
101
|
+
</div>
|
|
102
|
+
))}
|
|
103
|
+
<button type="button" className="em-item em-muted em-clear" onClick={() => onChange([...links, { url: '', label: '' }])}>
|
|
104
|
+
<span className="em-check">+</span>
|
|
105
|
+
Add link
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export default function Editor({ fqn, name, property = false, type, values: initial, options: listed = [] }) {
|
|
53
112
|
const [values, setValues] = useState(initial)
|
|
54
113
|
const [open, setOpen] = useState(false)
|
|
55
114
|
const [draft, setDraft] = useState(initial)
|
|
56
115
|
const [error, setError] = useState('')
|
|
57
116
|
const cancelled = useRef(false)
|
|
58
|
-
const
|
|
117
|
+
const labels = new Map(listed.map((option) => [option.value, option.label]))
|
|
118
|
+
const labelOf = (value) => labels.get(value) ?? value
|
|
119
|
+
const options = [...listed, ...draft.filter((value) => !labels.has(value)).map((value) => ({ value, label: value }))]
|
|
59
120
|
const listedType = ['select', 'multiselect', 'text'].includes(type)
|
|
60
121
|
|
|
61
122
|
const save = (value) => {
|
|
62
123
|
setOpen(false)
|
|
63
|
-
const next = listOf(value)
|
|
64
|
-
if (
|
|
124
|
+
const next = type === 'links' ? linksOf(value) : listOf(value)
|
|
125
|
+
if (keyOf(next) === keyOf(values)) return
|
|
65
126
|
const previous = values
|
|
66
127
|
setValues(next)
|
|
67
128
|
setError('')
|
|
68
|
-
post(fqn, name, value).catch((failure) => {
|
|
129
|
+
post({ fqn, [property ? 'property' : 'key']: name, value: type === 'links' ? next : value }).catch((failure) => {
|
|
69
130
|
setValues(previous)
|
|
70
131
|
setError(failure.message)
|
|
71
132
|
})
|
|
72
133
|
}
|
|
134
|
+
const saveDraft = () => save(type === 'multiselect' || type === 'links' ? draft : (draft[0] ?? '').trim())
|
|
73
135
|
|
|
74
|
-
//
|
|
136
|
+
// Everything but a select and text keeps a draft and saves it on close, unless Escape closed it.
|
|
75
137
|
// A multiselect can't save per toggle because every save reloads the page.
|
|
76
138
|
const onOpenChange = (next) => {
|
|
77
139
|
if (next) {
|
|
78
140
|
cancelled.current = false
|
|
79
|
-
setDraft(values)
|
|
141
|
+
setDraft(type === 'links' && !values.length ? [{ url: '', label: '' }] : values)
|
|
80
142
|
return setOpen(true)
|
|
81
143
|
}
|
|
82
|
-
if (!cancelled.current && (type === 'multiselect' || !listedType)) return
|
|
144
|
+
if (!cancelled.current && (type === 'multiselect' || !listedType)) return saveDraft()
|
|
83
145
|
setOpen(false)
|
|
84
146
|
}
|
|
85
147
|
const onChoose = (option) => {
|
|
@@ -87,7 +149,7 @@ export default function Editor({ fqn, name, type, values: initial, options: list
|
|
|
87
149
|
setDraft(draft.includes(option) ? draft.filter((value) => value !== option) : [...draft, option])
|
|
88
150
|
}
|
|
89
151
|
|
|
90
|
-
const
|
|
152
|
+
const text = shown(type, values, labelOf)
|
|
91
153
|
const problem = error && (
|
|
92
154
|
<span className="em-error" role="alert" title={error}>
|
|
93
155
|
{' '}
|
|
@@ -111,34 +173,51 @@ export default function Editor({ fqn, name, type, values: initial, options: list
|
|
|
111
173
|
)
|
|
112
174
|
}
|
|
113
175
|
|
|
176
|
+
const editor = listedType ? (
|
|
177
|
+
<Options type={type} options={options} chosen={draft} onChoose={onChoose} onClear={() => save('')} />
|
|
178
|
+
) : type === 'links' ? (
|
|
179
|
+
<Links links={draft} onChange={setDraft} onSave={saveDraft} />
|
|
180
|
+
) : type === 'textarea' ? (
|
|
181
|
+
<div className="em-command">
|
|
182
|
+
<textarea
|
|
183
|
+
className="em-input em-textarea"
|
|
184
|
+
aria-label={name}
|
|
185
|
+
value={draft[0] ?? ''}
|
|
186
|
+
onChange={(event) => setDraft([event.target.value])}
|
|
187
|
+
onKeyDown={(event) => event.key === 'Enter' && (event.metaKey || event.ctrlKey) && saveDraft()}
|
|
188
|
+
autoFocus
|
|
189
|
+
/>
|
|
190
|
+
<p className="em-hint">⌘ or Ctrl + Enter or a click outside saves. Escape cancels. Markdown works.</p>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
<div className="em-command">
|
|
194
|
+
<input
|
|
195
|
+
className="em-input"
|
|
196
|
+
type={type === 'line' ? 'text' : type}
|
|
197
|
+
aria-label={name}
|
|
198
|
+
value={draft[0] ?? ''}
|
|
199
|
+
onChange={(event) => setDraft([event.target.value])}
|
|
200
|
+
onKeyDown={(event) => event.key === 'Enter' && (event.preventDefault(), saveDraft())}
|
|
201
|
+
autoFocus
|
|
202
|
+
/>
|
|
203
|
+
<p className="em-hint">Enter or a click outside saves. Escape cancels.{name === 'title' ? '' : ' Empty clears it.'}</p>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
|
|
114
207
|
return (
|
|
115
208
|
<>
|
|
116
209
|
<Popover.Root open={open} onOpenChange={onOpenChange}>
|
|
117
|
-
<Popover.Trigger className=
|
|
118
|
-
{
|
|
210
|
+
<Popover.Trigger className={type === 'textarea' ? 'em-value em-multiline' : 'em-value'} aria-label={`Edit ${name}: ${text || 'not set'}`}>
|
|
211
|
+
{text || '—'}
|
|
119
212
|
</Popover.Trigger>
|
|
120
213
|
<Popover.Portal>
|
|
121
214
|
<Popover.Content
|
|
122
|
-
className=
|
|
215
|
+
className={type === 'textarea' || type === 'links' ? 'em-popover em-wide' : 'em-popover'}
|
|
123
216
|
align="start"
|
|
124
217
|
sideOffset={6}
|
|
125
218
|
onEscapeKeyDown={() => (cancelled.current = true)}
|
|
126
219
|
>
|
|
127
|
-
{
|
|
128
|
-
<Options type={type} options={options} chosen={draft} onChoose={onChoose} onClear={() => save('')} />
|
|
129
|
-
) : (
|
|
130
|
-
<div className="em-command">
|
|
131
|
-
<input
|
|
132
|
-
className="em-input"
|
|
133
|
-
type={type}
|
|
134
|
-
value={draft[0] ?? ''}
|
|
135
|
-
onChange={(event) => setDraft([event.target.value])}
|
|
136
|
-
onKeyDown={(event) => event.key === 'Enter' && (event.preventDefault(), save(draft[0]?.trim() ?? ''))}
|
|
137
|
-
autoFocus
|
|
138
|
-
/>
|
|
139
|
-
<p className="em-hint">Enter or a click outside saves. Escape cancels. Empty removes the key.</p>
|
|
140
|
-
</div>
|
|
141
|
-
)}
|
|
220
|
+
{editor}
|
|
142
221
|
</Popover.Content>
|
|
143
222
|
</Popover.Portal>
|
|
144
223
|
</Popover.Root>
|
package/Metadata.astro
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
-
import config from 'virtual:editable-metadata/config'
|
|
4
2
|
import Editor from './Editor.jsx'
|
|
3
|
+
import { fieldOf, people } from './fields.js'
|
|
5
4
|
import { model } from './model.js'
|
|
6
5
|
|
|
7
6
|
interface Props {
|
|
@@ -9,26 +8,43 @@ interface Props {
|
|
|
9
8
|
name: string
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
type Option = string | { value: string; label?: string }
|
|
12
|
+
|
|
13
|
+
const types = ['text', 'number', 'date', 'url', 'boolean', 'select', 'multiselect', 'people']
|
|
13
14
|
|
|
14
15
|
const { of, name } = Astro.props
|
|
15
|
-
const
|
|
16
|
-
const field: { type: string; options?: string[] } = fields[name] ?? { type: 'text' }
|
|
16
|
+
const field: { type: string; options?: Option[] } = fieldOf(name)
|
|
17
17
|
if (!types.includes(field.type)) {
|
|
18
18
|
throw new Error(`Unknown type "${field.type}" for metadata "${name}". Known types: ${types.join(', ')}`)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const valuesOf = (fqn: string) => [model.element(fqn).getMetadata(name) ?? []].flat()
|
|
22
22
|
const values = valuesOf(of)
|
|
23
|
-
const
|
|
24
|
-
|
|
23
|
+
const listed = ((field.type === 'people' ? people() : field.options) ?? []).map((option: Option) =>
|
|
24
|
+
typeof option === 'string' ? { value: option, label: option } : { value: option.value, label: option.label ?? option.value },
|
|
25
|
+
)
|
|
26
|
+
const labels = new Map(listed.map((option) => [option.value, option.label]))
|
|
27
|
+
const inUse = [...new Set([...model.elements()].flatMap((element) => valuesOf(element.id)))]
|
|
28
|
+
const options = [...listed, ...inUse.filter((value) => !labels.has(value)).map((value) => ({ value, label: value }))]
|
|
25
29
|
|
|
26
|
-
const text =
|
|
30
|
+
const text =
|
|
31
|
+
field.type === 'boolean' && values.length
|
|
32
|
+
? values[0] === 'true'
|
|
33
|
+
? 'Yes'
|
|
34
|
+
: 'No'
|
|
35
|
+
: values.map((value) => labels.get(value) ?? value).join(', ')
|
|
27
36
|
---
|
|
28
37
|
|
|
29
38
|
{
|
|
30
39
|
import.meta.env.DEV ? (
|
|
31
|
-
<Editor
|
|
40
|
+
<Editor
|
|
41
|
+
client:load
|
|
42
|
+
fqn={of}
|
|
43
|
+
name={name}
|
|
44
|
+
type={field.type === 'people' ? 'multiselect' : field.type}
|
|
45
|
+
values={values}
|
|
46
|
+
options={options}
|
|
47
|
+
/>
|
|
32
48
|
) : field.type === 'url' && text ? (
|
|
33
49
|
<a href={text}>{text}</a>
|
|
34
50
|
) : (
|
package/People.astro
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { peopleFields } from './fields.js'
|
|
3
|
+
import Metadata from './Metadata.astro'
|
|
4
|
+
import { model } from './model.js'
|
|
5
|
+
import { referencedBy } from './references.js'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
of: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { of } = Astro.props
|
|
12
|
+
const users = referencedBy(model.element(of))
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<table>
|
|
16
|
+
<tbody>
|
|
17
|
+
{
|
|
18
|
+
Object.entries(peopleFields).map(([key, label]) => (
|
|
19
|
+
<tr>
|
|
20
|
+
<th>{label}</th>
|
|
21
|
+
<td>
|
|
22
|
+
<Metadata of={of} name={key} />
|
|
23
|
+
</td>
|
|
24
|
+
</tr>
|
|
25
|
+
))
|
|
26
|
+
}
|
|
27
|
+
<tr><th>Referenced by</th><td>{users.join(', ')}</td></tr>
|
|
28
|
+
<tr>
|
|
29
|
+
<th>Last confirmed</th>
|
|
30
|
+
<td><Metadata of={of} name="peopleConfirmed" /></td>
|
|
31
|
+
</tr>
|
|
32
|
+
</tbody>
|
|
33
|
+
</table>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { peopleFields } from './fields.js'
|
|
3
|
+
import Metadata from './Metadata.astro'
|
|
4
|
+
import { model } from './model.js'
|
|
5
|
+
import { referencedBy } from './references.js'
|
|
6
|
+
|
|
7
|
+
const systems = [...model.elements()].filter((element) => !element.parent && element.shape !== 'person')
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<table>
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
<th>System</th>
|
|
14
|
+
{Object.values(peopleFields).map((label) => <th>{label}</th>)}
|
|
15
|
+
<th>Referenced by</th>
|
|
16
|
+
<th>Last confirmed</th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
{
|
|
21
|
+
systems.map((system) => (
|
|
22
|
+
<tr>
|
|
23
|
+
<td>{system.title}</td>
|
|
24
|
+
{Object.keys(peopleFields).map((key) => (
|
|
25
|
+
<td>
|
|
26
|
+
<Metadata of={system.id} name={key} />
|
|
27
|
+
</td>
|
|
28
|
+
))}
|
|
29
|
+
<td>{referencedBy(system).join(', ')}</td>
|
|
30
|
+
<td>
|
|
31
|
+
<Metadata of={system.id} name="peopleConfirmed" />
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
))
|
|
35
|
+
}
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
package/Property.astro
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Editor from './Editor.jsx'
|
|
3
|
+
import { model } from './model.js'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
of: string
|
|
7
|
+
name: 'title' | 'summary' | 'description' | 'technology' | 'links'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const types = { title: 'line', technology: 'line', summary: 'textarea', description: 'textarea', links: 'links' }
|
|
11
|
+
|
|
12
|
+
const { of, name } = Astro.props
|
|
13
|
+
const type = types[name]
|
|
14
|
+
if (!type) throw new Error(`Unknown property "${name}". Known properties: ${Object.keys(types).join(', ')}`)
|
|
15
|
+
|
|
16
|
+
const element = model.element(of)
|
|
17
|
+
const rich = name === 'summary' || name === 'description' ? element[name] : null
|
|
18
|
+
const links = element.links.map(({ url, title }) => (title ? { url, label: title } : { url }))
|
|
19
|
+
const values =
|
|
20
|
+
name === 'links' ? links : [rich ? (rich.$source?.md ?? rich.$source?.txt ?? '') : (element[name] ?? '')].filter(Boolean)
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
{
|
|
24
|
+
import.meta.env.DEV ? (
|
|
25
|
+
<Editor client:load fqn={of} name={name} property type={type} values={values} />
|
|
26
|
+
) : name === 'links' ? (
|
|
27
|
+
links.map((link, index) => (
|
|
28
|
+
<>
|
|
29
|
+
{index > 0 && ', '}
|
|
30
|
+
<a href={link.url}>{link.label ?? link.url}</a>
|
|
31
|
+
</>
|
|
32
|
+
))
|
|
33
|
+
) : rich?.isMarkdown ? (
|
|
34
|
+
<Fragment set:html={rich.html} />
|
|
35
|
+
) : (
|
|
36
|
+
values.join('')
|
|
37
|
+
)
|
|
38
|
+
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @architecture-model/editable-metadata
|
|
2
2
|
|
|
3
|
-
Shows the metadata of [LikeC4](https://likec4.dev/) elements in [Astro](https://astro.build/) pages. In the dev server the values are click-to-edit and save back to the model; in the built site they are plain text.
|
|
3
|
+
Shows the metadata and properties of [LikeC4](https://likec4.dev/) elements in [Astro](https://astro.build/) pages. In the dev server the values are click-to-edit and save back to the model; in the built site they are plain text.
|
|
4
4
|
|
|
5
5
|
Made for repositories created with `npm create @architecture-model`, and works in any Astro site that has a LikeC4 model.
|
|
6
6
|
|
|
@@ -53,7 +53,7 @@ The editors are React components built on [Radix](https://www.radix-ui.com/) and
|
|
|
53
53
|
|
|
54
54
|
## Field types
|
|
55
55
|
|
|
56
|
-
Without configuration every key is text. To give keys other types, create `model/metadata.json`:
|
|
56
|
+
Without configuration every key is text, except the [people keys](#people). To give keys other types, create `model/metadata.json`:
|
|
57
57
|
|
|
58
58
|
```json
|
|
59
59
|
{
|
|
@@ -70,20 +70,109 @@ Without configuration every key is text. To give keys other types, create `model
|
|
|
70
70
|
| `boolean` | switch | `'true'` or `'false'`, shown as Yes or No |
|
|
71
71
|
| `select` | searchable list of the options | string |
|
|
72
72
|
| `multiselect` | searchable list of the options, several can be checked | list of strings |
|
|
73
|
+
| `people` | searchable list of the people in `model/people.json`, several can be checked | list of handles |
|
|
73
74
|
|
|
74
75
|
The options of `select` and `multiselect` are the ones listed plus every value already in use in the model. A `url` is a link in the built site.
|
|
75
76
|
|
|
77
|
+
An option can have a label that pages and editors show in place of the stored value:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"owners": { "type": "multiselect", "options": [{ "value": "igor", "label": "Igor Morozov" }, "platform-team"] }
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Derived values
|
|
86
|
+
|
|
87
|
+
A value that follows from the model, such as whether an external system calls you back, is asked in the page instead of stored in the model. Define a function of the [element](https://likec4.dev/tooling/model-api/) and the model, and show its answer with `Derived`:
|
|
88
|
+
|
|
89
|
+
```mdx
|
|
90
|
+
import Derived from '@architecture-model/editable-metadata/Derived.astro'
|
|
91
|
+
|
|
92
|
+
export const ours = (element, model) => element.id === 'shop' || element.isDescendantOf(model.element('shop'))
|
|
93
|
+
export const callsBack = (element, model) => [...element.outgoing()].some(({ target }) => ours(target, model))
|
|
94
|
+
|
|
95
|
+
Payments calls us back: <Derived of="payments" value={callsBack} />
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A function returns a string, a number, a list, or a boolean, shown as Yes or No. A derived value is never editable.
|
|
99
|
+
|
|
100
|
+
## People
|
|
101
|
+
|
|
102
|
+
"Owner" rarely tells you whom to ask. Each system, internal or external, can say whom to go to for which job:
|
|
103
|
+
|
|
104
|
+
| Key | Who | Ask them when |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| `integratedBy` | integrated it originally | you need to know why it works the way it does |
|
|
107
|
+
| `experts` | know best how it works and how its integration is changed | there is an incident, an API or contract change, an upgrade, or a change to review |
|
|
108
|
+
| `contributors` | have worked on it | you need reviewers or extra hands, or the experts are away |
|
|
109
|
+
| `administrators` | manage it: for an external service, the vendor account, keys, billing and support; for our own, its accounts and access | credentials need rotating, someone needs access, or a support ticket or plan change is due |
|
|
110
|
+
| `peopleConfirmed` | | the date someone last checked these; older than about six months, check again |
|
|
111
|
+
|
|
112
|
+
The four people keys are `people` fields without any configuration. Each stores handles from one shared list, `model/people.json`, so a person is added once and every field picks from it:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
[
|
|
116
|
+
{ "value": "igor", "label": "Igor Morozov" },
|
|
117
|
+
{ "value": "dana", "label": "Dana Kim" }
|
|
118
|
+
]
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
stripe = external 'Stripe' {
|
|
123
|
+
metadata {
|
|
124
|
+
integratedBy 'igor'
|
|
125
|
+
experts 'dana'
|
|
126
|
+
contributors ['igor', 'dana']
|
|
127
|
+
administrators 'dana'
|
|
128
|
+
peopleConfirmed '2026-10-17'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`People` shows them for one system, with the systems and containers that reference it, worked out from the model's relationships. `PeopleTable` shows the same for every system:
|
|
134
|
+
|
|
135
|
+
```mdx
|
|
136
|
+
import People from '@architecture-model/editable-metadata/People.astro'
|
|
137
|
+
import PeopleTable from '@architecture-model/editable-metadata/PeopleTable.astro'
|
|
138
|
+
|
|
139
|
+
<People of="stripe" />
|
|
140
|
+
|
|
141
|
+
<PeopleTable />
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
In the dev server the people fields are click-to-edit, picking from the list. A new person is added to `model/people.json` first. Someone who has left keeps their entry, with `"left": "2026-05-01"`, so their handle still shows as their name and you can [find the systems whose people have left](https://arch.morozov.is/recipes/people-who-left/). [Model who knows and manages each system](https://arch.morozov.is/people/) says which questions the people fields let the model answer, and why.
|
|
145
|
+
|
|
146
|
+
## Properties
|
|
147
|
+
|
|
148
|
+
`Property` shows and edits an element's own `title`, `summary`, `description`, `technology` or `links`:
|
|
149
|
+
|
|
150
|
+
```mdx
|
|
151
|
+
import Property from '@architecture-model/editable-metadata/Property.astro'
|
|
152
|
+
|
|
153
|
+
<Property of="system" name="description" />
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
| Property | Editor | In the built site |
|
|
157
|
+
| --- | --- | --- |
|
|
158
|
+
| `title`, `technology` | one-line input | text |
|
|
159
|
+
| `summary`, `description` | text area; ⌘ or Ctrl + Enter saves, line breaks are saved as a Markdown string | Markdown rendered as HTML |
|
|
160
|
+
| `links` | a URL and a label per row | links |
|
|
161
|
+
|
|
162
|
+
A title can't be emptied. Links replace the element's own links, and a row without a URL is dropped.
|
|
163
|
+
|
|
76
164
|
## Options
|
|
77
165
|
|
|
78
166
|
```js
|
|
79
167
|
editableMetadata({
|
|
80
168
|
model: 'model', // folder with the .c4 files
|
|
81
169
|
fields: 'model/metadata.json', // field types
|
|
170
|
+
people: 'model/people.json', // the people the people fields pick from
|
|
82
171
|
})
|
|
83
172
|
```
|
|
84
173
|
|
|
85
|
-
|
|
174
|
+
The paths are relative to the Astro project root.
|
|
86
175
|
|
|
87
176
|
## How it saves
|
|
88
177
|
|
|
89
|
-
The dev server gets an endpoint that rewrites the
|
|
178
|
+
The dev server gets an endpoint that rewrites the edited metadata key or property of the element with [likec4-mutator](https://github.com/rebaseandpanic/likec4-mutator), leaving the rest of the file as it was. It only accepts requests from the page itself, and it does not exist in the built site.
|
package/editor.css
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
cursor: pointer;
|
|
4
4
|
border-bottom: 1px dashed currentColor;
|
|
5
5
|
}
|
|
6
|
+
.em-multiline {
|
|
7
|
+
white-space: pre-line;
|
|
8
|
+
}
|
|
6
9
|
.em-value:focus-visible {
|
|
7
10
|
outline: 2px solid var(--sl-color-accent, Highlight);
|
|
8
11
|
outline-offset: 2px;
|
|
@@ -28,6 +31,35 @@
|
|
|
28
31
|
line-height: 1.4;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
.em-wide {
|
|
35
|
+
width: min(32rem, calc(100vw - 2rem));
|
|
36
|
+
max-width: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.em-textarea {
|
|
40
|
+
min-height: 8rem;
|
|
41
|
+
resize: vertical;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.em-link {
|
|
45
|
+
display: grid;
|
|
46
|
+
grid-template-columns: 3fr 2fr auto;
|
|
47
|
+
align-items: center;
|
|
48
|
+
}
|
|
49
|
+
.em-link .em-input {
|
|
50
|
+
min-width: 0;
|
|
51
|
+
}
|
|
52
|
+
.em-remove {
|
|
53
|
+
all: unset;
|
|
54
|
+
padding: 0 0.5rem;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
color: var(--em-muted);
|
|
57
|
+
}
|
|
58
|
+
.em-remove:hover,
|
|
59
|
+
.em-remove:focus-visible {
|
|
60
|
+
color: inherit;
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
.em-command {
|
|
32
64
|
display: flex;
|
|
33
65
|
flex-direction: column;
|
package/editor.js
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
1
2
|
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
3
|
import { join } from 'node:path'
|
|
3
4
|
|
|
5
|
+
export const properties = ['title', 'summary', 'description', 'technology', 'links']
|
|
6
|
+
|
|
7
|
+
const update = async (dir, fqn, patch, rewrite = (text) => text) => {
|
|
8
|
+
const sources = Object.fromEntries(
|
|
9
|
+
globSync('**/*.c4', { cwd: dir }).map((file) => [file, readFileSync(join(dir, file), 'utf8')]),
|
|
10
|
+
)
|
|
11
|
+
const { LikeC4Mutator } = await import('likec4-mutator')
|
|
12
|
+
const mutator = LikeC4Mutator.fromFiles(sources)
|
|
13
|
+
const { changedFiles } = mutator.updateElement(fqn, patch)
|
|
14
|
+
const updated = mutator.serialize()
|
|
15
|
+
for (const file of changedFiles) writeFileSync(join(dir, file), rewrite(updated[file]))
|
|
16
|
+
}
|
|
17
|
+
|
|
4
18
|
export async function setMetadata(dir, { fqn, key, value }) {
|
|
5
19
|
const values = [value].flat().map((item) => (typeof item === 'string' ? item.trim() : item))
|
|
6
20
|
if (typeof fqn !== 'string' || !/^[A-Za-z_][\w-]*$/.test(key) || values.some((item) => typeof item !== 'string')) {
|
|
@@ -8,30 +22,46 @@ export async function setMetadata(dir, { fqn, key, value }) {
|
|
|
8
22
|
}
|
|
9
23
|
const present = values.filter(Boolean)
|
|
10
24
|
const stored = present.length ? (Array.isArray(value) ? present : present[0]) : null
|
|
25
|
+
await update(dir, fqn, { metadata: { [key]: stored } })
|
|
26
|
+
}
|
|
11
27
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
// likec4-mutator writes a summary or description on one line, so a multi-line one goes in as a token and is swapped for a Markdown string.
|
|
29
|
+
const asMarkdown = (token, value) => (text) =>
|
|
30
|
+
text.replace(new RegExp(`^([ \\t]*)(.*)'${token}'`, 'm'), (_, indent, before) => {
|
|
31
|
+
const lines = value.split(/\r?\n/).map((line) => line && `${indent} ${line.replace(/\\/g, '\\\\').replace(/'(?=')/g, "\\'")}`)
|
|
32
|
+
return `${indent}${before}'''\n${lines.join('\n')}\n${indent}'''`
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export async function setProperty(dir, { fqn, property, value }) {
|
|
36
|
+
if (typeof fqn !== 'string' || !properties.includes(property)) {
|
|
37
|
+
throw new Error(`Expected { fqn, property, value } with one of ${properties.join(', ')} as the property`)
|
|
38
|
+
}
|
|
39
|
+
if (property === 'links') {
|
|
40
|
+
const valid = Array.isArray(value) && value.every((link) => /^\S+$/.test(link?.url) && ['string', 'undefined'].includes(typeof link.label))
|
|
41
|
+
if (!valid) throw new Error('Expected links as a list of { url, label } with no spaces in the url')
|
|
42
|
+
return update(dir, fqn, { links: value.map(({ url, label }) => (label?.trim() ? { url, label: label.trim() } : { url })) })
|
|
43
|
+
}
|
|
44
|
+
if (typeof value !== 'string') throw new Error(`Expected ${property} as a string`)
|
|
45
|
+
const text = value.trim()
|
|
46
|
+
if (property === 'title' && !text) throw new Error('An element needs a title')
|
|
47
|
+
if (!/\n/.test(text) || ['title', 'technology'].includes(property)) return update(dir, fqn, { [property]: text })
|
|
48
|
+
const token = `editable${randomUUID().replaceAll('-', '')}`
|
|
49
|
+
await update(dir, fqn, { [property]: token }, asMarkdown(token, text))
|
|
20
50
|
}
|
|
21
51
|
|
|
22
|
-
export const editor = ({ modelDir, fieldsFile }) => ({
|
|
52
|
+
export const editor = ({ modelDir, fieldsFile, peopleFile }) => ({
|
|
23
53
|
name: 'editable-metadata-editor',
|
|
24
54
|
apply: 'serve',
|
|
25
55
|
configureServer(server) {
|
|
26
56
|
// Pages load the model once per module evaluation, so a changed model has to evict what was evaluated.
|
|
27
|
-
server.watcher.add([modelDir, fieldsFile])
|
|
57
|
+
server.watcher.add([modelDir, fieldsFile, peopleFile])
|
|
28
58
|
server.watcher.on('change', (file) => {
|
|
29
|
-
if (!file.endsWith('.c4') && file !== fieldsFile) return
|
|
59
|
+
if (!file.endsWith('.c4') && file !== fieldsFile && file !== peopleFile) return
|
|
30
60
|
for (const environment of Object.values(server.environments)) environment.moduleGraph.invalidateAll()
|
|
31
61
|
server.ws.send({ type: 'full-reload' })
|
|
32
62
|
})
|
|
33
63
|
|
|
34
|
-
server.middlewares.use('/__model/
|
|
64
|
+
server.middlewares.use('/__model/element', async (req, res) => {
|
|
35
65
|
// The endpoint writes to disk, so a page from another site must not be able to call it.
|
|
36
66
|
if (req.method !== 'POST' || req.headers['sec-fetch-site'] !== 'same-origin') {
|
|
37
67
|
res.statusCode = 403
|
|
@@ -40,7 +70,8 @@ export const editor = ({ modelDir, fieldsFile }) => ({
|
|
|
40
70
|
try {
|
|
41
71
|
let body = ''
|
|
42
72
|
for await (const chunk of req) body += chunk
|
|
43
|
-
|
|
73
|
+
const change = JSON.parse(body)
|
|
74
|
+
await ('property' in change ? setProperty : setMetadata)(modelDir, change)
|
|
44
75
|
res.statusCode = 204
|
|
45
76
|
res.end()
|
|
46
77
|
} catch (error) {
|
package/fields.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import config from 'virtual:editable-metadata/config'
|
|
3
|
+
|
|
4
|
+
export const peopleFields = {
|
|
5
|
+
integratedBy: 'Integrated by',
|
|
6
|
+
experts: 'Experts',
|
|
7
|
+
contributors: 'Contributors',
|
|
8
|
+
administrators: 'Administrators',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const defaults = {
|
|
12
|
+
...Object.fromEntries(Object.keys(peopleFields).map((key) => [key, { type: 'people' }])),
|
|
13
|
+
peopleConfirmed: { type: 'date' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const read = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback)
|
|
17
|
+
|
|
18
|
+
export const fieldOf = (name) => read(config.fieldsFile, {})[name] ?? defaults[name] ?? { type: 'text' }
|
|
19
|
+
|
|
20
|
+
export const people = () => read(config.peopleFile, [])
|
package/index.js
CHANGED
|
@@ -19,13 +19,17 @@ const withoutEditor = {
|
|
|
19
19
|
},
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export default function editableMetadata({
|
|
22
|
+
export default function editableMetadata({
|
|
23
|
+
model = 'model',
|
|
24
|
+
fields = 'model/metadata.json',
|
|
25
|
+
people = 'model/people.json',
|
|
26
|
+
} = {}) {
|
|
23
27
|
return {
|
|
24
28
|
name,
|
|
25
29
|
hooks: {
|
|
26
30
|
'astro:config:setup': ({ config, command, updateConfig }) => {
|
|
27
31
|
const root = fileURLToPath(config.root)
|
|
28
|
-
const paths = { modelDir: resolve(root, model), fieldsFile: resolve(root, fields) }
|
|
32
|
+
const paths = { modelDir: resolve(root, model), fieldsFile: resolve(root, fields), peopleFile: resolve(root, people) }
|
|
29
33
|
const hasReact = config.integrations.some((integration) => integration.name === '@astrojs/react')
|
|
30
34
|
updateConfig({
|
|
31
35
|
integrations: hasReact ? [] : [react()],
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@architecture-model/editable-metadata",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Show LikeC4 element metadata in Astro pages and edit
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Show LikeC4 element metadata and properties in Astro pages and edit them in place in the dev server",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro-integration",
|
|
7
7
|
"astro-component",
|
|
@@ -19,13 +19,23 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"exports": {
|
|
21
21
|
".": "./index.js",
|
|
22
|
-
"./Metadata.astro": "./Metadata.astro"
|
|
22
|
+
"./Metadata.astro": "./Metadata.astro",
|
|
23
|
+
"./Property.astro": "./Property.astro",
|
|
24
|
+
"./Derived.astro": "./Derived.astro",
|
|
25
|
+
"./People.astro": "./People.astro",
|
|
26
|
+
"./PeopleTable.astro": "./PeopleTable.astro"
|
|
23
27
|
},
|
|
24
28
|
"files": [
|
|
25
29
|
"index.js",
|
|
26
30
|
"editor.js",
|
|
27
31
|
"model.js",
|
|
32
|
+
"fields.js",
|
|
33
|
+
"references.js",
|
|
28
34
|
"Metadata.astro",
|
|
35
|
+
"Property.astro",
|
|
36
|
+
"Derived.astro",
|
|
37
|
+
"People.astro",
|
|
38
|
+
"PeopleTable.astro",
|
|
29
39
|
"Editor.jsx",
|
|
30
40
|
"NoEditor.jsx",
|
|
31
41
|
"editor.css"
|
package/references.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const systemOf = (element) => (element.parent ? systemOf(element.parent) : element)
|
|
2
|
+
|
|
3
|
+
export function referencedBy(element) {
|
|
4
|
+
const sources = [...element.incoming('all')]
|
|
5
|
+
.map((relationship) => relationship.source)
|
|
6
|
+
.filter((source) => source.id !== element.id && !source.isDescendantOf(element) && source.shape !== 'person')
|
|
7
|
+
const titles = sources.map((source) => (source.parent ? `${source.title} (${systemOf(source).title})` : source.title))
|
|
8
|
+
return [...new Set(titles)]
|
|
9
|
+
}
|