@architecture-model/editable-metadata 0.1.0 → 0.3.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 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 ADDED
@@ -0,0 +1,227 @@
1
+ import * as Popover from '@radix-ui/react-popover'
2
+ import * as Switch from '@radix-ui/react-switch'
3
+ import { Command } from 'cmdk'
4
+ import { useRef, useState } from 'react'
5
+ import './editor.css'
6
+
7
+ const post = async (change) => {
8
+ const response = await fetch('/__model/element', { method: 'POST', body: JSON.stringify(change) })
9
+ if (!response.ok) throw new Error(await response.text())
10
+ }
11
+
12
+ const listOf = (value) => [value].flat().filter(Boolean)
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(', ')
21
+
22
+ function Options({ type, options, chosen, onChoose, onClear }) {
23
+ const [query, setQuery] = useState('')
24
+ const typed = query.trim()
25
+ const known = options.some((option) => option.value === typed || option.label === typed)
26
+
27
+ return (
28
+ <Command className="em-command" loop>
29
+ <Command.Input
30
+ className="em-input"
31
+ value={query}
32
+ onValueChange={setQuery}
33
+ placeholder={type === 'text' ? 'Search or add a value…' : 'Search…'}
34
+ />
35
+ <Command.List className="em-list">
36
+ <Command.Empty className="em-empty">No options</Command.Empty>
37
+ {options.map((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}
47
+ </Command.Item>
48
+ ))}
49
+ {type === 'text' && typed && !known && (
50
+ <Command.Item value={typed} className="em-item" onSelect={() => (setQuery(''), onChoose(typed))}>
51
+ <span className="em-check">+</span>
52
+ Add “{typed}”
53
+ </Command.Item>
54
+ )}
55
+ </Command.List>
56
+ {type !== 'multiselect' && chosen.length > 0 && (
57
+ <button type="button" className="em-item em-muted em-clear" onClick={onClear}>
58
+ <span className="em-check" />
59
+ Clear
60
+ </button>
61
+ )}
62
+ </Command>
63
+ )
64
+ }
65
+
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 = [] }) {
112
+ const [values, setValues] = useState(initial)
113
+ const [open, setOpen] = useState(false)
114
+ const [draft, setDraft] = useState(initial)
115
+ const [error, setError] = useState('')
116
+ const cancelled = useRef(false)
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 }))]
120
+ const listedType = ['select', 'multiselect', 'text'].includes(type)
121
+
122
+ const save = (value) => {
123
+ setOpen(false)
124
+ const next = type === 'links' ? linksOf(value) : listOf(value)
125
+ if (keyOf(next) === keyOf(values)) return
126
+ const previous = values
127
+ setValues(next)
128
+ setError('')
129
+ post({ fqn, [property ? 'property' : 'key']: name, value: type === 'links' ? next : value }).catch((failure) => {
130
+ setValues(previous)
131
+ setError(failure.message)
132
+ })
133
+ }
134
+ const saveDraft = () => save(type === 'multiselect' || type === 'links' ? draft : (draft[0] ?? '').trim())
135
+
136
+ // Everything but a select and text keeps a draft and saves it on close, unless Escape closed it.
137
+ // A multiselect can't save per toggle because every save reloads the page.
138
+ const onOpenChange = (next) => {
139
+ if (next) {
140
+ cancelled.current = false
141
+ setDraft(type === 'links' && !values.length ? [{ url: '', label: '' }] : values)
142
+ return setOpen(true)
143
+ }
144
+ if (!cancelled.current && (type === 'multiselect' || !listedType)) return saveDraft()
145
+ setOpen(false)
146
+ }
147
+ const onChoose = (option) => {
148
+ if (type !== 'multiselect') return save(option)
149
+ setDraft(draft.includes(option) ? draft.filter((value) => value !== option) : [...draft, option])
150
+ }
151
+
152
+ const text = shown(type, values, labelOf)
153
+ const problem = error && (
154
+ <span className="em-error" role="alert" title={error}>
155
+ {' '}
156
+ not saved
157
+ </span>
158
+ )
159
+
160
+ if (type === 'boolean') {
161
+ return (
162
+ <>
163
+ <Switch.Root
164
+ className="em-switch"
165
+ checked={values[0] === 'true'}
166
+ onCheckedChange={(checked) => save(String(checked))}
167
+ aria-label={name}
168
+ >
169
+ <Switch.Thumb className="em-thumb" />
170
+ </Switch.Root>
171
+ {problem}
172
+ </>
173
+ )
174
+ }
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
+
207
+ return (
208
+ <>
209
+ <Popover.Root open={open} onOpenChange={onOpenChange}>
210
+ <Popover.Trigger className={type === 'textarea' ? 'em-value em-multiline' : 'em-value'} aria-label={`Edit ${name}: ${text || 'not set'}`}>
211
+ {text || '—'}
212
+ </Popover.Trigger>
213
+ <Popover.Portal>
214
+ <Popover.Content
215
+ className={type === 'textarea' || type === 'links' ? 'em-popover em-wide' : 'em-popover'}
216
+ align="start"
217
+ sideOffset={6}
218
+ onEscapeKeyDown={() => (cancelled.current = true)}
219
+ >
220
+ {editor}
221
+ </Popover.Content>
222
+ </Popover.Portal>
223
+ </Popover.Root>
224
+ {problem}
225
+ </>
226
+ )
227
+ }
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2020 Igor S. Morozov <igor@morozov.is>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/Metadata.astro CHANGED
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { existsSync, readFileSync } from 'node:fs'
3
3
  import config from 'virtual:editable-metadata/config'
