@architecture-model/editable-metadata 0.0.0-stage → 0.1.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/Metadata.astro +157 -0
- package/README.md +86 -2
- package/editor.js +52 -0
- package/index.js +31 -0
- package/model.js +6 -0
- package/package.json +41 -4
package/Metadata.astro
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import config from 'virtual:editable-metadata/config'
|
|
4
|
+
import { model } from './model.js'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
of: string
|
|
8
|
+
name: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const types = ['text', 'number', 'date', 'url', 'boolean', 'select', 'multiselect']
|
|
12
|
+
|
|
13
|
+
const { of, name } = Astro.props
|
|
14
|
+
const fields = existsSync(config.fieldsFile) ? JSON.parse(readFileSync(config.fieldsFile, 'utf8')) : {}
|
|
15
|
+
const field: { type: string; options?: string[] } = fields[name] ?? { type: 'text' }
|
|
16
|
+
if (!types.includes(field.type)) {
|
|
17
|
+
throw new Error(`Unknown type "${field.type}" for metadata "${name}". Known types: ${types.join(', ')}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const valuesOf = (fqn: string) => [model.element(fqn).getMetadata(name) ?? []].flat()
|
|
21
|
+
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(', ')
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
{
|
|
29
|
+
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>
|
|
39
|
+
) : field.type === 'url' && text ? (
|
|
40
|
+
<a href={text}>{text}</a>
|
|
41
|
+
) : (
|
|
42
|
+
text
|
|
43
|
+
)
|
|
44
|
+
}
|
|
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/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
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
|
|
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. Enter or a click elsewhere saves, Escape cancels, and an empty value removes the key from the model.
|
|
51
|
+
|
|
52
|
+
## Field types
|
|
53
|
+
|
|
54
|
+
Without configuration every key is text. To give keys other types, create `model/metadata.json`:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"lifecycle": { "type": "select", "options": ["experimental", "production", "deprecated"] },
|
|
59
|
+
"languages": { "type": "multiselect", "options": ["TypeScript", "Go", "Python"] },
|
|
60
|
+
"launched": { "type": "date" }
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Type | Editor | Stored in the model as |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `text` | text input that suggests values already used for the key | string |
|
|
67
|
+
| `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 |
|
|
71
|
+
|
|
72
|
+
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
|
+
|
|
74
|
+
## Options
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
editableMetadata({
|
|
78
|
+
model: 'model', // folder with the .c4 files
|
|
79
|
+
fields: 'model/metadata.json', // field types
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Both paths are relative to the Astro project root.
|
|
84
|
+
|
|
85
|
+
## How it saves
|
|
86
|
+
|
|
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.
|
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,31 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { editor } from './editor.js'
|
|
4
|
+
|
|
5
|
+
const name = '@architecture-model/editable-metadata'
|
|
6
|
+
const virtual = 'virtual:editable-metadata/config'
|
|
7
|
+
|
|
8
|
+
export default function editableMetadata({ model = 'model', fields = 'model/metadata.json' } = {}) {
|
|
9
|
+
return {
|
|
10
|
+
name,
|
|
11
|
+
hooks: {
|
|
12
|
+
'astro:config:setup': ({ config, updateConfig }) => {
|
|
13
|
+
const root = fileURLToPath(config.root)
|
|
14
|
+
const paths = { modelDir: resolve(root, model), fieldsFile: resolve(root, fields) }
|
|
15
|
+
updateConfig({
|
|
16
|
+
vite: {
|
|
17
|
+
ssr: { noExternal: [name] },
|
|
18
|
+
plugins: [
|
|
19
|
+
{
|
|
20
|
+
name: 'editable-metadata-config',
|
|
21
|
+
resolveId: (id) => (id === virtual ? `\0${virtual}` : undefined),
|
|
22
|
+
load: (id) => (id === `\0${virtual}` ? `export default ${JSON.stringify(paths)}` : undefined),
|
|
23
|
+
},
|
|
24
|
+
editor(paths),
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
package/model.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@architecture-model/editable-metadata",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.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
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22.22.3"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"astro": ">=7",
|
|
38
|
+
"likec4": "^1.59.4"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"likec4-mutator": "0.9.0"
|
|
42
|
+
}
|
|
43
|
+
}
|