@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 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.