4
+ import Editor from './Editor.jsx'
4
5
  import { model } from './model.js'
5
6
 
6
7
  interface Props {
@@ -8,150 +9,40 @@ interface Props {
8
9
  name: string
9
10
  }
10
11
 
12
+ type Option = string | { value: string; label?: string }
13
+
11
14
  const types = ['text', 'number', 'date', 'url', 'boolean', 'select', 'multiselect']
12
15
 
13
16
  const { of, name } = Astro.props
14
17
  const fields = existsSync(config.fieldsFile) ? JSON.parse(readFileSync(config.fieldsFile, 'utf8')) : {}
15
- const field: { type: string; options?: string[] } = fields[name] ?? { type: 'text' }
18
+ const field: { type: string; options?: Option[] } = fields[name] ?? { type: 'text' }
16
19
  if (!types.includes(field.type)) {
17
20
  throw new Error(`Unknown type "${field.type}" for metadata "${name}". Known types: ${types.join(', ')}`)
18
21
  }
19
22
 
20
23
  const valuesOf = (fqn: string) => [model.element(fqn).getMetadata(name) ?? []].flat()
21
24
  const values = valuesOf(of)
22
- const inUse = [...model.elements()].flatMap((element) => valuesOf(element.id))
23
- const options = [...new Set([...(field.options ?? []), ...inUse])]
24
-
25
- const text = field.type === 'boolean' && values.length ? (values[0] === 'true' ? 'Yes' : 'No') : values.join(', ')
25
+ const listed = (field.options ?? []).map((option) =>
26
+ typeof option === 'string' ? { value: option, label: option } : { value: option.value, label: option.label ?? option.value },
27
+ )
28
+ const labels = new Map(listed.map((option) => [option.value, option.label]))
29
+ const inUse = [...new Set([...model.elements()].flatMap((element) => valuesOf(element.id)))]
30
+ const options = [...listed, ...inUse.filter((value) => !labels.has(value)).map((value) => ({ value, label: value }))]
31
+
32
+ const text =
33
+ field.type === 'boolean' && values.length
34
+ ? values[0] === 'true'
35
+ ? 'Yes'
36
+ : 'No'
37
+ : values.map((value) => labels.get(value) ?? value).join(', ')
26
38
  ---
27
39
 
28
40
  {
29
41
  import.meta.env.DEV ? (
30
- <span
31
- class="model-metadata not-content"
32
- tabindex="0"
33
- data-fqn={of}
34
- data-key={name}
35
- data-type={field.type}
36
- data-values={JSON.stringify(values)}
37
- data-options={JSON.stringify(options)}
38
- >{text}</span>
42
+ <Editor client:load fqn={of} name={name} type={field.type} values={values} options={options} />
39
43
  ) : field.type === 'url' && text ? (
40
44
  <a href={text}>{text}</a>
41
45
  ) : (
42
46
  text
43
47
  )
44
48
  }
45
-
46
- <style is:global>
47
- .model-metadata {
48
- border-bottom: 1px dashed currentColor;
49
- cursor: pointer;
50
- }
51
- .model-metadata:empty::before {
52
- content: '—';
53
- }
54
- .model-metadata.editing {
55
- border-bottom: none;
56
- cursor: auto;
57
- }
58
- .model-metadata label {
59
- margin-inline-end: 0.75em;
60
- }
61
- </style>
62
-
63
- <script>
64
- if (import.meta.env.DEV) {
65
- const create = <K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> = {}) =>
66
- Object.assign(document.createElement(tag), props)
67
-
68
- const edit = (el: HTMLElement) => {
69
- const type = el.dataset.type!
70
- const values: string[] = JSON.parse(el.dataset.values!)
71
- const options: string[] = JSON.parse(el.dataset.options!)
72
- const display = el.textContent
73
- const controls: HTMLElement[] = []
74
- let read: () => string | string[]
75
-
76
- if (type === 'multiselect') {
77
- const boxes = options.map((option) =>
78
- create('input', { type: 'checkbox', value: option, checked: values.includes(option) }),
79
- )
80
- for (const box of boxes) {
81
- const label = create('label')
82
- label.append(box, ` ${box.value}`)
83
- controls.push(label)
84
- }
85
- read = () => boxes.filter((box) => box.checked).map((box) => box.value)
86
- } else if (type === 'select') {
87
- const select = create('select')
88
- for (const option of ['', ...options]) select.append(create('option', { value: option, textContent: option }))
89
- select.value = values[0] ?? ''
90
- controls.push(select)
91
- read = () => select.value
92
- } else if (type === 'boolean') {
93
- const box = create('input', { type: 'checkbox', checked: values[0] === 'true' })
94
- controls.push(box)
95
- read = () => String(box.checked)
96
- } else {
97
- const input = create('input', { type, value: values[0] ?? '' })
98
- controls.push(input)
99
- if (type === 'text') {
100
- const list = create('datalist', { id: `model-metadata-${el.dataset.key}` })
101
- for (const option of options) list.append(create('option', { value: option }))
102
- input.setAttribute('list', list.id)
103
- controls.push(list)
104
- }
105
- read = () => input.value.trim()
106
- }
107
-
108
- let dirty = false
109
- let closed = false
110
- // Checkboxes and labels don't reliably take focus on click, so leaving is detected by where the pointer goes.
111
- const outside = (event: Event) => el.contains(event.target as Node) || close(true)
112
- const close = async (save: boolean) => {
113
- if (closed) return
114
- closed = true
115
- document.removeEventListener('pointerdown', outside)
116
- const value = read()
117
- el.classList.remove('editing')
118
- el.textContent = display
119
- if (!save || !dirty) return
120
- const response = await fetch('/__model/metadata', {
121
- method: 'POST',
122
- body: JSON.stringify({ fqn: el.dataset.fqn, key: el.dataset.key, value }),
123
- })
124
- // On success the dev server sees the model change and reloads the page with the new value.
125
- if (response.ok) el.textContent = [value].flat().join(', ')
126
- else console.error(await response.text())
127
- }
128
-
129
- el.classList.add('editing')
130
- el.replaceChildren(...controls)
131
- el.querySelector<HTMLElement>('input, select')!.focus()
132
- el.oninput = () => (dirty = true)
133
- el.onchange = () => {
134
- dirty = true
135
- if (type === 'select' || type === 'boolean') close(true)
136
- }
137
- el.onkeydown = (event) => {
138
- if (event.key === 'Enter') close(true)
139
- if (event.key === 'Escape') close(false)
140
- }
141
- el.onfocusout = (event) => {
142
- const next = event.relatedTarget as Node | null
143
- if (next && !el.contains(next)) close(true)
144
- }
145
- document.addEventListener('pointerdown', outside)
146
- }
147
-
148
- const start = (event: Event) => {
149
- const el = (event.target as HTMLElement).closest<HTMLElement>('.model-metadata:not(.editing)')
150
- if (!el || (event instanceof KeyboardEvent && event.key !== 'Enter')) return
151
- event.preventDefault()
152
- edit(el)
153
- }
154
- document.addEventListener('click', start)
155
- document.addEventListener('keydown', start)
156
- }
157
- </script>
package/NoEditor.jsx ADDED
@@ -0,0 +1,3 @@
1
+ export default function NoEditor() {
2
+ return null
3
+ }
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
 
