@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
|
|
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.
|
|
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
|
|
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,
|