@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 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, '&amp;')
7
+ .replace(/</g, '&lt;')
8
+ .replace(/>/g, '&gt;')
9
+ .replace(/"/g, '&quot;')
10
+ .replace(/'/g, '&#39;');
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, '&amp;')
92
+ .replace(/</g, '&lt;')
93
+ .replace(/>/g, '&gt;');
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
+ }