@@ -13,7 +13,7 @@ npx astro add @architecture-model/editable-metadata
13
13
  or by hand:
14
14
 
15
15
  ```bash
16
- npm install @architecture-model/editable-metadata
16
+ npm install @architecture-model/editable-metadata @astrojs/react react react-dom
17
17
  ```
18
18
 
19
19
  ```js
@@ -47,7 +47,9 @@ Owner: <Metadata of="system" name="owner" />
47
47
 
48
48
  `of` is the element's fully qualified name, `name` is the metadata key. Keep the component on the same line as its label.
49
49
 
50
- Run `npm run dev` and click the value to edit it. Enter or a click elsewhere saves, Escape cancels, and an empty value removes the key from the model.
50
+ Run `npm run dev` and click the value to edit it. A select saves the option you choose, text saves the value you choose or add, and a multiselect, number, date or url saves when its editor closes. Escape closes without saving, and Clear or an empty value removes the key from the model.
51
+
52
+ The editors are React components built on [Radix](https://www.radix-ui.com/) and [cmdk](https://cmdk.paco.me/). The addon adds Astro's React integration if the site doesn't have it, and the built site contains none of the editor code.
51
53
 
52
54
  ## Field types
53
55
 
@@ -63,14 +65,55 @@ Without configuration every key is text. To give keys other types, create `model
63
65
 
