@nanobpm/urban 0.53.0 → 0.54.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.
@@ -575,6 +575,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
575
575
  .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
576
576
  .pc-group-toggle:hover { background:var(--nano-hover); }
577
577
  .pc-group-title { font-size:.9rem; }
578
+ /* Data-bound prose/markdown list (renderProse, #274). Long-form narrative
579
+ records — plan-review findings, coordination notes, escalation bodies — read
580
+ as a stacked, full-width document at a comfortable measure (~66ch) instead of
581
+ being jammed into a grid column. Each item stacks a small header over a
582
+ markdown-rendered body; the body max-width is set inline per node (props.measure)
583
+ with this rule as the no-JS fallback. */
584
+ .pc-prose-list { display:flex; flex-direction:column; gap:1.1rem; }
585
+ .pc-prose-item { border-top:1px solid var(--nano-edge); padding-top:.9rem; }
586
+ .pc-prose-item:first-child { border-top:0; padding-top:0; }
587
+ .pc-prose-head { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
588
+ .pc-prose-empty { color:var(--nano-text-faint); }
589
+ .pc-prose-body { max-width:66ch; }
590
+ .pc-prose-body > :first-child { margin-top:0; }
591
+ .pc-prose-body > :last-child { margin-bottom:0; }
592
+ .pc-md-p { margin:.55rem 0; overflow-wrap:anywhere; }
593
+ .pc-md-h { font-size:1.05rem; font-weight:650; margin:.95rem 0 .4rem; }
594
+ .pc-md-list { margin:.45rem 0; padding-left:1.35rem; }
595
+ .pc-md-list li { margin:.15rem 0; }
596
+ .pc-md-quote { margin:.6rem 0; padding:.1rem .9rem; border-left:3px solid var(--nano-edge-strong); color:var(--nano-text-muted); }
597
+ .pc-md-pre { background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.7rem .8rem; overflow:auto; margin:.6rem 0; }
598
+ .pc-md-pre code { font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--nano-text); white-space:pre; }
599
+ .pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
600
+ .pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
601
+ .pc-prose-body a { color:var(--nano-accent-strong); }
578
602
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
579
603
  .pc-buttonrow { margin:1rem 0; }
580
604
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -682,8 +706,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
682
706
  `;
683
707
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
684
708
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
685
- // fetches the home page's page.json and renders text / actionForm / dataGrid nodes,
686
- // wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
709
+ // fetches the home page's page.json and renders text / actionForm / dataGrid / prose
710
+ // nodes, wiring actionForm → /app/actions/start and dataGrid/prose → /app/data (with a refresh).
687
711
  const RENDERER_JS = String.raw `
688
712
  const root = document.getElementById("page");
689
713
  const HOME = root.dataset.home || "home";
@@ -1682,6 +1706,248 @@ function renderActionForm(node) {
1682
1706
  // Single source of truth for both the initial render and every refresh below.
1683
1707
  function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1684
1708
 
1709
+ // Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
1710
+ // threading the whitelisted where/order query params. Shared by dataGrid and the
1711
+ // prose renderer so the two can't drift on how a filter/orderBy is encoded. A
1712
+ // filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
1713
+ // current route PARAM — "show the selected entity's rows"), or { field, eq } (a
1714
+ // literal equals); orderBy is { field, dir }. The source/table/field names are
1715
+ // server-side whitelisted (the /app/data route rejects unknown columns).
1716
+ function dataUrl(source, tbl, filters, order) {
1717
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1718
+ const qs = [];
1719
+ for (const f of filters || []) {
1720
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1721
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1722
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1723
+ }
1724
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1725
+ return qs.length ? u + "?" + qs.join("&") : u;
1726
+ }
1727
+
1728
+ // ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
1729
+ // A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
1730
+ // inline token is materialised as a known element via el(), with text set through
1731
+ // textContent / createTextNode — so a record's body field may carry markdown
1732
+ // WITHOUT ever becoming an HTML/script injection vector (the same text-only
1733
+ // guarantee interpTemplate gives a grid cell). Supported: ATX headings
1734
+ // (#..######), fenced + inline code, bold (**/__), italic (*/_), links
1735
+ // [text](url) (http/https/mailto only — every other scheme degrades to its plain
1736
+ // label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
1737
+ // thematic breaks (---), and blank-line-separated paragraphs. The backtick used
1738
+ // for code spans/fences is built from a char code so this source stays inside the
1739
+ // String.raw template literal (which is itself delimited by a backtick).
1740
+ const MD_BACKTICK = String.fromCharCode(96);
1741
+ function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
1742
+ // Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
1743
+ // vbscript:, a bare relative path, …) returns "" so the caller renders the label
1744
+ // as plain text rather than a clickable, potentially-hostile link.
1745
+ function mdSafeHref(href) {
1746
+ const h = String(href == null ? "" : href).trim();
1747
+ return /^(?:https?:|mailto:)/i.test(h) ? h : "";
1748
+ }
1749
+ // Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
1750
+ // close must not be intra-word (a following alphanumeric means snake_case, not a
1751
+ // delimiter), so keep scanning past such runs; -1 when there is no valid close.
1752
+ function mdFindClose(s, marker, from, underscore) {
1753
+ let j = from;
1754
+ while (true) {
1755
+ const k = s.indexOf(marker, j);
1756
+ if (k < 0) return -1;
1757
+ if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
1758
+ j = k + marker.length;
1759
+ }
1760
+ }
1761
+ function mdInline(text) {
1762
+ const s = String(text == null ? "" : text);
1763
+ const nodes = [];
1764
+ let buf = "";
1765
+ const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
1766
+ let i = 0;
1767
+ while (i < s.length) {
1768
+ const ch = s[i];
1769
+ // Backslash escape: the next char is emitted literally, never as a delimiter.
1770
+ if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
1771
+ // Inline code span — verbatim, no nested inline parsing.
1772
+ if (ch === MD_BACKTICK) {
1773
+ const end = s.indexOf(MD_BACKTICK, i + 1);
1774
+ if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
1775
+ }
1776
+ // Strong (** or __). Underscore only when it opens on a word boundary so an
1777
+ // identifier like a__b doesn't get mangled into emphasis.
1778
+ if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
1779
+ const underscore = ch === "_";
1780
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1781
+ const marker = ch + ch;
1782
+ const end = mdFindClose(s, marker, i + 2, underscore);
1783
+ if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
1784
+ }
1785
+ }
1786
+ // Emphasis (* or _), same word-boundary guard for underscore.
1787
+ if (ch === "*" || ch === "_") {
1788
+ const underscore = ch === "_";
1789
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1790
+ const end = mdFindClose(s, ch, i + 1, underscore);
1791
+ if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
1792
+ }
1793
+ }
1794
+ // Link [label](href): href sanitised; an unsafe scheme drops to the label text.
1795
+ if (ch === "[") {
1796
+ const close = s.indexOf("]", i + 1);
1797
+ if (close > i && s[close + 1] === "(") {
1798
+ const paren = s.indexOf(")", close + 2);
1799
+ if (paren > close) {
1800
+ const href = mdSafeHref(s.slice(close + 2, paren));
1801
+ const label = mdInline(s.slice(i + 1, close));
1802
+ flush();
1803
+ if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
1804
+ else for (const l of label) nodes.push(l);
1805
+ i = paren + 1; continue;
1806
+ }
1807
+ }
1808
+ }
1809
+ buf += ch; i++;
1810
+ }
1811
+ flush();
1812
+ return nodes;
1813
+ }
1814
+ // Regexes that classify a block-opening line; shared by the block loop and the
1815
+ // paragraph terminator so the two can't disagree on where a paragraph ends.
1816
+ const MD_HEADING = /^(#{1,6})\s+(.*)$/;
1817
+ const MD_QUOTE = /^>\s?/;
1818
+ const MD_UL = /^[-*+]\s+/;
1819
+ const MD_OL = /^\d+[.)]\s+/;
1820
+ const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
1821
+ function mdBlockStart(t) {
1822
+ return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
1823
+ || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
1824
+ }
1825
+ // Parse a markdown string into an array of block-level DOM nodes.
1826
+ function mdToNodes(src) {
1827
+ const out = [];
1828
+ const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
1829
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1830
+ let i = 0;
1831
+ while (i < lines.length) {
1832
+ const t = lines[i].trim();
1833
+ if (t === "") { i++; continue; }
1834
+ // Fenced code block — content is verbatim (no inline parsing).
1835
+ if (t.slice(0, 3) === fence) {
1836
+ const body = [];
1837
+ i++;
1838
+ while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
1839
+ if (i < lines.length) i++; // consume the closing fence
1840
+ out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
1841
+ continue;
1842
+ }
1843
+ // Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
1844
+ if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
1845
+ // ATX heading.
1846
+ const h = MD_HEADING.exec(t);
1847
+ if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
1848
+ // Blockquote — strip one '>' per line and re-parse the inner block(s).
1849
+ if (MD_QUOTE.test(t)) {
1850
+ const inner = [];
1851
+ while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
1852
+ const bq = el("blockquote", { class: "pc-md-quote" });
1853
+ for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
1854
+ out.push(bq); continue;
1855
+ }
1856
+ // Unordered list.
1857
+ if (MD_UL.test(t)) {
1858
+ const ul = el("ul", { class: "pc-md-list" });
1859
+ while (i < lines.length && MD_UL.test(lines[i].trim())) {
1860
+ ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
1861
+ i++;
1862
+ }
1863
+ out.push(ul); continue;
1864
+ }
1865
+ // Ordered list.
1866
+ if (MD_OL.test(t)) {
1867
+ const ol = el("ol", { class: "pc-md-list" });
1868
+ while (i < lines.length && MD_OL.test(lines[i].trim())) {
1869
+ ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
1870
+ i++;
1871
+ }
1872
+ out.push(ol); continue;
1873
+ }
1874
+ // Paragraph — soft-wrapped lines joined with a space until a blank line or the
1875
+ // next block opener.
1876
+ const para = [];
1877
+ while (i < lines.length) {
1878
+ const lt = lines[i].trim();
1879
+ if (lt === "" || mdBlockStart(lt)) break;
1880
+ para.push(lt); i++;
1881
+ }
1882
+ out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
1883
+ }
1884
+ return out;
1885
+ }
1886
+
1887
+ // A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
1888
+ // (props.data = { source, table, orderBy, filter }) but renders each row as a
1889
+ // stacked prose block — a small header template (props.header, e.g.
1890
+ // "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
1891
+ // sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
1892
+ // This is the primitive for narrative records (plan-review findings, coordination
1893
+ // notes, escalation bodies) that read as a document, not a squeezed table cell.
1894
+ // It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
1895
+ // unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
1896
+ function renderProse(node) {
1897
+ const p = node.props || {};
1898
+ const data = p.data || {};
1899
+ const card = el("section", { class: "pc-card" });
1900
+ if (p.title) card.append(el("h2", {}, p.title));
1901
+ const listEl = el("div", { class: "pc-prose-list" });
1902
+ card.append(listEl);
1903
+ const headerTpl = typeof p.header === "string" ? p.header : null;
1904
+ const bodyField = p.body;
1905
+ // Clamp the reading measure to a sane range so a malformed schema can't set a
1906
+ // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
1907
+ let measure = Number(p.measure);
1908
+ if (!Number.isFinite(measure)) measure = 66;
1909
+ measure = Math.max(40, Math.min(100, Math.round(measure)));
1910
+ const activeFilter = Array.isArray(data.filter) ? data.filter : [];
1911
+
1912
+ function itemFor(row) {
1913
+ const item = el("article", { class: "pc-prose-item" });
1914
+ if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
1915
+ const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
1916
+ // Own-property gate (matching interpTemplate) so a body field name that
1917
+ // collides with a prototype key can't pick up prototype cruft.
1918
+ const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
1919
+ ? String(row[bodyField])
1920
+ : "";
1921
+ for (const b of mdToNodes(raw)) bodyEl.append(b);
1922
+ item.append(bodyEl);
1923
+ return item;
1924
+ }
1925
+
1926
+ async function refresh() {
1927
+ try {
1928
+ // A param-scoped list ("show the selected entity's records") with no route
1929
+ // param present renders nothing rather than a field=empty-string query.
1930
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1931
+ const { rows } = paramScoped && PARAM === ""
1932
+ ? { rows: [] }
1933
+ : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
1934
+ listEl.replaceChildren();
1935
+ if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
1936
+ for (const row of rows) listEl.append(itemFor(row));
1937
+ } catch (e) {
1938
+ listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
1939
+ }
1940
+ }
1941
+ document.addEventListener("pc:refresh", refresh);
1942
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
1943
+ if (p.refreshMs && p.refreshMs > 0) {
1944
+ const timer = setInterval(refresh, p.refreshMs);
1945
+ disposers.push(() => clearInterval(timer));
1946
+ }
1947
+ refresh();
1948
+ return card;
1949
+ }
1950
+
1685
1951
  function renderDataGrid(node) {
1686
1952
  const p = node.props;
1687
1953
  const card = el("section", { class: "pc-card" });
@@ -1755,18 +2021,6 @@ function renderDataGrid(node) {
1755
2021
  return v == null ? null : String(v);
1756
2022
  };
1757
2023
 
1758
- function dataUrl(source, tbl, filters, order) {
1759
- let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1760
- const qs = [];
1761
- for (const f of filters || []) {
1762
- if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1763
- else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1764
- else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1765
- }
1766
- if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1767
- return qs.length ? u + "?" + qs.join("&") : u;
1768
- }
1769
-
1770
2024
  async function fireAction(action, row) {
1771
2025
  // Route-driven row action: POST the action's body template (default {})
1772
2026
  // to its route path, with {{row.KEY}} tokens resolved from this row.
@@ -2172,7 +2426,7 @@ function renderNav(node) {
2172
2426
  return nav;
2173
2427
  }
2174
2428
 
2175
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
2429
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
2176
2430
 
2177
2431
  // Durable per-node UI state. localStorage is keyed by the home page id + node
2178
2432
  // id so two grids on the same page (or the same grid across pages) don't clash,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.53.0",
3
+ "version": "0.54.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -891,6 +891,93 @@ test("the renderer ships a button node that opens a copy-pasteable modal", async
891
891
  });
892
892
 
893
893
 
894
+ test("the renderer ships a data-bound prose/markdown list node (#274)", async () => {
895
+ const res = await dispatch("GET", "/app/runtime.js");
896
+ const js = res.body ?? "";
897
+ // prose is a first-class node type alongside dataGrid.
898
+ assert.match(js, /prose: renderProse/);
899
+ assert.match(js, /function renderProse\(node\)/);
900
+ // It binds a datasource like dataGrid via the SHARED dataUrl builder (no drift):
901
+ // dataUrl is hoisted to module scope and used by both renderers.
902
+ assert.match(js, /function dataUrl\(source, tbl, filters, order\)/);
903
+ assert.match(js, /getJSON\(dataUrl\(data\.source, data\.table, activeFilter, data\.orderBy\)\)/);
904
+ // A param-scoped list with no route param renders nothing (no field=empty query),
905
+ // mirroring the grid's eqParam short-circuit.
906
+ assert.match(js, /paramScoped && PARAM === ""/);
907
+ // The active filter is Array.isArray-guarded (matching other malformed-schema
908
+ // guards) so a truthy non-array filter degrades to [] instead of throwing on .some.
909
+ assert.match(js, /const activeFilter = Array\.isArray\(data\.filter\) \? data\.filter : \[\]/);
910
+ // Per-item: a header template (interpTemplate, text-only) over one markdown body field.
911
+ assert.match(js, /el\("div", \{ class: "pc-prose-head" \}, interpTemplate\(headerTpl, row\)\)/);
912
+ assert.match(js, /const bodyField = p\.body/);
913
+ assert.match(js, /for \(const b of mdToNodes\(raw\)\) bodyEl\.append\(b\)/);
914
+ // The reading measure is clamped to a sane ch range (default ~66) and applied inline.
915
+ assert.match(js, /measure = Math\.max\(40, Math\.min\(100, Math\.round\(measure\)\)\)/);
916
+ assert.match(js, /"max-width:" \+ measure \+ "ch"/);
917
+ // Own-property gate on the body field (matching interpTemplate) so it can't pick
918
+ // up prototype cruft.
919
+ assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, bodyField\)/);
920
+ // It polls + honours pc:refresh like dataGrid, and registers disposers so a page
921
+ // switch stops the poll / removes the listener.
922
+ assert.match(js, /document\.addEventListener\("pc:refresh", refresh\)/);
923
+ assert.match(js, /const timer = setInterval\(refresh, p\.refreshMs\)/);
924
+ assert.match(js, /disposers\.push\(\(\) => clearInterval\(timer\)\)/);
925
+ // It returns a pc-card with an <h2> title so makeCollapsible gives it
926
+ // collapsible/defaultCollapsed parity for free.
927
+ assert.match(js, /el\("h2", \{\}, p\.title\)/);
928
+ });
929
+
930
+ test("the prose renderer ships a safe, dependency-free markdown → DOM converter (#274)", async () => {
931
+ const res = await dispatch("GET", "/app/runtime.js");
932
+ const js = res.body ?? "";
933
+ // Block + inline parsers exist.
934
+ assert.match(js, /function mdToNodes\(src\)/);
935
+ assert.match(js, /function mdInline\(text\)/);
936
+ // NEVER innerHTML: the whole renderer builds DOM via el()/textContent only, so a
937
+ // record's markdown body can't become an HTML/script injection vector.
938
+ assert.doesNotMatch(js, /\.innerHTML\s*=/);
939
+ // Nodes are materialised as known elements with text set via createTextNode.
940
+ assert.match(js, /nodes\.push\(document\.createTextNode\(buf\)\)/);
941
+ // Links are scheme-sanitised: only http(s)/mailto become anchors; anything else
942
+ // degrades to plain label text (no javascript:/data: hrefs).
943
+ assert.match(js, /function mdSafeHref\(href\)/);
944
+ assert.match(js, /\^\(\?:https\?:\|mailto:\)/);
945
+ assert.match(js, /el\("a", \{ href: href, target: "_blank", rel: "noopener noreferrer" \}/);
946
+ // Emphasis, strong, inline code, headings, lists, quotes, fences are supported.
947
+ assert.match(js, /el\("strong", \{\}/);
948
+ assert.match(js, /el\("em", \{\}/);
949
+ assert.match(js, /el\("code", \{ class: "pc-md-code" \}/);
950
+ assert.match(js, /el\("pre", \{ class: "pc-md-pre" \}/);
951
+ assert.match(js, /el\("blockquote", \{ class: "pc-md-quote" \}/);
952
+ // Thematic breaks emit a themed <hr> (styled via .pc-md-hr), not a bare default rule.
953
+ assert.match(js, /el\("hr", \{ class: "pc-md-hr" \}\)/);
954
+ assert.match(js, /const MD_UL = \/\^\[-\*\+\]\\s\+\//);
955
+ assert.match(js, /const MD_OL = \/\^\\d\+\[\.\)\]\\s\+\//);
956
+ // Underscore emphasis is word-boundary guarded so snake_case identifiers survive.
957
+ assert.match(js, /function mdIsWordChar\(c\)/);
958
+ assert.match(js, /!mdIsWordChar\(s\[i - 1\]\)/);
959
+ // The code backtick is built from a char code so this source stays inside the
960
+ // String.raw template literal.
961
+ assert.match(js, /const MD_BACKTICK = String\.fromCharCode\(96\)/);
962
+ });
963
+
964
+ test("the shell styles the prose/markdown list at a comfortable reading measure (#274)", async () => {
965
+ const res = await dispatch("GET", "/");
966
+ const html = res.body ?? "";
967
+ // The list stacks items; each body is clamped to ~66ch by default (the JS sets
968
+ // the per-node measure inline, this is the no-JS fallback).
969
+ assert.match(html, /\.pc-prose-list \{[^}]*flex-direction:column[^}]*\}/);
970
+ assert.match(html, /\.pc-prose-body \{ max-width:66ch; \}/);
971
+ // The item header is a small muted caption; markdown blocks reuse --nano-* tokens.
972
+ assert.match(html, /\.pc-prose-head \{/);
973
+ assert.match(html, /\.pc-md-pre \{[^}]*var\(--nano-inset\)[^}]*\}/);
974
+ assert.match(html, /\.pc-md-quote \{[^}]*var\(--nano-edge-strong\)[^}]*\}/);
975
+ // Thematic breaks are themed via --nano-* tokens, matching the other markdown blocks.
976
+ assert.match(html, /\.pc-md-hr \{[^}]*var\(--nano-edge\)[^}]*\}/);
977
+ assert.match(html, /\.pc-prose-body a \{ color:var\(--nano-accent-strong\); \}/);
978
+ });
979
+
980
+
894
981
  test("GET /app/data/<source>/<table> returns rows", async () => {
895
982
  const res = await dispatch("GET", "/app/data/app/orders");
896
983
  assert.equal(res.status, 200);
@@ -627,6 +627,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
627
627
  .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
628
628
  .pc-group-toggle:hover { background:var(--nano-hover); }
629
629
  .pc-group-title { font-size:.9rem; }
630
+ /* Data-bound prose/markdown list (renderProse, #274). Long-form narrative
631
+ records — plan-review findings, coordination notes, escalation bodies — read
632
+ as a stacked, full-width document at a comfortable measure (~66ch) instead of
633
+ being jammed into a grid column. Each item stacks a small header over a
634
+ markdown-rendered body; the body max-width is set inline per node (props.measure)
635
+ with this rule as the no-JS fallback. */
636
+ .pc-prose-list { display:flex; flex-direction:column; gap:1.1rem; }
637
+ .pc-prose-item { border-top:1px solid var(--nano-edge); padding-top:.9rem; }
638
+ .pc-prose-item:first-child { border-top:0; padding-top:0; }
639
+ .pc-prose-head { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
640
+ .pc-prose-empty { color:var(--nano-text-faint); }
641
+ .pc-prose-body { max-width:66ch; }
642
+ .pc-prose-body > :first-child { margin-top:0; }
643
+ .pc-prose-body > :last-child { margin-bottom:0; }
644
+ .pc-md-p { margin:.55rem 0; overflow-wrap:anywhere; }
645
+ .pc-md-h { font-size:1.05rem; font-weight:650; margin:.95rem 0 .4rem; }
646
+ .pc-md-list { margin:.45rem 0; padding-left:1.35rem; }
647
+ .pc-md-list li { margin:.15rem 0; }
648
+ .pc-md-quote { margin:.6rem 0; padding:.1rem .9rem; border-left:3px solid var(--nano-edge-strong); color:var(--nano-text-muted); }
649
+ .pc-md-pre { background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.7rem .8rem; overflow:auto; margin:.6rem 0; }
650
+ .pc-md-pre code { font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--nano-text); white-space:pre; }
651
+ .pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
652
+ .pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
653
+ .pc-prose-body a { color:var(--nano-accent-strong); }
630
654
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
631
655
  .pc-buttonrow { margin:1rem 0; }
632
656
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -735,8 +759,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
735
759
 
736
760
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
737
761
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
738
- // fetches the home page's page.json and renders text / actionForm / dataGrid nodes,
739
- // wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
762
+ // fetches the home page's page.json and renders text / actionForm / dataGrid / prose
763
+ // nodes, wiring actionForm → /app/actions/start and dataGrid/prose → /app/data (with a refresh).
740
764
  const RENDERER_JS = String.raw`
741
765
  const root = document.getElementById("page");
742
766
  const HOME = root.dataset.home || "home";
@@ -1735,6 +1759,248 @@ function renderActionForm(node) {
1735
1759
  // Single source of truth for both the initial render and every refresh below.
1736
1760
  function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1737
1761
 
1762
+ // Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
1763
+ // threading the whitelisted where/order query params. Shared by dataGrid and the
1764
+ // prose renderer so the two can't drift on how a filter/orderBy is encoded. A
1765
+ // filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
1766
+ // current route PARAM — "show the selected entity's rows"), or { field, eq } (a
1767
+ // literal equals); orderBy is { field, dir }. The source/table/field names are
1768
+ // server-side whitelisted (the /app/data route rejects unknown columns).
1769
+ function dataUrl(source, tbl, filters, order) {
1770
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1771
+ const qs = [];
1772
+ for (const f of filters || []) {
1773
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1774
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1775
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1776
+ }
1777
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1778
+ return qs.length ? u + "?" + qs.join("&") : u;
1779
+ }
1780
+
1781
+ // ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
1782
+ // A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
1783
+ // inline token is materialised as a known element via el(), with text set through
1784
+ // textContent / createTextNode — so a record's body field may carry markdown
1785
+ // WITHOUT ever becoming an HTML/script injection vector (the same text-only
1786
+ // guarantee interpTemplate gives a grid cell). Supported: ATX headings
1787
+ // (#..######), fenced + inline code, bold (**/__), italic (*/_), links
1788
+ // [text](url) (http/https/mailto only — every other scheme degrades to its plain
1789
+ // label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
1790
+ // thematic breaks (---), and blank-line-separated paragraphs. The backtick used
1791
+ // for code spans/fences is built from a char code so this source stays inside the
1792
+ // String.raw template literal (which is itself delimited by a backtick).
1793
+ const MD_BACKTICK = String.fromCharCode(96);
1794
+ function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
1795
+ // Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
1796
+ // vbscript:, a bare relative path, …) returns "" so the caller renders the label
1797
+ // as plain text rather than a clickable, potentially-hostile link.
1798
+ function mdSafeHref(href) {
1799
+ const h = String(href == null ? "" : href).trim();
1800
+ return /^(?:https?:|mailto:)/i.test(h) ? h : "";
1801
+ }
1802
+ // Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
1803
+ // close must not be intra-word (a following alphanumeric means snake_case, not a
1804
+ // delimiter), so keep scanning past such runs; -1 when there is no valid close.
1805
+ function mdFindClose(s, marker, from, underscore) {
1806
+ let j = from;
1807
+ while (true) {
1808
+ const k = s.indexOf(marker, j);
1809
+ if (k < 0) return -1;
1810
+ if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
1811
+ j = k + marker.length;
1812
+ }
1813
+ }
1814
+ function mdInline(text) {
1815
+ const s = String(text == null ? "" : text);
1816
+ const nodes = [];
1817
+ let buf = "";
1818
+ const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
1819
+ let i = 0;
1820
+ while (i < s.length) {
1821
+ const ch = s[i];
1822
+ // Backslash escape: the next char is emitted literally, never as a delimiter.
1823
+ if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
1824
+ // Inline code span — verbatim, no nested inline parsing.
1825
+ if (ch === MD_BACKTICK) {
1826
+ const end = s.indexOf(MD_BACKTICK, i + 1);
1827
+ if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
1828
+ }
1829
+ // Strong (** or __). Underscore only when it opens on a word boundary so an
1830
+ // identifier like a__b doesn't get mangled into emphasis.
1831
+ if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
1832
+ const underscore = ch === "_";
1833
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1834
+ const marker = ch + ch;
1835
+ const end = mdFindClose(s, marker, i + 2, underscore);
1836
+ if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
1837
+ }
1838
+ }
1839
+ // Emphasis (* or _), same word-boundary guard for underscore.
1840
+ if (ch === "*" || ch === "_") {
1841
+ const underscore = ch === "_";
1842
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1843
+ const end = mdFindClose(s, ch, i + 1, underscore);
1844
+ if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
1845
+ }
1846
+ }
1847
+ // Link [label](href): href sanitised; an unsafe scheme drops to the label text.
1848
+ if (ch === "[") {
1849
+ const close = s.indexOf("]", i + 1);
1850
+ if (close > i && s[close + 1] === "(") {
1851
+ const paren = s.indexOf(")", close + 2);
1852
+ if (paren > close) {
1853
+ const href = mdSafeHref(s.slice(close + 2, paren));
1854
+ const label = mdInline(s.slice(i + 1, close));
1855
+ flush();
1856
+ if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
1857
+ else for (const l of label) nodes.push(l);
1858
+ i = paren + 1; continue;
1859
+ }
1860
+ }
1861
+ }
1862
+ buf += ch; i++;
1863
+ }
1864
+ flush();
1865
+ return nodes;
1866
+ }
1867
+ // Regexes that classify a block-opening line; shared by the block loop and the
1868
+ // paragraph terminator so the two can't disagree on where a paragraph ends.
1869
+ const MD_HEADING = /^(#{1,6})\s+(.*)$/;
1870
+ const MD_QUOTE = /^>\s?/;
1871
+ const MD_UL = /^[-*+]\s+/;
1872
+ const MD_OL = /^\d+[.)]\s+/;
1873
+ const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
1874
+ function mdBlockStart(t) {
1875
+ return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
1876
+ || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
1877
+ }
1878
+ // Parse a markdown string into an array of block-level DOM nodes.
1879
+ function mdToNodes(src) {
1880
+ const out = [];
1881
+ const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
1882
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1883
+ let i = 0;
1884
+ while (i < lines.length) {
1885
+ const t = lines[i].trim();
1886
+ if (t === "") { i++; continue; }
1887
+ // Fenced code block — content is verbatim (no inline parsing).
1888
+ if (t.slice(0, 3) === fence) {
1889
+ const body = [];
1890
+ i++;
1891
+ while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
1892
+ if (i < lines.length) i++; // consume the closing fence
1893
+ out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
1894
+ continue;
1895
+ }
1896
+ // Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
1897
+ if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
1898
+ // ATX heading.
1899
+ const h = MD_HEADING.exec(t);
1900
+ if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
1901
+ // Blockquote — strip one '>' per line and re-parse the inner block(s).
1902
+ if (MD_QUOTE.test(t)) {
1903
+ const inner = [];
1904
+ while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
1905
+ const bq = el("blockquote", { class: "pc-md-quote" });
1906
+ for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
1907
+ out.push(bq); continue;
1908
+ }
1909
+ // Unordered list.
1910
+ if (MD_UL.test(t)) {
1911
+ const ul = el("ul", { class: "pc-md-list" });
1912
+ while (i < lines.length && MD_UL.test(lines[i].trim())) {
1913
+ ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
1914
+ i++;
1915
+ }
1916
+ out.push(ul); continue;
1917
+ }
1918
+ // Ordered list.
1919
+ if (MD_OL.test(t)) {
1920
+ const ol = el("ol", { class: "pc-md-list" });
1921
+ while (i < lines.length && MD_OL.test(lines[i].trim())) {
1922
+ ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
1923
+ i++;
1924
+ }
1925
+ out.push(ol); continue;
1926
+ }
1927
+ // Paragraph — soft-wrapped lines joined with a space until a blank line or the
1928
+ // next block opener.
1929
+ const para = [];
1930
+ while (i < lines.length) {
1931
+ const lt = lines[i].trim();
1932
+ if (lt === "" || mdBlockStart(lt)) break;
1933
+ para.push(lt); i++;
1934
+ }
1935
+ out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
1936
+ }
1937
+ return out;
1938
+ }
1939
+
1940
+ // A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
1941
+ // (props.data = { source, table, orderBy, filter }) but renders each row as a
1942
+ // stacked prose block — a small header template (props.header, e.g.
1943
+ // "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
1944
+ // sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
1945
+ // This is the primitive for narrative records (plan-review findings, coordination
1946
+ // notes, escalation bodies) that read as a document, not a squeezed table cell.
1947
+ // It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
1948
+ // unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
1949
+ function renderProse(node) {
1950
+ const p = node.props || {};
1951
+ const data = p.data || {};
1952
+ const card = el("section", { class: "pc-card" });
1953
+ if (p.title) card.append(el("h2", {}, p.title));
1954
+ const listEl = el("div", { class: "pc-prose-list" });
1955
+ card.append(listEl);
1956
+ const headerTpl = typeof p.header === "string" ? p.header : null;
1957
+ const bodyField = p.body;
1958
+ // Clamp the reading measure to a sane range so a malformed schema can't set a
1959
+ // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
1960
+ let measure = Number(p.measure);
1961
+ if (!Number.isFinite(measure)) measure = 66;
1962
+ measure = Math.max(40, Math.min(100, Math.round(measure)));
1963
+ const activeFilter = Array.isArray(data.filter) ? data.filter : [];
1964
+
1965
+ function itemFor(row) {
1966
+ const item = el("article", { class: "pc-prose-item" });
1967
+ if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
1968
+ const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
1969
+ // Own-property gate (matching interpTemplate) so a body field name that
1970
+ // collides with a prototype key can't pick up prototype cruft.
1971
+ const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
1972
+ ? String(row[bodyField])
1973
+ : "";
1974
+ for (const b of mdToNodes(raw)) bodyEl.append(b);
1975
+ item.append(bodyEl);
1976
+ return item;
1977
+ }
1978
+
1979
+ async function refresh() {
1980
+ try {
1981
+ // A param-scoped list ("show the selected entity's records") with no route
1982
+ // param present renders nothing rather than a field=empty-string query.
1983
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1984
+ const { rows } = paramScoped && PARAM === ""
1985
+ ? { rows: [] }
1986
+ : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
1987
+ listEl.replaceChildren();
1988
+ if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
1989
+ for (const row of rows) listEl.append(itemFor(row));
1990
+ } catch (e) {
1991
+ listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
1992
+ }
1993
+ }
1994
+ document.addEventListener("pc:refresh", refresh);
1995
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
1996
+ if (p.refreshMs && p.refreshMs > 0) {
1997
+ const timer = setInterval(refresh, p.refreshMs);
1998
+ disposers.push(() => clearInterval(timer));
1999
+ }
2000
+ refresh();
2001
+ return card;
2002
+ }
2003
+
1738
2004
  function renderDataGrid(node) {
1739
2005
  const p = node.props;
1740
2006
  const card = el("section", { class: "pc-card" });
@@ -1808,18 +2074,6 @@ function renderDataGrid(node) {
1808
2074
  return v == null ? null : String(v);
1809
2075
  };
1810
2076
 
1811
- function dataUrl(source, tbl, filters, order) {
1812
- let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1813
- const qs = [];
1814
- for (const f of filters || []) {
1815
- if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1816
- else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1817
- else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1818
- }
1819
- if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1820
- return qs.length ? u + "?" + qs.join("&") : u;
1821
- }
1822
-
1823
2077
  async function fireAction(action, row) {
1824
2078
  // Route-driven row action: POST the action's body template (default {})
1825
2079
  // to its route path, with {{row.KEY}} tokens resolved from this row.
@@ -2225,7 +2479,7 @@ function renderNav(node) {
2225
2479
  return nav;
2226
2480
  }
2227
2481
 
2228
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
2482
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
2229
2483
 
2230
2484
  // Durable per-node UI state. localStorage is keyed by the home page id + node
2231
2485
  // id so two grids on the same page (or the same grid across pages) don't clash,