@instafy/cli 0.2.2 → 0.2.4
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/README.md +60 -3
- package/dist/bind/LICENSE +661 -0
- package/dist/bind/bin/instafy-bind.mjs +542 -0
- package/dist/bind/client/bind-client.js +1219 -0
- package/dist/bind/src/apply.mjs +293 -0
- package/dist/bind/src/bound.mjs +202 -0
- package/dist/bind/src/budget.mjs +58 -0
- package/dist/bind/src/check.mjs +176 -0
- package/dist/bind/src/config.mjs +180 -0
- package/dist/bind/src/controller.mjs +244 -0
- package/dist/bind/src/executors/agent.mjs +336 -0
- package/dist/bind/src/executors/script.mjs +163 -0
- package/dist/bind/src/handler-kit.mjs +150 -0
- package/dist/bind/src/init.mjs +238 -0
- package/dist/bind/src/jobs.mjs +316 -0
- package/dist/bind/src/net.mjs +281 -0
- package/dist/bind/src/page-fetch.mjs +43 -0
- package/dist/bind/src/preview-host.mjs +114 -0
- package/dist/bind/src/protocol.mjs +103 -0
- package/dist/bind/src/records-schema.mjs +580 -0
- package/dist/bind/src/records.mjs +161 -0
- package/dist/bind/src/save-owner.mjs +492 -0
- package/dist/bind/src/schema-lite.mjs +212 -0
- package/dist/bind/src/server.mjs +908 -0
- package/dist/bind/src/sse.mjs +119 -0
- package/dist/bind/src/starters/blank/app.js +17 -0
- package/dist/bind/src/starters/blank/index.html +22 -0
- package/dist/bind/src/starters/checklist/app.js +171 -0
- package/dist/bind/src/starters/checklist/bound.json +29 -0
- package/dist/bind/src/starters/checklist/data.json +26 -0
- package/dist/bind/src/starters/checklist/index.html +23 -0
- package/dist/bind/src/starters/compare/app.js +316 -0
- package/dist/bind/src/starters/compare/bound.json +26 -0
- package/dist/bind/src/starters/compare/data.json +170 -0
- package/dist/bind/src/starters/compare/index.html +23 -0
- package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
- package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
- package/dist/bind/src/starters/ranked/app.js +166 -0
- package/dist/bind/src/starters/ranked/bound.json +26 -0
- package/dist/bind/src/starters/ranked/data.json +142 -0
- package/dist/bind/src/starters/ranked/index.html +23 -0
- package/dist/bind/src/starters/site.js +888 -0
- package/dist/bind/src/starters/theme.css +492 -0
- package/dist/bind/src/starters/timeline/app.js +143 -0
- package/dist/bind/src/starters/timeline/data.json +41 -0
- package/dist/bind/src/starters/timeline/index.html +22 -0
- package/dist/bind/src/static.mjs +141 -0
- package/dist/bind/src/tokens.mjs +102 -0
- package/dist/bind/src/try.mjs +139 -0
- package/dist/bind/src/web.mjs +191 -0
- package/dist/cli.js +5031 -2446
- package/package.json +5 -4
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/* Compare starter: 2-8 options side by side. A table when the page is wide, a list on phones, same order and ids.
|
|
2
|
+
* You design the content in data.json; keep theme.css and site.js. Restyle only when asked.
|
|
3
|
+
*
|
|
4
|
+
* data.json:
|
|
5
|
+
* title, lede the page title and its one-line answer: the verdict, and the biggest unknown about the
|
|
6
|
+
* title's question if there is one ("Option B costs least. None was tested in the rain.")
|
|
7
|
+
* sort: [{key, label, dir: "asc"|"desc"}] the first entry is the person's main criterion: phones show its value
|
|
8
|
+
* on the right of each row. A "Sort by" select appears only with 5 or more options and more
|
|
9
|
+
* than one entry, and only then are rows sorted (fresh values first, then ones that may have
|
|
10
|
+
* changed, then missing ones). Otherwise rows keep the order of "items", before and after a
|
|
11
|
+
* price check: put them in the order the lede talks about them.
|
|
12
|
+
* columns: [{key, label, type: "price"|"number"|"text"}] at most 5; a "price" column can be checked live
|
|
13
|
+
* filter (only for a constraint the person stated): {key, max, label: "Under $160", rest: "over $160"}
|
|
14
|
+
* items: [{ref: "option:a", name, why (one short line), badge?: "Previous model", note?, facts: {key: fact},
|
|
15
|
+
* store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}]}]
|
|
16
|
+
* unchecked: ["what was not checked", …] listed once in the footer
|
|
17
|
+
* fact: {"v": "$145", "src": "Example Store", "url": "https://…", "date": "2026-10-17", "asof"?: "2026-08-05", "est": false}
|
|
18
|
+
* A source you read that does not give the value: the fact without "v" ({"src", "url", "date"}) reads "Not listed ·
|
|
19
|
+
* Maker ↗". "Not checked" means nobody checked it ({"note": "what to check"}).
|
|
20
|
+
* When most of a column comes from one source on one date, the cells show just the value and one line under the list
|
|
21
|
+
* names the source; so does a row whose own values come from one other source ("Option C (Support and Ride): Fleet
|
|
22
|
+
* Feet product page ↗"). Any other cell keeps its own caption.
|
|
23
|
+
* Query: ?sort=<key>&show=all&open=<slug>. Rows: id="row-<slug>", data-ref="<ref>".
|
|
24
|
+
* SPDX-License-Identifier: AGPL-3.0-only */
|
|
25
|
+
(function () {
|
|
26
|
+
"use strict";
|
|
27
|
+
var h = site.h;
|
|
28
|
+
var TABLE_MIN = 760; // the list's own width (not the screen's): Studio's pane and a phone get the same rule
|
|
29
|
+
var lastOrder = []; // row order while a live check runs, so rows never move under the pointer
|
|
30
|
+
|
|
31
|
+
site.start({
|
|
32
|
+
data: "data.json",
|
|
33
|
+
state: function (data) {
|
|
34
|
+
return { sort: (data.sort && data.sort[0] && data.sort[0].key) || "", show: data.filter ? "fit" : "all", open: "" };
|
|
35
|
+
},
|
|
36
|
+
select: function (ref) { return { open: site.slug(ref) }; },
|
|
37
|
+
render: render
|
|
38
|
+
});
|
|
39
|
+
var wideNow = null;
|
|
40
|
+
if (window.ResizeObserver) {
|
|
41
|
+
new ResizeObserver(function () { if (isWide() !== wideNow) site.render(); }).observe(document.querySelector("main") || document.body);
|
|
42
|
+
} else {
|
|
43
|
+
window.addEventListener("resize", function () { if (isWide() !== wideNow) site.render(); });
|
|
44
|
+
}
|
|
45
|
+
function isWide() {
|
|
46
|
+
var main = document.querySelector("main") || document.body;
|
|
47
|
+
var cs = getComputedStyle(main);
|
|
48
|
+
return main.clientWidth - parseFloat(cs.paddingLeft || 0) - parseFloat(cs.paddingRight || 0) >= TABLE_MIN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function columns(data) { return (data.columns || []).slice(0, 5); }
|
|
52
|
+
function isPrice(col) { return col && col.type === "price"; }
|
|
53
|
+
function colOf(data, key) { return columns(data).filter(function (c) { return c.key === key; })[0] || null; }
|
|
54
|
+
function seedOf(item, col) { return (item.facts || {})[col.key] || null; }
|
|
55
|
+
/** The value shown for a cell: a live check for the price column, else the seed fact. */
|
|
56
|
+
function valueOf(item, col) {
|
|
57
|
+
var seed = seedOf(item, col);
|
|
58
|
+
if (!isPrice(col)) return seed;
|
|
59
|
+
var live = site.live(item.ref, col.key);
|
|
60
|
+
if (live && site.blocked(item.ref)) return seed; // a blocked check keeps the seed value
|
|
61
|
+
return live || seed;
|
|
62
|
+
}
|
|
63
|
+
/** Per column: the source most of its cells share ({src, date, asof, url}), when two or more cells and over half do. */
|
|
64
|
+
function sharedSources(data, items) {
|
|
65
|
+
var out = {};
|
|
66
|
+
columns(data).forEach(function (c) {
|
|
67
|
+
if (isPrice(c)) return;
|
|
68
|
+
var count = {};
|
|
69
|
+
items.forEach(function (i) {
|
|
70
|
+
var f = seedOf(i, c);
|
|
71
|
+
if (!f || !f.src || f.est || f.v == null) return;
|
|
72
|
+
var k = [f.src, f.date || "", f.asof || ""].join("|");
|
|
73
|
+
count[k] = count[k] || { n: 0, fact: f };
|
|
74
|
+
count[k].n++;
|
|
75
|
+
});
|
|
76
|
+
var best = Object.keys(count).sort(function (a, b) { return count[b].n - count[a].n; })[0];
|
|
77
|
+
if (best && count[best].n >= 2 && count[best].n * 2 > items.length) out[c.key] = { key: best, fact: count[best].fact };
|
|
78
|
+
});
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function isShared(shared, item, col) {
|
|
82
|
+
var s = shared[col.key];
|
|
83
|
+
var f = seedOf(item, col);
|
|
84
|
+
return !!(s && f && f.src && !f.est && [f.src, f.date || "", f.asof || ""].join("|") === s.key);
|
|
85
|
+
}
|
|
86
|
+
/** Per row: the one source its own cells share ({key, fact, labels}), when two or more of its cells outside the
|
|
87
|
+
* price column come from one source on one date that is not their column's. The line under the list names it once
|
|
88
|
+
* ("Option C (Support and Ride): Fleet Feet product page ↗ · seen 16 Oct"), so the row is not a stack of links. */
|
|
89
|
+
function rowSources(data, items, shared) {
|
|
90
|
+
var out = {};
|
|
91
|
+
items.forEach(function (i) {
|
|
92
|
+
var by = {};
|
|
93
|
+
columns(data).forEach(function (c) {
|
|
94
|
+
if (isPrice(c) || isShared(shared, i, c)) return;
|
|
95
|
+
var f = seedOf(i, c);
|
|
96
|
+
if (!f || !f.src || f.est) return;
|
|
97
|
+
var k = [f.src, f.date || "", f.asof || ""].join("|");
|
|
98
|
+
(by[k] = by[k] || { key: k, fact: f, labels: [], values: 0 }).labels.push(c.label);
|
|
99
|
+
if (f.v != null) by[k].values++;
|
|
100
|
+
});
|
|
101
|
+
var keys = Object.keys(by);
|
|
102
|
+
if (keys.length === 1 && by[keys[0]].labels.length >= 2 && by[keys[0]].values >= 1) out[i.ref] = by[keys[0]];
|
|
103
|
+
});
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function ownRow(rows, item, col) {
|
|
107
|
+
var r = rows[item.ref];
|
|
108
|
+
var f = seedOf(item, col);
|
|
109
|
+
return !!(r && f && f.src && !f.est && !isPrice(col) && [f.src, f.date || "", f.asof || ""].join("|") === r.key);
|
|
110
|
+
}
|
|
111
|
+
/** label: say what was not checked ("Price not checked") where no column header or fact label says it. */
|
|
112
|
+
function cell(item, col, src, label) {
|
|
113
|
+
if (isPrice(col)) {
|
|
114
|
+
return site.trust(valueOf(item, col), { price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: seedOf(item, col), label: label });
|
|
115
|
+
}
|
|
116
|
+
return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label });
|
|
117
|
+
}
|
|
118
|
+
function hasMore(item) { return !!(item.note || (item.more && item.more.length) || (item.sources && item.sources.length)); }
|
|
119
|
+
|
|
120
|
+
/** The "Sort by" select shows (and rows are sorted) only with 5 or more options and more than one way to sort. */
|
|
121
|
+
function sortShown(data) { return (data.sort || []).length > 1 && (data.items || []).length >= 5; }
|
|
122
|
+
/** Sorted by the chosen key: fresh values first, then values that may have changed (an old price never leads), then
|
|
123
|
+
* rows without one. */
|
|
124
|
+
function sortItems(data, items, st) {
|
|
125
|
+
var def = (data.sort || []).filter(function (s) { return s.key === st.sort; })[0] || (data.sort || [])[0];
|
|
126
|
+
if (!def) return items;
|
|
127
|
+
var col = colOf(data, def.key) || { key: def.key };
|
|
128
|
+
var tier = function (i) { var v = valueOf(i, col); return site.num(v) == null ? 2 : site.old(v, isPrice(col)) ? 1 : 0; };
|
|
129
|
+
return items.slice().sort(function (a, b) {
|
|
130
|
+
var ta = tier(a), tb = tier(b);
|
|
131
|
+
if (ta !== tb) return ta - tb;
|
|
132
|
+
if (ta === 2) return 0;
|
|
133
|
+
var x = site.num(valueOf(a, col)), y = site.num(valueOf(b, col));
|
|
134
|
+
return def.dir === "desc" ? y - x : x - y;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function fits(data, item) {
|
|
138
|
+
var f = data.filter;
|
|
139
|
+
if (!f) return true;
|
|
140
|
+
var n = site.num(valueOf(item, colOf(data, f.key) || { key: f.key }));
|
|
141
|
+
if (n == null) return true;
|
|
142
|
+
return (f.max == null || n <= f.max) && (f.min == null || n >= f.min);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function render(data, st) {
|
|
146
|
+
site.head(data);
|
|
147
|
+
var all = data.items || [];
|
|
148
|
+
// without a "Sort by" select the rows keep their authored order: nothing moves when a check comes back
|
|
149
|
+
var items = sortShown(data) ? sortItems(data, all, st) : all.slice();
|
|
150
|
+
// while a check runs, keep the order the person was looking at; sort once when every check has finished
|
|
151
|
+
if (site.anyPending(all) && lastOrder.length === all.length) {
|
|
152
|
+
items = lastOrder.map(function (ref) { return all.filter(function (i) { return i.ref === ref; })[0]; }).filter(Boolean);
|
|
153
|
+
}
|
|
154
|
+
lastOrder = items.map(function (i) { return i.ref; });
|
|
155
|
+
var shown = st.show === "all" ? items : items.filter(function (i) { return fits(data, i); });
|
|
156
|
+
var hidden = items.length - shown.length;
|
|
157
|
+
var hasPrice = columns(data).some(isPrice);
|
|
158
|
+
var cols = sharedSources(data, all);
|
|
159
|
+
var shared = { cols: cols, rows: rowSources(data, all, cols) };
|
|
160
|
+
|
|
161
|
+
// the one control row: the sort (5+ options) and the one filter on the left, the one primary button on the right
|
|
162
|
+
var bar = document.getElementById("bar");
|
|
163
|
+
bar.replaceChildren();
|
|
164
|
+
if (sortShown(data)) {
|
|
165
|
+
bar.appendChild(site.selectField("sort", "Sort by", data.sort.map(function (s) { return { value: s.key, label: s.label }; }), st.sort,
|
|
166
|
+
function (v) { site.setState({ sort: v }); }));
|
|
167
|
+
}
|
|
168
|
+
if (data.filter) {
|
|
169
|
+
bar.appendChild(site.seg("show", "Show", [{ value: "all", label: "All" }, { value: "fit", label: data.filter.label }], st.show,
|
|
170
|
+
function (v) { site.setState({ show: v }); }));
|
|
171
|
+
}
|
|
172
|
+
if (hasPrice) site.append(bar, site.checkAll(all));
|
|
173
|
+
bar.hidden = !bar.firstChild;
|
|
174
|
+
|
|
175
|
+
var list = document.getElementById("list");
|
|
176
|
+
list.replaceChildren();
|
|
177
|
+
if (!items.length) {
|
|
178
|
+
list.appendChild(site.empty("No options yet.", null, "Ask in chat to add some."));
|
|
179
|
+
} else {
|
|
180
|
+
var stores = hasPrice ? site.priceNotice(all) : null;
|
|
181
|
+
wideNow = isWide();
|
|
182
|
+
var wrap = h("div", { "class": "stack" }, wideNow ? table(data, shown, st, shared) : cards(data, shown, st, shared));
|
|
183
|
+
var lines = [];
|
|
184
|
+
if (hidden > 0) {
|
|
185
|
+
lines.push(h("p", { "class": "cap hidden-line" }, hidden + " more " + (data.filter.rest || "hidden") + " · ",
|
|
186
|
+
h("button", { "class": "link-btn", text: "Show", attrs: { type: "button", "data-key": "show-all" }, on: { click: function () { site.setState({ show: "all" }); } } })));
|
|
187
|
+
}
|
|
188
|
+
var missing = sortShown(data) ? missingLine(data, shown, st) : null;
|
|
189
|
+
if (missing) lines.push(missing);
|
|
190
|
+
var from = sharedLine(data, shared, all);
|
|
191
|
+
if (from) lines.push(from);
|
|
192
|
+
if (lines.length) wrap.appendChild(h("div", { "class": "stack tight" }, lines));
|
|
193
|
+
if (stores) wrap.appendChild(stores);
|
|
194
|
+
list.appendChild(wrap);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
var sources = [];
|
|
198
|
+
items.forEach(function (i) {
|
|
199
|
+
columns(data).forEach(function (c) { sources = sources.concat(site.sourcesOf([(i.facts || {})[c.key]])); });
|
|
200
|
+
(i.sources || []).forEach(function (s) { sources.push(s); });
|
|
201
|
+
});
|
|
202
|
+
// a source the line under the list already names is not named again under "Sources"
|
|
203
|
+
var named = [];
|
|
204
|
+
Object.keys(cols).forEach(function (k) { named.push(cols[k].fact); });
|
|
205
|
+
Object.keys(shared.rows).forEach(function (k) { named.push(shared.rows[k].fact); });
|
|
206
|
+
sources = sources.filter(function (s) {
|
|
207
|
+
return !named.some(function (f) { return f.src === s.name || (site.host(f.url) && site.host(f.url) === site.host(s.url)); });
|
|
208
|
+
});
|
|
209
|
+
// the footer belongs to a list: an empty page has nothing to confirm
|
|
210
|
+
site.notes(items.length ? data.unchecked : [], items.length ? sources : []);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** "2 without a price are listed last." when the sort key is missing for some rows. */
|
|
214
|
+
function missingLine(data, items, st) {
|
|
215
|
+
var def = (data.sort || []).filter(function (s) { return s.key === st.sort; })[0] || (data.sort || [])[0];
|
|
216
|
+
if (!def) return null;
|
|
217
|
+
var col = colOf(data, def.key) || { key: def.key, label: def.key };
|
|
218
|
+
var n = items.filter(function (i) { return site.num(valueOf(i, col)) == null; }).length;
|
|
219
|
+
if (!n || n === items.length) return null;
|
|
220
|
+
return h("p", { "class": "cap", text: n + " without " + (/^[aeiou]/i.test(col.label) ? "an " : "a ") + String(col.label).toLowerCase() + (n === 1 ? " is" : " are") + " listed last." });
|
|
221
|
+
}
|
|
222
|
+
/** "Support and Heel drop: Running Shoes Guru ↗ · seen 16 Oct", plus ", unless a value says otherwise" when a cell
|
|
223
|
+
* of those columns has its own source. */
|
|
224
|
+
function sharedLine(data, src, items) {
|
|
225
|
+
var groups = {};
|
|
226
|
+
var and = function (list) { return list.length > 1 ? list.slice(0, -1).join(", ") + " and " + list[list.length - 1] : list[0]; };
|
|
227
|
+
columns(data).forEach(function (c) {
|
|
228
|
+
var s = src.cols[c.key];
|
|
229
|
+
if (!s) return;
|
|
230
|
+
var g = groups[s.key] = groups[s.key] || { fact: s.fact, labels: [], others: false };
|
|
231
|
+
g.labels.push(c.label);
|
|
232
|
+
// a value with its own source that no row line names keeps its own caption: "unless a value says otherwise"
|
|
233
|
+
if (items.some(function (i) { var f = seedOf(i, c); return f && f.src && f.v != null && !isShared(src.cols, i, c) && !ownRow(src.rows, i, c); })) g.others = true;
|
|
234
|
+
});
|
|
235
|
+
var out = Object.keys(groups).map(function (k) {
|
|
236
|
+
var g = groups[k];
|
|
237
|
+
var when = site.seen(g.fact);
|
|
238
|
+
return h("p", { "class": "cap" }, and(g.labels) + ": ", site.link(g.fact.url, g.fact.src), when ? [" · ", h("span", { "class": "nowrap", text: when })] : "", g.others ? ", unless a value says otherwise" : "");
|
|
239
|
+
});
|
|
240
|
+
items.forEach(function (i) {
|
|
241
|
+
var r = src.rows[i.ref];
|
|
242
|
+
if (!r) return;
|
|
243
|
+
var when = site.seen(r.fact);
|
|
244
|
+
out.push(h("p", { "class": "cap" }, i.name + " (" + and(r.labels) + "): ", site.link(r.fact.url, r.fact.src), when ? [" · ", h("span", { "class": "nowrap", text: when })] : ""));
|
|
245
|
+
});
|
|
246
|
+
return out.length ? h("div", null, out) : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function nameBlock(item) {
|
|
250
|
+
return [
|
|
251
|
+
h("div", { "class": "name-line" }, h("span", { "class": "name", text: item.name }), item.badge ? h("span", { "class": "badge", text: item.badge }) : null),
|
|
252
|
+
item.why ? h("p", { "class": "cap", text: item.why }) : null
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
function detailsButton(item, st) {
|
|
256
|
+
if (!hasMore(item)) return null;
|
|
257
|
+
var open = st.open === site.slug(item.ref);
|
|
258
|
+
return h("button", {
|
|
259
|
+
"class": "btn quiet more-btn",
|
|
260
|
+
text: open ? "Hide details" : "Details",
|
|
261
|
+
attrs: { type: "button", "aria-expanded": open ? "true" : "false", "aria-label": (open ? "Hide details for " : "Details for ") + item.name, "data-key": "details:" + item.ref },
|
|
262
|
+
on: { click: function () { site.setState({ open: open ? "" : site.slug(item.ref) }); } }
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
function more(item) {
|
|
266
|
+
var facts = (item.more || []).slice(0, 8);
|
|
267
|
+
var linked = {};
|
|
268
|
+
facts.forEach(function (f) { if (f.fact && f.fact.url) linked[f.fact.url] = 1; });
|
|
269
|
+
var sources = (item.sources || []).filter(function (s) { return s && !linked[s.url]; });
|
|
270
|
+
return h("div", { "class": "more" },
|
|
271
|
+
item.note ? h("p", { text: item.note }) : null,
|
|
272
|
+
facts.length ? h("dl", { "class": "facts" }, facts.map(function (f) {
|
|
273
|
+
return h("div", null, h("dt", { text: f.label }), h("dd", null, site.trust(f.fact)));
|
|
274
|
+
})) : null,
|
|
275
|
+
sources.length ? h("p", { "class": "cap" }, "Sources: ", sources.map(function (s, i) {
|
|
276
|
+
return [i ? ", " : "", site.link(s.url, s.name)];
|
|
277
|
+
})) : null);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function table(data, items, st, shared) {
|
|
281
|
+
var cols = columns(data);
|
|
282
|
+
var anyMore = items.some(hasMore);
|
|
283
|
+
var head = h("tr", null, h("th", { text: "Option", attrs: { scope: "col" } }),
|
|
284
|
+
cols.map(function (c) { return h("th", { "class": c.type === "text" ? null : "num", text: c.label, attrs: { scope: "col" } }); }),
|
|
285
|
+
anyMore ? h("th", { attrs: { scope: "col" } }, h("span", { "class": "sr-only", text: "Details" })) : null);
|
|
286
|
+
var body = h("tbody");
|
|
287
|
+
items.forEach(function (item) {
|
|
288
|
+
body.appendChild(h("tr", { attrs: site.rowAttrs(item.ref) },
|
|
289
|
+
h("td", null, nameBlock(item)),
|
|
290
|
+
cols.map(function (c) { return h("td", { "class": c.type === "text" ? null : "num" }, cell(item, c, shared)); }),
|
|
291
|
+
anyMore ? h("td", { "class": "act" }, detailsButton(item, st)) : null));
|
|
292
|
+
if (st.open === site.slug(item.ref) && hasMore(item)) {
|
|
293
|
+
body.appendChild(h("tr", { "class": "more-row" }, h("td", { attrs: { colspan: cols.length + 1 + (anyMore ? 1 : 0) } }, more(item))));
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
return h("div", { "class": "table-wrap" }, h("table", { "class": "table" }, h("thead", null, head), body));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Phones: one list, a row per option. The sorted value (with its caption) sits on the right, so you compare by
|
|
300
|
+
* scanning down the right edge; the other columns follow as facts in the same order on every row. */
|
|
301
|
+
function cards(data, items, st, shared) {
|
|
302
|
+
var cols = columns(data);
|
|
303
|
+
var sortCol = colOf(data, st.sort) || colOf(data, ((data.sort || [])[0] || {}).key);
|
|
304
|
+
return h("ol", { "class": "list cards" }, items.map(function (item) {
|
|
305
|
+
var open = st.open === site.slug(item.ref);
|
|
306
|
+
var rest = cols.filter(function (c) { return c !== sortCol; });
|
|
307
|
+
return h("li", { attrs: site.rowAttrs(item.ref) },
|
|
308
|
+
h("div", { "class": "card-head" },
|
|
309
|
+
h("div", null, nameBlock(item)),
|
|
310
|
+
sortCol ? h("div", { "class": "sortv" }, cell(item, sortCol, shared, sortCol.label)) : null),
|
|
311
|
+
rest.length ? h("dl", { "class": "facts" }, rest.map(function (c) { return h("div", null, h("dt", { text: c.label }), h("dd", null, cell(item, c, shared))); })) : null,
|
|
312
|
+
hasMore(item) ? h("div", { "class": "actions" }, detailsButton(item, st)) : null,
|
|
313
|
+
open && hasMore(item) ? more(item) : null);
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"title": "Compare three options",
|
|
4
|
+
"intents": {
|
|
5
|
+
"read_offer": {
|
|
6
|
+
"label": "Check price",
|
|
7
|
+
"describe": "Read one option's price from its store page (schema.org data). The URL comes from data.json, never from the click.",
|
|
8
|
+
"executor": "script",
|
|
9
|
+
"handler": "handlers/read_offer.mjs",
|
|
10
|
+
"params": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["ref"],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"ref": { "type": "string", "maxLength": 80, "pattern": "^[a-z][a-z0-9-]{0,23}:[a-z0-9-]{1,50}$" }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"target": { "records": "data/records/read_offer.json" },
|
|
19
|
+
"lanes": ["local"],
|
|
20
|
+
"pending": "item",
|
|
21
|
+
"cooldown_s": 10,
|
|
22
|
+
"timeout_s": 45
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"results": { "files": ["data/records/read_offer.json"], "poll_s": 3, "sse": true }
|
|
26
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Compare three options",
|
|
3
|
+
"lede": "Option B costs least and Option A is the lightest. Option C's price was not checked.",
|
|
4
|
+
"sort": [
|
|
5
|
+
{
|
|
6
|
+
"key": "price",
|
|
7
|
+
"label": "Price, low to high",
|
|
8
|
+
"dir": "asc"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"key": "weight",
|
|
12
|
+
"label": "Weight, light first",
|
|
13
|
+
"dir": "asc"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"key": "rating",
|
|
17
|
+
"label": "Rating, high first",
|
|
18
|
+
"dir": "desc"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"columns": [
|
|
22
|
+
{
|
|
23
|
+
"key": "price",
|
|
24
|
+
"label": "Price",
|
|
25
|
+
"type": "price"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"key": "weight",
|
|
29
|
+
"label": "Weight",
|
|
30
|
+
"type": "number"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"key": "rating",
|
|
34
|
+
"label": "Rating",
|
|
35
|
+
"type": "number"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"key": "warranty",
|
|
39
|
+
"label": "Warranty",
|
|
40
|
+
"type": "text"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"items": [
|
|
44
|
+
{
|
|
45
|
+
"ref": "option:option-a",
|
|
46
|
+
"name": "Option A",
|
|
47
|
+
"why": "The lightest of the three",
|
|
48
|
+
"facts": {
|
|
49
|
+
"price": {
|
|
50
|
+
"v": "$145",
|
|
51
|
+
"src": "Example Store",
|
|
52
|
+
"url": "https://example.com/option-a",
|
|
53
|
+
"date": "2026-10-17"
|
|
54
|
+
},
|
|
55
|
+
"weight": {
|
|
56
|
+
"v": "8.9 oz",
|
|
57
|
+
"src": "Maker",
|
|
58
|
+
"url": "https://example.com/option-a/specs",
|
|
59
|
+
"date": "2026-10-17"
|
|
60
|
+
},
|
|
61
|
+
"rating": {
|
|
62
|
+
"v": "4.6 of 5",
|
|
63
|
+
"src": "Example Reviews",
|
|
64
|
+
"url": "https://example.com/reviews/option-a",
|
|
65
|
+
"date": "2026-10-16"
|
|
66
|
+
},
|
|
67
|
+
"warranty": {
|
|
68
|
+
"v": "2 years",
|
|
69
|
+
"src": "Maker",
|
|
70
|
+
"url": "https://example.com/option-a/warranty",
|
|
71
|
+
"date": "2026-10-17"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"store": {
|
|
75
|
+
"name": "Example Store",
|
|
76
|
+
"url": "https://example.com/option-a",
|
|
77
|
+
"readable": true
|
|
78
|
+
},
|
|
79
|
+
"more": [
|
|
80
|
+
{
|
|
81
|
+
"label": "Colours",
|
|
82
|
+
"fact": {
|
|
83
|
+
"v": "Black, white",
|
|
84
|
+
"src": "Maker",
|
|
85
|
+
"url": "https://example.com/option-a/specs",
|
|
86
|
+
"date": "2026-10-17"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"sources": [
|
|
91
|
+
{
|
|
92
|
+
"name": "Maker",
|
|
93
|
+
"url": "https://example.com/option-a/specs"
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"ref": "option:option-b",
|
|
99
|
+
"name": "Option B",
|
|
100
|
+
"why": "The lowest price",
|
|
101
|
+
"facts": {
|
|
102
|
+
"price": {
|
|
103
|
+
"v": "$129",
|
|
104
|
+
"src": "Example Store",
|
|
105
|
+
"url": "https://example.com/option-b",
|
|
106
|
+
"date": "2026-10-17"
|
|
107
|
+
},
|
|
108
|
+
"weight": {
|
|
109
|
+
"v": "about 9.5 oz",
|
|
110
|
+
"n": 9.5,
|
|
111
|
+
"est": true,
|
|
112
|
+
"url": "https://example.com/option-b/specs"
|
|
113
|
+
},
|
|
114
|
+
"rating": {
|
|
115
|
+
"v": "4.3 of 5",
|
|
116
|
+
"src": "Example Reviews",
|
|
117
|
+
"url": "https://example.com/reviews/option-b",
|
|
118
|
+
"date": "2026-10-16"
|
|
119
|
+
},
|
|
120
|
+
"warranty": {
|
|
121
|
+
"src": "Maker",
|
|
122
|
+
"url": "https://example.com/option-b/specs",
|
|
123
|
+
"date": "2026-10-17"
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"store": {
|
|
127
|
+
"name": "Example Store",
|
|
128
|
+
"url": "https://example.com/option-b",
|
|
129
|
+
"readable": true
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"ref": "option:option-c",
|
|
134
|
+
"name": "Option C",
|
|
135
|
+
"why": "Last year's model, often discounted",
|
|
136
|
+
"badge": "Previous model",
|
|
137
|
+
"facts": {
|
|
138
|
+
"price": {
|
|
139
|
+
"note": "the store blocks price checks"
|
|
140
|
+
},
|
|
141
|
+
"weight": {
|
|
142
|
+
"v": "9.8 oz",
|
|
143
|
+
"src": "Maker",
|
|
144
|
+
"url": "https://example.com/option-c/specs",
|
|
145
|
+
"date": "2026-10-17"
|
|
146
|
+
},
|
|
147
|
+
"rating": {
|
|
148
|
+
"v": "4.4 of 5",
|
|
149
|
+
"src": "Example Reviews",
|
|
150
|
+
"url": "https://example.com/reviews/option-c",
|
|
151
|
+
"date": "2026-10-16"
|
|
152
|
+
},
|
|
153
|
+
"warranty": {
|
|
154
|
+
"v": "1 year",
|
|
155
|
+
"src": "Maker",
|
|
156
|
+
"url": "https://example.com/option-c/warranty",
|
|
157
|
+
"date": "2026-10-17"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"store": {
|
|
161
|
+
"name": "Other Store",
|
|
162
|
+
"url": "https://example.org/option-c",
|
|
163
|
+
"readable": false
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"unchecked": [
|
|
168
|
+
"How each holds up after a year: no long-term review was read"
|
|
169
|
+
]
|
|
170
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
|
7
|
+
<meta name="theme-color" content="#141414" media="(prefers-color-scheme: dark)">
|
|
8
|
+
<title>Compare</title>
|
|
9
|
+
<link rel="stylesheet" href="theme.css">
|
|
10
|
+
<!-- Restyle only when asked: override --if-* tokens in a <style> here, after theme.css. -->
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main class="page wide">
|
|
14
|
+
<header class="head"><h1 class="title" id="title">Compare</h1><p class="lede" id="lede"></p></header>
|
|
15
|
+
<div class="bar" id="bar" hidden></div>
|
|
16
|
+
<section id="list"></section>
|
|
17
|
+
<footer class="notes" id="notes" hidden></footer>
|
|
18
|
+
</main>
|
|
19
|
+
<script src="__bind/client.js"></script>
|
|
20
|
+
<script src="site.js"></script>
|
|
21
|
+
<script src="app.js"></script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// read_offer: read one row's price from its store page. The binder runs it with node, cwd = the site folder.
|
|
2
|
+
// The store URL comes from data.json (items[].store.url), never from the click, so a viewer cannot point it anywhere.
|
|
3
|
+
// Every row ends with a value or a reason: a blocked or unreadable page answers "uncertain" with a short note.
|
|
4
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
5
|
+
const kit = await import(process.env.BIND_KIT);
|
|
6
|
+
const { intent } = await kit.readInput();
|
|
7
|
+
await main(intent.params.ref);
|
|
8
|
+
|
|
9
|
+
async function main(ref) {
|
|
10
|
+
let items = [];
|
|
11
|
+
try {
|
|
12
|
+
items = kit.readJson("data.json").items || [];
|
|
13
|
+
} catch {
|
|
14
|
+
return kit.error("invalid", "data.json could not be read");
|
|
15
|
+
}
|
|
16
|
+
const item = items.find((i) => i.ref === ref);
|
|
17
|
+
const url = item?.store?.url;
|
|
18
|
+
if (!item || !url) return kit.error("invalid", "this row has no store link");
|
|
19
|
+
let host;
|
|
20
|
+
try {
|
|
21
|
+
host = new URL(url).hostname.replace(/^www\./, "");
|
|
22
|
+
} catch {
|
|
23
|
+
return kit.error("invalid", "the store link is not a web address");
|
|
24
|
+
}
|
|
25
|
+
const store = item.store.name || host;
|
|
26
|
+
const blocked = (note) => {
|
|
27
|
+
kit.records([kit.uncertain({ ref, attr: "price", url, note, label: "Price" })]);
|
|
28
|
+
kit.done(note);
|
|
29
|
+
};
|
|
30
|
+
kit.progress(1, 2, `Reading ${host}`, host);
|
|
31
|
+
let offers;
|
|
32
|
+
try {
|
|
33
|
+
offers = await kit.offers(await kit.fetch(url));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return blocked(error.code === "policy" ? `${host} blocks automated price checks`
|
|
36
|
+
: error.code === "unavailable" ? `${host} did not answer just now; check again later` : `${host} could not be read`);
|
|
37
|
+
}
|
|
38
|
+
kit.progress(2, 2, `Reading the price on ${host}`, host);
|
|
39
|
+
const offer = offers.find((o) => typeof o.amount === "number" && o.currency);
|
|
40
|
+
if (!offer) return blocked(`no price on the ${host} page`);
|
|
41
|
+
kit.records([
|
|
42
|
+
{
|
|
43
|
+
type: "offer",
|
|
44
|
+
oid: kit.rid("off", ref, host),
|
|
45
|
+
ref,
|
|
46
|
+
seller: { name: store },
|
|
47
|
+
price: { amount: offer.amount, currency: offer.currency },
|
|
48
|
+
availability: /instock|limitedavailability|onlineonly/i.test(offer.availability || "") ? "in_stock" : /outofstock|soldout|discontinued/i.test(offer.availability || "") ? "out" : "unknown",
|
|
49
|
+
handoff: { url, label: `Open ${store}` },
|
|
50
|
+
basis: "live",
|
|
51
|
+
prov: { source: host, url, ttl_s: 21600, class: "live" },
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
kit.done(`${offer.currency} ${offer.amount} at ${store}`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// set_check: store one tick of the checklist, so every viewer and device sees the same list.
|
|
2
|
+
// The binder keeps the claim (attr "done", or "done_<who>" for a per-person list) and streams it to every open page.
|
|
3
|
+
// Only refs and people listed in data.json are accepted.
|
|
4
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
5
|
+
const kit = await import(process.env.BIND_KIT);
|
|
6
|
+
const { intent } = await kit.readInput();
|
|
7
|
+
main(intent.params);
|
|
8
|
+
|
|
9
|
+
function main({ ref, done, who, at }) {
|
|
10
|
+
let data;
|
|
11
|
+
try {
|
|
12
|
+
data = kit.readJson("data.json");
|
|
13
|
+
} catch {
|
|
14
|
+
return kit.error("invalid", "data.json could not be read");
|
|
15
|
+
}
|
|
16
|
+
const refs = new Set((data.groups || []).flatMap((g) => (g.items || []).map((i) => i.ref)));
|
|
17
|
+
if (!refs.has(ref)) return kit.error("invalid", "this item is not on the list");
|
|
18
|
+
const people = Array.isArray(data.people) ? data.people : [];
|
|
19
|
+
if (people.length && !people.includes(who)) return kit.error("invalid", "pick whose list first");
|
|
20
|
+
if (!people.length && who) return kit.error("invalid", "this list is shared by everyone");
|
|
21
|
+
const attr = who ? `done_${who.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "x"}` : "done";
|
|
22
|
+
// The click's own time orders quick tick / untick pairs; it is kept within the last ten minutes.
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const clicked = Date.parse(at || "");
|
|
25
|
+
const observed = Number.isFinite(clicked) ? Math.min(now, Math.max(now - 600_000, clicked)) : now;
|
|
26
|
+
kit.records([
|
|
27
|
+
{
|
|
28
|
+
type: "claim",
|
|
29
|
+
cid: kit.rid("clm", ref, attr, "checklist"),
|
|
30
|
+
ref,
|
|
31
|
+
attr,
|
|
32
|
+
value: done === true,
|
|
33
|
+
label: "Done",
|
|
34
|
+
status: "ok",
|
|
35
|
+
basis: "user_note",
|
|
36
|
+
confidence: "high",
|
|
37
|
+
prov: { source: "checklist", lane: "local", observed_at: new Date(observed).toISOString(), ttl_s: 31536000 },
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
kit.done(done ? "Ticked" : "Unticked");
|
|
41
|
+
}
|