@panphora/hyper-cms 0.10.0 → 0.11.1

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/src/session.js ADDED
@@ -0,0 +1,303 @@
1
+ // The session core: everything an editing session is, independent of the chrome
2
+ // that renders it. Rule resolution, the ctx object every module reads, the first
3
+ // extract, the observer / undo / live-sync subscriptions, the global sortable
4
+ // wiring, event dispatch, and one idempotent teardown. A view (src/views/*)
5
+ // supplies the DOM; the session owns the state and the page.
6
+
7
+ import { engine } from 'hyper-html-api'
8
+ import { injectDefaults, injectComponents } from './templates.js'
9
+ import { deriveFormRules } from './form-rules.js'
10
+ import { warnUnmatchedTemplates } from './diagnostics.js'
11
+ import { findUnresolved, stripReadOnly } from './unresolved.js'
12
+ import { rowIdentitySeeder } from './row-identity.js'
13
+ import {
14
+ commit,
15
+ commitWithUndo,
16
+ suppressUndo,
17
+ extractFormData,
18
+ stableStringify,
19
+ restampAllSiblings,
20
+ coerceBooleans,
21
+ } from './events.js'
22
+ import { installObserver } from './refresh.js'
23
+ import { platform, onPlatformEvent, LIVESYNC_APPLIED } from './platform.js'
24
+
25
+ const ENGINE_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
26
+
27
+ export const state = {
28
+ isOpen: false,
29
+ ctx: null,
30
+ opts: null,
31
+ }
32
+
33
+ // Build the session for one open(): resolve the rules, hand them to the view for
34
+ // its own preparation, derive the form schema, and take the first read of the
35
+ // page. The view is not mounted yet — ctx reads its DOM handles lazily, so the
36
+ // mount can stay inside open()'s try block.
37
+ export function createSession({ view, doc, pageRoot, opts = {}, onCloseRequested, onViewRequested }) {
38
+ // Resolve rules via the union: an explicit rules object, a token string, or
39
+ // the default token "cms". findRules is document-scoped, so a head-mounted
40
+ // rules tag is found even though pageRoot defaults to <body>.
41
+ const source = opts.rules !== undefined ? opts.rules : 'cms'
42
+ const found = engine.findRules(doc, source)
43
+ if (!found) {
44
+ const what = typeof source === 'string' ? `data-rules-name~="${source}"` : 'the provided rules object'
45
+ throw new Error(`hypercms: no rules found for ${what}`)
46
+ }
47
+ // The view's rule seam. The sidebar applies the rich-text upgrade (default on):
48
+ // bare scalar rules whose page element contains child elements are rebound
49
+ // through @innerHTML so links and inline formatting survive the round-trip, and
50
+ // their form fields render the @richtext component.
51
+ const pageRules = view.prepareRules(found.rules)
52
+ const rulesTagNode = found.tagNode
53
+
54
+ injectDefaults(doc)
55
+ injectComponents(doc, pageRules)
56
+ warnUnmatchedTemplates(doc, pageRules)
57
+ const formRules = deriveFormRules(pageRules, doc)
58
+ // The write half of the rules; see stripReadOnly. The form and every extract
59
+ // keep the full tree.
60
+ const writeRules = stripReadOnly(pageRules)
61
+ // Runs before the first extract so a rule with invalid CSS names its field
62
+ // instead of throwing a bare SyntaxError out of querySelectorAll.
63
+ const unresolved = findUnresolved(pageRoot, pageRules)
64
+ // Bind each form row to the page row it stands for while the two are still
65
+ // known to correspond, which is now: the form the view builds comes from
66
+ // exactly this read. Without it the first structural operation of a session has
67
+ // no identity to go on and falls back to content matching.
68
+ const seeder = rowIdentitySeeder()
69
+ const data = coerceBooleans(engine.extract(pageRoot, pageRules, { ...ENGINE_OPTS, ...seeder.hooks }), pageRules)
70
+
71
+ const ctx = {
72
+ doc,
73
+ pageRoot,
74
+ pageRules,
75
+ writeRules,
76
+ formRules,
77
+ rulesTagNode,
78
+ rulesSource: source,
79
+ richText: view.richText,
80
+ view,
81
+ seeder,
82
+ initialData: data,
83
+ get formRoot() { return view.formRoot },
84
+ get shellRoot() { return view.root },
85
+ get errorEl() { return view.errorEl },
86
+ get noticeEl() { return view.noticeEl },
87
+ unresolved,
88
+ lastTwinSignature: null,
89
+ lastFingerprint: null,
90
+ lastData: null,
91
+ observerHandle: null,
92
+ undoUnsub: null,
93
+ livesyncUnsub: null,
94
+ onChange: opts.onChange,
95
+ onError: opts.onError,
96
+ confirmRemove: opts.confirmRemove,
97
+ previouslyFocused: doc.activeElement,
98
+ dispatch(name, detail) {
99
+ const Ctor = (doc.defaultView && doc.defaultView.CustomEvent) || (typeof CustomEvent !== 'undefined' ? CustomEvent : null)
100
+ if (!Ctor) return
101
+ // Every event carries the page it belongs to and which view is rendering
102
+ // it, so a document-level listener can tell a sidebar session from an
103
+ // inline one without inspecting the DOM. Merged into the caller's detail
104
+ // rather than replacing it: hcms:change still carries .data, hcms:error
105
+ // still carries .error.
106
+ const payload = { ...(detail || {}), pageRoot, view: view.name }
107
+ const ev = new Ctor(name, { bubbles: true, cancelable: name === 'hcms:change', detail: payload })
108
+ // A full-document morph (live sync, a version restore) can replace the
109
+ // view root between mount and dispatch. Falling back to the page keeps the
110
+ // event on the document instead of firing it into a detached tree where no
111
+ // listener will ever see it.
112
+ const target = view.root && view.root.isConnected !== false ? view.root : pageRoot
113
+ target.dispatchEvent(ev)
114
+ },
115
+ onCloseRequested,
116
+ // How a view asks to be rendered as a different one. The switch runs through
117
+ // open({ view }), which carries this session's options across it, so the
118
+ // request changes the presentation and not the session.
119
+ onViewRequested,
120
+ }
121
+ ctx.updateFingerprint = () => {
122
+ ctx.lastFingerprint = stableStringify(extractFormData(ctx))
123
+ }
124
+ view.ctx = ctx
125
+ return ctx
126
+ }
127
+
128
+ // Everything that outlives the mount: the fingerprint the commit path compares
129
+ // against, and the three external subscriptions plus the global sortable hook.
130
+ // Runs after the view is mounted, so a throw here is caught by open() and routed
131
+ // through teardownSession.
132
+ export function startSession(ctx) {
133
+ ctx.updateFingerprint()
134
+
135
+ ctx.observerHandle = installObserver({
136
+ onRefresh: (changes) => ctx.view.refresh('observer', changes),
137
+ })
138
+
139
+ // Undo/redo is a deliberate local action (unlike livesync or in-flight typing),
140
+ // so the focused form field SHOULD snap to the reverted value. The observer above
141
+ // is source-blind, so subscribe to the undo scope and force a non-ignoring refresh.
142
+ // Fires synchronously before the debounced observer refresh, which then no-ops on
143
+ // the active field. Guarded so it degrades to today's behavior when undo isn't loaded.
144
+ const u = platform('undo')
145
+ if (u && typeof u.on === 'function') {
146
+ const onRevert = () => {
147
+ if (state.ctx !== ctx) return
148
+ resyncForm(ctx, 'undo')
149
+ // A reverted change the consumer persists through onChange (e.g. the
150
+ // collection dashboard PUTs the record, not the page) would otherwise lag
151
+ // the undo: refreshForm re-syncs the form but never fires onChange, and a
152
+ // commit() here would be fingerprint-skipped (refreshForm already updated
153
+ // it). Extract the reverted state from the PAGE (the source of truth after
154
+ // an undo/redo; the form can lag a morph) and fire onChange directly, but
155
+ // only when it actually changed — guards spurious PUTs on undo/redo of
156
+ // unrelated edits elsewhere on the page.
157
+ const data = coerceBooleans(
158
+ engine.extract(ctx.pageRoot, ctx.pageRules, ENGINE_OPTS),
159
+ ctx.pageRules
160
+ )
161
+ if (stableStringify(data) !== stableStringify(ctx.lastData)) {
162
+ ctx.lastData = data
163
+ ctx.onChange?.(data, { path: '', structural: false })
164
+ }
165
+ }
166
+ u.on('undo', onRevert)
167
+ u.on('redo', onRevert)
168
+ ctx.undoUnsub = () => { u.off('undo', onRevert); u.off('redo', onRevert) }
169
+ }
170
+
171
+ // Live-sync / version-restore / any framework-applied full-document morph runs
172
+ // inside window.hyperclay.Mutation.pause(), which the source-blind observer
173
+ // above is deaf to — so without this the form stays stale after a remote edit
174
+ // and the next local commit re-applies the stale data, clobbering the remote
175
+ // change. Subscribe to the morph-applied signal live-sync emits and re-extract
176
+ // with ignoreActiveValue:true (preserve the user's in-progress field). Same
177
+ // document live-sync dispatches on; unsubscribed in teardownSession.
178
+ const onLivesync = () => resyncForm(ctx, 'livesync')
179
+ ctx.livesyncUnsub = onPlatformEvent(ctx.doc, LIVESYNC_APPLIED, onLivesync)
180
+
181
+ // Wire global sortable callback to current ctx (cleared by teardownSession).
182
+ globalCommitTarget.ctx = ctx
183
+ installGlobalSortableCommit(ctx.doc)
184
+ }
185
+
186
+ // Re-sync the view to the page after a change hypercms did NOT originate.
187
+ // `reason` selects whether the field the user is currently typing in snaps:
188
+ // 'undo' / 'redo' — the user's own deliberate action, so the focused field
189
+ // SHOULD snap to the reverted value (ignoreActiveValue:false).
190
+ // 'livesync' — someone else's / the framework's change, so the user's
191
+ // in-progress focused field must NOT be yanked away
192
+ // (ignoreActiveValue:true). morphForm re-extracts every
193
+ // other field to the new page value, killing the stale-form
194
+ // clobber, while leaving the active field alone until blur.
195
+ // Both reasons route through the view's one refresh entry point.
196
+ function resyncForm(ctx, reason) {
197
+ // open() may have moved on (closed, or reopened with a new ctx) between an
198
+ // async morph completing and this firing.
199
+ if (state.ctx !== ctx) return
200
+ ctx.view.refresh(reason)
201
+ }
202
+
203
+ // The one teardown. close(), the failed-open path inside open(), and (later) a
204
+ // view switch all route through it; the options say only which of the three
205
+ // close-specific side effects apply.
206
+ export function teardownSession(ctx, { dispatch = true, restoreFocus = true, updateUrl = true, reason = 'close' } = {}) {
207
+ if (!ctx || ctx.closed) return
208
+ // Mark this ctx dead so any in-flight async work (e.g. an upload awaiting the
209
+ // host uploader) bails instead of mutating the page / firing onChange after
210
+ // teardown. Mirrors the state.ctx !== ctx guards on the undo/livesync paths.
211
+ ctx.closed = true
212
+ // An upload still in flight is torn down like any other subscription. The
213
+ // ctx.closed check above already stops it writing; this stops it running.
214
+ for (const controller of ctx.uploads || []) { try { controller.abort() } catch {} }
215
+ ctx.uploads?.clear()
216
+ const previouslyFocused = ctx.previouslyFocused
217
+ if (dispatch) ctx.dispatch('hcms:close', { reason })
218
+ if (updateUrl) toggleCloseParam()
219
+ ctx.observerHandle?.unsubscribe?.()
220
+ ctx.undoUnsub?.()
221
+ ctx.livesyncUnsub?.()
222
+ ctx.detachEvents?.()
223
+ // destroy() removes the chrome-only body classes; suppress so the inverse
224
+ // class removal doesn't enter the undo stack either.
225
+ suppressUndo(() => ctx.view.destroy())
226
+ clearState()
227
+ if (restoreFocus && typeof previouslyFocused?.focus === 'function') {
228
+ try { previouslyFocused.focus() } catch (_) {}
229
+ }
230
+ }
231
+
232
+ function clearState() {
233
+ state.isOpen = false
234
+ state.ctx = null
235
+ state.opts = null
236
+ globalCommitTarget.ctx = null
237
+ }
238
+
239
+ const globalCommitTarget = { ctx: null }
240
+
241
+ function installGlobalSortableCommit(doc) {
242
+ const win = doc.defaultView || (typeof globalThis !== 'undefined' ? globalThis : null)
243
+ if (!win) return
244
+ const hypercmsCommitFn = function hypercmsCommitGlobal() {
245
+ const ctx = globalCommitTarget.ctx
246
+ if (!ctx) return
247
+ restampAllSiblings(ctx.formRoot)
248
+ // Route through commitWithUndo so a drag-reorder lands as a labeled
249
+ // 'Reorder' commit (not a generic idle 'Edit') and a failed apply's
250
+ // mutate+rollback is never recorded as a no-op commit. No-op pass-through
251
+ // when undo isn't loaded.
252
+ return commitWithUndo('Reorder', () =>
253
+ commit(extractFormData(ctx), { path: '', structural: true }, ctx)
254
+ )
255
+ }
256
+ if (typeof win.hypercmsCommit !== 'function') win.hypercmsCommit = hypercmsCommitFn
257
+ // Also mirror to globalThis so non-window contexts (Node tests, workers)
258
+ // can resolve the bare name from `new Function('hypercmsCommit()')`.
259
+ if (typeof globalThis !== 'undefined' && typeof globalThis.hypercmsCommit !== 'function') {
260
+ globalThis.hypercmsCommit = hypercmsCommitFn
261
+ }
262
+ }
263
+
264
+ // The query param that drives ?cms=true auto-open. Closing the CMS rewrites it
265
+ // to cms=false (param kept) so a refresh/share of the post-close URL does not
266
+ // auto-reopen.
267
+ const AUTO_OPEN_PARAM = 'cms'
268
+
269
+ // Pure: given a location.search string, return the search string after a close.
270
+ // Only rewrites when cms=true is present; every other param + ordering is
271
+ // preserved, and a search without cms (or already cms=false) is returned
272
+ // unchanged so we never inject the param into a page that never carried it.
273
+ export function nextSearchAfterClose(search) {
274
+ const str = typeof search === 'string' ? search : ''
275
+ const qIndex = str.indexOf('?')
276
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
277
+ if (!query) return str
278
+ const params = new URLSearchParams(query)
279
+ if (params.get(AUTO_OPEN_PARAM) !== 'true') return str
280
+ params.set(AUTO_OPEN_PARAM, 'false')
281
+ return '?' + params.toString()
282
+ }
283
+
284
+ // Pure: should the CMS auto-open for this location.search? Exactly 'true'.
285
+ export function shouldAutoOpenFromSearch(search) {
286
+ const str = typeof search === 'string' ? search : ''
287
+ const qIndex = str.indexOf('?')
288
+ const query = qIndex === -1 ? str : str.slice(qIndex + 1)
289
+ if (!query) return false
290
+ return new URLSearchParams(query).get(AUTO_OPEN_PARAM) === 'true'
291
+ }
292
+
293
+ // Rewrite ?cms=true → ?cms=false via history.replaceState (no reload, hash and
294
+ // every other param preserved). No-op when the param isn't present or the
295
+ // History API is unavailable.
296
+ function toggleCloseParam() {
297
+ if (typeof window === 'undefined' || !window.location || !window.history) return
298
+ if (typeof window.history.replaceState !== 'function') return
299
+ const current = window.location.search
300
+ const next = nextSearchAfterClose(current)
301
+ if (next === current) return
302
+ window.history.replaceState(window.history.state, '', next + window.location.hash)
303
+ }
package/src/shell.js CHANGED
@@ -1,5 +1,10 @@
1
1
  const SHELL_STYLE_ID = 'hcms-shell-styles'
