@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 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
- # Temporary Holding Version
1
+ # @architecture-model/editable-metadata
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -0,0 +1,6 @@
1
+ import { LikeC4 } from 'likec4'
2
+ import config from 'virtual:editable-metadata/config'
3
+
4
+ const likec4 = await LikeC4.fromWorkspace(config.modelDir, { throwIfInvalid: true })
5
+
6
+ export const model = await likec4.computedModel()
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@architecture-model/editable-metadata",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }