@instafy/cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,7 +56,9 @@ Run Instafy spaces locally and connect them back to Instafy Studio — from any
56
56
  - `instafy tunnel logs <tunnelId> --follow` — tail tunnel logs.
57
57
  - `instafy tunnel stop <tunnelId>` — stop + revoke a tunnel.
58
58
  - `instafy preview check --port <port>` — load a local site in a headless browser and list its errors, failed requests and a blank page, with desktop and phone screenshots. Local only; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#previews) for exit codes and settings.
59
- - `instafy web read <url…> --json` — read up to 6 public pages over plain HTTP and get their text, links and structured data with provenance, wrapped as untrusted page content. robots.txt (including AI-agent user agents), skill-listed link-only sites, private-address checks at every redirect, stop rules and per-turn caps are enforced; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#reading-web-pages-instafy-web-read).
59
+ - `instafy web read <url…> --json` — read up to 6 public pages over plain HTTP and get their text, links and structured data with provenance, wrapped as untrusted page content. robots.txt (including AI-agent user agents), the web-research skill's `no-agents` link-only sites (built in, plus the space's additions) and translators', caches' and archives' copies of them, private-address checks at every redirect, stop rules and per-turn caps are enforced; a `no-reuse` site is read and marked `noReuse`; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#reading-web-pages-instafy-web-read).
60
+ - `instafy web search "<query>" --json` — find links to read, in Octo's turns only, with the space's own search key (the web-research skill's providers: Brave, then Kagi). Results are untrusted leads: a link-only site's result keeps only its link and site name, keys go only to their provider's host, nothing is cached, and the room keeps only the query and the count; see [docs/CLI.md](https://github.com/instafy-dev/instafy/blob/main/docs/CLI.md#searching-for-links-instafy-web-search).
61
+ - `instafy web link <site> [name=value…] --json` — the link the person opens in their own tab for a `no-agents` site (guided mode): the site's search template from the web-research skill's list, filled with checked, URL-encoded values, or its home page and what to type there when it bars deep links. Reads nothing.
60
62
  - `instafy secrets list` — list space secret metadata (names/descriptions, never values).
61
63
  - `instafy secrets get <name-or-id>` — inspect one space secret metadata entry.
62
64
  - `instafy secrets put <name> --value ...` — create/update a space secret.
@@ -59,7 +59,10 @@ init Write a starter bound.json, index.html and handlers/hello.mjs. --star
59
59
  files are kept; --force overwrites them.
60
60
  check Does the site show every item the request named (each product, stop or entry)? Reads the text of its
61
61
  HTML pages and every value in its data files (data.json, data/**/*.json), and lists the items it does
62
- not show; exit 1 when one is missing, 2 when there is nothing to check. Items: --expect, plus an
62
+ not show. On a page of options (the compare and ranked starters' data.json) it also lists values that
63
+ only say nothing ("—", "n/a"), values without the page and day they were read, and a filter that
64
+ would count a row whose value is unknown; and it notes the rows a filter lists apart. Exit 1 when an
65
+ item is missing or a value needs fixing, 2 when there is nothing to check. Items: --expect, plus an
63
66
  "expect" array in data.json. Run it before telling anyone the page is done.
64
67
  command Push a command (select, highlight, toast, reload) to every open viewer. Uses the
65
68
  admin token in .bind/admin-token.
@@ -4,7 +4,8 @@
4
4
  // every string in its data files (data.json, data/**/*.json, records included). Items come from --expect and from
5
5
  // an "expect" array in data.json. Text that only says an item is unavailable ("Not found yet: Tokyo", a data file's
6
6
  // "missing" list) does not count as showing it: builders are told to say on the page what they could not add, and
7
- // that line must not pass the check. Pure helpers are exported for the tests.
7
+ // that line must not pass the check. On a page of options it also checks the values (below). Pure helpers are
8
+ // exported for the tests.
8
9
  // SPDX-License-Identifier: AGPL-3.0-only
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
@@ -157,20 +158,206 @@ export function checkSite(dir, expect = []) {
157
158
  return { items, shown, missing, files: site.parts.map((p) => p.file) };
158
159
  }
159
160
 
160
- /** The CLI's lines and exit code: 0 every item shown, 1 some missing, 2 nothing to check. */
161
+ // ---------- the values of a page of options (the compare and ranked starters' data.json) ----------
162
+ // Each value on such a page names the page it was read on and the day ("checked 27 Oct on maker.com"), and a value
163
+ // nobody found says so with why; a dash or "n/a" is not a value. A filter from a stated limit ("under €600") counts
164
+ // only values the page shows: the starters' site.byFilter lists a row whose value is unknown apart ("Price
165
+ // unknown"), never as within the limit. These rules read the data the same way site.js draws it.
166
+
167
+ /** A value that only says nothing is known: blank, dashes, "?", "n/a", "unknown", "TBD" (site.js: no value). */
168
+ export const NO_VALUE = /^(?:[\s\-\u2010-\u2015?.\u00b7\u2026/]*|n\s*\/\s*a|unknown|tb[ad])$/i;
169
+ const valueOf = (fact) => (fact && typeof fact === "object" && fact.v != null ? String(fact.v) : "");
170
+ const hasValue = (fact) => !!valueOf(fact) && !NO_VALUE.test(valueOf(fact).trim());
171
+ const httpsUrl = (url) => {
172
+ try {
173
+ return new URL(String(url)).protocol === "https:";
174
+ } catch {
175
+ return false;
176
+ }
177
+ };
178
+
179
+ /** The first number in a text, read the way shops write it (site.js: numberIn): a ".", ",", space or "'" before
180
+ * exactly three digits groups thousands ("1.299", "1 299", "1,299" are 1299), and the last "." or "," is the decimal
181
+ * mark otherwise ("1.299,00" is 1299, "549,99" is 549.99, "4.6" is 4.6). */
182
+ export function numberIn(text) {
183
+ const m = /(-?)(\d+)((?:[.,'\u2019 \u00a0\u2009\u202f]\d+)*)/.exec(String(text ?? ""));
184
+ if (!m) return null;
185
+ const head = m[2];
186
+ const parts = m[3] ? m[3].match(/[.,'\u2019 \u00a0\u2009\u202f]\d+/g) : [];
187
+ const decimalMark = (p) => p[0] === "." || p[0] === ",";
188
+ let dec = -1;
189
+ parts.forEach((p, i) => {
190
+ if (decimalMark(p)) dec = i;
191
+ });
192
+ // the last "." or "," groups thousands when three digits follow it, one to three (not just 0) lead, and no other kind
193
+ // of decimal mark comes before it: "1.299", "1,299,000"
194
+ if (dec >= 0 && parts[dec].length === 4 && head.length <= 3 && head !== "0" && parts.slice(0, dec).every((p) => !decimalMark(p) || p[0] === parts[dec][0])) dec = -1;
195
+ let whole = head;
196
+ for (const part of parts.slice(0, dec >= 0 ? dec : parts.length)) {
197
+ // a mark that does not group thousands ends the number: "2 3-year plans" is 2
198
+ if (head.length > 3 || part.length !== 4) return Number(m[1] + whole);
199
+ whole += part.slice(1);
200
+ }
201
+ return Number(m[1] + whole + (dec >= 0 ? `.${parts[dec].slice(1)}` : ""));
202
+ }
203
+
204
+ /** The number a page shows for a seed fact (site.known): a value with its source, or an estimate; else null. */
205
+ export function knownNumber(fact) {
206
+ if (!hasValue(fact) || !(fact.src || fact.est)) return null;
207
+ if (typeof fact.n === "number") return fact.n;
208
+ return numberIn(valueOf(fact));
209
+ }
210
+
211
+ /** Is this data.json a page of options: compare (columns of facts per item) or ranked (a price and specs per item)? */
212
+ export function isOptionsData(data) {
213
+ if (!data || typeof data !== "object" || !Array.isArray(data.items)) return false;
214
+ return Array.isArray(data.columns) || data.items.some((i) => i && typeof i === "object" && ("price" in i || "specs" in i || "rank" in i));
215
+ }
216
+
217
+ /** Every value of a page of options: [{item, ref, label, key, fact, price}]. Compare: each column of each item;
218
+ * ranked: each item's price (when it has one or a store), specs and details. */
219
+ export function optionCells(data) {
220
+ const out = [];
221
+ if (!isOptionsData(data)) return out;
222
+ const columns = Array.isArray(data.columns) ? data.columns.filter((c) => c && typeof c === "object" && c.key) : null;
223
+ for (const item of data.items) {
224
+ if (!item || typeof item !== "object") continue;
225
+ const base = { item: String(item.name ?? item.ref ?? "an item"), ref: item.ref };
226
+ if (columns) {
227
+ for (const c of columns.slice(0, 5)) out.push({ ...base, label: String(c.label ?? c.key), key: c.key, fact: item.facts?.[c.key], price: c.type === "price" });
228
+ continue;
229
+ }
230
+ if ("price" in item || item.store) out.push({ ...base, label: "Price", key: "price", fact: item.price, price: true });
231
+ for (const fact of Array.isArray(item.specs) ? item.specs : []) out.push({ ...base, label: "spec", key: null, fact });
232
+ for (const d of Array.isArray(item.details) ? item.details : []) out.push({ ...base, label: String(d?.label ?? d?.key ?? "detail"), key: d?.key ?? null, fact: d?.fact });
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /** The newest live record per ref and attribute in the site's record files (what a check wrote): Map ref -> record[]. */
238
+ function liveRecords(root) {
239
+ const byRef = new Map();
240
+ const dir = path.join(root, "data", "records");
241
+ let files = [];
242
+ try {
243
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
244
+ } catch {
245
+ return byRef;
246
+ }
247
+ for (const file of files) {
248
+ let value;
249
+ try {
250
+ value = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
251
+ } catch {
252
+ continue;
253
+ }
254
+ for (const r of Array.isArray(value?.records) ? value.records : []) {
255
+ if (!r || typeof r !== "object" || typeof r.ref !== "string") continue;
256
+ (byRef.get(r.ref) || byRef.set(r.ref, []).get(r.ref)).push(r);
257
+ }
258
+ }
259
+ return byRef;
260
+ }
261
+
262
+ /** Does a check record give the row a value for `key` that the page shows (a priced offer, or a claim that found it)? */
263
+ function liveValue(records, key) {
264
+ const list = (records || []).filter((r) => (key === "price" && r.type === "offer" && typeof r.price?.amount === "number") || (r.type === "claim" && r.attr === key));
265
+ const newest = list.sort((a, b) => (Date.parse(b.prov?.observed_at) || 0) - (Date.parse(a.prov?.observed_at) || 0))[0];
266
+ if (!newest) return null;
267
+ if (newest.type === "offer") return newest.price.amount;
268
+ if (["uncertain", "not_found", "error"].includes(newest.status) || newest.value == null) return null;
269
+ return NO_VALUE.test(String(newest.value).trim()) ? null : numberIn(String(newest.value));
270
+ }
271
+
272
+ /** The values of a site's page of options: problems to fix before the card, and notes for the reply. */
273
+ export function checkValues(dir) {
274
+ const root = path.resolve(dir);
275
+ const problems = [];
276
+ const notes = [];
277
+ let data;
278
+ try {
279
+ data = JSON.parse(fs.readFileSync(path.join(root, "data.json"), "utf8"));
280
+ } catch {
281
+ return { checked: false, problems, notes };
282
+ }
283
+ if (!isOptionsData(data)) return { checked: false, problems, notes };
284
+ for (const cell of optionCells(data)) {
285
+ const { fact } = cell;
286
+ const where = `${cell.item} · ${cell.label}`;
287
+ if (!fact || typeof fact !== "object") continue; // drawn "Unknown": nothing was found, and the page says so
288
+ if (fact.v != null && !hasValue(fact)) {
289
+ problems.push(`${where}: "${valueOf(fact).trim() || " "}" says nothing. Leave out "v" and say why in "note" ("no price on maker.com's page"), or give the page you read that does not list it ({"src", "url", "date"}); the page then says "Unknown".`);
290
+ continue;
291
+ }
292
+ if (!hasValue(fact) || fact.est) continue;
293
+ const lacks = [!fact.src && '"src"', !httpsUrl(fact.url) && '"url" (the page)', !fact.date && !fact.asof && '"date" (the day)'].filter(Boolean);
294
+ const list = lacks.length > 1 ? `${lacks.slice(0, -1).join(", ")} or ${lacks[lacks.length - 1]}` : lacks[0];
295
+ if (lacks.length) problems.push(`${where}: "${valueOf(fact)}" has no ${list}: add where you read it and when ("checked 27 Oct on maker.com"), or leave the value out.${fact.src ? "" : " Without a source the page shows it as not checked."}`);
296
+ }
297
+
298
+ const filter = data.filter;
299
+ if (filter && typeof filter === "object") {
300
+ const label = String(filter.label || "the filter");
301
+ const columns = Array.isArray(data.columns) ? data.columns : null;
302
+ const isNumber = (v) => v != null && v !== "" && Number.isFinite(Number(v));
303
+ if (!filter.key) problems.push(`the filter "${label}" has no "key".`);
304
+ if (!filter.label) problems.push('the filter has no "label" (what the person asked for: "€600 or less").');
305
+ else if (columns && !columns.some((c) => c && c.key === filter.key)) problems.push(`the filter's key "${filter.key}" is not a column, so "${label}" filters nothing.`);
306
+ if (!isNumber(filter.max) && !isNumber(filter.min)) problems.push(`the filter "${label}" has no number in "max" or "min".`);
307
+ let app = "";
308
+ try {
309
+ app = fs.readFileSync(path.join(root, "app.js"), "utf8");
310
+ } catch {
311
+ /* a page without app.js draws no filter */
312
+ }
313
+ if (app && !/\bsite\.byFilter\s*\(|\bsite\.within\s*\(/.test(app)) {
314
+ problems.push(`data.json has a filter, and app.js applies it without site.byFilter: a row whose value is unknown may be listed as "${label}". Use the starter's filter code (site.byFilter), which lists such rows apart.`);
315
+ }
316
+ if (filter.key) {
317
+ const live = liveRecords(root);
318
+ // per item, as the page's filter reads it: its value for the key (none counts as unknown), or a check's
319
+ const factOf = (item) => columns ? item.facts?.[filter.key]
320
+ : filter.key === "price" ? item.price : (Array.isArray(item.details) ? item.details : []).find((d) => d?.key === filter.key)?.fact;
321
+ const unknown = data.items
322
+ .filter((item) => item && typeof item === "object")
323
+ .filter((item) => knownNumber(factOf(item)) == null && liveValue(live.get(item.ref), filter.key) == null)
324
+ .map((item) => String(item.name ?? item.ref ?? "an item"));
325
+ if (unknown.length) {
326
+ const col = columns?.find((c) => c && c.key === filter.key);
327
+ const title = filter.unknown || `${col?.label ?? (filter.key === "price" ? "Price" : filter.key)} unknown`;
328
+ notes.push(`${unknown.length} item(s) have no ${String(col?.label ?? filter.key).toLowerCase()} the page can show: ${unknown.join(", ")}. The page lists them apart ("${title}"), never as "${label}"; say so in the lede and the reply, with why for each.`);
329
+ }
330
+ }
331
+ }
332
+ return { checked: true, problems, notes };
333
+ }
334
+
335
+ /** The CLI's lines and exit code: 0 every item shown and every value fine, 1 an item missing or a value to fix, 2
336
+ * nothing to check. */
161
337
  export function checkReport(dir, expect, { json = false } = {}) {
162
338
  const result = checkSite(dir, expect);
163
- if (!result.items.length) {
339
+ const values = checkValues(dir);
340
+ const bad = result.missing.length > 0 || values.problems.length > 0;
341
+ if (!result.items.length && !values.problems.length) {
164
342
  const message = 'nothing to check: pass --expect "<item>,<item>" (each item the request named) or put "expect": [...] in data.json';
165
- return { exit: 2, lines: [json ? JSON.stringify({ ok: false, error: message }) : message] };
343
+ return { exit: 2, lines: [json ? JSON.stringify({ ok: false, error: message, values }) : message, ...(json ? [] : values.notes.map((n) => `NOTE: ${n}`))] };
166
344
  }
167
- if (json) return { exit: result.missing.length ? 1 : 0, lines: [JSON.stringify({ ok: !result.missing.length, ...result })] };
345
+ if (json) return { exit: bad ? 1 : 0, lines: [JSON.stringify({ ok: !bad, ...result, values })] };
168
346
  const lines = [];
169
347
  if (result.missing.length) {
170
348
  lines.push(`MISSING: the request named ${result.missing.length} item(s) the site does not show: ${result.missing.join(", ")}.`);
171
349
  lines.push("Add each one to the page (its data file), or say plainly in the reply which are missing and why.");
172
350
  }
173
- lines.push(`${result.shown.length} of ${result.items.length} expected item(s) shown${result.shown.length ? `: ${result.shown.map((s) => s.item).join(", ")}` : ""}.`);
174
- lines.push(`Read ${result.files.length} file(s): the HTML text and the data files (what a script renders from elsewhere is not seen).`);
175
- return { exit: result.missing.length ? 1 : 0, lines };
351
+ if (values.problems.length) {
352
+ lines.push(`VALUES: ${values.problems.length} value(s) to fix in data.json:`);
353
+ for (const problem of values.problems) lines.push(`- ${problem}`);
354
+ }
355
+ for (const note of values.notes) lines.push(`NOTE: ${note}`);
356
+ if (result.items.length) {
357
+ lines.push(`${result.shown.length} of ${result.items.length} expected item(s) shown${result.shown.length ? `: ${result.shown.map((s) => s.item).join(", ")}` : ""}.`);
358
+ lines.push(`Read ${result.files.length} file(s): the HTML text and the data files (what a script renders from elsewhere is not seen).`);
359
+ } else {
360
+ lines.push('No items to check: pass --expect "<item>,<item>" (each item the request named) or put "expect": [...] in data.json.');
361
+ }
362
+ return { exit: bad ? 1 : 0, lines };
176
363
  }
@@ -10,16 +10,20 @@
10
10
  * changed, then missing ones). Otherwise rows keep the order of "items", before and after a
11
11
  * price check: put them in the order the lede talks about them.
12
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"}
13
+ * filter (only for a constraint the person stated): {key, max, label: "Under $160", rest: "over $160",
14
+ * unknown?: "Price unknown"}. Only a value the page shows counts: a row whose value is
15
+ * unknown is never listed as under $160; it is listed apart, under "Price unknown".
14
16
  * items: [{ref: "option:a", name, why (one short line), badge?: "Previous model", note?, facts: {key: fact},
15
17
  * store: {name, url, readable}, more?: [{label, fact}], sources?: [{name, url}]}]
16
18
  * unchecked: ["what was not checked", …] listed once in the footer
17
19
  * 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.
20
+ * Each value cites its page and day ("checked 17 Oct on maker.com ↗"), and no cell is blank: a source you read that
21
+ * does not give the value is the fact without "v" ({"src", "url", "date"}): "Not listed · checked 17 Oct on
22
+ * maker.com ↗"; any other missing value reads "Unknown", with {"note": "the store blocks price checks"} saying why.
23
+ * Never a dash.
24
+ * When one page gives most of a column on one date, the cells show just the value and one line under the list cites
25
+ * that page; so does a row whose own values come from one other page ("Option C (Support and Ride): checked 16 Oct on
26
+ * fleetfeet.com ↗"). Any other cell keeps its own caption.
23
27
  * Query: ?sort=<key>&show=all&open=<slug>. Rows: id="row-<slug>", data-ref="<ref>".
24
28
  * SPDX-License-Identifier: AGPL-3.0-only */
25
29
  (function () {
@@ -60,7 +64,12 @@
60
64
  if (live && site.blocked(item.ref)) return seed; // a blocked check keeps the seed value
61
65
  return live || seed;
62
66
  }
63
- /** Per column: the source most of its cells share ({src, date, asof, url}), when two or more cells and over half do. */
67
+ /** A fact's page and day: two values share a citation only when one page on one day gives both. */
68
+ function citeKey(f) {
69
+ var page = site.safeUrl(f.url);
70
+ return [page ? page.split("#")[0] : "src:" + f.src, f.date || "", f.asof || ""].join("|");
71
+ }
72
+ /** Per column: the page most of its cells share ({src, date, asof, url}), when two or more cells and over half do. */
64
73
  function sharedSources(data, items) {
65
74
  var out = {};
66
75
  columns(data).forEach(function (c) {
@@ -68,8 +77,8 @@
68
77
  var count = {};
69
78
  items.forEach(function (i) {
70
79
  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("|");
80
+ if (!f || !f.src || f.est || !site.valueText(f)) return;
81
+ var k = citeKey(f);
73
82
  count[k] = count[k] || { n: 0, fact: f };
74
83
  count[k].n++;
75
84
  });
@@ -81,11 +90,11 @@
81
90
  function isShared(shared, item, col) {
82
91
  var s = shared[col.key];
83
92
  var f = seedOf(item, col);
84
- return !!(s && f && f.src && !f.est && [f.src, f.date || "", f.asof || ""].join("|") === s.key);
93
+ return !!(s && f && f.src && !f.est && citeKey(f) === s.key);
85
94
  }
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. */
95
+ /** Per row: the one page its own cells share ({key, fact, labels}), when two or more of its cells outside the price
96
+ * column come from one page on one date that is not their column's. The line under the list cites it once
97
+ * ("Option C (Support and Ride): checked 16 Oct on fleetfeet.com ↗"), so the row is not a stack of links. */
89
98
  function rowSources(data, items, shared) {
90
99
  var out = {};
91
100
  items.forEach(function (i) {
@@ -94,9 +103,9 @@
94
103
  if (isPrice(c) || isShared(shared, i, c)) return;
95
104
  var f = seedOf(i, c);
96
105
  if (!f || !f.src || f.est) return;
97
- var k = [f.src, f.date || "", f.asof || ""].join("|");
106
+ var k = citeKey(f);
98
107
  (by[k] = by[k] || { key: k, fact: f, labels: [], values: 0 }).labels.push(c.label);
99
- if (f.v != null) by[k].values++;
108
+ if (site.valueText(f)) by[k].values++;
100
109
  });
101
110
  var keys = Object.keys(by);
102
111
  if (keys.length === 1 && by[keys[0]].labels.length >= 2 && by[keys[0]].values >= 1) out[i.ref] = by[keys[0]];
@@ -106,14 +115,14 @@
106
115
  function ownRow(rows, item, col) {
107
116
  var r = rows[item.ref];
108
117
  var f = seedOf(item, col);
109
- return !!(r && f && f.src && !f.est && !isPrice(col) && [f.src, f.date || "", f.asof || ""].join("|") === r.key);
118
+ return !!(r && f && f.src && !f.est && !isPrice(col) && citeKey(f) === r.key);
110
119
  }
111
- /** label: say what was not checked ("Price not checked") where no column header or fact label says it. */
120
+ /** A missing value reads "Unknown" (with label: "Price unknown", where no column header or fact label says what). */
112
121
  function cell(item, col, src, label) {
113
122
  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 });
123
+ return site.trust(valueOf(item, col), { price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: seedOf(item, col), label: label, unknown: "Unknown" });
115
124
  }
116
- return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label });
125
+ return site.trust(valueOf(item, col), { shared: isShared(src.cols, item, col) || ownRow(src.rows, item, col), label: label, unknown: "Unknown" });
117
126
  }
118
127
  function hasMore(item) { return !!(item.note || (item.more && item.more.length) || (item.sources && item.sources.length)); }
119
128
 
@@ -125,22 +134,28 @@
125
134
  var def = (data.sort || []).filter(function (s) { return s.key === st.sort; })[0] || (data.sort || [])[0];
126
135
  if (!def) return items;
127
136
  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; };
137
+ var tier = function (i) { var v = valueOf(i, col); return site.known(v) == null ? 2 : site.old(v, isPrice(col)) ? 1 : 0; };
129
138
  return items.slice().sort(function (a, b) {
130
139
  var ta = tier(a), tb = tier(b);
131
140
  if (ta !== tb) return ta - tb;
132
141
  if (ta === 2) return 0;
133
- var x = site.num(valueOf(a, col)), y = site.num(valueOf(b, col));
142
+ var x = site.known(valueOf(a, col)), y = site.known(valueOf(b, col));
134
143
  return def.dir === "desc" ? y - x : x - y;
135
144
  });
136
145
  }
137
- function fits(data, item) {
146
+ /** The rows by the page's one filter: {in, unknown, out}. A row whose value is unknown is never "in". */
147
+ function byFilter(data, items) {
148
+ var f = data.filter;
149
+ var col = f ? colOf(data, f.key) || { key: f.key } : null;
150
+ return site.byFilter(items, f, function (i) { return valueOf(i, col); });
151
+ }
152
+ /** "Price unknown": the heading of the rows the filter cannot place. */
153
+ function unknownTitle(data) {
138
154
  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);
155
+ var col = colOf(data, f.key);
156
+ return f.unknown || ((col ? col.label : f.key) + " unknown");
143
157
  }
158
+ function lowerFirst(text) { text = String(text || ""); return text.charAt(0).toLowerCase() + text.slice(1); }
144
159
 
145
160
  function render(data, st) {
146
161
  site.head(data);
@@ -152,8 +167,12 @@
152
167
  items = lastOrder.map(function (ref) { return all.filter(function (i) { return i.ref === ref; })[0]; }).filter(Boolean);
153
168
  }
154
169
  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;
170
+ // "All" is one list; the stated limit shows the rows within it, then the rows nobody could place ("Price unknown",
171
+ // never counted as within it), and one line for the rest
172
+ var parts = st.show === "all" || !data.filter ? { "in": items, unknown: [], out: [] } : byFilter(data, items);
173
+ var shown = parts["in"];
174
+ var apart = parts.unknown;
175
+ var hidden = parts.out.length;
157
176
  var hasPrice = columns(data).some(isPrice);
158
177
  var cols = sharedSources(data, all);
159
178
  var shared = { cols: cols, rows: rowSources(data, all, cols) };
@@ -179,7 +198,15 @@
179
198
  } else {
180
199
  var stores = hasPrice ? site.priceNotice(all) : null;
181
200
  wideNow = isWide();
182
- var wrap = h("div", { "class": "stack" }, wideNow ? table(data, shown, st, shared) : cards(data, shown, st, shared));
201
+ var wrap = h("div", { "class": "stack" });
202
+ if (shown.length) wrap.appendChild(wideNow ? table(data, shown, st, shared) : cards(data, shown, st, shared));
203
+ else wrap.appendChild(h("p", { "class": "cap", text: "None is known to be " + lowerFirst(data.filter.label || "within the limit") + "." }));
204
+ if (apart.length) {
205
+ wrap.appendChild(h("section", { "class": "group", attrs: { id: "group-unknown", "aria-label": unknownTitle(data) } },
206
+ h("div", { "class": "group-head" }, h("h2", { text: unknownTitle(data) }),
207
+ h("span", { "class": "cap", text: "Not counted as " + lowerFirst(data.filter.label || "within the limit") })),
208
+ wideNow ? table(data, apart, st, shared) : cards(data, apart, st, shared)));
209
+ }
183
210
  var lines = [];
184
211
  if (hidden > 0) {
185
212
  lines.push(h("p", { "class": "cap hidden-line" }, hidden + " more " + (data.filter.rest || "hidden") + " · ",
@@ -215,12 +242,12 @@
215
242
  var def = (data.sort || []).filter(function (s) { return s.key === st.sort; })[0] || (data.sort || [])[0];
216
243
  if (!def) return null;
217
244
  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;
245
+ var n = items.filter(function (i) { return site.known(valueOf(i, col)) == null; }).length;
219
246
  if (!n || n === items.length) return null;
220
247
  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
248
  }
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. */
249
+ /** "Support and Heel drop: checked 16 Oct on runningshoesguru.com ↗", plus ", unless a value says otherwise" when a
250
+ * cell of those columns has its own source. */
224
251
  function sharedLine(data, src, items) {
225
252
  var groups = {};
226
253
  var and = function (list) { return list.length > 1 ? list.slice(0, -1).join(", ") + " and " + list[list.length - 1] : list[0]; };
@@ -230,18 +257,16 @@
230
257
  var g = groups[s.key] = groups[s.key] || { fact: s.fact, labels: [], others: false };
231
258
  g.labels.push(c.label);
232
259
  // 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;
260
+ if (items.some(function (i) { var f = seedOf(i, c); return f && f.src && site.valueText(f) && !isShared(src.cols, i, c) && !ownRow(src.rows, i, c); })) g.others = true;
234
261
  });
235
262
  var out = Object.keys(groups).map(function (k) {
236
263
  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" : "");
264
+ return h("p", { "class": "cap" }, and(g.labels) + ": ", site.cite(g.fact), g.others ? ", unless a value says otherwise" : "");
239
265
  });
240
266
  items.forEach(function (i) {
241
267
  var r = src.rows[i.ref];
242
268
  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 })] : ""));
269
+ out.push(h("p", { "class": "cap" }, i.name + " (" + and(r.labels) + "): ", site.cite(r.fact)));
245
270
  });
246
271
  return out.length ? h("div", null, out) : null;
247
272
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "title": "Compare three options",
3
- "lede": "Option B costs least and Option A is the lightest. Option C's price was not checked.",
3
+ "lede": "Option B costs least and Option A is the lightest. Option C's price is unknown.",
4
4
  "sort": [
5
5
  {
6
6
  "key": "price",
@@ -4,7 +4,9 @@
4
4
  * data.json:
5
5
  * title, lede the page title and its one-line answer (the verdict, and the biggest unknown if there is one)
6
6
  * basis how it is ranked, one line: "Ranked by safety reviews, then price."
7
- * filter (only for a constraint the person stated): {key: "price", max: 300, label: "Under $300"}
7
+ * filter (only for a constraint the person stated): {key: "price", max: 300, label: "Under $300", unknown?:
8
+ * "Price unknown"}. Only a price the page shows counts: a row without one is never listed as under $300;
9
+ * it is listed apart, under "Price unknown".
8
10
  * items: [{ref: "option:a", rank, name, why (one short line), badge?, specs: [fact] (at most 3), price?: fact,
9
11
  * store?: {name, url, readable}, details?: [{label, fact}], sources?: [{name, url}]}]
10
12
  * badge: only for a label the person must notice. "Our pick" only when it is your own pick and "why" says why;
@@ -12,8 +14,9 @@
12
14
  * "BabyGearLab: best budget"), never a "Details" toggle. Nothing is added by itself.
13
15
  * price: the price as a fact (not in specs), with "asof" when the source dates it; a live check replaces it. A
14
16
  * review site's price is its list price: "list": true, so the row says "list price" and never reads as the
15
- * store's price now. When every price comes from one source on one date, one line under the list names the
16
- * source once ("List prices on BabyGearLab ↗ · seen 5 Aug"), and "Sources" does not name it again.
17
+ * store's price now. When one page on one date gives every price, one line under the list names it once
18
+ * ("List prices on BabyGearLab ↗ · seen 5 Aug"), and "Sources" does not name it again. A missing price reads
19
+ * "Price unknown", then why ({"note": "the store blocks price checks"}).
17
20
  * details: only facts the row does not already show.
18
21
  * unchecked: ["what was not checked", …]
19
22
  * fact: {"v": "$145", "src": "Example Store", "url": "https://…", "date": "2026-10-17", "est": false}
@@ -31,24 +34,36 @@
31
34
  render: render
32
35
  });
33
36
 
34
- function priceOf(item) { return site.live(item.ref, "price") || item.price || null; }
37
+ /** The price a row shows: a live check, else the seed price (also when the check found the store blocked). */
38
+ function priceOf(item) {
39
+ var live = site.live(item.ref, "price");
40
+ if (live && site.blocked(item.ref)) return item.price || live;
41
+ return live || item.price || null;
42
+ }
35
43
  function valueFor(item, key) {
36
44
  if (key === "price") return priceOf(item);
37
45
  var d = (item.details || []).filter(function (x) { return x.key === key; })[0];
38
46
  return d ? d.fact : null;
39
47
  }
40
- function fits(data, item) {
48
+ /** "Price unknown": the heading of the rows the filter cannot place. */
49
+ function unknownTitle(data) {
41
50
  var f = data.filter;
42
- if (!f) return true;
43
- var n = site.num(valueFor(item, f.key));
44
- if (n == null) return true;
45
- return (f.max == null || n <= f.max) && (f.min == null || n >= f.min);
51
+ if (f.unknown) return f.unknown;
52
+ if (f.key === "price") return "Price unknown";
53
+ var d = (data.items || []).map(function (i) { return (i.details || []).filter(function (x) { return x.key === f.key; })[0]; }).filter(Boolean)[0];
54
+ return ((d && d.label) || f.key) + " unknown";
46
55
  }
56
+ function lowerFirst(text) { text = String(text || ""); return text.charAt(0).toLowerCase() + text.slice(1); }
47
57
  function render(data, st) {
48
58
  site.head(data); // title, lede and the basis line ("Ranked by …") come first, before any button
49
59
  var items = (data.items || []).slice().sort(function (a, b) { return (a.rank || 0) - (b.rank || 0); });
50
- var shown = st.show === "all" ? items : items.filter(function (i) { return fits(data, i); });
51
- var hidden = items.length - shown.length;
60
+ // the stated limit shows the rows within it, then the rows nobody could place ("Price unknown", never counted as
61
+ // within it); "All" is the whole ranking
62
+ var parts = st.show === "all" || !data.filter ? { "in": items, unknown: [], out: [] }
63
+ : site.byFilter(items, data.filter, function (i) { return valueFor(i, data.filter.key); });
64
+ var shown = parts["in"];
65
+ var apart = parts.unknown;
66
+ var hidden = parts.out.length;
52
67
  var priced = items.some(function (i) { return i.price || i.store; });
53
68
  var shared = sharedPrice(items);
54
69
 
@@ -72,7 +87,14 @@
72
87
  // today's price is ("Open a store for today's price."), instead of a box about checks nobody was offered
73
88
  var inline = !!(shared && stores && site.storesBlocked(items));
74
89
  if (inline) stores = null;
75
- wrap.appendChild(h("ol", { "class": "list ranked" }, shown.map(function (item) { return row(item, st, priced, shared); })));
90
+ if (shown.length) wrap.appendChild(h("ol", { "class": "list ranked" }, shown.map(function (item) { return row(item, st, priced, shared); })));
91
+ else wrap.appendChild(h("p", { "class": "cap", text: "None is known to be " + lowerFirst(data.filter.label || "within the limit") + "." }));
92
+ if (apart.length) {
93
+ wrap.appendChild(h("section", { "class": "group", attrs: { id: "group-unknown", "aria-label": unknownTitle(data) } },
94
+ h("div", { "class": "group-head" }, h("h2", { text: unknownTitle(data) }),
95
+ h("span", { "class": "cap", text: "Not counted as " + lowerFirst(data.filter.label || "within the limit") })),
96
+ h("ol", { "class": "list ranked" }, apart.map(function (item) { return row(item, st, priced, shared); }))));
97
+ }
76
98
  if (hidden > 0) {
77
99
  wrap.appendChild(h("p", { "class": "cap" }, hidden + " hidden · ",
78
100
  h("button", { "class": "link-btn", text: "Show all", attrs: { type: "button", "data-key": "show-all" }, on: { click: function () { site.setState({ show: "all" }); } } })));
@@ -93,11 +115,12 @@
93
115
  site.notes(items.length ? data.unchecked : [], items.length ? sources : []);
94
116
  }
95
117
 
96
- /** The price fact every priced row shares (same source and date), else null. */
118
+ /** The price fact every priced row shares (one page on one date), else null. */
97
119
  function sharedPrice(items) {
98
120
  var facts = items.map(function (i) { return i.price; }).filter(Boolean);
99
121
  if (facts.length < 2) return null;
100
- var key = function (f) { return f && f.src && !f.est && f.v != null ? [f.src, f.asof || f.date || "", f.list ? "list" : ""].join("|") : null; };
122
+ var page = function (f) { var u = site.safeUrl(f.url); return u ? u.split("#")[0] : "src:" + f.src; };
123
+ var key = function (f) { return f && f.src && !f.est && site.valueText(f) ? [page(f), f.asof || f.date || "", f.list ? "list" : ""].join("|") : null; };
101
124
  var k = key(facts[0]);
102
125
  return k && facts.every(function (f) { return key(f) === k; }) ? facts[0] : null;
103
126
  }
@@ -149,7 +172,7 @@
149
172
  item.why ? h("p", { "class": "why", text: item.why }) : null,
150
173
  specs.length ? h("p", { "class": "cap", text: specs.join(" · ") }) : null,
151
174
  // every priced row has the same price line, with or without a store
152
- priced ? site.trust(live || item.price || null, { label: "Price", price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: item.price || null, shared: !live && !!shared && !!item.price }) : null,
175
+ priced ? site.trust(live || item.price || null, { label: "Price", unknown: "Unknown", price: true, ref: item.ref, intent: "read_offer", store: item.store, seed: item.price || null, shared: !live && !!shared && !!item.price }) : null,
153
176
  open && hasMore(item) ? more(item) : null));
154
177
  }
155
178
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "title": "Top three options",
3
- "lede": "Option A is the best all-round pick. Option C costs least, but its price was not checked.",
3
+ "lede": "Option A is the best all-round pick. Option C's price is unknown: its store blocks price checks.",
4
4
  "basis": "Ranked by review scores, then price.",
5
5
  "items": [
6
6
  {
@@ -106,7 +106,7 @@
106
106
  "ref": "option:option-c",
107
107
  "rank": 3,
108
108
  "name": "Option C",
109
- "why": "The lowest price, fewer reviews",
109
+ "why": "Well reviewed, by fewer people",
110
110
  "specs": [
111
111
  {
112
112
  "v": "9.8 oz",