@apiosk/mcp 1.7.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +83 -612
  2. package/assets/brand/apiosk-a-20260921.png +0 -0
  3. package/assets/brand/apple-touch-icon.png +0 -0
  4. package/assets/brand/favicon-dark.ico +0 -0
  5. package/assets/brand/favicon-light.ico +0 -0
  6. package/assets/brand/favicon.ico +0 -0
  7. package/assets/brand/icon-192.png +0 -0
  8. package/assets/brand/icon-512.png +0 -0
  9. package/assets/brand/icon-maskable-512.png +0 -0
  10. package/assets/brand/inter-OFL.txt +93 -0
  11. package/assets/brand/inter-latin-400-normal.woff2 +0 -0
  12. package/assets/brand/inter-latin-500-normal.woff2 +0 -0
  13. package/assets/brand/inter-latin-600-normal.woff2 +0 -0
  14. package/assets/brand/mark-20260905-transparent.svg +1 -0
  15. package/assets/brand/mark-20260918.svg +1 -0
  16. package/assets/brand/mark-black-20260918.svg +1 -0
  17. package/assets/brand/mark-dark-20260905-transparent.png +0 -0
  18. package/assets/brand/mark-dark-20260918.png +0 -0
  19. package/assets/brand/mark-dark-96.png +0 -0
  20. package/assets/brand/mark-light-20260905-transparent.png +0 -0
  21. package/assets/brand/mark-light-20260918.png +0 -0
  22. package/assets/brand/mark-light-96.png +0 -0
  23. package/assets/brand/wordmark-black-320.png +0 -0
  24. package/assets/brand/wordmark-white-320.png +0 -0
  25. package/docs/branding.md +11 -0
  26. package/docs/marketplace-submission-2026-04-08.md +4 -0
  27. package/docs/marketplace-submission-2026-08-20.md +157 -0
  28. package/docs/openai-plugin-submission-2026-09-05.md +143 -0
  29. package/dxt.json +26 -22
  30. package/index.mjs +3 -1
  31. package/logo-optimized-light.png +0 -0
  32. package/package.json +14 -14
  33. package/plugin/apiosk/.codex-plugin/plugin.json +45 -0
  34. package/plugin/apiosk/.mcp.json +8 -0
  35. package/plugin/apiosk/assets/icon-dark.png +0 -0
  36. package/plugin/apiosk/assets/icon.png +0 -0
  37. package/plugin/apiosk/assets/icon.svg +1 -0
  38. package/plugin/apiosk/assets/logo.png +0 -0
  39. package/plugin/apiosk/skills/apiosk/SKILL.md +33 -0
  40. package/plugin/apiosk/skills/apiosk/agents/openai.yaml +12 -0
  41. package/server.json +58 -24
  42. package/server.mjs +136 -151
  43. package/src/approval-feedback.mjs +21 -0
  44. package/src/brand-routes.mjs +28 -0
  45. package/src/create-server.mjs +160 -46
  46. package/src/display-money.mjs +20 -0
  47. package/src/display-text.mjs +67 -0
  48. package/src/gateway-client.mjs +42 -0
  49. package/src/gateway-v2-ask.mjs +50 -0
  50. package/src/gateway-v2-card-account.mjs +23 -0
  51. package/src/gateway-v2-card-actions.mjs +67 -0
  52. package/src/gateway-v2-card-answer-text.mjs +130 -0
  53. package/src/gateway-v2-card-answer.mjs +68 -0
  54. package/src/gateway-v2-card-blocks.mjs +166 -0
  55. package/src/gateway-v2-card-body.mjs +259 -0
  56. package/src/gateway-v2-card-budget.mjs +21 -0
  57. package/src/gateway-v2-card-cbs.mjs +56 -0
  58. package/src/gateway-v2-card-choices.mjs +4 -0
  59. package/src/gateway-v2-card-clarification.mjs +25 -0
  60. package/src/gateway-v2-card-compact.mjs +34 -0
  61. package/src/gateway-v2-card-events.mjs +41 -0
  62. package/src/gateway-v2-card-presentation.mjs +125 -0
  63. package/src/gateway-v2-card-research.mjs +63 -0
  64. package/src/gateway-v2-card-result.mjs +32 -0
  65. package/src/gateway-v2-card-search.mjs +39 -0
  66. package/src/gateway-v2-card-sources.mjs +32 -0
  67. package/src/gateway-v2-card-style.mjs +76 -0
  68. package/src/gateway-v2-card-verdict.mjs +66 -0
  69. package/src/gateway-v2-card.mjs +91 -0
  70. package/src/gateway-v2-contracts.json +66 -0
  71. package/src/gateway-v2-instructions.md +113 -0
  72. package/src/gateway-v2-recovery.mjs +18 -0
  73. package/src/gateway-v2-report-links.mjs +14 -0
  74. package/src/gateway-v2-workflows.mjs +17 -0
  75. package/src/gateway-v2.mjs +179 -0
  76. package/src/oauth.mjs +402 -1949
  77. package/src/observability.mjs +19 -3
  78. package/src/result-presentation.mjs +9 -0
  79. package/src/runtime.mjs +24 -3670
  80. package/src/source-groups.mjs +91 -0
  81. package/src/source-value-format.mjs +25 -0
  82. package/src/tool-result.mjs +20 -0
  83. package/src/ui-bridge.mjs +185 -0
  84. package/src/well-known-routes.mjs +132 -0
  85. package/src/assets/wallet-accounts.mjs +0 -20513
  86. package/src/assets/walletconnect-provider.mjs +0 -6319
  87. package/src/discovery.mjs +0 -929
  88. package/src/external-fetch.mjs +0 -203
  89. package/src/funding-options.mjs +0 -255
  90. package/src/gateway-management.mjs +0 -107
  91. package/src/hosted-payment.mjs +0 -552
  92. package/src/hosted-wallets.mjs +0 -530
  93. package/src/listing-metadata.mjs +0 -287
  94. package/src/local-config.mjs +0 -256
  95. package/src/payment-guidance.mjs +0 -298
  96. package/src/publisher.mjs +0 -1288
  97. package/src/result-canvas.mjs +0 -16
  98. package/src/source-registry.mjs +0 -215
  99. package/src/wallet-store.mjs +0 -476
  100. package/src/x402-inspect.mjs +0 -361
