@architecture-model/editable-metadata 0.0.0-stage → 0.2.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/Editor.jsx +148 -0
- package/LICENSE +7 -0
- package/Metadata.astro +37 -0
- package/NoEditor.jsx +3 -0
- package/README.md +88 -2
- package/editor.css +131 -0
- package/editor.js +52 -0
- package/index.js +48 -0
- package/model.js +6 -0
- package/package.json +49 -3
package/Editor.jsx
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
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 (fqn, key, value) => {
|
|
8
|
+
const response = await fetch('/__model/metadata', { method: 'POST', body: JSON.stringify({ fqn, key, value }) })
|
|
9
|
+
if (!response.ok) throw new Error(await response.text())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const listOf = (value) => [value].flat().filter(Boolean)
|
|
13
|
+
const same = (a, b) => a.length === b.length && a.every((value) => b.includes(value))
|
|
14
|
+
|
|
15
|
+
function Options({ type, options, chosen, onChoose, onClear }) {
|
|
16
|
+
const [query, setQuery] = useState('')
|
|
17
|
+
const typed = query.trim()
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<Command className="em-command" loop>
|
|
21
|
+
<Command.Input
|
|
22
|
+
className="em-input"
|
|
23
|
+
value={query}
|
|
24
|
+
onValueChange={setQuery}
|
|
25
|
+
placeholder={type === 'text' ? 'Search or add a value…' : 'Search…'}
|
|
26
|
+
/>
|
|
27
|
+
<Command.List className="em-list">
|
|
28
|
+
<Command.Empty className="em-empty">No options</Command.Empty>
|
|
29
|
+
{options.map((option) => (
|
|
30
|
+
<Command.Item key={option} value={option} className="em-item" onSelect={() => onChoose(option)}>
|
|
31
|
+
<span className="em-check">{chosen.includes(option) ? '✓' : ''}</span>
|
|
32
|
+
{option}
|
|
33
|
+
</Command.Item>
|
|
34
|
+
))}
|
|
35
|
+
{type === 'text' && typed && !options.includes(typed) && (
|
|
36
|
+
<Command.Item value={typed} className="em-item" onSelect={() => (setQuery(''), onChoose(typed))}>
|
|
37
|
+
<span className="em-check">+</span>
|
|
38
|
+
Add “{typed}”
|
|
39
|
+
</Command.Item>
|
|
40
|
+
)}
|
|
41
|
+
</Command.List>
|
|
42
|
+
{type !== 'multiselect' && chosen.length > 0 && (
|
|
43
|
+
<button type="button" className="em-item em-muted em-clear" onClick={onClear}>
|
|
44
|
+
<span className="em-check" />
|
|
45
|
+
Clear
|
|
46
|
+
</button>
|
|
47
|
+
)}
|
|
48
|
+
</Command>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export default function Editor({ fqn, name, type, values: initial, options: listed }) {
|
|
53
|
+
const [values, setValues] = useState(initial)
|
|
54
|
+
const [open, setOpen] = useState(false)
|
|
55
|
+
const [draft, setDraft] = useState(initial)
|
|
56
|
+
const [error, setError] = useState('')
|
|
57
|
+
const cancelled = useRef(false)
|
|
58
|
+
const options = [...new Set([...listed, ...draft])]
|
|
59
|
+
const listedType = ['select', 'multiselect', 'text'].includes(type)
|
|
60
|
+
|
|
61
|
+
const save = (value) => {
|
|
62
|
+
setOpen(false)
|
|
63
|
+
const next = listOf(value)
|
|
64
|
+
if (same(next, values)) return
|
|
65
|
+
const previous = values
|
|
66
|
+
setValues(next)
|
|
67
|
+
setError('')
|
|
68
|
+
post(fqn, name, value).catch((failure) => {
|
|
69
|
+
setValues(previous)
|
|
70
|
+
setError(failure.message)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A multiselect and the plain inputs keep a draft and save it on close, unless Escape closed them.
|
|
75
|
+
// A multiselect can't save per toggle because every save reloads the page.
|
|
76
|
+
const onOpenChange = (next) => {
|
|
77
|
+
if (next) {
|
|
78
|
+
cancelled.current = false
|
|
79
|
+
setDraft(values)
|
|
80
|
+
return setOpen(true)
|
|
81
|
+
}
|
|
82
|
+
if (!cancelled.current && (type === 'multiselect' || !listedType)) return save(type === 'multiselect' ? draft : draft[0] ?? '')
|
|
83
|
+
setOpen(false)
|
|
84
|
+
}
|
|
85
|
+
const onChoose = (option) => {
|
|
86
|
+
if (type !== 'multiselect') return save(option)
|
|
87
|
+
setDraft(draft.includes(option) ? draft.filter((value) => value !== option) : [...draft, option])
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const label = `${name}: ${values.join(', ') || 'not set'}`
|
|
91
|
+
const problem = error && (
|
|
92
|
+
<span className="em-error" role="alert" title={error}>
|
|
93
|
+
{' '}
|
|
94
|
+
not saved
|
|
95
|
+
</span>
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if (type === 'boolean') {
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
<Switch.Root
|
|
102
|
+
className="em-switch"
|
|
103
|
+
checked={values[0] === 'true'}
|
|
104
|
+
onCheckedChange={(checked) => save(String(checked))}
|
|
105
|
+
aria-label={name}
|
|
106
|
+
>
|
|
107
|
+
<Switch.Thumb className="em-thumb" />
|
|
108
|
+
</Switch.Root>
|
|
109
|
+
{problem}
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
<Popover.Root open={open} onOpenChange={onOpenChange}>
|
|
117
|
+
<Popover.Trigger className="em-value" aria-label={`Edit ${label}`}>
|
|
118
|
+
{values.join(', ') || '—'}
|
|
119
|
+
</Popover.Trigger>
|
|
120
|
+
<Popover.Portal>
|
|
121
|
+
<Popover.Content
|
|
122
|
+
className="em-popover"
|
|
123
|
+
align="start"
|
|
124
|
+
sideOffset={6}
|
|
125
|
+
onEscapeKeyDown={() => (cancelled.current = true)}
|
|
126
|
+
>
|
|
127
|
+
{listedType ? (
|
|
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
|
+
)}
|
|
142
|
+
</Popover.Content>
|
|
143
|
+
</Popover.Portal>
|
|
144
|
+
</Popover.Root>
|
|
145
|
+
{problem}
|
|
146
|
+
</>
|
|
147
|
+
)
|
|
148
|
+
}
|
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
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import config from 'virtual:editable-metadata/config'
|
|
4
|
+
import Editor from './Editor.jsx'
|
|
5
|
+
import { model } from './model.js'
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
of: string
|
|
9
|
+
name: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const types = ['text', 'number', 'date', 'url', 'boolean', 'select', 'multiselect']
|
|
13
|
+
|
|
14
|
+
const { of, name } = Astro.props
|
|
15
|
+
const fields = existsSync(config.fieldsFile) ? JSON.parse(readFileSync(config.fieldsFile, 'utf8')) : {}
|
|
16
|
+
const field: { type: string; options?: string[] } = fields[name] ?? { type: 'text' }
|
|
17
|
+
if (!types.includes(field.type)) {
|
|
18
|
+
throw new Error(`Unknown type "${field.type}" for metadata "${name}". Known types: ${types.join(', ')}`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const valuesOf = (fqn: string) => [model.element(fqn).getMetadata(name) ?? []].flat()
|
|
22
|
+
const values = valuesOf(of)
|
|
23
|
+
const inUse = [...model.elements()].flatMap((element) => valuesOf(element.id))
|
|
24
|
+
const options = [...new Set([...(field.options ?? []), ...inUse])]
|
|
25
|
+
|
|
26
|
+
const text = field.type === 'boolean' && values.length ? (values[0] === 'true' ? 'Yes' : 'No') : values.join(', ')
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
{
|
|
30
|
+
import.meta.env.DEV ? (
|
|
31
|
+
<Editor client:load fqn={of} name={name} type={field.type} values={values} options={options} />
|
|
32
|
+
) : field.type === 'url' && text ? (
|
|
33
|
+
<a href={text}>{text}</a>
|
|
34
|
+
) : (
|
|
35
|
+
text
|
|
36
|
+
)
|
|
37
|
+
}
|
package/NoEditor.jsx
ADDED
package/README.md
CHANGED
|
@@ -1,3 +1,89 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @architecture-model/editable-metadata
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
+
|
|
5
|
+
Made for repositories created with `npm create @architecture-model`, and works in any Astro site that has a LikeC4 model.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx astro add @architecture-model/editable-metadata
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
or by hand:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @architecture-model/editable-metadata @astrojs/react react react-dom
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// astro.config.mjs
|
|
21
|
+
import editableMetadata from '@architecture-model/editable-metadata'
|
|
22
|
+
|
|
23
|
+
export default defineConfig({
|
|
24
|
+
integrations: [editableMetadata()],
|
|
25
|
+
})
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Use
|
|
29
|
+
|
|
30
|
+
Give an element metadata in the model:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
system = system 'Shop' {
|
|
34
|
+
metadata {
|
|
35
|
+
owner 'igor'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
and show it in a page:
|
|
41
|
+
|
|
42
|
+
```mdx
|
|
43
|
+
import Metadata from '@architecture-model/editable-metadata/Metadata.astro'
|
|
44
|
+
|
|
45
|
+
Owner: <Metadata of="system" name="owner" />
|
|
46
|
+
```
|
|
47
|
+
|
|
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
|
+
|
|
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.
|
|
53
|
+
|
|
54
|
+
## Field types
|
|
55
|
+
|
|
56
|
+
Without configuration every key is text. To give keys other types, create `model/metadata.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"lifecycle": { "type": "select", "options": ["experimental", "production", "deprecated"] },
|
|
61
|
+
"languages": { "type": "multiselect", "options": ["TypeScript", "Go", "Python"] },
|
|
62
|
+
"launched": { "type": "date" }
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Type | Editor | Stored in the model as |
|
|
67
|
+
| --- | --- | --- |
|
|
68
|
+
| `text` | searchable list of the values already used for the key, and adds what you type | string |
|
|
69
|
+
| `number`, `date`, `url` | the browser's input for that type | string |
|
|
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 |
|
|
73
|
+
|
|
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.
|
|
75
|
+
|
|
76
|
+
## Options
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
editableMetadata({
|
|
80
|
+
model: 'model', // folder with the .c4 files
|
|
81
|
+
fields: 'model/metadata.json', // field types
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Both paths are relative to the Astro project root.
|
|
86
|
+
|
|
87
|
+
## How it saves
|
|
88
|
+
|
|
89
|
+
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.
|
package/editor.css
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
.em-value {
|
|
2
|
+
all: unset;
|
|
3
|
+
cursor: pointer;
|
|
4
|
+
border-bottom: 1px dashed currentColor;
|
|
5
|
+
}
|
|
6
|
+
.em-value:focus-visible {
|
|
7
|
+
outline: 2px solid var(--sl-color-accent, Highlight);
|
|
8
|
+
outline-offset: 2px;
|
|
9
|
+
border-radius: 2px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.em-popover {
|
|
13
|
+
--em-bg: var(--sl-color-bg, Canvas);
|
|
14
|
+
--em-border: var(--sl-color-hairline-light, #d4d4d8);
|
|
15
|
+
--em-hover: var(--sl-color-gray-6, #f4f4f5);
|
|
16
|
+
--em-muted: var(--sl-color-gray-3, #71717a);
|
|
17
|
+
z-index: 100;
|
|
18
|
+
min-width: 14rem;
|
|
19
|
+
max-width: 20rem;
|
|
20
|
+
padding: 0.25rem;
|
|
21
|
+
border: 1px solid var(--em-border);
|
|
22
|
+
border-radius: 0.5rem;
|
|
23
|
+
background: var(--em-bg);
|
|
24
|
+
color: var(--sl-color-text, CanvasText);
|
|
25
|
+
box-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.25);
|
|
26
|
+
font-family: var(--sl-font, system-ui, sans-serif);
|
|
27
|
+
font-size: var(--sl-text-sm, 0.875rem);
|
|
28
|
+
line-height: 1.4;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.em-command {
|
|
32
|
+
display: flex;
|
|
33
|
+
flex-direction: column;
|
|
34
|
+
margin: 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.em-input {
|
|
38
|
+
font: inherit;
|
|
39
|
+
color: inherit;
|
|
40
|
+
background: transparent;
|
|
41
|
+
border: none;
|
|
42
|
+
border-bottom: 1px solid var(--em-border);
|
|
43
|
+
padding: 0.5rem 0.5rem 0.6rem;
|
|
44
|
+
outline: none;
|
|
45
|
+
}
|
|
46
|
+
.em-input::placeholder {
|
|
47
|
+
color: var(--em-muted);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.em-list {
|
|
51
|
+
max-height: 16rem;
|
|
52
|
+
overflow-y: auto;
|
|
53
|
+
padding-top: 0.25rem;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.em-item {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 0.5rem;
|
|
60
|
+
padding: 0.375rem 0.5rem;
|
|
61
|
+
border-radius: 0.25rem;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
user-select: none;
|
|
64
|
+
}
|
|
65
|
+
.em-item[data-selected='true'],
|
|
66
|
+
.em-clear:hover,
|
|
67
|
+
.em-clear:focus-visible {
|
|
68
|
+
background: var(--em-hover);
|
|
69
|
+
}
|
|
70
|
+
.em-clear {
|
|
71
|
+
all: unset;
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 0.5rem;
|
|
75
|
+
margin-top: 0.25rem;
|
|
76
|
+
padding: 0.375rem 0.5rem;
|
|
77
|
+
border-top: 1px solid var(--em-border);
|
|
78
|
+
border-radius: 0 0 0.25rem 0.25rem;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
.em-error {
|
|
82
|
+
color: var(--sl-color-red, #dc2626);
|
|
83
|
+
font-size: 0.85em;
|
|
84
|
+
}
|
|
85
|
+
.em-muted,
|
|
86
|
+
.em-empty,
|
|
87
|
+
.em-hint {
|
|
88
|
+
color: var(--em-muted);
|
|
89
|
+
}
|
|
90
|
+
.em-empty,
|
|
91
|
+
.em-hint {
|
|
92
|
+
margin: 0;
|
|
93
|
+
padding: 0.5rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.em-check {
|
|
97
|
+
width: 1rem;
|
|
98
|
+
text-align: center;
|
|
99
|
+
color: var(--sl-color-accent, currentColor);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.em-switch {
|
|
103
|
+
all: unset;
|
|
104
|
+
position: relative;
|
|
105
|
+
display: inline-block;
|
|
106
|
+
vertical-align: middle;
|
|
107
|
+
width: 2.25rem;
|
|
108
|
+
height: 1.25rem;
|
|
109
|
+
border-radius: 999px;
|
|
110
|
+
background: var(--sl-color-gray-5, #d4d4d8);
|
|
111
|
+
cursor: pointer;
|
|
112
|
+
}
|
|
113
|
+
.em-switch[data-state='checked'] {
|
|
114
|
+
background: var(--sl-color-accent, #2563eb);
|
|
115
|
+
}
|
|
116
|
+
.em-switch:focus-visible {
|
|
117
|
+
outline: 2px solid var(--sl-color-accent, Highlight);
|
|
118
|
+
outline-offset: 2px;
|
|
119
|
+
}
|
|
120
|
+
.em-thumb {
|
|
121
|
+
display: block;
|
|
122
|
+
width: 1rem;
|
|
123
|
+
height: 1rem;
|
|
124
|
+
margin: 0.125rem;
|
|
125
|
+
border-radius: 999px;
|
|
126
|
+
background: white;
|
|
127
|
+
transition: transform 100ms;
|
|
128
|
+
}
|
|
129
|
+
.em-thumb[data-state='checked'] {
|
|
130
|
+
transform: translateX(1rem);
|
|
131
|
+
}
|
package/editor.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export async function setMetadata(dir, { fqn, key, value }) {
|
|
5
|
+
const values = [value].flat().map((item) => (typeof item === 'string' ? item.trim() : item))
|
|
6
|
+
if (typeof fqn !== 'string' || !/^[A-Za-z_][\w-]*$/.test(key) || values.some((item) => typeof item !== 'string')) {
|
|
7
|
+
throw new Error('Expected { fqn, key, value } with an identifier as the key and a string or list of strings as the value')
|
|
8
|
+
}
|
|
9
|
+
const present = values.filter(Boolean)
|
|
10
|
+
const stored = present.length ? (Array.isArray(value) ? present : present[0]) : null
|
|
11
|
+
|
|
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])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const editor = ({ modelDir, fieldsFile }) => ({
|
|
23
|
+
name: 'editable-metadata-editor',
|
|
24
|
+
apply: 'serve',
|
|
25
|
+
configureServer(server) {
|
|
26
|
+
// Pages load the model once per module evaluation, so a changed model has to evict what was evaluated.
|
|
27
|
+
server.watcher.add([modelDir, fieldsFile])
|
|
28
|
+
server.watcher.on('change', (file) => {
|
|
29
|
+
if (!file.endsWith('.c4') && file !== fieldsFile) return
|
|
30
|
+
for (const environment of Object.values(server.environments)) environment.moduleGraph.invalidateAll()
|
|
31
|
+
server.ws.send({ type: 'full-reload' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
server.middlewares.use('/__model/metadata', async (req, res) => {
|
|
35
|
+
// The endpoint writes to disk, so a page from another site must not be able to call it.
|
|
36
|
+
if (req.method !== 'POST' || req.headers['sec-fetch-site'] !== 'same-origin') {
|
|
37
|
+
res.statusCode = 403
|
|
38
|
+
return res.end()
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
let body = ''
|
|
42
|
+
for await (const chunk of req) body += chunk
|
|
43
|
+
await setMetadata(modelDir, JSON.parse(body))
|
|
44
|
+
res.statusCode = 204
|
|
45
|
+
res.end()
|
|
46
|
+
} catch (error) {
|
|
47
|
+
res.statusCode = 400
|
|
48
|
+
res.end(error.message)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
})
|
package/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import react from '@astrojs/react'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { editor } from './editor.js'
|
|
5
|
+
|
|
6
|
+
const name = '@architecture-model/editable-metadata'
|
|
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
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function editableMetadata({ model = 'model', fields = 'model/metadata.json' } = {}) {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
hooks: {
|
|
26
|
+
'astro:config:setup': ({ config, command, updateConfig }) => {
|
|
27
|
+
const root = fileURLToPath(config.root)
|
|
28
|
+
const paths = { modelDir: resolve(root, model), fieldsFile: resolve(root, fields) }
|
|
29
|
+
const hasReact = config.integrations.some((integration) => integration.name === '@astrojs/react')
|
|
30
|
+
updateConfig({
|
|
31
|
+
integrations: hasReact ? [] : [react()],
|
|
32
|
+
vite: {
|
|
33
|
+
ssr: { noExternal: [name] },
|
|
34
|
+
plugins: [
|
|
35
|
+
{
|
|
36
|
+
name: 'editable-metadata-config',
|
|
37
|
+
resolveId: (id) => (id === virtual ? `\0${virtual}` : undefined),
|
|
38
|
+
load: (id) => (id === `\0${virtual}` ? `export default ${JSON.stringify(paths)}` : undefined),
|
|
39
|
+
},
|
|
40
|
+
editor(paths),
|
|
41
|
+
command === 'build' && withoutEditor,
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
package/model.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@architecture-model/editable-metadata",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Show LikeC4 element metadata in Astro pages and edit it in place in the dev server",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"astro-integration",
|
|
7
|
+
"astro-component",
|
|
8
|
+
"likec4"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Morozzzko/architecture-model-template.git",
|
|
14
|
+
"directory": "addons/editable-metadata"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.js",
|
|
22
|
+
"./Metadata.astro": "./Metadata.astro"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.js",
|
|
26
|
+
"editor.js",
|
|
27
|
+
"model.js",
|
|
28
|
+
"Metadata.astro",
|
|
29
|
+
"Editor.jsx",
|
|
30
|
+
"NoEditor.jsx",
|
|
31
|
+
"editor.css"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.22.3"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@astrojs/react": ">=7",
|
|
38
|
+
"astro": ">=7",
|
|
39
|
+
"likec4": "^1.59.4",
|
|
40
|
+
"react": ">=19",
|
|
41
|
+
"react-dom": ">=19"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@radix-ui/react-popover": "^1.3.1",
|
|
45
|
+
"@radix-ui/react-switch": "^1.4.1",
|
|
46
|
+
"cmdk": "^1.1.1",
|
|
47
|
+
"likec4-mutator": "0.9.0"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"test": "node --test"
|
|
51
|
+
}
|
|
6
52
|
}
|