2
2
  const SHELL_BUNDLED_FLAG = 'hcms-bundled-styles-installed'
3
+ // "A session of either kind is open", so a page (or the toggle) can style off
4
+ // open state without reaching for hcms-open, which describes only this panel's
5
+ // page shift and is absent for an inline session. Additive: hcms-open keeps its
6
+ // meaning exactly. Both views add it on mount and drop it on destroy.
7
+ export const SESSION_OPEN_CLASS = 'hcms-session-open'
3
8
  const FOCUSABLE = 'a[href], area[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
4
9
 
5
10
  const styledDocs = new WeakSet()
@@ -17,6 +22,14 @@ export function markStylesBundled(doc) {
17
22
  doc[SHELL_BUNDLED_FLAG] = true
18
23
  }
19
24
 
25
+ export function markSessionOpen(doc) {
26
+ doc?.body?.classList.add(SESSION_OPEN_CLASS)
27
+ }
28
+
29
+ export function clearSessionOpen(doc) {
30
+ doc?.body?.classList.remove(SESSION_OPEN_CLASS)
31
+ }
32
+
20
33
  let titleIdCounter = 0
21
34
 
22
35
  function escapeHtml(s) {
@@ -39,6 +52,7 @@ export function mountShell({
39
52
  const titleId = `hcms-shell-title-${++titleIdCounter}`
40
53
  const root = doc.createElement('div')
41
54
  root.setAttribute('data-hcms-shell', '')
55
+ root.setAttribute('editor-ui', '')
42
56
  root.setAttribute('save-remove', '')
43
57
  root.setAttribute('save-ignore', '')
44
58
  root.setAttribute('tabindex', '-1')
@@ -52,7 +66,7 @@ export function mountShell({
52
66
  // (otherwise the panel follows the OS via prefers-color-scheme).
53
67
  const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
54
68
  root.className =
55
- 'hcms-shell pixel-quiet hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
69
+ 'hcms-shell pixel-quiet hcms-panel hcms-side-' + side + (overlay ? ' hcms-overlay' : '') + themeClass
56
70
  const titleHtml = escapeHtml(title)
57
71
  const eyebrowHtml = escapeHtml(eyebrow)
58
72
  // Pixel Quiet shell: a scroll-away header + in-flow Save inside .hcms-shell-body,
@@ -98,6 +112,7 @@ export function mountShell({
98
112
 
99
113
  const body = doc.body
100
114
  body.classList.add('hcms-open')
115
+ markSessionOpen(doc)
101
116
  if (overlay) body.classList.add('hcms-overlay')
102
117
  if (side === 'left') body.classList.add('hcms-side-left')
103
118
 
@@ -115,6 +130,7 @@ export function mountShell({
115
130
  condense.detach()
116
131
  root.remove()
117
132
  body.classList.remove('hcms-open', 'hcms-overlay', 'hcms-side-left')
133
+ clearSessionOpen(doc)
118
134
  },
119
135
  // Re-assert the shell's out-of-subtree chrome after a full-document morph
120
136
  // (e.g. a live-sync apply) wipes it: the stylesheet lives in <head> and the
@@ -123,6 +139,7 @@ export function mountShell({
123
139
  restoreChrome() {
124
140
  reensureStyles(doc)
125
141
  body.classList.add('hcms-open')
142
+ markSessionOpen(doc)
126
143
  if (overlay) body.classList.add('hcms-overlay')
127
144
  if (side === 'left') body.classList.add('hcms-side-left')
128
145
  },
@@ -184,11 +201,15 @@ function ensureStyles(doc) {
184
201
  // No bundled CSS and no resolvable co-located theme (e.g. the IIFE bundle,
185
202
  // where import.meta is undefined). Warn loudly rather than mount a silently
186
203
  // unstyled shell — the host must call installStyles(themeText) before opening.
187
- console.warn(
188
- 'hypercms: shell stylesheet not applied — cssText is empty and the ' +
189
- 'co-located theme fallback is unavailable. Call installStyles(themeText) ' +
190
- 'before opening the CMS.'
191
- )
204
+ const warn = () => {
205
+ if (!link.isConnected) return
206
+ console.warn(
207
+ 'hypercms: shell stylesheet not applied — cssText is empty and the ' +
208
+ 'co-located theme fallback is unavailable. Call installStyles(themeText) ' +
209
+ 'before opening the CMS.'
210
+ )
211
+ }
212
+ doc.defaultView?.queueMicrotask ? doc.defaultView.queueMicrotask(warn) : warn()
192
213
  }
193
214
  }
194
215
 
package/src/targets.js ADDED
@@ -0,0 +1,149 @@
1
+ // Every editable thing on the page, as the inline view needs to see it: the
2
+ // element a rule resolved to, the real data path that reaches it, and which
3
+ // affordance belongs on it.
4
+ //
5
+ // This is the same walk `unresolved.js` does, with the opposite output. That one
6
+ // reports paths as strings and collapses every row of a list to a single '*',
7
+ // because it answers "which fields are broken" once for the whole page. The
8
+ // inline view needs the resolved element and the real row index, because it puts
9
+ // a control on each one.
10
+
11
+ import { createDomAdapter } from 'hyper-html-api/dom'
12
+ import { ruleAttrIndex } from 'hyper-html-api/engine'
13
+
14
+ const FIND_OPTS = { skip: '[data-hcms-shell]', templateAttr: 'cms-template' }
15
+ // templateAttr:null includes the hidden [cms-template] seed. A list that has been
16
+ // emptied down to its seed still has a place on the page — the seed is sitting in
17
+ // it — and that is the one list most in need of an "+ Add" row. Finding the seed
18
+ // is the only way to know where the row goes, so an empty list resolves its
19
+ // container this way and nothing else uses these opts.
20
+ const SEED_OPTS = { skip: '[data-hcms-shell]', templateAttr: null }
21
+
22
+ // How someone changes this field, in place:
23
+ //
24
+ // 'text' the page element IS the affordance. richclay binds to it and the
25
+ // text is edited where it sits; commit on blur.
26
+ // 'native' the page already carries the control — an enabled input, textarea
27
+ // or select bound through @value. Add nothing at all: let them use
28
+ // the control that is already there and commit on change. A readonly
29
+ // or disabled one is NOT this: the author disabled it on purpose, so
30
+ // it falls through to a handle rather than being quietly re-enabled.
31
+ // 'handle' everything else gets a small handle over the anchor's corner,
32
+ // which opens the popover holding that field's real form control.
33
+ const TEXT = 'text'
34
+ const NATIVE = 'native'
35
+ const HANDLE = 'handle'
36
+
37
+ const NATIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT'])
38
+
39
+ // Elements that cannot hold editable text, either because they are void or
40
+ // replaced, or because their text content is their value and belongs to the
41
+ // control rather than to the document. A bare rule pointing at one of these is
42
+ // unusual but legal (it extracts ''), and binding a rich text editor to it would
43
+ // put a caret inside an <img>. They get a handle like anything else.
44
+ const NON_TEXT_TAGS = new Set([
45
+ 'IMG', 'INPUT', 'TEXTAREA', 'SELECT', 'OPTION', 'BR', 'HR', 'VIDEO', 'AUDIO',
46
+ 'IFRAME', 'EMBED', 'OBJECT', 'CANVAS', 'SOURCE', 'TRACK', 'AREA', 'COL', 'PARAM',
47
+ 'BUTTON',
48
+ ])
49
+
50
+ export function resolveTargets(root, rules) {
51
+ return resolvePageTargets(root, rules)
52
+ }
53
+
54
+ function resolvePageTargets(root, rules) {
55
+ const targets = []
56
+ const lists = []
57
+ const adapter = createDomAdapter(root)
58
+ walk(adapter, root, rules, [], targets, lists)
59
+ return { targets, lists }
60
+ }
61
+
62
+ function walk(adapter, ctx, rule, path, targets, lists) {
63
+ if (typeof rule === 'string') {
64
+ // A scalar array ("ul.tags li[]"): every match is its own editable item, and
65
+ // the run of them is a list that can be added to and removed from.
66
+ if (rule.endsWith('[]')) {
67
+ const selector = rule.slice(0, -2)
68
+ if (!selector) return
69
+ const items = find(adapter, ctx, selector)
70
+ items.forEach((el, i) => {
71
+ targets.push(describe([...path, i], el, rule, null))
72
+ })
73
+ lists.push(makeList(adapter, ctx, path, selector, items, true))
74
+ return
75
+ }
76
+ const at = ruleAttrIndex(rule)
77
+ const attr = at === -1 ? null : rule.slice(at + 1)
78
+ const selector = at === -1 ? rule : rule.slice(0, at)
79
+ // "." and a bare "@attr" both address the context node itself, so they can
80
+ // never fail to resolve and there is nothing to query.
81
+ const el = !selector || selector === '.' ? ctx : find(adapter, ctx, selector)[0]
82
+ if (el) targets.push(describe(path, el, rule, attr))
83
+ return
84
+ }
85
+
86
+ if (Array.isArray(rule)) {
87
+ const [selector, shape] = rule
88
+ if (typeof selector !== 'string' || !selector) return
89
+ const items = find(adapter, ctx, selector)
90
+ items.forEach((el, i) => walk(adapter, el, shape, [...path, i], targets, lists))
91
+ lists.push(makeList(adapter, ctx, path, selector, items, typeof shape === 'string'))
92
+ return
93
+ }
94
+
95
+ if (rule && typeof rule === 'object') {
96
+ for (const [key, sub] of Object.entries(rule)) walk(adapter, ctx, sub, [...path, key], targets, lists)
97
+ }
98
+ }
99
+
100
+ // `container` is where a new row goes. With rows present it is their shared
101
+ // parent; with none it comes from the seed, which is why an emptied list can
102
+ // still be grown back.
103
+ function makeList(adapter, ctx, path, selector, items, scalar) {
104
+ let container = items[0] ? items[0].parentElement : null
105
+ if (!container) {
106
+ const seed = find(adapter, ctx, selector, SEED_OPTS)[0]
107
+ container = seed ? seed.parentElement : null
108
+ }
109
+ return { path, items, container, scalar }
110
+ }
111
+
112
+ function describe(path, el, rule, attr) {
113
+ return { path, el, rule, attr, kind: kindOf(el, attr), icon: iconOf(el, attr) }
114
+ }
115
+
116
+ function kindOf(el, attr) {
117
+ const tag = (el.tagName || '').toUpperCase()
118
+ if (!attr || attr === 'innerHTML') return NON_TEXT_TAGS.has(tag) ? HANDLE : TEXT
119
+ if (attr === 'value' && NATIVE_TAGS.has(tag) && !el.readOnly && !isEffectivelyDisabled(el)) return NATIVE
120
+ return HANDLE
121
+ }
122
+
123
+ // el.disabled is false for an input inside <fieldset disabled>, but :disabled
124
+ // matches it and the browser will not let anyone type in it. Checking only the
125
+ // property leaves an unusable field with no control at all.
126
+ function isEffectivelyDisabled(el) {
127
+ if (el.disabled) return true
128
+ return typeof el.matches === 'function' && el.matches(':disabled')
129
+ }
130
+
131
+ function iconOf(el, attr) {
132
+ const tag = (el.tagName || '').toUpperCase()
133
+ if ((!attr || attr === 'innerHTML') && !NON_TEXT_TAGS.has(tag)) return null
134
+ if (tag === 'IMG' || attr === 'srcset') return 'camera'
135
+ if (tag === 'A' && attr === 'href') return 'paperclip'
136
+ return 'pencil'
137
+ }
138
+
139
+ function find(adapter, ctx, selector, opts = FIND_OPTS) {
140
+ try {
141
+ return adapter.find(ctx, selector, opts)
142
+ } catch (_) {
143
+ // unresolved.js already turns an invalid selector into a named
144
+ // InvalidRuleSelector before the first extract, so by the time the inline
145
+ // view walks, a throw here would be a duplicate of an error the session has
146
+ // already surfaced. Skip the rule instead of taking the whole view down.
147
+ return []
148
+ }
149
+ }