64
66
  | Type | Editor | Stored in the model as |
65
67
  | --- | --- | --- |
66
- | `text` | text input that suggests values already used for the key | string |
68
+ | `text` | searchable list of the values already used for the key, and adds what you type | string |
67
69
  | `number`, `date`, `url` | the browser's input for that type | string |
68
- | `boolean` | checkbox | `'true'` or `'false'`, shown as Yes or No |
69
- | `select` | dropdown | string |
70
- | `multiselect` | a checkbox per option | list of strings |
70
+ | `boolean` | switch | `'true'` or `'false'`, shown as Yes or No |
71
+ | `select` | searchable list of the options | string |
72
+ | `multiselect` | searchable list of the options, several can be checked | list of strings |
71
73
 
72
74
  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.
73
75
 
76
+ An option can have a label that pages and editors show in place of the stored value:
77
+
78
+ ```json
79
+ {
80
+ "owners": { "type": "multiselect", "options": [{ "value": "igor", "label": "Igor Morozov" }, "platform-team"] }
81
+ }
82
+ ```
83
+
84
+ ## Derived values
85
+
86
+ 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`:
87
+
88
+ ```mdx
89
+ import Derived from '@architecture-model/editable-metadata/Derived.astro'
90
+
91
+ export const ours = (element, model) => element.id === 'shop' || element.isDescendantOf(model.element('shop'))
92
+ export const callsBack = (element, model) => [...element.outgoing()].some(({ target }) => ours(target, model))
93
+
94
+ Payments calls us back: <Derived of="payments" value={callsBack} />
95
+ ```
96
+
97
+ A function returns a string, a number, a list, or a boolean, shown as Yes or No. A derived value is never editable.
98
+
99
+ ## Properties
100
+
101
+ `Property` shows and edits an element's own `title`, `summary`, `description`, `technology` or `links`:
102
+
103
+ ```mdx
104
+ import Property from '@architecture-model/editable-metadata/Property.astro'
105
+
106
+ <Property of="system" name="description" />
107
+ ```
108
+
109
+ | Property | Editor | In the built site |
110
+ | --- | --- | --- |
111
+ | `title`, `technology` | one-line input | text |
112
+ | `summary`, `description` | text area; ⌘ or Ctrl + Enter saves, line breaks are saved as a Markdown string | Markdown rendered as HTML |
113
+ | `links` | a URL and a label per row | links |
114
+
115
+ A title can't be emptied. Links replace the element's own links, and a row without a URL is dropped.
116
+
74
117
  ## Options
75
118
 
76
119
  ```js
@@ -84,4 +127,4 @@ Both paths are relative to the Astro project root.
84
127
 
85
128
  ## How it saves
86
129
 
