@panphora/hyper-cms 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/LICENSE +14 -0
- package/README.md +247 -0
- package/dist/hypercms.min.js +362 -0
- package/package.json +50 -0
- package/src/apply-loop.js +161 -0
- package/src/diagnostics.js +44 -0
- package/src/events.js +457 -0
- package/src/form-builder.js +361 -0
- package/src/form-rules.js +192 -0
- package/src/hypercms-bundle.js +7 -0
- package/src/hypercms.js +321 -0
- package/src/morph.js +10 -0
- package/src/path.js +61 -0
- package/src/refresh.js +57 -0
- package/src/scaffold.js +10 -0
- package/src/shell.js +132 -0
- package/src/styles.css +298 -0
- package/src/templates.js +184 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Miranda
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
9
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
10
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
11
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
12
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
13
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
14
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# hypercms
|
|
2
|
+
|
|
3
|
+
Live edit-in-place CMS sidebar for any HTML page that carries a [`hyper-html-api`](https://github.com/panphora/hyper-html-api) rules tag. The rules tag tells the engine what's data. hypercms builds a form from those rules, mounts it as a right-side panel, and streams edits back into the page DOM in real time.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
You have a page with a rules tag:
|
|
8
|
+
|
|
9
|
+
```html
|
|
10
|
+
<script data-rules-name="cms" data-rules-version="1" type="application/json">
|
|
11
|
+
{
|
|
12
|
+
"title": ".page-title",
|
|
13
|
+
"products": [".product", { "name": ".name", "price": ".price@data-cents" }]
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`cms.open()` looks up a rules tag by the token `cms` (`data-rules-name~="cms"`).
|
|
19
|
+
A single tag can carry several space-separated tokens (e.g.
|
|
20
|
+
`data-rules-name="api cms collection"`) to serve more than one consumer.
|
|
21
|
+
|
|
22
|
+
Add hypercms and a trigger:
|
|
23
|
+
|
|
24
|
+
```html
|
|
25
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/hyperclayjs@latest/src/utilities/mutation.js"></script>
|
|
26
|
+
<script type="module">
|
|
27
|
+
import { cms } from 'https://cdn.jsdelivr.net/npm/hypercms@latest/src/hypercms.js'
|
|
28
|
+
document.getElementById('edit-btn').addEventListener('click', () => cms.open())
|
|
29
|
+
</script>
|
|
30
|
+
<button id="edit-btn">Edit</button>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's the whole integration.
|
|
34
|
+
|
|
35
|
+
`cms.open()` also accepts an explicit rules source:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
cms.open() // default token "cms"
|
|
39
|
+
cms.open({ rules: 'collection' })// a tag with data-rules-name~="collection"
|
|
40
|
+
cms.open({ rules: { title: '.title' } }) // a literal rules object, passed by hand
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Hard requirement: `window.hyperclay.Mutation`
|
|
44
|
+
|
|
45
|
+
hypercms uses [`hyperclayjs`'s Mutation utility](https://github.com/panphora/hyperclayjs/blob/main/src/utilities/mutation.js) as its page-change backend. It's a singleton that already understands `save-ignore`, `save-freeze`, `save-remove`, and `mutations-ignore`, which lets hypercms ignore its own DOM activity without per-event bookkeeping.
|
|
46
|
+
|
|
47
|
+
If `window.hyperclay.Mutation` is missing when you call `cms.open()`, it throws. Load the utility before initializing, either as a side-effect import (above) or by loading the full hyperclayjs library, which installs it automatically.
|
|
48
|
+
|
|
49
|
+
## How it works
|
|
50
|
+
|
|
51
|
+
1. On `open()`, the engine reads your page via `engine.extract(pageRoot, rules)` and builds a form whose fields match every leaf in the rule tree.
|
|
52
|
+
2. As you type, `input`/`change` events trigger `engine.apply(pageRoot, rules, formData)`, which writes back to the page DOM.
|
|
53
|
+
3. The Mutation observer watches the page. When anything outside the sidebar changes, hypercms re-extracts and morphs the form to match. The sidebar marks itself with `data-hcms-shell` + `save-ignore`, so its own mutations are filtered out at the observer level and skipped by the engine.
|
|
54
|
+
4. Form rendering uses a small default template set, overridable per path with `<template data-hcms-tpl="...">`.
|
|
55
|
+
|
|
56
|
+
## The rules tag
|
|
57
|
+
|
|
58
|
+
The full grammar lives in the [hyper-html-api docs](https://github.com/panphora/hyper-html-api). The shapes you'll see in form rendering:
|
|
59
|
+
|
|
60
|
+
| Rule shape | Example | Form widget |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| Scalar (text) | `"title": ".page-title"` | input |
|
|
63
|
+
| Scalar (attr) | `"href": "a.link@href"` | input |
|
|
64
|
+
| Scalar (prop) | `"checked": "input.cb@checked"` | checkbox |
|
|
65
|
+
| Scalar array | `"tags": "ul.tags li[]"` | list of inputs |
|
|
66
|
+
| Object | `"author": { "name": ".name", "email": ".email" }` | grouped fields |
|
|
67
|
+
| Object array | `"products": [".product", { "name": ".name" }]` | list of cards |
|
|
68
|
+
| Nested | object arrays inside object arrays | recurses |
|
|
69
|
+
|
|
70
|
+
## Attributes you can use in your page HTML
|
|
71
|
+
|
|
72
|
+
| Attribute | On | Effect |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `save-ignore` | any element | Engine skips during extract and apply. Mutation observer ignores changes inside. The shell sets this on itself. |
|
|
75
|
+
| `save-freeze`, `save-remove`, `mutations-ignore` | any element | Same observer-ignore behavior as `save-ignore`. Use whichever fits your save-pipeline convention. |
|
|
76
|
+
| `cms-template` | any element matching a list rule | Engine treats it as a seed template, not real data. Used to grow lists from empty. See [Template seeding](#template-seeding) below. |
|
|
77
|
+
| `data-hcms-min-items="N"` | a `<template data-hcms-tpl>` | Sidebar refuses to remove below N items. Hides the remove button when at the floor. |
|
|
78
|
+
| `data-hcms-max-items="N"` | a `<template data-hcms-tpl>` | Sidebar refuses to add above N items. Hides the add button at the ceiling. |
|
|
79
|
+
| `data-hcms-no-add` | a `<template data-hcms-tpl>` | No add button. |
|
|
80
|
+
| `data-hcms-no-remove` | a `<template data-hcms-tpl>` | No remove buttons. |
|
|
81
|
+
| `data-hcms-no-reorder` | a `<template data-hcms-tpl>` | No drag handles or move buttons. |
|
|
82
|
+
|
|
83
|
+
## Template seeding
|
|
84
|
+
|
|
85
|
+
The engine seeds new list items by cloning an existing one. When a list reaches zero items (because the user removed them all, or because addItem created a new parent whose nested arrays empty out), there's nothing to clone. Without a fallback, the engine throws `EmptyListInsert`.
|
|
86
|
+
|
|
87
|
+
To make growing-from-zero work, mark one element per growable list with `cms-template` and hide it however suits you:
|
|
88
|
+
|
|
89
|
+
```html
|
|
90
|
+
<div class="variants">
|
|
91
|
+
<div class="variant">…real data…</div>
|
|
92
|
+
<div class="variant">…real data…</div>
|
|
93
|
+
<div class="variant" cms-template hidden>
|
|
94
|
+
<strong class="variant-label"></strong>
|
|
95
|
+
<em class="variant-stock"></em>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The engine ignores `cms-template` elements during extract and apply (they're never read as data, never written into). When a list needs to grow from zero, the engine walks up from the parent context looking for a matching element with `cms-template`, clones it, strips the attribute from the clone, and inserts. The original template stays in place.
|
|
101
|
+
|
|
102
|
+
This is the panphora-style colocation pattern: extras live in the DOM, marked, ready when needed.
|
|
103
|
+
|
|
104
|
+
## Custom form templates
|
|
105
|
+
|
|
106
|
+
Drop a `<template>` anywhere in the page to override the default form for a given path or shape:
|
|
107
|
+
|
|
108
|
+
```html
|
|
109
|
+
<template data-hcms-tpl="products.*">
|
|
110
|
+
<article class="my-product-card">
|
|
111
|
+
<input data-hcms-field="name" />
|
|
112
|
+
<input data-hcms-field="price" />
|
|
113
|
+
<button data-hcms-action="remove">×</button>
|
|
114
|
+
</article>
|
|
115
|
+
</template>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The `data-hcms-tpl` value resolves in order:
|
|
119
|
+
|
|
120
|
+
1. Exact path (`products.0.name`)
|
|
121
|
+
2. Wildcard path (`products.*`, `products.*.variants`, `products.*.variants.*`)
|
|
122
|
+
3. Shape key (`@scalar`, `@object`, `@object-array`, `@scalar-array`)
|
|
123
|
+
|
|
124
|
+
Inside a template, the engine looks for these slots:
|
|
125
|
+
|
|
126
|
+
| Marker | Purpose |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `data-hcms-field` | Input/textarea/select that holds a scalar value. Optionally `data-hcms-field="name"` to bind by key inside an object template. |
|
|
129
|
+
| `data-hcms-label` | Where the field label gets written. |
|
|
130
|
+
| `data-hcms-action="add"` | Add-item button for a list. |
|
|
131
|
+
| `data-hcms-action="remove"` | Remove-item button on a card. |
|
|
132
|
+
| `data-hcms-action="move-up"` / `"move-down"` | Reorder buttons. |
|
|
133
|
+
| `.hcms-object-fields` or `.hcms-card-fields` | Slot where child fields render. Required on slotted templates that aren't fully inline. |
|
|
134
|
+
|
|
135
|
+
## API
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
import { cms } from 'hypercms'
|
|
139
|
+
|
|
140
|
+
cms.open(opts?)
|
|
141
|
+
cms.close()
|
|
142
|
+
cms.refresh()
|
|
143
|
+
cms.isOpen // getter
|
|
144
|
+
|
|
145
|
+
cms.api.getData() // current form data (object)
|
|
146
|
+
cms.api.setValue(path, value) // write a scalar leaf
|
|
147
|
+
cms.api.addItem(arrayPath) // push a new item
|
|
148
|
+
cms.api.removeItem(itemPath) // drop an item by index
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### `open(opts)`
|
|
152
|
+
|
|
153
|
+
| Option | Default | Effect |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `pageRoot` | `document.body` | Where extract/apply run. |
|
|
156
|
+
| `mountTo` | `document.body` | Where the shell DOM mounts. Can be a descendant of pageRoot. |
|
|
157
|
+
| `side` | `'right'` | `'right'` or `'left'`. |
|
|
158
|
+
| `overlay` | `false` | If true, sidebar covers the page; if false, page content gets a padding offset. |
|
|
159
|
+
| `showSaveButton` | `false` | Renders a Save button in the shell footer; clicking dispatches `hcms:save`. |
|
|
160
|
+
| `onChange(data, info)` | none | Per-commit callback. |
|
|
161
|
+
| `onError(err)` | none | Per-commit error callback. |
|
|
162
|
+
| `onSave(data)` | none | Save-button callback. |
|
|
163
|
+
|
|
164
|
+
### Path syntax
|
|
165
|
+
|
|
166
|
+
Dotted strings with integer indices for arrays: `products.0.variants.1.label`. Helpers exposed at `cms.path` (`fromString`, `toString`, `getRuleAtPath`).
|
|
167
|
+
|
|
168
|
+
## Events
|
|
169
|
+
|
|
170
|
+
All events dispatch on the shell root and bubble through the document.
|
|
171
|
+
|
|
172
|
+
| Event | Detail | When |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `hcms:open` | `{ pageRoot }` | After mount, before first interaction. |
|
|
175
|
+
| `hcms:change` | `{ data, path, structural }` | Per successful commit. `structural` is true for add/remove/reorder. |
|
|
176
|
+
| `hcms:error` | `{ error, attemptedData }` | When apply throws (shape mismatch, missing template, etc.). |
|
|
177
|
+
| `hcms:save` | `{ data }` | Save button clicked. |
|
|
178
|
+
| `hcms:close` | `null` | Before teardown. |
|
|
179
|
+
|
|
180
|
+
`hcms:change` and `hcms:save` are cancelable.
|
|
181
|
+
|
|
182
|
+
## Lifecycle
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
open()
|
|
186
|
+
→ engine.findRules(page, source) // resolve rules (token "cms" by default, or an object/named token)
|
|
187
|
+
→ engine.extract(page, rules) // read data (skips [data-hcms-shell] and [cms-template])
|
|
188
|
+
→ deriveFormRules + buildForm // build sidebar DOM
|
|
189
|
+
→ mountShell // append to mountTo
|
|
190
|
+
→ installObserver // subscribe to Mutation.onAnyChange
|
|
191
|
+
→ focusFirstIn(shell)
|
|
192
|
+
→ dispatch('hcms:open')
|
|
193
|
+
|
|
194
|
+
per-keystroke:
|
|
195
|
+
input event
|
|
196
|
+
→ extractFormData // read form into js object
|
|
197
|
+
→ fingerprint compare // skip if unchanged
|
|
198
|
+
→ applyWithRollback
|
|
199
|
+
pause observer
|
|
200
|
+
engine.apply(page, rules, data, { skip, templateAttr })
|
|
201
|
+
resume observer
|
|
202
|
+
→ dispatch('hcms:change')
|
|
203
|
+
|
|
204
|
+
per-page-change-from-outside:
|
|
205
|
+
Mutation.onAnyChange (debounced 100ms, filtered by save-ignore)
|
|
206
|
+
→ refreshForm
|
|
207
|
+
engine.findRules (re-resolve by source; livesync may have swapped a token tag)
|
|
208
|
+
engine.extract
|
|
209
|
+
buildForm into fragment
|
|
210
|
+
morphForm(formRoot, fragment) // hyper-morph preserves focus + values
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Errors
|
|
214
|
+
|
|
215
|
+
Apply runs inside a try/catch. On failure:
|
|
216
|
+
|
|
217
|
+
- The error is shown inline in the shell header.
|
|
218
|
+
- `hcms:error` is dispatched with `{ error, attemptedData }`.
|
|
219
|
+
- For structural commits, the affected page subtree is restored from a snapshot taken just before apply.
|
|
220
|
+
- For scalar commits, the page may diverge from the form temporarily; the next refresh will bring them back in sync.
|
|
221
|
+
|
|
222
|
+
Common errors thrown by the engine:
|
|
223
|
+
|
|
224
|
+
| Name | Cause | Fix |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| `ShapeMismatch` | Form data shape doesn't match rule shape (e.g. scalar received an object). | Bug in form rendering; report it. |
|
|
227
|
+
| `EmptyListInsert` | List needs to grow but has zero items and no `cms-template`. | Add a `cms-template` element per growable list, OR set `data-hcms-min-items="1"`. |
|
|
228
|
+
| `MaxRuleDepthExceeded` | Rules nest beyond the engine's depth limit. | Flatten the rules; this almost certainly indicates a mistake. |
|
|
229
|
+
|
|
230
|
+
## Drag-to-reorder
|
|
231
|
+
|
|
232
|
+
In hyperclayjs apps, drag works out of the box (hypercms's object-array templates carry the `[sortable]` custom-attribute, which hyperclayjs upgrades). For standalone use, install `sortablejs` and either upgrade the `[sortable]` attribute yourself or replace the template with your own reorder UX. Add/remove still work without sortable.
|
|
233
|
+
|
|
234
|
+
## Build, develop, test
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
npm install
|
|
238
|
+
npm run build # bundle to dist/hypercms.min.js (esbuild, IIFE)
|
|
239
|
+
npm test # node --test test-node/**/*.test.js
|
|
240
|
+
npm run demo # http-server on port 5694 → /demo/
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Tests run against jsdom. Test helpers stub `window.hyperclay.Mutation` so the hard requirement is satisfied without pulling in the real library.
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
0BSD.
|