@webwriter/widget 0.0.0-stage → 1.0.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Frederic Salmen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/index.js ADDED
@@ -0,0 +1,308 @@
1
+ // index.ts
2
+ var javaScriptMimeTypes = /* @__PURE__ */ new Set([
3
+ "application/ecmascript",
4
+ "application/javascript",
5
+ "application/x-ecmascript",
6
+ "application/x-javascript",
7
+ "text/ecmascript",
8
+ "text/javascript",
9
+ "text/javascript1.0",
10
+ "text/javascript1.1",
11
+ "text/javascript1.2",
12
+ "text/javascript1.3",
13
+ "text/javascript1.4",
14
+ "text/javascript1.5",
15
+ "text/jscript",
16
+ "text/livescript",
17
+ "text/x-ecmascript",
18
+ "text/x-javascript"
19
+ ]);
20
+ function isDataBlockType(type) {
21
+ const essence = type.split(";")[0].trim().toLowerCase();
22
+ return /^[^\s/]+\/[^\s/]+$/.test(essence) && !javaScriptMimeTypes.has(essence);
23
+ }
24
+ var contextFollowers = /* @__PURE__ */ new WeakMap();
25
+ var dataEvents = ["datapending", "datachange", "dataerror", "datarelease"];
26
+ var widgetRuntimeAttributes = ["contenteditable", "lang", "shared", "id", "standalone"];
27
+ function widgetLanguage(host) {
28
+ return host.closest("[lang]")?.getAttribute("lang") ?? "";
29
+ }
30
+ var WidgetRuntime = class {
31
+ constructor(host, callbacks = {}) {
32
+ this.host = host;
33
+ this.callbacks = callbacks;
34
+ }
35
+ #observedDocument;
36
+ #language;
37
+ #editable;
38
+ #listeners = /* @__PURE__ */ new Set();
39
+ #snapshot;
40
+ get lang() {
41
+ return this.callbacks.language?.() ?? widgetLanguage(this.host);
42
+ }
43
+ get editable() {
44
+ return this.callbacks.editable?.() ?? this.host.isContentEditable;
45
+ }
46
+ get standalone() {
47
+ return this.host.getAttribute("standalone");
48
+ }
49
+ set standalone(value) {
50
+ if (value === null) this.host.removeAttribute("standalone");
51
+ else this.host.setAttribute("standalone", value);
52
+ }
53
+ /** The same snapshot object is returned until an observed value changes. */
54
+ getSnapshot = () => {
55
+ const dataBlock = this.dataBlock;
56
+ const previous = this.#snapshot;
57
+ const attributes = Object.fromEntries((this.callbacks.attributes?.() ?? []).map((name) => [name, this.host.getAttribute(name)]));
58
+ const sameAttributes = previous && Object.keys(attributes).length === Object.keys(previous.attributes).length && Object.entries(attributes).every(([name, value]) => previous.attributes[name] === value);
59
+ const next = {
60
+ editable: this.editable,
61
+ lang: this.lang,
62
+ standalone: this.standalone,
63
+ attributes: sameAttributes ? previous.attributes : Object.freeze(attributes),
64
+ data: dataBlock?.textContent ?? null,
65
+ dataType: this.#typeOf(dataBlock),
66
+ dataBlock,
67
+ dataSlot: dataBlock?.getAttribute("slot") ?? null,
68
+ dataMode: this.#mode,
69
+ dataReady: this.#isDataReady(dataBlock),
70
+ dataError: this.#error
71
+ };
72
+ if (previous && Object.entries(next).every(([key, value]) => previous[key] === value)) return previous;
73
+ return this.#snapshot = Object.freeze(next);
74
+ };
75
+ /** Subscribe to host updates and return an unsubscribe function. */
76
+ subscribe = (listener) => {
77
+ this.#listeners.add(listener);
78
+ return () => {
79
+ this.#listeners.delete(listener);
80
+ };
81
+ };
82
+ #publish(name, previous) {
83
+ this.callbacks.update?.(name, previous);
84
+ this.#listeners.forEach((listener) => listener());
85
+ }
86
+ attributeChanged(name, oldValue, value) {
87
+ if (oldValue === value) return;
88
+ if (name === "lang" || name === "contenteditable") {
89
+ this.contextChanged();
90
+ if (this.callbacks.attributes?.().includes(name)) this.#publish(name, oldValue);
91
+ } else if (name === "shared" || name === "id") {
92
+ this.#readyBlock = null;
93
+ if (this.host.isConnected) this.#syncData();
94
+ else if (this.callbacks.attributes?.().includes(name)) this.#publish(name, oldValue);
95
+ } else this.#publish(name, oldValue);
96
+ }
97
+ contextChanged() {
98
+ const language = this.lang, editable = this.editable;
99
+ if (language !== this.#language) {
100
+ const before = this.#language;
101
+ this.#language = language;
102
+ this.#publish("lang", before);
103
+ }
104
+ if (editable !== this.#editable) {
105
+ const before = this.#editable;
106
+ this.#editable = editable;
107
+ this.#publish("editable", before);
108
+ }
109
+ }
110
+ get dataBlock() {
111
+ const blocks = Array.from(this.host.children).filter((child) => child.namespaceURI === "http://www.w3.org/1999/xhtml" && child.localName === "script" && isDataBlockType(child.type) && !child.hasAttribute("src"));
112
+ return blocks.find((block) => this.#scoped(block)) ?? blocks[0] ?? null;
113
+ }
114
+ #scoped(block) {
115
+ return !!block && block.getAttribute("slot") === "data" && ["application/json", "application/xml"].includes(block.type);
116
+ }
117
+ #scope(block) {
118
+ return JSON.stringify([
119
+ this.host.ownerDocument.documentElement.id,
120
+ this.host.id,
121
+ this.host.getAttribute("shared"),
122
+ block?.type,
123
+ block?.getAttribute("slot"),
124
+ ...Array.from(this.host.childNodes).filter((node) => node.nodeType === 7 && node.nodeName === "ww-grouping").map((node) => node.nodeValue)
125
+ ]);
126
+ }
127
+ get data() {
128
+ return this.dataBlock?.textContent ?? null;
129
+ }
130
+ set data(value) {
131
+ this.setData(this.dataType ?? "text/plain", value ?? "");
132
+ }
133
+ get dataType() {
134
+ return this.#typeOf(this.dataBlock);
135
+ }
136
+ #typeOf(block) {
137
+ return block?.type ?? this.host.constructor.dataType;
138
+ }
139
+ #block;
140
+ #bindingScope;
141
+ #readyBlock = null;
142
+ #readyScope;
143
+ #managed = false;
144
+ #lastText;
145
+ #mode = "local";
146
+ #syncing = false;
147
+ #contentObserver;
148
+ #error = null;
149
+ /** False while the current participant scope is loading or unavailable. */
150
+ get dataReady() {
151
+ return this.#isDataReady(this.dataBlock);
152
+ }
153
+ #isDataReady(block) {
154
+ if (!this.host.isConnected) return false;
155
+ if (!this.#scoped(block)) return true;
156
+ const scope = this.#scope(block);
157
+ return block === this.#block && this.#bindingScope === scope && (!this.#managed || block === this.#readyBlock && this.#readyScope === scope);
158
+ }
159
+ #context(block = this.dataBlock) {
160
+ return { block, mode: this.#mode };
161
+ }
162
+ #syncData() {
163
+ if (!this.host.isConnected || this.#syncing) return;
164
+ this.#syncing = true;
165
+ try {
166
+ const block = this.dataBlock, scope = this.#scope(block);
167
+ if (block !== this.#block || scope !== this.#bindingScope) {
168
+ this.#block = block;
169
+ this.#bindingScope = scope;
170
+ this.#readyBlock = null;
171
+ this.#error = null;
172
+ this.#managed = false;
173
+ this.#mode = "local";
174
+ if (this.#scoped(block)) {
175
+ const Request = this.host.ownerDocument.defaultView?.CustomEvent ?? CustomEvent;
176
+ const request = new Request("datarequest", { bubbles: true, cancelable: true });
177
+ if (!block.dispatchEvent(request)) this.#managed = true;
178
+ }
179
+ if (!this.#managed) this.#acceptData("local");
180
+ } else if (!this.#managed && this.data !== this.#lastText) this.#acceptData("local");
181
+ this.#publish("data");
182
+ } finally {
183
+ this.#syncing = false;
184
+ }
185
+ }
186
+ #acceptData(mode) {
187
+ this.#mode = mode;
188
+ this.#readyBlock = this.dataBlock;
189
+ this.#readyScope = this.#scope(this.#readyBlock);
190
+ this.#lastText = this.data;
191
+ this.#error = null;
192
+ this.callbacks.dataChanged?.(this.data, this.#context());
193
+ this.#publish("data");
194
+ }
195
+ #dataEvent = (event) => {
196
+ const block = this.dataBlock;
197
+ if (!this.host.isConnected || !block || event.target !== block || !this.#scoped(block)) return;
198
+ if (event.type === "datarelease") {
199
+ this.#block = void 0;
200
+ this.#syncData();
201
+ return;
202
+ }
203
+ const detail = event.detail;
204
+ this.#managed = true;
205
+ this.#block = block;
206
+ this.#bindingScope = this.#scope(block);
207
+ if (detail?.mode) this.#mode = detail.mode;
208
+ else if (this.#mode === "local") this.#mode = !this.host.hasAttribute("shared") ? "individual" : this.host.getAttribute("shared") === "group" ? "group" : "all";
209
+ if (event.type === "datachange") this.#acceptData(this.#mode);
210
+ else {
211
+ if (event.type === "datapending") {
212
+ this.#readyBlock = null;
213
+ this.#error = null;
214
+ this.callbacks.dataPending?.(this.#context(block));
215
+ } else {
216
+ this.#error = new Error(detail?.message ?? "Widget data is unavailable");
217
+ this.callbacks.dataError?.(this.#error, this.#context(block));
218
+ }
219
+ this.#publish("data");
220
+ }
221
+ };
222
+ /** Updates a data block in place, preserving its identity and attributes.
223
+ * A managed block cannot be written until its current scope is ready. */
224
+ setData(type, value, attrs = {}) {
225
+ if (!isDataBlockType(type)) throw new TypeError(`'${type}' is not a data block type`);
226
+ if (Object.keys(attrs).some((name) => name.toLowerCase() === "src")) throw new TypeError("Data blocks cannot have src");
227
+ this.#syncData();
228
+ const old = this.dataBlock;
229
+ if (old && this.#managed && !this.dataReady) throw new DOMException("Wait for the current data scope to become ready", "InvalidStateError");
230
+ const text = type === "application/json" ? value.replace(/</g, "\\u003c") : value;
231
+ if (/<\/script[\s/>]/i.test(text)) throw new TypeError("Data blocks cannot contain a closing script tag");
232
+ const block = old ?? this.host.ownerDocument.createElement("script");
233
+ for (const [name, value2] of Object.entries(attrs)) block.setAttribute(name, value2);
234
+ block.type = type;
235
+ block.textContent = text;
236
+ if (!old) this.host.append(block);
237
+ this.#publish("data");
238
+ }
239
+ connect() {
240
+ const { host } = this;
241
+ if (this.#observedDocument) return;
242
+ const doc = this.#observedDocument = host.ownerDocument;
243
+ const Observer = doc.defaultView?.MutationObserver ?? MutationObserver;
244
+ let followers = contextFollowers.get(doc);
245
+ if (!followers) {
246
+ const runtimes = /* @__PURE__ */ new Set();
247
+ const observer = new Observer(() => runtimes.forEach((runtime) => runtime.contextChanged()));
248
+ observer.observe(doc.documentElement, { attributes: true, attributeFilter: ["lang", "contenteditable"], subtree: true });
249
+ followers = { runtimes, observer };
250
+ contextFollowers.set(doc, followers);
251
+ }
252
+ followers.runtimes.add(this);
253
+ this.#language = void 0;
254
+ this.contextChanged();
255
+ const events = doc.defaultView?.EventTarget.prototype ?? EventTarget.prototype;
256
+ for (const type of dataEvents) events.addEventListener.call(host, type, this.#dataEvent);
257
+ this.#contentObserver = new Observer(() => this.#syncData());
258
+ this.#contentObserver.observe(host, { childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["type", "src", "slot"] });
259
+ this.#block = void 0;
260
+ this.#syncData();
261
+ }
262
+ disconnect() {
263
+ const doc = this.#observedDocument;
264
+ if (!doc) return;
265
+ this.#observedDocument = void 0;
266
+ this.#contentObserver?.disconnect();
267
+ this.#contentObserver = void 0;
268
+ const events = doc.defaultView?.EventTarget.prototype ?? EventTarget.prototype;
269
+ for (const type of dataEvents) events.removeEventListener.call(this.host, type, this.#dataEvent);
270
+ this.#readyBlock = null;
271
+ const followers = contextFollowers.get(doc);
272
+ followers?.runtimes.delete(this);
273
+ if (followers && !followers.runtimes.size) {
274
+ followers.observer.disconnect();
275
+ contextFollowers.delete(doc);
276
+ }
277
+ }
278
+ };
279
+ function withWidgetRuntime(Base) {
280
+ return class WidgetElement extends Base {
281
+ static widgetAttributes = [];
282
+ static get observedAttributes() {
283
+ return [.../* @__PURE__ */ new Set([...super.observedAttributes ?? [], ...widgetRuntimeAttributes, ...this.widgetAttributes])];
284
+ }
285
+ widgetRuntime = new WidgetRuntime(this, {
286
+ attributes: () => this.constructor.widgetAttributes
287
+ });
288
+ connectedCallback() {
289
+ this.widgetRuntime.connect();
290
+ super.connectedCallback?.();
291
+ }
292
+ disconnectedCallback() {
293
+ this.widgetRuntime.disconnect();
294
+ super.disconnectedCallback?.();
295
+ }
296
+ attributeChangedCallback(name, oldValue, value) {
297
+ if (Base.observedAttributes?.includes(name)) super.attributeChangedCallback?.(name, oldValue, value);
298
+ this.widgetRuntime.attributeChanged(name, oldValue, value);
299
+ }
300
+ };
301
+ }
302
+ export {
303
+ WidgetRuntime,
304
+ isDataBlockType,
305
+ widgetLanguage,
306
+ widgetRuntimeAttributes,
307
+ withWidgetRuntime
308
+ };
package/index.ts ADDED
@@ -0,0 +1,349 @@
1
+ import type {LocalizedText} from "./options"
2
+
3
+ export type * from "./options"
4
+
5
+ /** MIME type essences browsers execute as classic scripts. */
6
+ const javaScriptMimeTypes = new Set([
7
+ "application/ecmascript", "application/javascript", "application/x-ecmascript", "application/x-javascript",
8
+ "text/ecmascript", "text/javascript", "text/javascript1.0", "text/javascript1.1", "text/javascript1.2",
9
+ "text/javascript1.3", "text/javascript1.4", "text/javascript1.5", "text/jscript", "text/livescript",
10
+ "text/x-ecmascript", "text/x-javascript",
11
+ ])
12
+
13
+ /** Whether a script `type` is an inert data block type, such as `application/json`. WebWriter keeps such `<script>` children of a widget as its data containers. */
14
+ export function isDataBlockType(type: string) {
15
+ const essence = type.split(";")[0].trim().toLowerCase()
16
+ return /^[^\s/]+\/[^\s/]+$/.test(essence) && !javaScriptMimeTypes.has(essence)
17
+ }
18
+
19
+ export type WidgetDataMode = "individual" | "all" | "group" | "local"
20
+ export type WidgetDataContext = {block: HTMLScriptElement | null, mode: WidgetDataMode}
21
+
22
+ export type WidgetSnapshot = Readonly<{
23
+ editable: boolean
24
+ lang: string
25
+ standalone: string | null
26
+ attributes: Readonly<Record<string, string | null>>
27
+ data: string | null
28
+ dataType: string | undefined
29
+ dataBlock: HTMLScriptElement | null
30
+ dataSlot: string | null
31
+ dataMode: WidgetDataMode
32
+ dataReady: boolean
33
+ dataError: Error | null
34
+ }>
35
+
36
+ type WidgetRuntimeCallbacks = {
37
+ attributes?: () => readonly string[]
38
+ language?: () => string
39
+ editable?: () => boolean
40
+ update?: (name?: string, previous?: unknown) => void
41
+ dataChanged?: (value: string | null, context: WidgetDataContext) => void
42
+ dataPending?: (context: WidgetDataContext) => void
43
+ dataError?: (error: Error, context: WidgetDataContext) => void
44
+ }
45
+
46
+ type ContextFollowers = {runtimes: Set<WidgetRuntime>, observer: MutationObserver}
47
+ const contextFollowers = new WeakMap<Document, ContextFollowers>()
48
+ const dataEvents = ["datapending", "datachange", "dataerror", "datarelease"] as const
49
+ export const widgetRuntimeAttributes: readonly string[] = ["contenteditable", "lang", "shared", "id", "standalone"]
50
+
51
+ export function widgetLanguage(host: HTMLElement) {
52
+ return host.closest("[lang]")?.getAttribute("lang") ?? ""
53
+ }
54
+
55
+ /** Observes an authored custom element. Snapshots are read-only views of its DOM. */
56
+ export class WidgetRuntime {
57
+ constructor(readonly host: HTMLElement, private readonly callbacks: WidgetRuntimeCallbacks = {}) {}
58
+
59
+ #observedDocument?: Document
60
+ #language?: string
61
+ #editable?: boolean
62
+ #listeners = new Set<() => void>()
63
+ #snapshot?: WidgetSnapshot
64
+
65
+ get lang() { return this.callbacks.language?.() ?? widgetLanguage(this.host) }
66
+ get editable() { return this.callbacks.editable?.() ?? this.host.isContentEditable }
67
+
68
+ get standalone() { return this.host.getAttribute("standalone") }
69
+ set standalone(value: string | null) {
70
+ if(value === null) this.host.removeAttribute("standalone")
71
+ else this.host.setAttribute("standalone", value)
72
+ }
73
+
74
+ /** The same snapshot object is returned until an observed value changes. */
75
+ readonly getSnapshot = (): WidgetSnapshot => {
76
+ const dataBlock = this.dataBlock
77
+ const previous = this.#snapshot
78
+ const attributes = Object.fromEntries((this.callbacks.attributes?.() ?? []).map(name => [name, this.host.getAttribute(name)]))
79
+ const sameAttributes = previous && Object.keys(attributes).length === Object.keys(previous.attributes).length
80
+ && Object.entries(attributes).every(([name, value]) => previous.attributes[name] === value)
81
+ const next = {
82
+ editable: this.editable,
83
+ lang: this.lang,
84
+ standalone: this.standalone,
85
+ attributes: sameAttributes ? previous.attributes : Object.freeze(attributes),
86
+ data: dataBlock?.textContent ?? null,
87
+ dataType: this.#typeOf(dataBlock),
88
+ dataBlock,
89
+ dataSlot: dataBlock?.getAttribute("slot") ?? null,
90
+ dataMode: this.#mode,
91
+ dataReady: this.#isDataReady(dataBlock),
92
+ dataError: this.#error,
93
+ }
94
+ if(previous && Object.entries(next).every(([key, value]) => previous[key as keyof WidgetSnapshot] === value)) return previous
95
+ return this.#snapshot = Object.freeze(next)
96
+ }
97
+
98
+ /** Subscribe to host updates and return an unsubscribe function. */
99
+ readonly subscribe = (listener: () => void) => {
100
+ this.#listeners.add(listener)
101
+ return () => { this.#listeners.delete(listener) }
102
+ }
103
+
104
+ #publish(name?: string, previous?: unknown) {
105
+ this.callbacks.update?.(name, previous)
106
+ this.#listeners.forEach(listener => listener())
107
+ }
108
+
109
+ attributeChanged(name: string, oldValue: string | null, value: string | null) {
110
+ if(oldValue === value) return
111
+ if(name === "lang" || name === "contenteditable") {
112
+ this.contextChanged()
113
+ if(this.callbacks.attributes?.().includes(name)) this.#publish(name, oldValue)
114
+ }
115
+ else if(name === "shared" || name === "id") {
116
+ this.#readyBlock = null
117
+ if(this.host.isConnected) this.#syncData()
118
+ else if(this.callbacks.attributes?.().includes(name)) this.#publish(name, oldValue)
119
+ }
120
+ else this.#publish(name, oldValue)
121
+ }
122
+
123
+ contextChanged() {
124
+ const language = this.lang, editable = this.editable
125
+ if(language !== this.#language) {
126
+ const before = this.#language
127
+ this.#language = language
128
+ this.#publish("lang", before)
129
+ }
130
+ if(editable !== this.#editable) {
131
+ const before = this.#editable
132
+ this.#editable = editable
133
+ this.#publish("editable", before)
134
+ }
135
+ }
136
+
137
+ get dataBlock(): HTMLScriptElement | null {
138
+ const blocks = Array.from(this.host.children).filter((child): child is HTMLScriptElement =>
139
+ child.namespaceURI === "http://www.w3.org/1999/xhtml" && child.localName === "script"
140
+ && isDataBlockType((child as HTMLScriptElement).type) && !child.hasAttribute("src"))
141
+ return blocks.find(block => this.#scoped(block)) ?? blocks[0] ?? null
142
+ }
143
+
144
+ #scoped(block: HTMLScriptElement | null) {
145
+ return !!block && block.getAttribute("slot") === "data"
146
+ && ["application/json", "application/xml"].includes(block.type)
147
+ }
148
+
149
+ #scope(block: HTMLScriptElement | null) {
150
+ return JSON.stringify([this.host.ownerDocument.documentElement.id, this.host.id, this.host.getAttribute("shared"),
151
+ block?.type, block?.getAttribute("slot"),
152
+ ...Array.from(this.host.childNodes).filter(node => node.nodeType === 7 && node.nodeName === "ww-grouping").map(node => node.nodeValue)])
153
+ }
154
+
155
+ get data() { return this.dataBlock?.textContent ?? null }
156
+ set data(value: string | null) { this.setData(this.dataType ?? "text/plain", value ?? "") }
157
+ get dataType() { return this.#typeOf(this.dataBlock) }
158
+
159
+ #typeOf(block: HTMLScriptElement | null) {
160
+ return block?.type ?? (this.host.constructor as {dataType?: string}).dataType
161
+ }
162
+
163
+ #block: HTMLScriptElement | null | undefined
164
+ #bindingScope?: string
165
+ #readyBlock: HTMLScriptElement | null = null
166
+ #readyScope?: string
167
+ #managed = false
168
+ #lastText: string | null | undefined
169
+ #mode: WidgetDataMode = "local"
170
+ #syncing = false
171
+ #contentObserver?: MutationObserver
172
+ #error: Error | null = null
173
+
174
+ /** False while the current participant scope is loading or unavailable. */
175
+ get dataReady() { return this.#isDataReady(this.dataBlock) }
176
+
177
+ #isDataReady(block: HTMLScriptElement | null) {
178
+ if(!this.host.isConnected) return false
179
+ if(!this.#scoped(block)) return true
180
+ const scope = this.#scope(block)
181
+ return block === this.#block && this.#bindingScope === scope
182
+ && (!this.#managed || block === this.#readyBlock && this.#readyScope === scope)
183
+ }
184
+
185
+ #context(block = this.dataBlock): WidgetDataContext { return {block, mode: this.#mode} }
186
+
187
+ #syncData() {
188
+ if(!this.host.isConnected || this.#syncing) return
189
+ this.#syncing = true
190
+ try {
191
+ const block = this.dataBlock, scope = this.#scope(block)
192
+ if(block !== this.#block || scope !== this.#bindingScope) {
193
+ this.#block = block
194
+ this.#bindingScope = scope
195
+ this.#readyBlock = null
196
+ this.#error = null
197
+ this.#managed = false
198
+ this.#mode = "local"
199
+ if(this.#scoped(block)) {
200
+ const Request = this.host.ownerDocument.defaultView?.CustomEvent ?? CustomEvent
201
+ // Cancellation acknowledges ownership; an unhandled request means a
202
+ // standalone document using ordinary local authored data.
203
+ const request = new Request("datarequest", {bubbles: true, cancelable: true})
204
+ if(!block!.dispatchEvent(request)) this.#managed = true
205
+ }
206
+ if(!this.#managed) this.#acceptData("local")
207
+ }
208
+ else if(!this.#managed && this.data !== this.#lastText) this.#acceptData("local")
209
+ this.#publish("data")
210
+ }
211
+ finally { this.#syncing = false }
212
+ }
213
+
214
+ #acceptData(mode: WidgetDataMode) {
215
+ this.#mode = mode
216
+ this.#readyBlock = this.dataBlock
217
+ this.#readyScope = this.#scope(this.#readyBlock)
218
+ this.#lastText = this.data
219
+ this.#error = null
220
+ this.callbacks.dataChanged?.(this.data, this.#context())
221
+ this.#publish("data")
222
+ }
223
+
224
+ #dataEvent = (event: Event) => {
225
+ const block = this.dataBlock
226
+ if(!this.host.isConnected || !block || event.target !== block || !this.#scoped(block)) return
227
+ if(event.type === "datarelease") {
228
+ this.#block = undefined
229
+ this.#syncData()
230
+ return
231
+ }
232
+ const detail = (event as CustomEvent<{mode?: WidgetDataMode, message?: string}>).detail
233
+ this.#managed = true
234
+ this.#block = block
235
+ this.#bindingScope = this.#scope(block)
236
+ if(detail?.mode) this.#mode = detail.mode
237
+ else if(this.#mode === "local") this.#mode = !this.host.hasAttribute("shared") ? "individual" : this.host.getAttribute("shared") === "group" ? "group" : "all"
238
+ if(event.type === "datachange") this.#acceptData(this.#mode)
239
+ else {
240
+ if(event.type === "datapending") {
241
+ this.#readyBlock = null
242
+ this.#error = null
243
+ this.callbacks.dataPending?.(this.#context(block))
244
+ }
245
+ else {
246
+ this.#error = new Error(detail?.message ?? "Widget data is unavailable")
247
+ this.callbacks.dataError?.(this.#error, this.#context(block))
248
+ }
249
+ this.#publish("data")
250
+ }
251
+ }
252
+
253
+ /** Updates a data block in place, preserving its identity and attributes.
254
+ * A managed block cannot be written until its current scope is ready. */
255
+ setData(type: string, value: string, attrs: LocalizedText = {}) {
256
+ if(!isDataBlockType(type)) throw new TypeError(`'${type}' is not a data block type`)
257
+ if(Object.keys(attrs).some(name => name.toLowerCase() === "src")) throw new TypeError("Data blocks cannot have src")
258
+ this.#syncData()
259
+ const old = this.dataBlock
260
+ if(old && this.#managed && !this.dataReady) throw new DOMException("Wait for the current data scope to become ready", "InvalidStateError")
261
+ const text = type === "application/json" ? value.replace(/</g, "\\u003c") : value
262
+ if(/<\/script[\s/>]/i.test(text)) throw new TypeError("Data blocks cannot contain a closing script tag")
263
+ const block = old ?? this.host.ownerDocument.createElement("script")
264
+ for(const [name, value] of Object.entries(attrs)) block.setAttribute(name, value)
265
+ block.type = type
266
+ block.textContent = text
267
+ if(!old) this.host.append(block)
268
+ this.#publish("data")
269
+ }
270
+
271
+ connect() {
272
+ const {host} = this
273
+ if(this.#observedDocument) return
274
+ const doc = this.#observedDocument = host.ownerDocument
275
+ const Observer = doc.defaultView?.MutationObserver ?? MutationObserver
276
+ let followers = contextFollowers.get(doc)
277
+ if(!followers) {
278
+ const runtimes = new Set<WidgetRuntime>()
279
+ const observer = new Observer(() => runtimes.forEach(runtime => runtime.contextChanged()))
280
+ observer.observe(doc.documentElement, {attributes: true, attributeFilter: ["lang", "contenteditable"], subtree: true})
281
+ followers = {runtimes, observer}
282
+ contextFollowers.set(doc, followers)
283
+ }
284
+ followers.runtimes.add(this)
285
+ this.#language = undefined
286
+ this.contextChanged()
287
+ // Listen to native DOM events on hosts that also expose framework events.
288
+ const events = doc.defaultView?.EventTarget.prototype ?? EventTarget.prototype
289
+ for(const type of dataEvents) events.addEventListener.call(host, type, this.#dataEvent)
290
+ this.#contentObserver = new Observer(() => this.#syncData())
291
+ this.#contentObserver.observe(host, {childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ["type", "src", "slot"]})
292
+ this.#block = undefined
293
+ this.#syncData()
294
+ }
295
+
296
+ disconnect() {
297
+ const doc = this.#observedDocument
298
+ if(!doc) return
299
+ this.#observedDocument = undefined
300
+ this.#contentObserver?.disconnect()
301
+ this.#contentObserver = undefined
302
+ const events = doc.defaultView?.EventTarget.prototype ?? EventTarget.prototype
303
+ for(const type of dataEvents) events.removeEventListener.call(this.host, type, this.#dataEvent)
304
+ this.#readyBlock = null
305
+ const followers = contextFollowers.get(doc)
306
+ followers?.runtimes.delete(this)
307
+ if(followers && !followers.runtimes.size) {
308
+ followers.observer.disconnect()
309
+ contextFollowers.delete(doc)
310
+ }
311
+ }
312
+ }
313
+
314
+ type Lifecycle = {
315
+ connectedCallback?(): void
316
+ disconnectedCallback?(): void
317
+ attributeChangedCallback?(name: string, oldValue: string | null, value: string | null): void
318
+ }
319
+ type WidgetElementConstructor = (new (...args: any[]) => HTMLElement & Lifecycle) & {observedAttributes?: readonly string[]}
320
+ export type WidgetHost = HTMLElement & {readonly widgetRuntime: WidgetRuntime}
321
+
322
+ /** Add the runtime to a custom-element class before registering it. */
323
+ export function withWidgetRuntime<T extends WidgetElementConstructor>(Base: T) {
324
+ return class WidgetElement extends Base {
325
+ static readonly widgetAttributes: readonly string[] = []
326
+ static get observedAttributes(): string[] {
327
+ return [...new Set([...(super.observedAttributes ?? []), ...widgetRuntimeAttributes, ...this.widgetAttributes])]
328
+ }
329
+
330
+ readonly widgetRuntime = new WidgetRuntime(this, {
331
+ attributes: () => (this.constructor as typeof WidgetElement).widgetAttributes,
332
+ })
333
+
334
+ connectedCallback() {
335
+ this.widgetRuntime.connect()
336
+ super.connectedCallback?.()
337
+ }
338
+
339
+ disconnectedCallback() {
340
+ this.widgetRuntime.disconnect()
341
+ super.disconnectedCallback?.()
342
+ }
343
+
344
+ attributeChangedCallback(name: string, oldValue: string | null, value: string | null) {
345
+ if(Base.observedAttributes?.includes(name)) super.attributeChangedCallback?.(name, oldValue, value)
346
+ this.widgetRuntime.attributeChanged(name, oldValue, value)
347
+ }
348
+ }
349
+ }
package/options.ts ADDED
@@ -0,0 +1,131 @@
1
+ export type LocalizedText = string | Record<string, string>
2
+
3
+ export interface OptionAvailability {
4
+ hidden?: boolean
5
+ disabled?: boolean
6
+ disabledReason?: LocalizedText
7
+ }
8
+
9
+ export interface OptionGroupDeclaration {
10
+ label?: LocalizedText
11
+ description?: LocalizedText
12
+ collapsible?: boolean
13
+ collapsed?: boolean
14
+ advanced?: boolean
15
+ }
16
+
17
+ interface BaseOptionDeclaration<TypeHint=any> extends OptionAvailability {
18
+ attribute?: boolean | string
19
+ group?: string
20
+ readOnly?: boolean
21
+ unit?: string
22
+ type?: TypeHint
23
+ label?: LocalizedText
24
+ placeholder?: LocalizedText
25
+ description?: LocalizedText
26
+ }
27
+
28
+ export interface BooleanOptionDeclaration extends BaseOptionDeclaration<BooleanConstructor | "boolean"> {
29
+ control?: "checkbox" | "switch"
30
+ }
31
+
32
+ /** A boolean widget capability shown under Edit in element distribution permissions. */
33
+ export interface PermissionDeclaration extends Pick<BooleanOptionDeclaration, "label" | "description" | "hidden" | "disabled" | "disabledReason" | "readOnly"> {
34
+ attribute?: string
35
+ }
36
+
37
+ interface InputOptionDeclaration<TypeHint=any> extends BaseOptionDeclaration<TypeHint> {
38
+ pattern?: string
39
+ minlength?: number
40
+ maxlength?: number
41
+ autocapitalize?: "none" | "characters" | "words" | "sentences"
42
+ spellcheck?: "true" | "false"
43
+ autocomplete?: HTMLInputElement["autocomplete"]
44
+ inputmode?: "none" | "text" | "decimal" | "numeric" | "tel" | "search" | "email" | "url"
45
+ }
46
+
47
+ export interface StringOptionDeclaration extends InputOptionDeclaration<StringConstructor | "string"> {
48
+ multiline?: boolean
49
+ }
50
+
51
+ export interface NumberOptionDeclaration extends InputOptionDeclaration<NumberConstructor | "number"> {
52
+ control?: "input" | "range" | "duration"
53
+ steps?: number[]
54
+ min?: number
55
+ max?: number
56
+ step?: number
57
+ }
58
+
59
+ export interface DateOptionDeclaration extends InputOptionDeclaration<"date"> {
60
+ min?: number
61
+ max?: number
62
+ step?: number
63
+ }
64
+ export interface DatetimeLocalOptionDeclaration extends InputOptionDeclaration<"datetime-local"> {
65
+ min?: number
66
+ max?: number
67
+ step?: number
68
+ }
69
+ export interface EmailOptionDeclaration extends InputOptionDeclaration<"email"> {}
70
+ export interface PasswordOptionDeclaration extends InputOptionDeclaration<"password"> {}
71
+ export interface TelOptionDeclaration extends InputOptionDeclaration<"tel"> {}
72
+ export interface TimeOptionDeclaration extends InputOptionDeclaration<"time"> {}
73
+ export interface UrlOptionDeclaration extends InputOptionDeclaration<"url"> {}
74
+
75
+ export interface ObjectOptionDeclaration extends BaseOptionDeclaration<ObjectConstructor | "object" | ArrayConstructor | "array"> {}
76
+
77
+ export interface ColorOptionDeclaration extends BaseOptionDeclaration<"color"> {
78
+ swatches?: string[]
79
+ }
80
+
81
+ export type SelectOptionChoice = {
82
+ value: string
83
+ label?: LocalizedText
84
+ description?: LocalizedText
85
+ children?: never
86
+ } | {
87
+ label?: LocalizedText
88
+ children: SelectOptionChoice[]
89
+ value?: never
90
+ }
91
+
92
+ export interface ReferenceOptionDeclaration extends BaseOptionDeclaration<"reference"> {
93
+ selector?: string
94
+ excludeSelf?: boolean
95
+ }
96
+
97
+ export interface SelectOptionDeclaration extends BaseOptionDeclaration<"select"> {
98
+ control?: "select" | "radio" | "segmented" | "tree"
99
+ multiple?: boolean
100
+ options?: SelectOptionChoice[]
101
+ }
102
+
103
+ export type OptionDeclaration =
104
+ | BooleanOptionDeclaration
105
+ | StringOptionDeclaration
106
+ | NumberOptionDeclaration
107
+ | DateOptionDeclaration
108
+ | DatetimeLocalOptionDeclaration
109
+ | EmailOptionDeclaration
110
+ | PasswordOptionDeclaration
111
+ | TelOptionDeclaration
112
+ | TimeOptionDeclaration
113
+ | UrlOptionDeclaration
114
+ | ObjectOptionDeclaration
115
+ | ColorOptionDeclaration
116
+ | SelectOptionDeclaration
117
+ | ReferenceOptionDeclaration
118
+
119
+ export interface ActionDeclaration extends OptionAvailability {
120
+ /** Native chooser input; the action method receives transient File[]. */
121
+ input?: {type: "file", accept?: string, multiple?: boolean}
122
+ group?: string
123
+ /** Joins adjacent visible actions with the same ID within an option group. */
124
+ buttonGroup?: string
125
+ icon?: string
126
+ intent?: "default" | "danger"
127
+ confirm?: LocalizedText
128
+ label?: LocalizedText
129
+ placeholder?: LocalizedText
130
+ description?: LocalizedText
131
+ }
package/package.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "name": "@webwriter/widget",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "DOM-backed runtime for WebWriter widgets",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.ts",
13
+ "default": "./index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "index.js",
18
+ "index.ts",
19
+ "options.ts"
20
+ ],
21
+ "scripts": {
22
+ "build": "esbuild ./index.ts --bundle --target=es2022 --format=esm --outfile=index.js",
23
+ "prepack": "npm run build"
24
+ },
25
+ "devDependencies": {
26
+ "esbuild": "^0.25.4"
27
+ }
28
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
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.