87
- The dev server gets an endpoint that rewrites the element's `metadata` block 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.
130
+ 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 ADDED
@@ -0,0 +1,163 @@
1
+ .em-value {
2
+ all: unset;
3
+ cursor: pointer;
4
+ border-bottom: 1px dashed currentColor;
5
+ }
6
+ .em-multiline {
7
+ white-space: pre-line;
8
+ }
9
+ .em-value:focus-visible {
10
+ outline: 2px solid var(--sl-color-accent, Highlight);
11
+ outline-offset: 2px;
12
+ border-radius: 2px;
13
+ }
14
+
15
+ .em-popover {
16
+ --em-bg: var(--sl-color-bg, Canvas);
17
+ --em-border: var(--sl-color-hairline-light, #d4d4d8);
18
+ --em-hover: var(--sl-color-gray-6, #f4f4f5);
19
+ --em-muted: var(--sl-color-gray-3, #71717a);
20
+ z-index: 100;
21
+ min-width: 14rem;
22
+ max-width: 20rem;
23
+ padding: 0.25rem;
24
+ border: 1px solid var(--em-border);
25
+ border-radius: 0.5rem;
26
+ background: var(--em-bg);
27
+ color: var(--sl-color-text, CanvasText);
28
+ box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.25);
29
+ font-family: var(--sl-font, system-ui, sans-serif);
30
+ font-size: var(--sl-text-sm, 0.875rem);
31
+ line-height: 1.4;
32
+ }
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
+
63
+ .em-command {
64
+ display: flex;
65
+ flex-direction: column;
66
+ margin: 0;
67
+ }
68
+
69
+ .em-input {
70
+ font: inherit;
71
+ color: inherit;
72
+ background: transparent;
73
+ border: none;
74
+ border-bottom: 1px solid var(--em-border);
75
+ padding: 0.5rem 0.5rem 0.6rem;
76
+ outline: none;
77
+ }
78
+ .em-input::placeholder {
79
+ color: var(--em-muted);
80
+ }
81
+
82
+ .em-list {
83
+ max-height: 16rem;
84
+ overflow-y: auto;
85
+ padding-top: 0.25rem;
86
+ }
87
+
88
+ .em-item {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 0.5rem;
92
+ padding: 0.375rem 0.5rem;
93
+ border-radius: 0.25rem;
94
+ cursor: pointer;
95
+ user-select: none;
96
+ }
97
+ .em-item[data-selected='true'],
98
+ .em-clear:hover,
99
+ .em-clear:focus-visible {
100
+ background: var(--em-hover);
101
+ }
102
+ .em-clear {
103
+ all: unset;
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.5rem;
107
+ margin-top: 0.25rem;
108
+ padding: 0.375rem 0.5rem;
109
+ border-top: 1px solid var(--em-border);
110
+ border-radius: 0 0 0.25rem 0.25rem;
111
+ cursor: pointer;
112
+ }
113
+ .em-error {
114
+ color: var(--sl-color-red, #dc2626);
115
+ font-size: 0.85em;
116
+ }
117
+ .em-muted,
118
+ .em-empty,
119
+ .em-hint {
120
+ color: var(--em-muted);
121
+ }
122
+ .em-empty,
123
+ .em-hint {
124
+ margin: 0;
125
+ padding: 0.5rem;
126
+ }
127
+
128
+ .em-check {
129
+ width: 1rem;
130
+ text-align: center;
131
+ color: var(--sl-color-accent, currentColor);
132
+ }
133
+
134
+ .em-switch {
135
+ all: unset;
136
+ position: relative;
137
+ display: inline-block;
138
+ vertical-align: middle;
139
+ width: 2.25rem;
140
+ height: 1.25rem;
141
+ border-radius: 999px;
142
+ background: var(--sl-color-gray-5, #d4d4d8);
143
+ cursor: pointer;
144
+ }
145
+ .em-switch[data-state='checked'] {
146
+ background: var(--sl-color-accent, #2563eb);
147
+ }
148
+ .em-switch:focus-visible {
149
+ outline: 2px solid var(--sl-color-accent, Highlight);
150
+ outline-offset: 2px;
151
+ }
152
+ .em-thumb {
153
+ display: block;
154
+ width: 1rem;
155
+ height: 1rem;
156
+ margin: 0.125rem;
157
+ border-radius: 999px;
158
+ background: white;
159
+ transition: transform 100ms;
160
+ }
161
+ .em-thumb[data-state='checked'] {
162
+ transform: translateX(1rem);
163
+ }
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,15 +22,31 @@ 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
- const sources = Object.fromEntries(
13
- globSync('**/*.c4', { cwd: dir }).map((file) => [file, readFileSync(join(dir, file), 'utf8')]),
14
- )
15
- const { LikeC4Mutator } = await import('likec4-mutator')
16
- const mutator = LikeC4Mutator.fromFiles(sources)
17
- const { changedFiles } = mutator.updateElement(fqn, { metadata: { [key]: stored } })
18
- const updated = mutator.serialize()
19
- for (const file of changedFiles) writeFileSync(join(dir, file), updated[file])
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
52
  export const editor = ({ modelDir, fieldsFile }) => ({
@@ -31,7 +61,7 @@ export const editor = ({ modelDir, fieldsFile }) => ({
31
61
  server.ws.send({ type: 'full-reload' })
32
62
  })
33
63
 
34
- server.middlewares.use('/__model/metadata', async (req, res) => {
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
- await setMetadata(modelDir, JSON.parse(body))
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/index.js CHANGED
@@ -1,18 +1,34 @@
1
- import { resolve } from 'node:path'
1
+ import react from '@astrojs/react'
2
+ import { dirname, resolve } from 'node:path'
2
3
  import { fileURLToPath } from 'node:url'
3
4
  import { editor } from './editor.js'
4
5
 
5
6
  const name = '@architecture-model/editable-metadata'
6
7
  const virtual = 'virtual:editable-metadata/config'
8
+ const here = dirname(fileURLToPath(import.meta.url))
9
+ const editorFile = resolve(here, 'Editor.jsx')
10
+
11
+ // The editor only runs in the dev server, so the build gets an empty component instead of Radix and cmdk.
12
+ const withoutEditor = {
13
+ name: 'editable-metadata-no-editor',
14
+ enforce: 'pre',
15
+ resolveId: (id, importer) => {
16
+ const path = id.split('?')[0]
17
+ const absolute = path.startsWith('.') && importer ? resolve(dirname(importer.split('?')[0]), path) : path
18
+ return absolute === editorFile ? resolve(here, 'NoEditor.jsx') : undefined
19
+ },
20
+ }
7
21
 
8
22
  export default function editableMetadata({ model = 'model', fields = 'model/metadata.json' } = {}) {
9
23
  return {
10
24
  name,
11
25
  hooks: {
12
- 'astro:config:setup': ({ config, updateConfig }) => {
26
+ 'astro:config:setup': ({ config, command, updateConfig }) => {
13
27
  const root = fileURLToPath(config.root)
14
28
  const paths = { modelDir: resolve(root, model), fieldsFile: resolve(root, fields) }
29
+ const hasReact = config.integrations.some((integration) => integration.name === '@astrojs/react')
15
30
  updateConfig({
31
+ integrations: hasReact ? [] : [react()],
16
32
  vite: {
17
33
  ssr: { noExternal: [name] },
18
34
  plugins: [
@@ -22,6 +38,7 @@ export default function editableMetadata({ model = 'model', fields = 'model/meta
22
38
  load: (id) => (id === `\0${virtual}` ? `export default ${JSON.stringify(paths)}` : undefined),
23
39
  },
24
40
  editor(paths),
41
+ command === 'build' && withoutEditor,
25
42
  ],
26
43
  },
27
44
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@architecture-model/editable-metadata",
3
- "version": "0.1.0",
4
- "description": "Show LikeC4 element metadata in Astro pages and edit it in place in the dev server",
3
+ "version": "0.3.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,25 +19,38 @@
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"
23
25
  },
24
26
  "files": [
25
27
  "index.js",
26
28
  "editor.js",
27
29
  "model.js",
28
- "Metadata.astro"
30
+ "Metadata.astro",
31
+ "Property.astro",
32
+ "Derived.astro",
33
+ "Editor.jsx",
34
+ "NoEditor.jsx",
35
+ "editor.css"
29
36
  ],
30
37
  "engines": {
31
38
  "node": ">=22.22.3"
32
39
  },
33
- "scripts": {
34
- "test": "node --test"
35
- },
36
40
  "peerDependencies": {
41
+ "@astrojs/react": ">=7",
37
42
  "astro": ">=7",
38
- "likec4": "^1.59.4"
43
+ "likec4": "^1.59.4",
44
+ "react": ">=19",
45
+ "react-dom": ">=19"
39
46
  },
40
47
  "dependencies": {
48
+ "@radix-ui/react-popover": "^1.3.1",
49
+ "@radix-ui/react-switch": "^1.4.1",
50
+ "cmdk": "^1.1.1",
41
51
  "likec4-mutator": "0.9.0"
52
+ },
53
+ "scripts": {
54
+ "test": "node --test"
42
55
  }
43
- }
56
+ }