@nukipa/post-content 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/package.json +33 -0
- package/src/components.js +386 -0
- package/src/escape.js +19 -0
- package/src/index.d.ts +50 -0
- package/src/index.js +94 -0
- package/src/markers.js +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# @nukipa/post-content
|
|
2
|
+
|
|
3
|
+
Framework-agnostic processor for CMS post bodies.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { renderPostBody, renderSourcesList } from '@nukipa/post-content';
|
|
7
|
+
|
|
8
|
+
const { html, mounts } = renderPostBody({
|
|
9
|
+
body: post.body,
|
|
10
|
+
components: post.components,
|
|
11
|
+
sources: post.sources,
|
|
12
|
+
options: { lang: post.language, postId: post.id }
|
|
13
|
+
});
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it handles
|
|
17
|
+
|
|
18
|
+
| Marker / type | Behaviour |
|
|
19
|
+
| -------------------- | ----------------------------------------------------- |
|
|
20
|
+
| `{{fact}}…{{/fact}}` | Wrapper stripped, inner text kept |
|
|
21
|
+
| `{{cite:N}}` | Replaced with `<sup><a href="#source-N">…` after MD |
|
|
22
|
+
| `{{component:UUID}}` | Rendered to HTML; interactive types add to `mounts` |
|
|
23
|
+
| Component types | callout, faq, steps, card, data_table, comparison, process, image, image_carousel, chart, widget, quote, cta, form, contact_form |
|
|
24
|
+
|
|
25
|
+
CTAs render as a real `<a href>` (so SEO + no-JS still navigate) plus a
|
|
26
|
+
`data-island="cta"` placeholder — framework adapters attach click tracking
|
|
27
|
+
on top via the `mounts` entry. Lead-gen forms, contact forms, charts,
|
|
28
|
+
carousels, and widgets emit `data-island="..."` placeholders only; the
|
|
29
|
+
adapter is responsible for hydration.
|
|
30
|
+
|
|
31
|
+
## Class names
|
|
32
|
+
|
|
33
|
+
All static blocks use `bp-*` classes matching the Vue site at
|
|
34
|
+
`apps/public/`. Lift the relevant CSS rules from
|
|
35
|
+
`apps/public/app/components/BlogArticle.vue` (scoped style block) into
|
|
36
|
+
your tenant site's stylesheet — keys remain stable.
|
|
37
|
+
|
|
38
|
+
## Peer deps
|
|
39
|
+
|
|
40
|
+
`marked@^17` — bring your own. The package uses one shared instance
|
|
41
|
+
internally for component-content rendering and the host's instance via
|
|
42
|
+
`renderPostBody` for the outer body.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nukipa/post-content",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic processor for Nukipa CMS post bodies — markdown + component markers + citations → HTML + interactive island descriptors.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Nukipa Labs",
|
|
7
|
+
"homepage": "https://github.com/nukipa-labs/nukipa/tree/main/packages/post-content",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/nukipa-labs/nukipa.git",
|
|
11
|
+
"directory": "packages/post-content"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/nukipa-labs/nukipa/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": ["nukipa", "cms", "markdown", "post", "renderer"],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "./src/index.js",
|
|
19
|
+
"types": "./src/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.d.ts",
|
|
23
|
+
"import": "./src/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": ["src", "README.md"],
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"marked": "^17.0.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Component registry. One render function per component_type, ported from
|
|
2
|
+
// apps/public/app/utils/postComponents.ts. Class names match (`bp-*`) so
|
|
3
|
+
// CSS lifted from the Vue site keeps working.
|
|
4
|
+
//
|
|
5
|
+
// Each renderer either:
|
|
6
|
+
// - returns `{ html }` for a static block, or
|
|
7
|
+
// - returns `{ html, mount }` where `html` carries `data-island="..."`
|
|
8
|
+
// placeholders and `mount` describes the framework-side hydration.
|
|
9
|
+
//
|
|
10
|
+
// CTA is hybrid: emits a real <a href> for SEO + navigation without JS, plus
|
|
11
|
+
// `data-island="cta"` so a framework adapter can attach click tracking.
|
|
12
|
+
|
|
13
|
+
import { Marked } from 'marked';
|
|
14
|
+
import { escapeHtml, escapeAttr, safeUrl } from './escape.js';
|
|
15
|
+
|
|
16
|
+
const innerMarked = new Marked();
|
|
17
|
+
const md = (text) => (text ? innerMarked.parse(String(text)) : '');
|
|
18
|
+
const mdInline = (text) => (text ? innerMarked.parseInline(String(text).replace(/\n+/g, ' ')) : '');
|
|
19
|
+
|
|
20
|
+
// ─── interactive primitives (CTA / form / contact_form) ─────────────────
|
|
21
|
+
|
|
22
|
+
function renderCta(component) {
|
|
23
|
+
const content = component.content || {};
|
|
24
|
+
const resolved = content._resolved;
|
|
25
|
+
if (!resolved) return { html: '' };
|
|
26
|
+
|
|
27
|
+
const id = resolved.id || '';
|
|
28
|
+
const ctaType = resolved.cta_type || 'url';
|
|
29
|
+
const styleClass = resolved.style === 'outline'
|
|
30
|
+
? 'cta-outline'
|
|
31
|
+
: (resolved.style === 'subtle' ? 'cta-subtle' : 'cta-primary');
|
|
32
|
+
const label = String(content.label_override ?? resolved.label ?? '').trim();
|
|
33
|
+
const description = String(content.description_override ?? resolved.description ?? '').trim();
|
|
34
|
+
const url = safeUrl(resolved.url);
|
|
35
|
+
|
|
36
|
+
if (ctaType === 'form') {
|
|
37
|
+
const attrs = [
|
|
38
|
+
`data-island="cta-form"`,
|
|
39
|
+
`data-cta-id="${escapeAttr(id)}"`,
|
|
40
|
+
label ? `data-cta-label="${escapeAttr(label)}"` : '',
|
|
41
|
+
description ? `data-cta-description="${escapeAttr(description)}"` : ''
|
|
42
|
+
].filter(Boolean).join(' ');
|
|
43
|
+
return {
|
|
44
|
+
html: `<div class="inline-contact-form-mount" ${attrs}></div>`,
|
|
45
|
+
mount: { kind: 'cta-form', id, label, description }
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!label || url === '#') return { html: '' };
|
|
50
|
+
const desc = description ? `<p class="inline-cta-description">${escapeHtml(description)}</p>` : '';
|
|
51
|
+
return {
|
|
52
|
+
html:
|
|
53
|
+
`<div class="inline-cta-block" data-island="cta">${desc}` +
|
|
54
|
+
`<a href="${escapeAttr(url)}" class="inline-cta-btn ${styleClass}" ` +
|
|
55
|
+
`data-cta-id="${escapeAttr(id)}" data-cta-label="${escapeAttr(label)}" ` +
|
|
56
|
+
`target="_blank" rel="noopener">${escapeHtml(label)}</a></div>`,
|
|
57
|
+
mount: { kind: 'cta', id, label, url, postId: null /* filled by caller */ }
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderForm(component) {
|
|
62
|
+
const content = component.content || {};
|
|
63
|
+
const slug = content.form_slug || content.slug;
|
|
64
|
+
if (!slug) return { html: '' };
|
|
65
|
+
const attrs = [
|
|
66
|
+
`data-island="form"`,
|
|
67
|
+
`data-form-slug="${escapeAttr(slug)}"`,
|
|
68
|
+
content.title ? `data-form-title="${escapeAttr(content.title)}"` : '',
|
|
69
|
+
content.description ? `data-form-description="${escapeAttr(content.description)}"` : '',
|
|
70
|
+
content.submit_label ? `data-form-submit-label="${escapeAttr(content.submit_label)}"` : ''
|
|
71
|
+
].filter(Boolean).join(' ');
|
|
72
|
+
return {
|
|
73
|
+
html: `<div class="lead-form-mount" ${attrs}></div>`,
|
|
74
|
+
mount: {
|
|
75
|
+
kind: 'form',
|
|
76
|
+
slug,
|
|
77
|
+
title: content.title ?? null,
|
|
78
|
+
description: content.description ?? null,
|
|
79
|
+
submitLabel: content.submit_label ?? null
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderContactForm(component) {
|
|
85
|
+
const content = component.content || {};
|
|
86
|
+
const payload = {
|
|
87
|
+
title: content.title ?? null,
|
|
88
|
+
description: content.description ?? null,
|
|
89
|
+
submit_label: content.submit_label ?? null,
|
|
90
|
+
success_message: content.success_message ?? null,
|
|
91
|
+
fields: Array.isArray(content.fields) ? content.fields : null
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
html:
|
|
95
|
+
`<div class="contact-form-mount" data-island="contact-form" ` +
|
|
96
|
+
`data-component-id="${escapeAttr(component.id)}" ` +
|
|
97
|
+
`data-content="${escapeAttr(JSON.stringify(payload))}"></div>`,
|
|
98
|
+
mount: { kind: 'contact-form', componentId: component.id, ...payload }
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── static block components ────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const CALLOUT_STYLES = {
|
|
105
|
+
note: { icon: 'info', label: 'Note' },
|
|
106
|
+
tip: { icon: 'lightbulb', label: 'Tip' },
|
|
107
|
+
important: { icon: 'star', label: 'Important' },
|
|
108
|
+
warning: { icon: 'warning', label: 'Warning' },
|
|
109
|
+
caution: { icon: 'block', label: 'Caution' }
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function renderCallout(content) {
|
|
113
|
+
const level = String(content.level || 'note');
|
|
114
|
+
const style = CALLOUT_STYLES[level] || CALLOUT_STYLES.note;
|
|
115
|
+
return {
|
|
116
|
+
html:
|
|
117
|
+
`<div class="bp-component bp-callout bp-callout--${escapeAttr(level)}">` +
|
|
118
|
+
`<div class="bp-callout__header"><span class="material-icons">${escapeHtml(style.icon)}</span> ${escapeHtml(style.label)}</div>` +
|
|
119
|
+
`<div class="bp-callout__body">${md(content.content)}</div>` +
|
|
120
|
+
`</div>`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderFaq(content) {
|
|
125
|
+
const items = (Array.isArray(content.items) ? content.items : []).map((item) =>
|
|
126
|
+
`<details class="bp-faq-item">` +
|
|
127
|
+
`<summary><span class="material-icons bp-faq-icon">help_outline</span>` +
|
|
128
|
+
`<span class="bp-faq-question">${escapeHtml(item?.question)}</span>` +
|
|
129
|
+
`<span class="material-icons bp-faq-chevron">expand_more</span></summary>` +
|
|
130
|
+
`<div class="bp-faq-answer">${md(item?.answer)}</div>` +
|
|
131
|
+
`</details>`
|
|
132
|
+
).join('');
|
|
133
|
+
return { html: `<div class="bp-component bp-faq">${items}</div>` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderSteps(content) {
|
|
137
|
+
const list = Array.isArray(content.items) ? content.items : [];
|
|
138
|
+
const total = list.length;
|
|
139
|
+
const items = list.map((item, i) =>
|
|
140
|
+
`<div class="bp-step${i === total - 1 ? ' bp-step--last' : ''}">` +
|
|
141
|
+
`<div class="bp-step__marker">` +
|
|
142
|
+
`<span class="bp-step__num">${i + 1}</span>` +
|
|
143
|
+
(i < total - 1 ? '<span class="bp-step__line"></span>' : '') +
|
|
144
|
+
`</div>` +
|
|
145
|
+
`<div class="bp-step__content">` +
|
|
146
|
+
`<strong>${escapeHtml(item?.title)}</strong>` +
|
|
147
|
+
`<p>${mdInline(item?.description)}</p>` +
|
|
148
|
+
`</div></div>`
|
|
149
|
+
).join('');
|
|
150
|
+
return { html: `<div class="bp-component bp-steps">${items}</div>` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderCard(content) {
|
|
154
|
+
const img = content.image_url ? `<img src="${escapeAttr(content.image_url)}" alt="" class="bp-card__img" />` : '';
|
|
155
|
+
return {
|
|
156
|
+
html:
|
|
157
|
+
`<div class="bp-component bp-card">${img}` +
|
|
158
|
+
`<div class="bp-card__body">` +
|
|
159
|
+
`<div class="bp-card__title">${escapeHtml(content.title)}</div>` +
|
|
160
|
+
md(content.content) +
|
|
161
|
+
`</div></div>`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderDataTable(content) {
|
|
166
|
+
const cols = Array.isArray(content.columns) ? content.columns : [];
|
|
167
|
+
const rows = Array.isArray(content.rows) ? content.rows : [];
|
|
168
|
+
const thead = cols.map((c) => `<th>${escapeHtml(typeof c === 'string' ? c : c?.label)}</th>`).join('');
|
|
169
|
+
const tbody = rows.map((row) => {
|
|
170
|
+
if (Array.isArray(row)) {
|
|
171
|
+
return `<tr>${row.map((cell) => `<td>${escapeHtml(String(cell))}</td>`).join('')}</tr>`;
|
|
172
|
+
}
|
|
173
|
+
return `<tr>${cols.map((c) => `<td>${escapeHtml(String(row?.[typeof c === 'string' ? c : c?.key] ?? ''))}</td>`).join('')}</tr>`;
|
|
174
|
+
}).join('');
|
|
175
|
+
return {
|
|
176
|
+
html:
|
|
177
|
+
`<div class="bp-component bp-data-table">` +
|
|
178
|
+
(content.title ? `<div class="bp-data-table__title">${escapeHtml(content.title)}</div>` : '') +
|
|
179
|
+
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>` +
|
|
180
|
+
`</div>`
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderComparison(content) {
|
|
185
|
+
const rawCols = Array.isArray(content.columns) ? content.columns : [];
|
|
186
|
+
const rawRows = Array.isArray(content.rows) ? content.rows : [];
|
|
187
|
+
|
|
188
|
+
// The comparison schema spec is `columns: ["Feature", "Us", "Them"]` (string
|
|
189
|
+
// array) and `rows: [["row1col1", "row1col2", ...]]` (array of arrays).
|
|
190
|
+
// The writer LLM occasionally emits the data_table shape instead — column
|
|
191
|
+
// objects (`{key, label}`) plus row OBJECTS keyed by column.key. We
|
|
192
|
+
// detect that shape and project it into the canonical form rather than
|
|
193
|
+
// string-coercing the objects (which renders as "[object Object]").
|
|
194
|
+
const looksLikeDataTable = rawCols.length > 0
|
|
195
|
+
&& rawCols.every((c) => c && typeof c === 'object')
|
|
196
|
+
&& (rawCols[0].label !== undefined || rawCols[0].key !== undefined || rawCols[0].title !== undefined);
|
|
197
|
+
|
|
198
|
+
let cols;
|
|
199
|
+
let rows;
|
|
200
|
+
if (looksLikeDataTable) {
|
|
201
|
+
cols = rawCols.map((c) => c.label ?? c.title ?? c.key ?? '');
|
|
202
|
+
const keys = rawCols.map((c) => c.key ?? c.label ?? c.title ?? '');
|
|
203
|
+
rows = rawRows.map((row) => {
|
|
204
|
+
// Accept both object-form (keyed by column.key) and array-form rows
|
|
205
|
+
// mixed inside the same component — the LLM is occasionally
|
|
206
|
+
// inconsistent. Arrays still pass through positionally.
|
|
207
|
+
if (row && typeof row === 'object' && !Array.isArray(row)) {
|
|
208
|
+
return keys.map((k) => row[k] ?? '');
|
|
209
|
+
}
|
|
210
|
+
return Array.isArray(row) ? row : [];
|
|
211
|
+
});
|
|
212
|
+
} else {
|
|
213
|
+
cols = rawCols.map((c) => (c && typeof c === 'object' ? (c.label ?? c.title ?? c.key ?? '') : c));
|
|
214
|
+
rows = rawRows.map((row) => (Array.isArray(row) ? row : []));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const thead = cols.map((c) => `<th>${escapeHtml(String(c))}</th>`).join('');
|
|
218
|
+
const tbody = rows.map((row) =>
|
|
219
|
+
`<tr>${row.map((cell, i) =>
|
|
220
|
+
`<td${i === 0 ? '' : ' class="bp-comparison__cell"'}>${escapeHtml(String(cell ?? ''))}</td>`
|
|
221
|
+
).join('')}</tr>`
|
|
222
|
+
).join('');
|
|
223
|
+
return {
|
|
224
|
+
html:
|
|
225
|
+
`<div class="bp-component bp-comparison">` +
|
|
226
|
+
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>` +
|
|
227
|
+
`</div>`
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderProcess(content) {
|
|
232
|
+
const list = Array.isArray(content.items) ? content.items : [];
|
|
233
|
+
const total = list.length;
|
|
234
|
+
const items = list.map((item, i) =>
|
|
235
|
+
`<div class="bp-process__stage">` +
|
|
236
|
+
`<div class="bp-process__icon">${item?.icon
|
|
237
|
+
? `<span class="material-icons">${escapeHtml(item.icon)}</span>`
|
|
238
|
+
: `<span class="bp-process__num">${i + 1}</span>`}</div>` +
|
|
239
|
+
`<div class="bp-process__title">${escapeHtml(item?.title)}</div>` +
|
|
240
|
+
(item?.description ? `<div class="bp-process__desc">${escapeHtml(item.description)}</div>` : '') +
|
|
241
|
+
`</div>` +
|
|
242
|
+
(i < total - 1
|
|
243
|
+
? '<div class="bp-process__connector"><span class="material-icons">arrow_forward</span></div>'
|
|
244
|
+
: '')
|
|
245
|
+
).join('');
|
|
246
|
+
return { html: `<div class="bp-component bp-process">${items}</div>` };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderImage(component) {
|
|
250
|
+
const content = component.content || {};
|
|
251
|
+
const status = String(content.status || '').toLowerCase();
|
|
252
|
+
if (status === 'pending' || status === 'generating') {
|
|
253
|
+
return { html: `<div class="bp-image-placeholder">Generating image…</div>` };
|
|
254
|
+
}
|
|
255
|
+
const url = typeof content.url === 'string' ? content.url.trim() : '';
|
|
256
|
+
if (!url || status === 'error') return { html: '' };
|
|
257
|
+
|
|
258
|
+
const alt = String(content.alt ?? '');
|
|
259
|
+
const captionRaw = content.caption ? String(content.caption) : '';
|
|
260
|
+
const caption = captionRaw ? `<figcaption class="bp-image__caption">${md(captionRaw)}</figcaption>` : '';
|
|
261
|
+
|
|
262
|
+
let credit = '';
|
|
263
|
+
const attr = content.attribution;
|
|
264
|
+
if (attr && typeof attr === 'object') {
|
|
265
|
+
const author = typeof attr.author === 'string' ? attr.author.trim() : '';
|
|
266
|
+
const source = typeof attr.source === 'string' ? attr.source.trim() : '';
|
|
267
|
+
const sourceUrl = typeof attr.source_url === 'string' ? attr.source_url.trim() : '';
|
|
268
|
+
const safeSrcUrl = (sourceUrl.startsWith('http://') || sourceUrl.startsWith('https://')) ? sourceUrl : '';
|
|
269
|
+
const labelParts = [author, source].filter(Boolean);
|
|
270
|
+
if (labelParts.length > 0) {
|
|
271
|
+
const label = escapeHtml(labelParts.join(' / '));
|
|
272
|
+
const inner = safeSrcUrl
|
|
273
|
+
? `<a href="${escapeAttr(safeSrcUrl)}" target="_blank" rel="noopener nofollow">${label}</a>`
|
|
274
|
+
: label;
|
|
275
|
+
credit = `<small class="bp-image__credit">Photo: ${inner}</small>`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
html:
|
|
281
|
+
`<figure class="bp-component bp-image" data-component-id="${escapeAttr(component.id)}">` +
|
|
282
|
+
`<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}" loading="lazy" />` +
|
|
283
|
+
caption + credit +
|
|
284
|
+
`</figure>`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function renderImageCarousel(content) {
|
|
289
|
+
const list = Array.isArray(content.items) ? content.items : [];
|
|
290
|
+
const items = list.map((item, i) =>
|
|
291
|
+
`<div class="bp-carousel__slide${i === 0 ? ' active' : ''}">` +
|
|
292
|
+
`<img src="${escapeAttr(item?.image_url)}" alt="${escapeAttr(item?.alt || '')}" />` +
|
|
293
|
+
(item?.caption ? `<div class="bp-carousel__caption">${escapeHtml(item.caption)}</div>` : '') +
|
|
294
|
+
`</div>`
|
|
295
|
+
).join('');
|
|
296
|
+
return {
|
|
297
|
+
html:
|
|
298
|
+
`<div class="bp-component bp-carousel" data-island="carousel" data-carousel>` +
|
|
299
|
+
`<div class="bp-carousel__track">${items}</div>` +
|
|
300
|
+
`<button class="bp-carousel__prev" data-carousel-prev><span class="material-icons">chevron_left</span></button>` +
|
|
301
|
+
`<button class="bp-carousel__next" data-carousel-next><span class="material-icons">chevron_right</span></button>` +
|
|
302
|
+
`</div>`,
|
|
303
|
+
mount: { kind: 'carousel' }
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function renderChart(content) {
|
|
308
|
+
const title = content.title ? `<div class="bp-chart__title">${escapeHtml(content.title)}</div>` : '';
|
|
309
|
+
const encoded = escapeAttr(JSON.stringify({
|
|
310
|
+
type: content.chart_type,
|
|
311
|
+
data: content.data,
|
|
312
|
+
options: content.options
|
|
313
|
+
}));
|
|
314
|
+
return {
|
|
315
|
+
html:
|
|
316
|
+
`<div class="bp-component bp-chart" data-island="chart" data-chart="${encoded}">` +
|
|
317
|
+
title +
|
|
318
|
+
`<canvas class="bp-chart__canvas" width="600" height="300"></canvas>` +
|
|
319
|
+
`</div>`,
|
|
320
|
+
mount: { kind: 'chart', chartType: content.chart_type, data: content.data, options: content.options, title: content.title || null }
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function renderWidget(component, opts) {
|
|
325
|
+
const content = component.content || {};
|
|
326
|
+
const status = String(content.status || 'pending');
|
|
327
|
+
const ready = status === 'ready' && typeof content.html_content === 'string' && content.html_content.length > 0;
|
|
328
|
+
|
|
329
|
+
if (ready) {
|
|
330
|
+
const lang = (opts?.lang || 'en').split('-')[0];
|
|
331
|
+
return {
|
|
332
|
+
html:
|
|
333
|
+
`<div class="bp-component bp-widget">` +
|
|
334
|
+
`<div class="inline-widget-container" data-island="widget" ` +
|
|
335
|
+
`data-widget-id="${escapeAttr(component.id)}" ` +
|
|
336
|
+
`data-widget-lang="${escapeAttr(lang)}"></div>` +
|
|
337
|
+
`</div>`,
|
|
338
|
+
mount: { kind: 'widget', componentId: component.id, html: content.html_content, lang }
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let message = 'Widget generating...';
|
|
343
|
+
if (status === 'pending') message = 'Widget queued for generation...';
|
|
344
|
+
if (status === 'generating') message = 'Widget generating...';
|
|
345
|
+
if (status === 'error') message = 'Widget generation failed';
|
|
346
|
+
return {
|
|
347
|
+
html:
|
|
348
|
+
`<div class="bp-component bp-widget-placeholder bp-widget-placeholder--${escapeAttr(status)}">` +
|
|
349
|
+
escapeHtml(message) +
|
|
350
|
+
`</div>`
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderQuote(content) {
|
|
355
|
+
const cite = content.cite ? `<cite class="bp-quote__cite">${escapeHtml(content.cite)}</cite>` : '';
|
|
356
|
+
return {
|
|
357
|
+
html: `<blockquote class="bp-component bp-quote">${md(content.content)}${cite}</blockquote>`
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const RENDERERS = {
|
|
362
|
+
cta: (c) => renderCta(c),
|
|
363
|
+
form: (c) => renderForm(c),
|
|
364
|
+
contact_form: (c) => renderContactForm(c),
|
|
365
|
+
callout: (c) => renderCallout(c.content || {}),
|
|
366
|
+
faq: (c) => renderFaq(c.content || {}),
|
|
367
|
+
steps: (c) => renderSteps(c.content || {}),
|
|
368
|
+
card: (c) => renderCard(c.content || {}),
|
|
369
|
+
data_table: (c) => renderDataTable(c.content || {}),
|
|
370
|
+
comparison: (c) => renderComparison(c.content || {}),
|
|
371
|
+
process: (c) => renderProcess(c.content || {}),
|
|
372
|
+
image: (c) => renderImage(c),
|
|
373
|
+
image_carousel: (c) => renderImageCarousel(c.content || {}),
|
|
374
|
+
chart: (c) => renderChart(c.content || {}),
|
|
375
|
+
widget: (c, opts) => renderWidget(c, opts),
|
|
376
|
+
quote: (c) => renderQuote(c.content || {})
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Render a single component to `{ html, mount? }`. Unknown types produce
|
|
381
|
+
* empty html — the marker drops, surrounding markdown stays clean.
|
|
382
|
+
*/
|
|
383
|
+
export function renderComponent(component, opts = {}) {
|
|
384
|
+
const fn = RENDERERS[component?.component_type];
|
|
385
|
+
return fn ? fn(component, opts) : { html: '' };
|
|
386
|
+
}
|
package/src/escape.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Tiny HTML/attr escape used by the component renderers. Same shape as the
|
|
2
|
+
// Vue renderer at apps/public/app/utils/postComponents.ts so styles port.
|
|
3
|
+
|
|
4
|
+
export function escapeHtml(s) {
|
|
5
|
+
return String(s ?? '')
|
|
6
|
+
.replace(/&/g, '&')
|
|
7
|
+
.replace(/</g, '<')
|
|
8
|
+
.replace(/>/g, '>')
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/'/g, ''');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const escapeAttr = escapeHtml;
|
|
14
|
+
|
|
15
|
+
const SAFE_URL_RE = /^(https?:\/\/|\/|mailto:|tel:|#)/i;
|
|
16
|
+
export function safeUrl(u) {
|
|
17
|
+
const s = String(u ?? '').trim();
|
|
18
|
+
return SAFE_URL_RE.test(s) ? s : '#';
|
|
19
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Type declarations for @nukipa/post-content.
|
|
2
|
+
|
|
3
|
+
export interface PostComponent {
|
|
4
|
+
id: string;
|
|
5
|
+
component_type: string;
|
|
6
|
+
content: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PostSource {
|
|
10
|
+
idx: number;
|
|
11
|
+
title?: string | null;
|
|
12
|
+
url?: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface RenderOpts {
|
|
16
|
+
lang?: string;
|
|
17
|
+
postId?: string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Mount =
|
|
21
|
+
| { kind: 'cta'; id: string; label: string; url: string; postId: string | null }
|
|
22
|
+
| { kind: 'cta-form'; id: string; label: string; description: string }
|
|
23
|
+
| { kind: 'form'; slug: string; title: string | null; description: string | null; submitLabel: string | null }
|
|
24
|
+
| { kind: 'contact-form'; componentId: string; title: string | null; description: string | null; submit_label: string | null; success_message: string | null; fields: Array<Record<string, unknown>> | null }
|
|
25
|
+
| { kind: 'carousel' }
|
|
26
|
+
| { kind: 'chart'; chartType: string; data: unknown; options: unknown; title: string | null }
|
|
27
|
+
| { kind: 'widget'; componentId: string; html: string; lang: string };
|
|
28
|
+
|
|
29
|
+
export interface RenderPostBodyInput {
|
|
30
|
+
body: string;
|
|
31
|
+
components?: PostComponent[];
|
|
32
|
+
sources?: PostSource[];
|
|
33
|
+
options?: RenderOpts;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RenderPostBodyResult {
|
|
37
|
+
html: string;
|
|
38
|
+
mounts: Mount[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renderPostBody(input: RenderPostBodyInput): RenderPostBodyResult;
|
|
42
|
+
export function renderComponent(component: PostComponent, opts?: RenderOpts): { html: string; mount?: Mount };
|
|
43
|
+
export function renderSourcesList(sources: PostSource[]): string;
|
|
44
|
+
export function stripFactMarkers(markdown: string): string;
|
|
45
|
+
export function applyCiteMarkers(html: string, sourceCount: number): string;
|
|
46
|
+
export function substituteComponentMarkers(
|
|
47
|
+
markdown: string,
|
|
48
|
+
components?: PostComponent[],
|
|
49
|
+
options?: RenderOpts
|
|
50
|
+
): string;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Public entry point for @nukipa/post-content.
|
|
2
|
+
//
|
|
3
|
+
// One main function: `renderPostBody({ body, components, sources, options })`.
|
|
4
|
+
// Returns `{ html, mounts }` — the processed HTML and a list of interactive
|
|
5
|
+
// island descriptors to hydrate framework-side.
|
|
6
|
+
|
|
7
|
+
import { marked } from 'marked';
|
|
8
|
+
import {
|
|
9
|
+
stripFactMarkers,
|
|
10
|
+
substituteComponentMarkers as substituteCore,
|
|
11
|
+
applyCiteMarkers
|
|
12
|
+
} from './markers.js';
|
|
13
|
+
import { renderComponent } from './components.js';
|
|
14
|
+
|
|
15
|
+
export { renderComponent } from './components.js';
|
|
16
|
+
export { stripFactMarkers, applyCiteMarkers } from './markers.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convenience wrapper: takes the raw `components` array (as the CMS returns
|
|
20
|
+
* it) and renders the markers in-place using the package's component
|
|
21
|
+
* registry. Returns the substituted markdown only — callers that need the
|
|
22
|
+
* `mounts` list should use `renderPostBody` instead.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the legacy `apps/public/app/utils/postComponents.ts#substituteComponentMarkers`
|
|
25
|
+
* shape so existing call sites can drop the local file in favour of this.
|
|
26
|
+
*/
|
|
27
|
+
export function substituteComponentMarkers(markdown, components = [], options = {}) {
|
|
28
|
+
const byId = new Map((components || []).map((c) => [c.id, c]));
|
|
29
|
+
const { markdown: out } = substituteCore(
|
|
30
|
+
markdown,
|
|
31
|
+
byId,
|
|
32
|
+
(component) => renderComponent(component, options)
|
|
33
|
+
);
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Process a CMS post body.
|
|
39
|
+
*
|
|
40
|
+
* Pipeline:
|
|
41
|
+
* 1. Strip `{{fact}}…{{/fact}}` wrappers (keep the inner text).
|
|
42
|
+
* 2. Substitute `{{component:UUID}}` markers — emit HTML and collect
|
|
43
|
+
* `mounts` for interactive components.
|
|
44
|
+
* 3. Run marked with GFM enabled.
|
|
45
|
+
* 4. Replace `{{cite:N}}` superscripts against the sources list.
|
|
46
|
+
*/
|
|
47
|
+
export function renderPostBody({ body, components = [], sources = [], options = {} }) {
|
|
48
|
+
const stripped = stripFactMarkers(body);
|
|
49
|
+
const byId = new Map((components || []).map((c) => [c.id, c]));
|
|
50
|
+
|
|
51
|
+
const { markdown: substituted, mounts } = substituteCore(
|
|
52
|
+
stripped,
|
|
53
|
+
byId,
|
|
54
|
+
(component) => renderComponent(component, options)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Annotate `cta` mounts with the post id so the framework adapter can
|
|
58
|
+
// pass it through to the click-tracking endpoint — the renderer doesn't
|
|
59
|
+
// have it directly.
|
|
60
|
+
const postId = options.postId ?? null;
|
|
61
|
+
for (const m of mounts) {
|
|
62
|
+
if (m.kind === 'cta') m.postId = postId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rawHtml = marked.parse(substituted, { gfm: true, breaks: false });
|
|
66
|
+
const html = applyCiteMarkers(typeof rawHtml === 'string' ? rawHtml : '', (sources || []).length);
|
|
67
|
+
|
|
68
|
+
return { html, mounts };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render the sources list as `<ol>` matching the `#source-N` anchors that
|
|
73
|
+
* `applyCiteMarkers` emits. Caller chooses where to place it (typically at
|
|
74
|
+
* the foot of the article).
|
|
75
|
+
*/
|
|
76
|
+
export function renderSourcesList(sources) {
|
|
77
|
+
if (!Array.isArray(sources) || sources.length === 0) return '';
|
|
78
|
+
const items = sources.map((s) => {
|
|
79
|
+
const idx = s.idx;
|
|
80
|
+
const title = s.title ? String(s.title) : (s.url ? String(s.url) : `Source ${idx}`);
|
|
81
|
+
const safe = (s.url && /^https?:\/\//.test(s.url))
|
|
82
|
+
? `<a href="${s.url}" target="_blank" rel="noopener nofollow">${escape(title)}</a>`
|
|
83
|
+
: escape(title);
|
|
84
|
+
return `<li id="source-${idx}">${safe}</li>`;
|
|
85
|
+
}).join('');
|
|
86
|
+
return `<ol class="bp-sources">${items}</ol>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escape(s) {
|
|
90
|
+
return String(s)
|
|
91
|
+
.replace(/&/g, '&')
|
|
92
|
+
.replace(/</g, '<')
|
|
93
|
+
.replace(/>/g, '>');
|
|
94
|
+
}
|
package/src/markers.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Marker handling for CMS post bodies.
|
|
2
|
+
//
|
|
3
|
+
// CMS bodies are markdown that may contain three classes of marker:
|
|
4
|
+
//
|
|
5
|
+
// {{component:UUID}} — substituted with rendered HTML before marked.
|
|
6
|
+
// {{cite:N}} — replaced with footnote-style anchors after marked.
|
|
7
|
+
// {{fact}}…{{/fact}} — wraps a claim that the LLM marked as load-bearing.
|
|
8
|
+
// The public renderer keeps the inner text and drops
|
|
9
|
+
// the wrapper. (Editorial review uses the marker; the
|
|
10
|
+
// public site doesn't.)
|
|
11
|
+
|
|
12
|
+
const FACT_OPEN_RE = /\{\{\s*fact\s*\}\}/gi;
|
|
13
|
+
const FACT_CLOSE_RE = /\{\{\s*\/\s*fact\s*\}\}/gi;
|
|
14
|
+
const COMPONENT_RE = /\{\{component:([a-f0-9-]+)\}\}/gi;
|
|
15
|
+
const CITE_RE = /\{\{\s*cite:(\d+)\s*\}\}/g;
|
|
16
|
+
|
|
17
|
+
/** Strip `{{fact}}…{{/fact}}` wrappers, keeping the inner text. */
|
|
18
|
+
export function stripFactMarkers(markdown) {
|
|
19
|
+
if (!markdown) return '';
|
|
20
|
+
return String(markdown)
|
|
21
|
+
.replace(FACT_OPEN_RE, '')
|
|
22
|
+
.replace(FACT_CLOSE_RE, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Substitute every `{{component:UUID}}` marker with its rendered HTML.
|
|
27
|
+
* Run BEFORE marked.parse so the emitted HTML isn't wrapped in <p>.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} markdown
|
|
30
|
+
* @param {{render: (component: object) => {html: string, mount?: object}}} opts
|
|
31
|
+
* `render` returns the HTML for the substituted block plus an optional
|
|
32
|
+
* `mount` descriptor — populated when the component is interactive (the
|
|
33
|
+
* placeholder needs framework-side hydration).
|
|
34
|
+
* @param {Map<string, object>} byId — components keyed by id.
|
|
35
|
+
* @returns {{markdown: string, mounts: object[]}}
|
|
36
|
+
*/
|
|
37
|
+
export function substituteComponentMarkers(markdown, byId, render) {
|
|
38
|
+
const mounts = [];
|
|
39
|
+
if (!markdown) return { markdown: '', mounts };
|
|
40
|
+
if (byId.size === 0) {
|
|
41
|
+
return { markdown: String(markdown).replace(COMPONENT_RE, ''), mounts };
|
|
42
|
+
}
|
|
43
|
+
const out = String(markdown).replace(COMPONENT_RE, (_match, id) => {
|
|
44
|
+
const comp = byId.get(id);
|
|
45
|
+
if (!comp) return '';
|
|
46
|
+
const { html, mount } = render(comp) || { html: '' };
|
|
47
|
+
if (mount) mounts.push(mount);
|
|
48
|
+
return html ? `\n\n${compactInlineHtml(html)}\n\n` : '';
|
|
49
|
+
});
|
|
50
|
+
return { markdown: out, mounts };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Replace `{{cite:N}}` with footnote-style superscripts. Run AFTER marked
|
|
55
|
+
* since cite markers often live inside paragraphs / lists. Markers whose
|
|
56
|
+
* `N` has no matching source render as plain `[N]` so data isn't lost.
|
|
57
|
+
*/
|
|
58
|
+
export function applyCiteMarkers(html, sourceCount) {
|
|
59
|
+
if (!html) return '';
|
|
60
|
+
return String(html).replace(CITE_RE, (_match, n) => {
|
|
61
|
+
const idx = Number(n);
|
|
62
|
+
if (!Number.isFinite(idx) || idx < 1) return '';
|
|
63
|
+
const valid = sourceCount > 0 && idx <= sourceCount;
|
|
64
|
+
return valid
|
|
65
|
+
? `<sup class="bp-cite"><a href="#source-${idx}" data-source-idx="${idx}">[${idx}]</a></sup>`
|
|
66
|
+
: `<sup class="bp-cite bp-cite--orphan">[${idx}]</sup>`;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Collapse whitespace BETWEEN tags so multi-line render strings produce a
|
|
71
|
+
// single HTML block. Without this, marked treats indented blank lines
|
|
72
|
+
// inside the HTML as code-block boundaries — see the Vue version's comment
|
|
73
|
+
// at apps/public/app/utils/postComponents.ts:457 for the failure mode.
|
|
74
|
+
function compactInlineHtml(html) {
|
|
75
|
+
return String(html).replace(/\n[ \t]*/g, '');
|
|
76
|
+
}
|