@@ -0,0 +1,130 @@
1
+ import { formatDisplayText, formatDisplayNarrative, isLiteralField } from './display-text.mjs';
2
+ import { sourceCurrency } from './source-value-format.mjs';
3
+ import { readBlocks } from './gateway-v2-card-body.mjs';
4
+ // The App's answer text (app/src/lib/domain/gateway-v2-answer.ts and
5
+ // answer-format.ts): every observation, period headings, source notes, every
6
+ // specific limitation and the follow-up question. Display only; the saved
7
+ // analysis and its evidence stay unchanged. Keep in sync with the App.
8
+ const ANSWER_GENERIC_LIMITATIONS = new Set([
9
+ 'Some requested source calls did not return usable data. This analysis is incomplete.',
10
+ 'Some source information is unavailable. The answer uses the available results.',
11
+ 'The returned source response contains no usable records for this question. No finding can be supported from it.',
12
+ ]);
13
+ const ANSWER_PERIOD_KEYS = ['fiscal_year', 'financial_year', 'year', 'fiscalYear', 'financialYear', 'period_end', 'periodEnd', 'fiscal_year_end', 'financialYearEnd', 'reporting_year', 'date_fin_exercice'];
14
+
15
+ function answerRecord(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
16
+ export function answerLocale(question, analysis) {
17
+ const text = (analysis?.observations || []).map(o => o?.text || '').join(' ') || question || '';
18
+ const dutch = text.match(/\b(de|het|een|zijn|voor|van|heeft|bedraagt|jaarrekening|omzet|winst|volgens|toont|werden)\b/gi) ?? [];
19
+ return dutch.length >= 2 ? 'nl-NL' : 'en-US';
20
+ }
21
+ /** The App's formatSourceValue, with its locale: Dutch groups with dots. */
22
+ export function answerValue(value, key = '', currency = null, locale = 'en-US') {
23
+ const raw = String(value ?? '');
24
+ if (isLiteralField(key)) return raw;
25
+ if (typeof value === 'string') value = formatDisplayText(value, key);
26
+ const label = String(key).split('/').at(-1).replace(/[\s_.-]/g, '');
27
+ if (/year|date|code|identifier|kvk|postcode|postal|phone|iban/i.test(label) || /(?:^id$|Id$|ID$|Number$|Nummer$)/.test(label)) return raw;
28
+ if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) return formatDisplayNarrative(String(value ?? ''));
29
+ const negative = raw.startsWith('-'), unsigned = negative ? raw.slice(1) : raw, [whole, fraction] = unsigned.split('.'), dutch = locale.startsWith('nl');
30
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, dutch ? '.' : ',') + (fraction == null ? '' : (dutch ? ',' : '.') + fraction);
31
+ const monetary = /^(?:(?:total|net|gross))?(Assets|Liabilities|Equity|Receivables|Cash|Property|Provisions|Share|RetainedEarnings|FinancialIncome|Income|Result|Depreciation|EmployeeBenefits|Operating|GrossMargin|Impairment|SumOfExpenses|Revenue|Profit|Turnover|Amount|Balance|Price|Cost|Tax|Bedrag|Omzet|Winst|Kosten)/i.test(label) && !/ratio|percent|rate|count|margin|sharesoutstanding|sharesissued/i.test(label);
32
+ const code = monetary ? sourceCurrency(currency) : null, symbol = code && ({ EUR: '€', USD: '$', GBP: '£' }[code] || code);
33
+ return (negative ? '-' : '') + (symbol ? symbol + ' ' : '') + grouped;
34
+ }
35
+ /** Units and periods next to the exact cited field, never from another result. */
36
+ export function answerValueContext(evidence, results) {
37
+ const pointer = String(evidence?.pointer || ''), doc = (results || []).map(answerRecord).find(d => d.result_ref === evidence?.result_ref), parents = [];
38
+ let node = doc && { ...doc, data: doc.data ?? doc.preview_data };
39
+ for (const part of pointer.split('/').slice(1)) {
40
+ if (!Array.isArray(node)) parents.unshift(answerRecord(node));
41
+ node = node != null && typeof node === 'object' ? node[part.replaceAll('~1', '/').replaceAll('~0', '~')] : undefined;
42
+ }
43
+ const currencyOwner = parents.find(p => p.currency != null || p.currencyCode != null || p.unit != null);
44
+ const period = parents.flatMap(p => ANSWER_PERIOD_KEYS.map(k => p[k])).find(v => /^(?:19|20)\d{2}(?:-\d{2}-\d{2})?$/.test(String(v)));
45
+ return { currency: sourceCurrency(currencyOwner), period: period == null ? null : String(period), field: String(parents[0]?.key ?? pointer.split('/').at(-1) ?? '') };
46
+ }
47
+ export function formatAnswerObservation(observation, results, locale) {
48
+ const values = new Map(), evidence = Array.isArray(observation.evidence) ? observation.evidence : [];
49
+ for (const item of evidence) {
50
+ const raw = String(item?.value);
51
+ if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw)) continue;
52
+ const context = answerValueContext(item, results), field = item.field ?? context.field, pointer = String(item.pointer || ''), parent = pointer.slice(0, pointer.lastIndexOf('/'));
53
+ const currency = context.currency ?? sourceCurrency(evidence.find(e => e?.result_ref === item.result_ref && e.pointer === parent + '/currency')?.value);
54
+ const display = answerValue(item.value, field, currency, locale), previous = values.get(raw);
55
+ values.set(raw, values.has(raw) && previous?.display !== display ? null : { display, currency });
56
+ }
57
+ const codes = 'EUR|USD|GBP|CHF|JPY|CAD|AUD|SEK|NOK|DKK';
58
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_/@.:$€£+\\-])(?:(${codes}|[$€£])\\s*)?(-?(?:0|[1-9]\\d*)(?:\\.\\d+)?)(?:\\s*(${codes})(?!\\p{L}))?(?![\\p{L}\\p{N}_/@]|[.:,\\-]\\d)`, 'gu');
59
+ return String(observation.text).replace(pattern, (match, prefix, raw, suffix) => {
60
+ const value = values.get(raw);
61
+ if (!value) return match;
62
+ const explicit = prefix ?? suffix;
63
+ if (!explicit) return value.display;
64
+ const code = { '$': 'USD', '€': 'EUR', '£': 'GBP' }[explicit] ?? explicit;
65
+ return value.currency && value.currency !== code ? match : answerValue(raw, 'Amount', code, locale);
66
+ });
67
+ }
68
+ /** A heading may name only a period this observation actually cites. */
69
+ export function observationPeriod(observation, results) {
70
+ const periods = (observation.evidence || []).flatMap(e => {
71
+ const key = String(e?.field ?? e?.pointer ?? '').split('/').at(-1) ?? '';
72
+ if (ANSWER_PERIOD_KEYS.some(k => k.toLowerCase() === key.toLowerCase()) && /^(?:19|20)\d{2}(?:-\d{2}-\d{2})?$/.test(String(e.value))) return [String(e.value)];
73
+ const context = answerValueContext(e, results);
74
+ return context.period ? [context.period] : [];
75
+ });
76
+ const unique = [...new Set(periods)];
77
+ return unique.length === 1 ? unique[0] : null;
78
+ }
79
+ export function answerText(analysis, context) {
80
+ if (!analysis) return '';
81
+ const question = context?.question || '', results = context?.results || [], locale = answerLocale(question, analysis), nl = locale === 'nl-NL';
82
+ const observations = (analysis.observations || []).filter(o => typeof o?.text === 'string' && o.text.trim());
83
+ const limitations = (analysis.limitations || []).filter(text => typeof text === 'string' && text.trim() && !ANSWER_GENERIC_LIMITATIONS.has(text));
84
+ const structured = observations.length > 1 && !/\b(one sentence|single sentence|één zin|een zin)\b/i.test(question);
85
+ const blocks = [];
86
+ let previousHeading = null;
87
+ for (const observation of observations) {
88
+ if (structured) {
89
+ const period = observationPeriod(observation, results);
90
+ const heading = period ? (nl ? 'Verslagperiode ' : 'Reporting period ') + period : nl ? 'Belangrijkste bevindingen' : 'Key findings';
91
+ if (heading !== previousHeading) blocks.push('## ' + heading);
92
+ previousHeading = heading;
93
+ }
94
+ blocks.push(formatAnswerObservation(observation, results, locale));
95
+ }
96
+ if (limitations.length) {
97
+ if (observations.length) blocks.push('## ' + (nl ? 'Bronnen en beperkingen' : 'Source notes'));
98
+ blocks.push(...new Set(limitations));
99
+ }
100
+ if (analysis.follow_up_question) blocks.push(analysis.follow_up_question);
101
+ return blocks.length ? blocks.join('\n\n') : [...new Set((analysis.limitations || []).filter(text => typeof text === 'string'))].join('\n\n');
102
+ }
103
+ export function answerDocumentRequested(analysis, question) {
104
+ return analysis?.presentation?.kind === 'document' || /\b(document|pdf|rapport|report|dossier)\b/i.test(question || '');
105
+ }
106
+
107
+ // ---- DOM, run inside the card only --------------------------------------------
108
+ function answerInline(node, value) {
109
+ const parts = String(value).split(/(-?(?:[$€£]|USD|EUR|GBP|CHF|JPY|CAD|AUD|SEK|NOK|DKK)\s-?\d[\d.,]*)/g);
110
+ if (parts.length === 1) { node.textContent = value; return node; }
111
+ parts.forEach((part, index) => { if (part) node.append(el(index % 2 ? 'strong' : 'span', index % 2 ? 'answer-amount' : '', part)); });
112
+ return node;
113
+ }
114
+ /** The App's StreamingReply, settled: headings, paragraphs and lists. */
115
+ function renderAnswerText(value, nameContext) {
116
+ const wrap = el('div', 'answer-text');
117
+ for (const block of readBlocks(formatDisplayNarrative(value, nameContext))) {
118
+ if (block.kind === 'heading') { wrap.append(el('h3', 'answer-heading', block.text)); continue; }
119
+ if (block.kind === 'paragraph') { wrap.append(answerInline(el('p'), block.text)); continue; }
120
+ const list = el(block.kind === 'numbers' ? 'ol' : 'ul');
121
+ for (const item of block.items) list.append(answerInline(el('li'), item));
122
+ wrap.append(list);
123
+ }
124
+ return wrap;
125
+ }
126
+
127
+ export const V2_CARD_ANSWER_TEXT = [
128
+ `const ANSWER_GENERIC_LIMITATIONS=new Set(${JSON.stringify([...ANSWER_GENERIC_LIMITATIONS])}),ANSWER_PERIOD_KEYS=${JSON.stringify(ANSWER_PERIOD_KEYS)};`,
129
+ answerRecord, answerLocale, answerValue, answerValueContext, formatAnswerObservation, observationPeriod, answerText, answerDocumentRequested, answerInline, renderAnswerText,
130
+ ].map(value => typeof value === 'function' ? value.toString() : value).join('\n');
@@ -0,0 +1,68 @@
1
+ // Presentation of returned evidence only. This is not an onboarding approval,
2
+ // a compliance certification, or a replacement for the gateway's payment verdict.
3
+ export function ukSupplierSummary(data, results) {
4
+ const context = data?.context_view || {};
5
+ const question = context.conversation?.[0]?.question || '';
6
+ const notices = Array.isArray(context.coverage_notices) ? context.coverage_notices : [];
7
+ const ukCheck = notices.some(note => typeof note === 'string' && note.includes('complete roster of up to five active directors'))
8
+ || (/\b(?:UK|British|United Kingdom)\b/i.test(question) && /supplier|onboard/i.test(question) && /VAT/i.test(question) && /director|screening/i.test(question));
9
+ if (!ukCheck || context.worker_active || context.analyzing || context.result_is_historical || !['succeeded', 'partial'].includes(data.status)) return null;
10
+ const profiles = results.filter(r => r.source?.provider === 'companies-house' && typeof r.data?.company_number === 'string' && typeof r.data?.company_status === 'string');
11
+ if (profiles.length !== 1 || !profiles[0].subject?.entity_ref) return null;
12
+ const profile = profiles[0], company = profile.data;
13
+ const related = results.filter(r => r.subject?.entity_ref === profile.subject.entity_ref && r.coverage !== 'partial' && !r.error);
14
+ const completeProfile = related.includes(profile);
15
+ const psc = related.find(r => r.source?.provider === 'companies-house' && /persons-with-significant-control/.test(r.source.path || ''));
16
+ const pscCount = Number.isInteger(psc?.data?.total_results) ? psc.data.total_results : null;
17
+ const accounts = completeProfile ? company.accounts : null;
18
+ const period = accounts?.last_accounts?.made_up_to;
19
+ const overdue = accounts?.overdue === true || accounts?.next_accounts?.overdue === true;
20
+ const vat = related.find(r => r.source?.provider === 'ask-merlin');
21
+ const vatValue = vat?.data?.data?.valid ?? vat?.data?.valid;
22
+ const invalidCandidate = vatValue === false;
23
+ const screening = related.filter(r => r.source?.provider === 'opensanctions');
24
+ const responses = screening.flatMap(r => Object.values(r.data?.responses || {}));
25
+ const candidateCount = responses.reduce((count, response) => count + (Array.isArray(response?.results) ? response.results.length : 0), 0);
26
+ const noCandidates = responses.length > 0 && responses.every(response => Array.isArray(response?.results) && response.results.length === 0 && response.total?.value === 0 && !response.error);
27
+ const active = completeProfile && company.company_status === 'active';
28
+ const checks = [
29
+ {label:'UK entity', status:active?'pass':'review', detail:completeProfile ? company.company_status + ' · ' + company.company_number : 'Registration evidence incomplete'},
30
+ {label:'Ownership', status:pscCount>0?'info':'unknown', detail:pscCount>0 ? pscCount + ' PSC records · ownership still needs review' : pscCount===0 ? 'Not established · no PSC records returned' : 'Not verified · no complete PSC result'},
31
+ {label:'Filed accounts', status:overdue?'review':period?'info':'unknown', detail:period ? 'Period ' + period + (overdue?' · overdue':' · filing status only') : 'Not verified · no accounts period returned'},
32
+ {label:'VAT', status:vat&&!invalidCandidate?'review':'unknown', detail:invalidCandidate ? 'Candidate invalid · supplier VAT unverified' : vatValue===true ? 'Number valid · supplier identity match needs review' : vat ? 'Validation returned · review validity and identity match' : 'Not verified · no validation result'},
33
+ {label:'Director screening', status:screening.length?'review':'unknown', detail:candidateCount ? candidateCount + ' potential ' + (candidateCount===1?'match':'matches') + ' · manual review' : noCandidates ? 'No candidates returned · review roster coverage' : screening.length ? 'Screening returned · manual review required' : 'Not verified · no screening result'},
34
+ ];
35
+ const incomplete = data.status === 'partial' || checks.some(check => check.status === 'unknown');
36
+ return {subject:company.company_name || profile.subject.label, label:incomplete?'Not fully verified':'Review required', checks,
37
+ summary:active ? 'The UK entity is active. That does not establish full supplier-onboarding clearance.' : 'The saved checks do not establish an active, fully verified UK supplier.',
38
+ note:'Missing evidence is not a failed check. A screening candidate is not a confirmed sanctions finding.'};
39
+ }
40
+
41
+ // The generic answer mirrors the App's Ask task view (GatewayV2Outputs): the
42
+ // full answer text, then blocks (answer_schema_version 2) or the presentation
43
+ // table. Without an analysis there is no answer section: the source result is
44
+ // drawn directly (see gateway-v2-card-research.mjs).
45
+ export const V2_CARD_ANSWER = `
46
+ ${ukSupplierSummary.toString()}
47
+ function renderResearchAnswer(data,results){
48
+ const uk=ukSupplierSummary(data,results),analysis=data.context_view?.analysis;
49
+ if(!uk&&!analysis)return null;
50
+ let s;
51
+ if(uk){
52
+ s=section(formatDisplayNarrative(uk.subject,data));s.classList.add('answer-section','research-answer');
53
+ const headline=el('p','answer-verdict',uk.label);headline.setAttribute('role','status');s.append(headline,el('p','answer-summary',uk.summary));
54
+ const checks=el('dl','answer-checks');
55
+ for(const check of uk.checks){const row=el('div','answer-check '+check.status);row.append(el('dt','',check.label),el('dd','',formatDisplayNarrative(check.detail,data)));checks.append(row)}
56
+ s.append(checks,el('p','meta answer-caveat',uk.note));
57
+ }else{
58
+ s=el('section','section answer-section research-answer');s.setAttribute('aria-label','Answer');sections.append(s);
59
+ const question=data.context_view?.conversation?.at(-1)?.question||'',value=answerText(analysis,{results,question});
60
+ if(data.status==='partial'||analysis.status==='partial')s.append(el('p','meta answer-partial','Some information is missing. Review the available results in Sources and details.'));
61
+ if(value)s.append(renderAnswerText(value,{results,analysis}));
62
+ const detail=isBlocksAnswer(analysis)?renderAnswerBlocks(analysis):renderAnswerPresentation(question,results,analysis);
63
+ if(detail)s.append(detail);
64
+ }
65
+ const links=el('div','result-links');s.append(links);
66
+ return {section:s,links,title:uk?'Supplier verification':'Answer',subtitle:uk?'Based on saved source evidence.':'Based on the saved source results.'};
67
+ }
68
+ `;
@@ -0,0 +1,166 @@
1
+ // The App's versioned answer blocks (answer_schema_version 2): data tables,
2
+ // bar charts and the generic fallback that renders any other kind as facts
3
+ // (app/src/lib/domain/answer-blocks-view.ts, components/ask/answer-blocks).
4
+ // Every Fact arrives resolved by the gateway; the card only displays, sorts
5
+ // and filters it. Keep in sync with the App.
6
+ export function isBlocksAnswer(analysis) { return !!analysis && (analysis.answer_schema_version ?? 1) >= 2 && Array.isArray(analysis.blocks); }
7
+ export function factOrigin(fact, collection, rowIndex, relativePointer) {
8
+ if (fact?.result_ref && fact.pointer) return { result_ref: fact.result_ref, pointer: fact.pointer };
9
+ if (collection && rowIndex != null && relativePointer) return { result_ref: collection.result_ref, pointer: collection.rows_pointer + '/' + rowIndex + relativePointer };
10
+ return null;
11
+ }
12
+ /** Sorts the complete selected set, nulls last in both directions, before paging. */
13
+ export function sortTableRows(columns, rows, sort) {
14
+ const index = sort ? columns.findIndex(c => c.id === sort.column_id) : -1;
15
+ if (index < 0) return rows;
16
+ const direction = sort.direction === 'asc' ? 1 : -1, key = row => row?.cells?.[index]?.value ?? null;
17
+ return [...rows].sort((a, b) => {
18
+ const av = key(a), bv = key(b);
19
+ if (av == null && bv == null) return 0;
20
+ if (av == null) return 1;
21
+ if (bv == null) return -1;
22
+ return typeof av === 'number' && typeof bv === 'number' ? (av - bv) * direction : String(av).localeCompare(String(bv)) * direction;
23
+ });
24
+ }
25
+ export function filterTableRows(rows, query) {
26
+ const q = String(query || '').trim().toLowerCase();
27
+ return q ? rows.filter(row => (row?.cells || []).some(cell => cell?.value != null && String(cell.value).toLowerCase().includes(q))) : rows;
28
+ }
29
+ /** Fixed over every selected bar, so revealing more never rescales. */
30
+ export function barScale(block) {
31
+ if (block.scale) return block.scale;
32
+ const values = (block.bars || []).map(bar => typeof bar.fact?.value === 'number' ? bar.fact.value : null).filter(v => v != null);
33
+ return { min: Math.min(0, ...values), max: Math.max(0, ...values) };
34
+ }
35
+ export function rankingScopeLabel(scope) {
36
+ return scope === 'total_available' ? 'ranked across every available result' : scope === 'fetched' ? 'ranked across every fetched result' : 'ranked across the selected results';
37
+ }
38
+ function blockLabelOf(label, fact, id) { return label ? label : fact?.value != null ? String(fact.value) : id; }
39
+ /** Any kind's facts as labelled rows; unknown kinds render through this. */
40
+ export function blockFacts(block) {
41
+ const list = value => Array.isArray(value) ? value : [];
42
+ switch (block.kind) {
43
+ case 'summary': return list(block.conclusion?.facts).map((fact, i) => ({ label: 'Fact ' + (i + 1), fact }));
44
+ case 'data_table': return list(block.rows).flatMap((row, ri) => list(block.columns).map((column, ci) => ({ label: column.label, fact: row.cells?.[ci], collection: block.collection, rowIndex: row.row_index ?? ri, relativePointer: column.pointer })));
45
+ case 'comparison': return list(block.rows).flatMap(row => list(row.cells).map((fact, i) => ({ label: row.label + ' — ' + (block.entities?.[i]?.label ?? ''), fact })));
46
+ case 'kpi': return list(block.cards).flatMap(card => [{ label: card.label, fact: card.fact }, ...(card.change ? [{ label: card.label + ' (baseline)', fact: card.change.baseline }] : [])]);
47
+ case 'line_chart': return list(block.series).flatMap(series => list(series.points).map((point, pi) => ({ label: series.label + ' — ' + (point.period?.key ?? point.period?.value ?? ''), fact: point.fact ?? null, collection: series.collection, rowIndex: point.row_index ?? pi, relativePointer: series.value_pointer })));
48
+ case 'bar_chart': return list(block.bars).map((bar, bi) => ({ label: blockLabelOf(bar.label, bar.label_fact, 'Bar ' + (bi + 1)), fact: bar.fact ?? null, collection: block.collection, rowIndex: bar.row_index ?? bi, relativePointer: block.value_pointer }));
49
+ case 'map': return list(block.features).flatMap(feature => list(feature.facts).map(lf => ({ label: blockLabelOf(feature.label, feature.label_fact, feature.id) + ' — ' + lf.label, fact: lf.fact ?? null, collection: block.collection, rowIndex: feature.row_index })));
50
+ case 'timeline': return list(block.events).flatMap(event => [
51
+ { label: blockLabelOf(event.label, event.label_fact, event.id) + ' — date', fact: event.date ?? null, collection: block.collection, rowIndex: event.row_index, relativePointer: block.date_pointer },
52
+ ...list(event.facts).map(lf => ({ label: blockLabelOf(event.label, event.label_fact, event.id) + ' — ' + lf.label, fact: lf.fact ?? null, collection: block.collection, rowIndex: event.row_index }))]);
53
+ case 'profile': return [
54
+ ...(block.name ? [{ label: 'Name', fact: block.name }] : []),
55
+ ...list(block.identifiers).map(lf => ({ label: lf.label, fact: lf.fact ?? null })),
56
+ ...(block.status ? [{ label: block.status.label, fact: block.status.fact ?? null }] : []),
57
+ ...list(block.attributes).map(lf => ({ label: lf.label, fact: lf.fact ?? null }))];
58
+ case 'relationship': return list(block.edges).flatMap(edge => list(edge.evidence).map((fact, i) => ({ label: edge.label + ' (' + (i + 1) + ')', fact })));
59
+ default: return [];
60
+ }
61
+ }
62
+
63
+ // ---- DOM, run inside the card only --------------------------------------------
64
+ function renderAnswerBlocks(analysis) {
65
+ const wrap = el('div', 'answer-blocks');wrap.setAttribute('aria-label', 'Answer data');
66
+ // A narrative block is the observations, already shown as the answer text.
67
+ for (const block of analysis.blocks) if (block && block.kind !== 'narrative') wrap.append(block.kind === 'data_table' ? dataTableBlock(block, analysis) : block.kind === 'bar_chart' ? barChartBlock(block, analysis) : genericBlock(block, analysis));
68
+ return wrap.children.length ? wrap : null;
69
+ }
70
+ function blockFactValue(fact) {
71
+ if (!fact || fact.value == null) return el('span', 'body-muted', 'Unknown');
72
+ const value = answerValue(fact.value, fact.field ?? '', fact.currency?.value != null ? String(fact.currency.value) : null, 'nl-NL');
73
+ const labels = { estimated: 'Estimated', provisional: 'Provisional', revised: 'Revised', rounded: 'Rounded', confidential: 'Confidential' };
74
+ if (!fact.flags?.length) return el('span', 'fact-value', value);
75
+ const span = el('span', 'fact-value');span.append(el('span', '', value));
76
+ for (const flag of fact.flags) span.append(el('span', 'fact-flag', labels[flag?.kind] || text(flag?.kind)));
77
+ return span;
78
+ }
79
+ function blockFactSource(fact, sourceIndex, collection, rowIndex, relativePointer) {
80
+ const origin = factOrigin(fact, collection, rowIndex, relativePointer);
81
+ if (!origin) return fact?.derivation_id ? el('span', 'fact-source', 'Calculated') : null;
82
+ const entry = (sourceIndex || []).find(s => s.result_ref === origin.result_ref);
83
+ return el('span', 'fact-source', (entry?.name ?? entry?.provider ?? 'Source') + ' · ' + origin.pointer);
84
+ }
85
+ function blockCell(fact, sourceIndex, collection, rowIndex, relativePointer) {
86
+ const td = el('td'), source = blockFactSource(fact, sourceIndex, collection, rowIndex, relativePointer);
87
+ td.append(blockFactValue(fact));if (source) td.append(source);
88
+ return td;
89
+ }
90
+ function blockTable(label, headings, rows) {
91
+ const region = el('div', 'block-table-wrap'), table = el('table', 'block-table'), head = el('tr'), thead = el('thead'), tbody = el('tbody');
92
+ region.setAttribute('role', 'region');region.setAttribute('aria-label', label);region.tabIndex = 0;
93
+ for (const heading of headings) { const th = el('th', '', heading);th.setAttribute('scope', 'col');head.append(th); }
94
+ thead.append(head);tbody.append(...rows);table.append(thead, tbody);region.append(table);
95
+ return region;
96
+ }
97
+ function blockMore(label, onClick) { const b = el('button', 'quiet', label);b.type = 'button';b.onclick = () => { onClick();window.apiosk.resize(); };return b; }
98
+ function dataTableBlock(block, analysis) {
99
+ const s = el('section', 'answer-block'), columns = block.columns || [], all = block.rows || [], sourceIndex = analysis?.source_index || [];
100
+ const counts = el('p', 'meta'), region = el('div'), more = el('div'), controls = el('div', 'block-controls'), field = el('input', 'block-filter'), toggles = el('fieldset', 'block-columns');
101
+ const hidden = new Set(columns.filter(c => c.hidden).map(c => c.id)), sorted = sortTableRows(columns, all, block.sort);
102
+ let shown = 20, query = '';
103
+ s.setAttribute('aria-label', block.title ?? 'Data table');if (block.title) s.append(el('h3', '', block.title));
104
+ field.type = 'search';field.placeholder = 'Filter rows';field.setAttribute('aria-label', 'Filter rows');
105
+ const draw = () => {
106
+ const filtered = filterTableRows(sorted, query), visible = columns.filter(c => !hidden.has(c.id)), coverage = block.coverage;
107
+ counts.textContent = (coverage ? coverage.selected + ' selected · ' + coverage.fetched + ' fetched' + (coverage.total_available !== undefined ? ' · ' + coverage.total_available + ' total available' : '') : all.length + ' rows') + (filtered.length !== all.length ? ' · ' + filtered.length + ' match the filter' : '');
108
+ region.replaceChildren(visible.length ? blockTable((block.title ?? 'Data table') + ' table', visible.map(c => c.label), filtered.slice(0, shown).map((row, ri) => {
109
+ const tr = el('tr');
110
+ for (const column of visible) tr.append(blockCell(row.cells?.[columns.indexOf(column)], sourceIndex, block.collection, row.row_index ?? ri, column.pointer));
111
+ return tr;
112
+ })) : el('p', 'meta', 'Every column is hidden.'));
113
+ more.replaceChildren(...(shown < filtered.length ? [blockMore('Show more rows (' + (filtered.length - shown) + ' remaining)', () => { shown += 20;draw(); })] : []));
114
+ };
115
+ field.oninput = () => { query = field.value;shown = 20;draw();window.apiosk.resize(); };
116
+ toggles.append(el('legend', 'sr-only', 'Columns shown'));
117
+ for (const column of columns) {
118
+ const option = el('label'), box = el('input');box.type = 'checkbox';box.checked = !hidden.has(column.id);
119
+ box.onchange = () => { box.checked ? hidden.delete(column.id) : hidden.add(column.id);draw();window.apiosk.resize(); };
120
+ option.append(box, el('span', '', column.label));toggles.append(option);
121
+ }
122
+ controls.append(field, toggles);s.append(counts, controls, region, more);draw();
123
+ return s;
124
+ }
125
+ function barChartBlock(block, analysis) {
126
+ const figure = el('figure', 'answer-block bar-chart'), bars = block.bars || [], scale = barScale(block), range = (scale.max - scale.min) || 1, zero = (0 - scale.min) / range * 100;
127
+ const caption = el('figcaption', '', block.title ?? block.value_label), list = el('div', 'bar-list'), more = el('div');
128
+ if (block.ranking) caption.append(el('span', 'meta block-note', (block.ranking.direction === 'desc' ? 'Highest first, ' : 'Lowest first, ') + rankingScopeLabel(block.ranking.scope) + '.'));
129
+ figure.setAttribute('aria-label', block.title ?? block.value_label ?? 'Chart');
130
+ let shown = 10;
131
+ const draw = () => {
132
+ list.replaceChildren(...bars.slice(0, shown).map((bar, i) => {
133
+ const value = typeof bar.fact?.value === 'number' ? bar.fact.value : null, end = value == null ? zero : (value - scale.min) / range * 100;
134
+ const name = bar.label ?? (bar.label_fact?.value != null ? String(bar.label_fact.value) : 'Bar ' + (i + 1));
135
+ const row = el('div', 'bar-row'), line = el('div', 'bar-line'), source = blockFactSource(bar.fact, analysis?.source_index, block.collection, bar.row_index ?? i, block.value_pointer);
136
+ line.append(el('span', 'bar-label', (bar.rank != null ? '#' + bar.rank + ' ' : '') + name), blockFactValue(bar.fact));
137
+ row.append(line, answerBar(zero, value == null ? null : end));if (source) row.append(source);
138
+ return row;
139
+ }));
140
+ more.replaceChildren(...(shown < bars.length ? [blockMore('Show more bars (' + (bars.length - shown) + ' remaining)', () => { shown += 10;draw(); })] : []));
141
+ };
142
+ figure.append(caption, list, more);draw();
143
+ return figure;
144
+ }
145
+ function answerBar(zero, end) {
146
+ const track = el('div', 'bar-track'), axis = el('span', 'bar-zero');
147
+ track.setAttribute('aria-hidden', 'true');axis.setAttribute('style', 'left:' + zero + '%');track.append(axis);
148
+ if (end != null) { const fill = el('span', 'bar-fill');fill.setAttribute('style', 'left:' + Math.min(zero, end) + '%;width:' + Math.abs(end - zero) + '%');track.append(fill); }
149
+ return track;
150
+ }
151
+ function genericBlock(block, analysis) {
152
+ const rows = blockFacts(block), title = block.title ?? String(block.kind || 'block').replace('_', ' '), s = el('section', 'answer-block');
153
+ s.setAttribute('aria-label', title);s.append(el('h3', 'block-kind', title));
154
+ if (!rows.length) { s.append(el('p', 'meta', 'No details to show for this ' + String(block.kind || 'block').replace('_', ' ') + '.'));return s; }
155
+ s.append(blockTable(title + ' facts', ['Field', 'Value', 'Source'], rows.map(row => {
156
+ const tr = el('tr'), value = el('td'), source = el('td'), origin = blockFactSource(row.fact, analysis?.source_index, row.collection, row.rowIndex, row.relativePointer);
157
+ value.append(blockFactValue(row.fact));if (origin) source.append(origin);tr.append(el('td', '', row.label), value, source);
158
+ return tr;
159
+ })));
160
+ return s;
161
+ }
162
+
163
+ export const V2_CARD_BLOCKS = [
164
+ isBlocksAnswer, factOrigin, sortTableRows, filterTableRows, barScale, rankingScopeLabel, blockLabelOf, blockFacts,
165
+ renderAnswerBlocks, blockFactValue, blockFactSource, blockCell, blockTable, blockMore, dataTableBlock, barChartBlock, answerBar, genericBlock,
166
+ ].map(fn => fn.toString()).join('\n');
@@ -0,0 +1,259 @@
1
+ // The App's answer reader (app/src/components/discover/answer-view.tsx and
2
+ // app/src/lib/ui/answer.ts), for the card. A source result is drawn as what it
3
+ // is: records as rows, a record's name as its heading, links live, prose as
4
+ // prose. Nothing is summarised or dropped; the readable view contains no JSON.
5
+ // Rules are by key name and shape, never by value. Keep in sync with the App.
6
+ const BODY_NOISE_KEYS = new Set(["id", "uuid", "guid", "objectid", "key", "slug", "hash", "etag", "checksum", "signature", "nonce", "cursor", "pagetoken", "nextpagetoken", "page", "pagenumber", "pagina", "pagesize", "perpage", "resultsperpage", "resultatenperpagina", "offset", "hasmore", "hasnext", "hasnextpage", "nextpage", "prevpage", "previouspage", "contenttype", "mimetype", "charset", "encoding", "statuscode", "httpstatus", "requestid", "traceid", "correlationid", "apiversion", "schemaversion", "typename"]);
7
+ const BODY_LINK_OBJECT_KEYS = new Set(["href", "rel", "type", "title", "name", "method", "templated", "hreflang", "profile", "deprecation"]);
8
+ const BODY_ENVELOPE_KEYS = new Set(["data", "result", "results", "response", "payload", "body", "content", "items", "records", "rows", "list", "output", "value", "values", "fields", "opendatafields"]);
9
+ const BODY_NAME_KEYS = ["sourcename", "name", "title", "headline", "label", "provider", "symbol"];
10
+ const BODY_IMAGE_KEYS = ["sourceimg", "image", "img", "logo", "icon", "avatar", "thumbnail", "picture"];
11
+ const BODY_LINK_KEYS = ["weburl", "url", "link", "href", "website", "site"];
12
+
13
+ export function bodyIsRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
14
+ export function bodyIsUrl(value) { return /^https?:\/\/\S+$/i.test(String(value).trim()); }
15
+ export function bodyIsImageUrl(value) { return /\.(png|jpe?g|gif|webp|avif|svg)(\?|#|$)/i.test(String(value).trim()); }
16
+ export function bodyAsDate(value) {
17
+ if (!/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2})?/.test(value)) return null;
18
+ const at = new Date(value);
19
+ return Number.isNaN(at.getTime()) ? null : at.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
20
+ }
21
+ export function bodyLabel(key) {
22
+ const words = String(key).replace(/^_+/, "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_-]+/g, " ").trim().toLowerCase();
23
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : String(key);
24
+ }
25
+ export function bodyHost(url) { try { return new URL(url).host; } catch { return url; } }
26
+ function bodyFlatten(key) { return String(key).replace(/[\s_-]+/g, "").toLowerCase(); }
27
+ export function isNoiseKey(key) {
28
+ if (BODY_NOISE_KEYS.has(bodyFlatten(key))) return true;
29
+ const bare = String(key).replace(/^_+/, "");
30
+ return /[_\-\s](id|ids|uuid|guid|key|hash|token|checksum)$/i.test(bare) || /[a-z0-9](Id|Ids|UUID|Uuid|GUID|Guid|Key|Hash|Token|Checksum)$/.test(bare);
31
+ }
32
+ export function isEmptyValue(value) {
33
+ if (value === null || value === undefined) return true;
34
+ if (typeof value === "string") return value.trim() === "";
35
+ if (Array.isArray(value)) return value.length === 0;
36
+ if (bodyIsRecord(value)) return Object.keys(value).length === 0;
37
+ return false;
38
+ }
39
+ function isLinkObject(value) {
40
+ if (!bodyIsRecord(value)) return false;
41
+ const keys = Object.keys(value).map(bodyFlatten);
42
+ return keys.includes("href") && keys.every(key => BODY_LINK_OBJECT_KEYS.has(key));
43
+ }
44
+ export function isLinkPlumbing(value) {
45
+ if (isLinkObject(value)) return true;
46
+ if (Array.isArray(value)) return value.length > 0 && value.every(isLinkObject);
47
+ if (bodyIsRecord(value)) { const values = Object.values(value); return values.length > 0 && values.every(inner => isLinkPlumbing(inner)); }
48
+ return false;
49
+ }
50
+ /** Plumbing out, but never emptied: a record of only identifiers is drawn whole. */
51
+ export function withoutPlumbing(record) {
52
+ const kept = Object.entries(record).filter(([key, value]) => !isNoiseKey(key) && !isEmptyValue(value) && !isLinkPlumbing(value));
53
+ return kept.length ? Object.fromEntries(kept) : record;
54
+ }
55
+ /** Peel `{ data: ... }`-style envelopes while exactly one meaningful key remains. */
56
+ export function unwrapEnvelope(value, limit = 4) {
57
+ let current = value;
58
+ for (let i = 0; i < limit; i++) {
59
+ if (!bodyIsRecord(current)) return current;
60
+ const entries = Object.entries(current).filter(([key, inner]) => !isNoiseKey(key) && !isEmptyValue(inner) && !isLinkPlumbing(inner));
61
+ if (entries.length !== 1 || !BODY_ENVELOPE_KEYS.has(bodyFlatten(entries[0][0]))) return current;
62
+ current = entries[0][1];
63
+ }
64
+ return current;
65
+ }
66
+ function readPair(item) {
67
+ if (!bodyIsRecord(item)) return null;
68
+ let key = null, value, hasValue = false, children = null;
69
+ for (const [name, inner] of Object.entries(item)) {
70
+ const flat = bodyFlatten(name);
71
+ if (key === null && ["key", "name", "field"].includes(flat) && typeof inner === "string") { key = inner.trim(); continue; }
72
+ if (!hasValue && ["value", "val"].includes(flat)) { value = inner; hasValue = true; continue; }
73
+ if (children === null && Array.isArray(inner)) { children = inner; continue; }
74
+ return null;
75
+ }
76
+ if (!key || (!hasValue && children === null)) return null;
77
+ if (children !== null && children.length > 0) { const nested = pairsToRecord(children); return nested === null ? null : [key, nested]; }
78
+ return [key, hasValue ? value : null];
79
+ }
80
+ /** A list of `{ key, value }` pairs is one record, e.g. KVK annual accounts. */
81
+ export function pairsToRecord(items) {
82
+ if (!items.length) return null;
83
+ const held = new Map();
84
+ for (const item of items) {
85
+ const pair = readPair(item);
86
+ if (!pair) return null;
87
+ if (held.has(pair[0])) held.get(pair[0]).push(pair[1]); else held.set(pair[0], [pair[1]]);
88
+ }
89
+ return Object.fromEntries([...held].map(([key, values]) => [key, values.length === 1 ? values[0] : values]));
90
+ }
91
+ export function isProse(value) {
92
+ const text = String(value).trim();
93
+ return !!text && !bodyIsUrl(text) && (text.includes("\n") || text.length > 180);
94
+ }
95
+ /** Headings, paragraphs, bullets and numbered lists; no inline markup. */
96
+ export function readBlocks(text) {
97
+ const blocks = [];
98
+ let paragraph = [], items = [], listKind = null;
99
+ const endParagraph = () => { if (paragraph.length) blocks.push({ kind: "paragraph", text: paragraph.join("\n") }); paragraph = []; };
100
+ const endList = () => { if (listKind && items.length) blocks.push({ kind: listKind, items }); items = []; listKind = null; };
101
+ for (const line of String(text).replace(/\r\n?/g, "\n").split("\n")) {
102
+ if (!line.trim()) { endParagraph(); endList(); continue; }
103
+ const heading = /^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
104
+ if (heading) { endParagraph(); endList(); blocks.push({ kind: "heading", text: heading[2], level: Math.min(heading[1].length, 3) }); continue; }
105
+ const bullet = /^ {0,3}[-*•]\s+(.+)$/.exec(line), numbered = /^ {0,3}\d{1,3}[.)]\s+(.+)$/.exec(line);
106
+ if (bullet || numbered) {
107
+ const kind = bullet ? "bullets" : "numbers";
108
+ endParagraph(); if (listKind !== kind) endList(); listKind = kind; items.push((bullet || numbered)[1].trim()); continue;
109
+ }
110
+ endList(); paragraph.push(line.trimEnd());
111
+ }
112
+ endParagraph(); endList();
113
+ return blocks;
114
+ }
115
+
116
+ // ---- DOM, run inside the card only (uses the card's el/text/window.apiosk) ----
117
+ function answerBody(value) {
118
+ const body = unwrapEnvelope(displayData(value)), wrap = el("div", "answer-body");
119
+ if (bodyIsRecord(body) && Array.isArray(body.value) && Object.keys(body).some(key => key.startsWith("@odata."))) {
120
+ const { value: records, ...metadata } = body, meta = el("details", "body-meta");
121
+ meta.append(el("summary", "", "Source metadata"), bodyFields(metadata, 0)); meta.ontoggle = () => window.apiosk.resize();
122
+ wrap.append(bodyNode(records, 0), meta); return wrap;
123
+ }
124
+ wrap.append(bodyNode(body, 0)); return wrap;
125
+ }
126
+ function bodyMuted(value) { return el("span", "body-muted", value); }
127
+ function bodyNode(value, depth) {
128
+ if (value === null || value === undefined) return bodyMuted("null");
129
+ if (typeof value === "string") return isProse(value) ? bodyProse(value) : bodyScalar(value);
130
+ if (typeof value === "number" || typeof value === "boolean") return el("span", "body-number", String(value));
131
+ if (Array.isArray(value)) return bodyList(value, depth);
132
+ if (bodyIsRecord(value)) return bodyFields(value, depth);
133
+ return bodyMuted("not shown");
134
+ }
135
+ function bodyTableColumns(items) {
136
+ if (items.length < 2 || !items.every(bodyIsRecord)) return null;
137
+ const columns = Object.keys(items[0]);
138
+ if (!columns.length || columns.length > 10 || columns.some(key => BODY_NAME_KEYS.includes(key.toLowerCase()) || BODY_IMAGE_KEYS.includes(key.toLowerCase()))) return null;
139
+ return items.every(item => Object.keys(item).length === columns.length && columns.every(key => Object.hasOwn(item, key) && !bodyIsRecord(item[key]) && !Array.isArray(item[key]) && !(typeof item[key] === "string" && isProse(item[key])))) ? columns : null;
140
+ }
141
+ function bodyList(items, depth) {
142
+ if (!items.length) return bodyMuted("Empty list.");
143
+ const spelled = pairsToRecord(items);
144
+ if (spelled) return bodyFields(spelled, depth);
145
+ const scalars = items.every(item => !bodyIsRecord(item) && !Array.isArray(item));
146
+ if (scalars && items.some(item => typeof item === "string" && isProse(item))) {
147
+ const list = el("ul", "body-bullets");
148
+ for (const item of items) { const li = el("li"); li.append(el("span", "body-dot", "•"), typeof item === "string" ? el("span", "body-prose-item", item) : bodyNode(item, depth + 1)); list.append(li); }
149
+ return list;
150
+ }
151
+ if (scalars) { const list = el("ul", "body-chips"); for (const item of items) { const li = el("li"); li.append(bodyNode(item, depth + 1)); list.append(li); } return list; }
152
+ const wrap = el("div", "body-list"), columns = bodyTableColumns(items);
153
+ const draw = all => {
154
+ const shown = all ? items : items.slice(0, 20), rest = items.length - shown.length, parts = [];
155
+ if (columns) {
156
+ const region = el("div", "body-table-wrap"), table = el("table", "body-table"), head = el("tr"), thead = el("thead"), tbody = el("tbody");
157
+ region.setAttribute("role", "region"); region.setAttribute("aria-label", "Result records"); region.tabIndex = 0;
158
+ table.append(el("caption", "", items.length + " results"));
159
+ for (const key of columns) { const th = el("th", "", bodyLabel(key)); th.setAttribute("scope", "col"); head.append(th); }
160
+ thead.append(head);
161
+ for (const item of shown) { const tr = el("tr"); for (const key of columns) { const td = el("td"); td.append(bodyNode(item[key], depth + 1)); tr.append(td); } tbody.append(tr); }
162
+ table.append(thead, tbody); region.append(table); parts.push(region);
163
+ } else {
164
+ const list = el("ol", "body-records");
165
+ for (const item of shown) { const li = el("li"); li.append(bodyIsRecord(item) ? bodyRecord(item, depth + 1) : bodyNode(item, depth + 1)); list.append(li); }
166
+ parts.push(el("p", "body-count", items.length + (items.length === 1 ? " result" : " results")), list);
167
+ }
168
+ if (rest > 0) { const more = el("button", "body-more", "Show " + rest + " more"); more.type = "button"; more.onclick = () => { draw(true); window.apiosk.resize(); }; parts.push(more); }
169
+ wrap.replaceChildren(...parts);
170
+ };
171
+ draw(false);
172
+ return wrap;
173
+ }
174
+ function bodyRecord(record, depth) {
175
+ const entries = Object.entries(withoutPlumbing(record)), wrap = el("div", "body-record");
176
+ const nameKey = entries.find(([key, value]) => typeof value === "string" && BODY_NAME_KEYS.includes(key.toLowerCase()));
177
+ const imageKey = entries.find(([key, value]) => typeof value === "string" && BODY_IMAGE_KEYS.includes(key.toLowerCase()) && bodyIsUrl(value));
178
+ const linkKey = entries.find(([key, value]) => typeof value === "string" && BODY_LINK_KEYS.includes(key.toLowerCase()) && bodyIsUrl(value));
179
+ const rest = entries.filter(([key]) => key !== nameKey?.[0] && key !== imageKey?.[0]);
180
+ if (nameKey || imageKey) {
181
+ const head = el("div", "body-record-head"), copy = el("div", "body-record-copy");
182
+ if (imageKey) head.append(bodyThumb(imageKey[1]));
183
+ if (nameKey) copy.append(el("p", "body-record-name", nameKey[1]));
184
+ if (linkKey) copy.append(bodyLink(linkKey[1], bodyHost(linkKey[1])));
185
+ head.append(copy); wrap.append(head);
186
+ }
187
+ if (rest.length) wrap.append(bodyFields(Object.fromEntries(rest), depth));
188
+ return wrap;
189
+ }
190
+ function bodyFields(record, depth) {
191
+ const entries = Object.entries(withoutPlumbing(record));
192
+ if (!entries.length) return bodyMuted("Nothing in it.");
193
+ const grid = rows => {
194
+ const dl = el("dl", "body-fields");
195
+ for (const [key, value] of rows) {
196
+ const row = el("div", "result-row"), dd = el("dd", "value");
197
+ dd.append(depth >= 8 && (bodyIsRecord(value) || Array.isArray(value)) ? bodyMuted(bodySummarise(value)) : bodyNode(value, depth >= 8 ? depth : depth + 1));
198
+ row.append(el("dt", "key", bodyLabel(key)), dd); dl.append(row);
199
+ }
200
+ return dl;
201
+ };
202
+ if (depth >= 8) return grid(entries);
203
+ const wrap = el("div", "body-group");
204
+ const flat = entries.filter(([, value]) => !bodyIsRecord(value) && !Array.isArray(value) && !(typeof value === "string" && isProse(value)));
205
+ if (flat.length) wrap.append(grid(flat));
206
+ const prose = entries.filter(([, value]) => typeof value === "string" && isProse(value)), nested = entries.filter(([, value]) => bodyIsRecord(value) || Array.isArray(value));
207
+ for (const [key, value] of [...prose, ...nested]) {
208
+ const part = el("section", "body-section");
209
+ part.append(el("h4", "", bodyLabel(key)), typeof value === "string" ? bodyProse(value) : bodyNode(value, depth + 1));
210
+ wrap.append(part);
211
+ }
212
+ return wrap;
213
+ }
214
+ function bodyProse(value) {
215
+ const blocks = readBlocks(value), wrap = el("div", "body-prose");
216
+ if (!blocks.length) return bodyMuted("empty");
217
+ for (const block of blocks) {
218
+ if (block.kind === "heading") { wrap.append(el("p", "body-prose-heading", block.text)); continue; }
219
+ if (block.kind === "paragraph") { wrap.append(el("p", "", block.text)); continue; }
220
+ const list = el(block.kind === "numbers" ? "ol" : "ul");
221
+ for (const item of block.items) list.append(el("li", "", item));
222
+ wrap.append(list);
223
+ }
224
+ return wrap;
225
+ }
226
+ function bodyScalar(value) {
227
+ const trimmed = value.trim();
228
+ if (!trimmed) return bodyMuted("empty");
229
+ if (bodyIsUrl(trimmed)) {
230
+ if (!bodyIsImageUrl(trimmed)) return bodyLink(trimmed, trimmed);
231
+ const wrap = el("div", "body-image"); wrap.append(bodyThumb(trimmed), bodyLink(trimmed, trimmed)); return wrap;
232
+ }
233
+ const when = bodyAsDate(trimmed);
234
+ if (when) { const span = el("span", "", when); span.title = trimmed; return span; }
235
+ return el("span", "body-text", trimmed);
236
+ }
237
+ function bodyThumb(src) {
238
+ const img = el("img", "body-thumb"); img.alt = ""; img.loading = "lazy"; img.decoding = "async"; img.referrerPolicy = "no-referrer";
239
+ img.onerror = () => img.remove(); img.src = src; return img;
240
+ }
241
+ function bodyLink(href, label) {
242
+ const a = el("a", "body-link", label); a.href = href; a.target = "_blank"; a.rel = "noopener noreferrer";
243
+ a.onclick = event => { event?.preventDefault?.(); void window.apiosk.openLink(href); };
244
+ return a;
245
+ }
246
+ function bodySummarise(value) {
247
+ if (Array.isArray(value)) return value.length + (value.length === 1 ? " item" : " items");
248
+ const count = Object.keys(value).length;
249
+ return count + (count === 1 ? " detail" : " details");
250
+ }
251
+
252
+ const CONSTANTS = { BODY_NOISE_KEYS, BODY_LINK_OBJECT_KEYS, BODY_ENVELOPE_KEYS };
253
+ export const V2_CARD_BODY = [
254
+ ...Object.entries(CONSTANTS).map(([name, set]) => `const ${name}=new Set(${JSON.stringify([...set])});`),
255
+ `const BODY_NAME_KEYS=${JSON.stringify(BODY_NAME_KEYS)},BODY_IMAGE_KEYS=${JSON.stringify(BODY_IMAGE_KEYS)},BODY_LINK_KEYS=${JSON.stringify(BODY_LINK_KEYS)};`,
256
+ bodyIsRecord, bodyIsUrl, bodyIsImageUrl, bodyAsDate, bodyLabel, bodyHost, bodyFlatten, isNoiseKey, isEmptyValue, isLinkObject, isLinkPlumbing,
257
+ withoutPlumbing, unwrapEnvelope, readPair, pairsToRecord, isProse, readBlocks,
258
+ answerBody, bodyMuted, bodyNode, bodyTableColumns, bodyList, bodyRecord, bodyFields, bodyProse, bodyScalar, bodyThumb, bodyLink, bodySummarise,
259
+ ].map(value => typeof value === "function" ? value.toString() : value).join("\n");