@druck-editorial/engine 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/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/format.d.ts +5 -0
- package/dist/format.js +115 -0
- package/dist/frontpage.d.ts +8 -0
- package/dist/frontpage.js +55 -0
- package/dist/frontpage.test.d.ts +1 -0
- package/dist/frontpage.test.js +75 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/render.d.ts +5 -0
- package/dist/render.js +224 -0
- package/dist/render.test.d.ts +1 -0
- package/dist/render.test.js +116 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.js +1 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Iagovdik <artyom.yagovdik@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @druck-editorial/engine
|
|
2
|
+
|
|
3
|
+
Editorial rendering engine. Takes structured `ArticleData` JSON and returns magazine-quality HTML strings.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @druck-editorial/engine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { renderArticle, renderCard, buildFrontPage, renderFrontPage } from '@druck-editorial/engine';
|
|
15
|
+
import type { ArticleData, RenderOptions } from '@druck-editorial/engine';
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### renderArticle
|
|
19
|
+
|
|
20
|
+
Renders a full article page shell with hero, chapters, key points, and related articles.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
const html = renderArticle(
|
|
24
|
+
{
|
|
25
|
+
title: 'The infrastructure gap',
|
|
26
|
+
subtitle: 'Why deployment costs diverged from benchmark results in 2025.',
|
|
27
|
+
metaDescription: 'A look at the growing gap between frontier model benchmarks and production deployment.',
|
|
28
|
+
slug: 'infrastructure-gap-2025',
|
|
29
|
+
format: 'feature',
|
|
30
|
+
category: 'ai',
|
|
31
|
+
publishedAt: '2025-11-14T09:00:00Z',
|
|
32
|
+
heroImage: 'https://example.com/hero.webp',
|
|
33
|
+
chapters: [
|
|
34
|
+
{
|
|
35
|
+
title: 'The economics',
|
|
36
|
+
bodyHtml: '<p>Running GPT-4-class inference at scale costs between $12-30 per million tokens.</p>',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{ lang: 'en', theme: 'light' }
|
|
41
|
+
);
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`format` is `'feature' | 'quick_take' | 'wire'`. Feature articles use the full chapter + key-points layout. Wire uses a single `bodyHtml` block.
|
|
45
|
+
|
|
46
|
+
### renderCard
|
|
47
|
+
|
|
48
|
+
Renders a card tile for use in a feed grid.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const cardHtml = renderCard(articleData, options);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### buildFrontPage + renderFrontPage
|
|
55
|
+
|
|
56
|
+
`buildFrontPage` groups an array of `ArticleData` into layout rows (`hero`, `feature`, `triple`, `brief`). `renderFrontPage` turns those rows into HTML.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const rows = buildFrontPage(items);
|
|
60
|
+
const html = renderFrontPage(rows, { lang: 'de' });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## RenderOptions
|
|
64
|
+
|
|
65
|
+
| Field | Type | Description |
|
|
66
|
+
|---|---|---|
|
|
67
|
+
| `lang` | `string` | Sets `lang` attribute on the article shell. |
|
|
68
|
+
| `theme` | `'light' \| 'dark'` | Sets `data-theme` on the shell. |
|
|
69
|
+
| `accentColor` | `string` | CSS value applied to `--accent`. |
|
|
70
|
+
| `siteName` | `string` | Site name shown in byline and colophon. |
|
|
71
|
+
| `siteUrl` | `string` | Used for share and canonical links. |
|
|
72
|
+
| `canonicalUrl` | `string` | Override for the canonical URL. |
|
|
73
|
+
| `ogImageUrl` | `string` | Override for the OG image meta tag. |
|
package/dist/format.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
declare function escapeHtml(s: string): string;
|
|
2
|
+
declare function sanitizeInline(html: string): string;
|
|
3
|
+
declare function transformInlineBlocks(bodyHtml: string): string;
|
|
4
|
+
declare function safeUrl(url: string): string;
|
|
5
|
+
export { escapeHtml, sanitizeInline, transformInlineBlocks, safeUrl };
|
package/dist/format.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const INLINE_ALLOWED_TAGS = new Set(['strong', 'em', 'b', 'i', 'span', 'a']);
|
|
2
|
+
const INLINE_ALLOWED_ATTRS = {
|
|
3
|
+
a: new Set(['href']),
|
|
4
|
+
span: new Set(['class']),
|
|
5
|
+
strong: new Set(['class']),
|
|
6
|
+
em: new Set(['class']),
|
|
7
|
+
};
|
|
8
|
+
const SAFE_HREF_RE = /^(?:https?:|mailto:|\/|#)/i;
|
|
9
|
+
function escapeHtml(s) {
|
|
10
|
+
return s
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>');
|
|
15
|
+
}
|
|
16
|
+
function sanitizeInline(html) {
|
|
17
|
+
let out = '';
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < html.length) {
|
|
20
|
+
if (html[i] === '<') {
|
|
21
|
+
const closeMatch = html.slice(i).match(/^<\/(\w+)\s*>/);
|
|
22
|
+
if (closeMatch) {
|
|
23
|
+
const tag = closeMatch[1].toLowerCase();
|
|
24
|
+
if (INLINE_ALLOWED_TAGS.has(tag))
|
|
25
|
+
out += `</${tag}>`;
|
|
26
|
+
i += closeMatch[0].length;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const openMatch = html.slice(i).match(/^<(\w+)([^>]*?)>/);
|
|
30
|
+
if (openMatch) {
|
|
31
|
+
const tag = openMatch[1].toLowerCase();
|
|
32
|
+
if (INLINE_ALLOWED_TAGS.has(tag)) {
|
|
33
|
+
const attrs = openMatch[2];
|
|
34
|
+
const allowed = INLINE_ALLOWED_ATTRS[tag] ?? new Set();
|
|
35
|
+
const kept = [];
|
|
36
|
+
const attrRe = /(\w+)(?:="([^"]*)")?/g;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = attrRe.exec(attrs)) !== null) {
|
|
39
|
+
const name = m[1].toLowerCase();
|
|
40
|
+
if (name.startsWith('on'))
|
|
41
|
+
continue;
|
|
42
|
+
if (!allowed.has(name))
|
|
43
|
+
continue;
|
|
44
|
+
if (name === 'href') {
|
|
45
|
+
const val = m[2] ?? '';
|
|
46
|
+
if (!val || !SAFE_HREF_RE.test(val))
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
kept.push(` ${name}="${escapeHtml(m[2] ?? '')}"`);
|
|
50
|
+
}
|
|
51
|
+
out += `<${tag}${kept.join('')}>`;
|
|
52
|
+
}
|
|
53
|
+
i += openMatch[0].length;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
else if (html[i] === '&') {
|
|
59
|
+
const entityMatch = html.slice(i).match(/^&(#?\w+);/);
|
|
60
|
+
if (entityMatch) {
|
|
61
|
+
out += entityMatch[0];
|
|
62
|
+
i += entityMatch[0].length;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
out += '&';
|
|
66
|
+
i += 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
out += html[i];
|
|
71
|
+
i += 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
const STAT_RE = /<aside\s+data-stat="(?<value>[^"]+)">(?<label>.*?)<\/aside>/gis;
|
|
77
|
+
const QUOTE_RE = /<blockquote\s+(?:data-source="(?<src>[^"]*)"\s*)?(?:data-source-url="(?<url>[^"]*)"\s*)?(?:data-attr="(?<attr>[^"]*)"\s*)?>(?<text>.*?)<\/blockquote>/gis;
|
|
78
|
+
const TAG_STRIP_RE = /<[^>]+>/g;
|
|
79
|
+
function plainStatValue(raw) {
|
|
80
|
+
return raw.replace(TAG_STRIP_RE, '').trim();
|
|
81
|
+
}
|
|
82
|
+
function transformInlineBlocks(bodyHtml) {
|
|
83
|
+
return bodyHtml
|
|
84
|
+
.replace(STAT_RE, (_match, _value, _label, offset, str, groups) => {
|
|
85
|
+
const value = plainStatValue(groups?.value ?? _value);
|
|
86
|
+
const label = (groups?.label ?? _label ?? '').trim();
|
|
87
|
+
return ('<div class="article-stat">' +
|
|
88
|
+
`<div class="big">${escapeHtml(value)}</div>` +
|
|
89
|
+
`<div class="lbl">${sanitizeInline(label)}</div>` +
|
|
90
|
+
'</div>');
|
|
91
|
+
})
|
|
92
|
+
.replace(QUOTE_RE, (_match, _src, _url, _attr, _text, offset, str, groups) => {
|
|
93
|
+
const text = (groups?.text ?? _text ?? '').trim();
|
|
94
|
+
const attr = groups?.attr ?? _attr ?? '';
|
|
95
|
+
const url = groups?.url ?? _url ?? '';
|
|
96
|
+
const src = groups?.src ?? _src ?? '';
|
|
97
|
+
let attrHtml = '';
|
|
98
|
+
if (attr || src) {
|
|
99
|
+
const linkLabel = src || 'Source';
|
|
100
|
+
const safeUrl = url && SAFE_HREF_RE.test(url) ? url : '';
|
|
101
|
+
const link = safeUrl
|
|
102
|
+
? ` · <a href="${escapeHtml(safeUrl)}">${escapeHtml(linkLabel)}</a>`
|
|
103
|
+
: '';
|
|
104
|
+
attrHtml = `<div class="attr">${escapeHtml(attr)}${link}</div>`;
|
|
105
|
+
}
|
|
106
|
+
return ('<figure class="source-quote">' +
|
|
107
|
+
`<p class="q">${sanitizeInline(text)}</p>` +
|
|
108
|
+
attrHtml +
|
|
109
|
+
'</figure>');
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function safeUrl(url) {
|
|
113
|
+
return SAFE_HREF_RE.test(url) ? url : '';
|
|
114
|
+
}
|
|
115
|
+
export { escapeHtml, sanitizeInline, transformInlineBlocks, safeUrl };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ArticleData, RenderOptions } from './types.js';
|
|
2
|
+
export type FrontPageRowType = 'hero' | 'feature' | 'triple' | 'brief';
|
|
3
|
+
export interface FrontPageRow {
|
|
4
|
+
type: FrontPageRowType;
|
|
5
|
+
items: ArticleData[];
|
|
6
|
+
}
|
|
7
|
+
export declare function buildFrontPage(items: ArticleData[]): FrontPageRow[];
|
|
8
|
+
export declare function renderFrontPage(rows: FrontPageRow[], opts?: RenderOptions): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { escapeHtml, safeUrl } from './format.js';
|
|
2
|
+
import { categoryClass, renderCard } from './render.js';
|
|
3
|
+
const BRIEF_MAX = 5;
|
|
4
|
+
export function buildFrontPage(items) {
|
|
5
|
+
if (!items.length)
|
|
6
|
+
return [];
|
|
7
|
+
const pool = [...items];
|
|
8
|
+
const hotIdx = pool.findIndex((entry) => entry.hot);
|
|
9
|
+
if (hotIdx > 0)
|
|
10
|
+
pool.unshift(pool.splice(hotIdx, 1)[0]);
|
|
11
|
+
const rows = [{ type: 'hero', items: pool.splice(0, 1) }];
|
|
12
|
+
if (pool.length >= 2)
|
|
13
|
+
rows.push({ type: 'feature', items: pool.splice(0, 2) });
|
|
14
|
+
if (pool.length >= 3)
|
|
15
|
+
rows.push({ type: 'triple', items: pool.splice(0, 3) });
|
|
16
|
+
const brief = pool.splice(0, BRIEF_MAX);
|
|
17
|
+
if (brief.length)
|
|
18
|
+
rows.push({ type: 'brief', items: brief });
|
|
19
|
+
return rows;
|
|
20
|
+
}
|
|
21
|
+
function renderHeroCard(data) {
|
|
22
|
+
const href = safeUrl(data.shareUrl ?? '') || '#';
|
|
23
|
+
const imgSrc = safeUrl(data.heroImage) || 'data:,';
|
|
24
|
+
return (`<a class="df-hero-card ${categoryClass(data.category)}" href="${escapeHtml(href)}">` +
|
|
25
|
+
`<img class="df-hero-img" src="${escapeHtml(imgSrc)}" alt="${escapeHtml(data.heroImageAlt ?? data.title)}" loading="lazy" width="1200" height="675">` +
|
|
26
|
+
'<div class="df-hero-scrim" aria-hidden="true"></div>' +
|
|
27
|
+
'<div class="df-hero-text">' +
|
|
28
|
+
(data.hot ? '<span class="df-hot">HOT</span>' : '') +
|
|
29
|
+
`<div class="card-kicker">${escapeHtml(data.category)}</div>` +
|
|
30
|
+
`<h3 class="df-hero-title">${escapeHtml(data.title)}</h3>` +
|
|
31
|
+
`<p class="df-hero-sub">${escapeHtml(data.subtitle)}</p>` +
|
|
32
|
+
'</div></a>');
|
|
33
|
+
}
|
|
34
|
+
function renderBriefItem(data) {
|
|
35
|
+
const href = safeUrl(data.shareUrl ?? '') || '#';
|
|
36
|
+
return (`<li><a href="${escapeHtml(href)}">` +
|
|
37
|
+
`<span class="df-brief-title">${escapeHtml(data.title)}</span>` +
|
|
38
|
+
`<time>${escapeHtml(data.publishedAt)}</time>` +
|
|
39
|
+
'</a></li>');
|
|
40
|
+
}
|
|
41
|
+
export function renderFrontPage(rows, opts) {
|
|
42
|
+
const rendered = rows.map((row) => {
|
|
43
|
+
if (row.type === 'hero') {
|
|
44
|
+
return `<div class="df-row df-row--hero">${renderHeroCard(row.items[0])}</div>`;
|
|
45
|
+
}
|
|
46
|
+
if (row.type === 'brief') {
|
|
47
|
+
const lis = row.items.map(renderBriefItem).join('');
|
|
48
|
+
return `<div class="df-row df-row--brief"><div class="df-brief-label">In brief</div><ul>${lis}</ul></div>`;
|
|
49
|
+
}
|
|
50
|
+
const cls = row.type === 'feature' ? 'df-row--feature' : 'df-row--triple';
|
|
51
|
+
const cards = row.items.map((entry) => renderCard(entry, opts)).join('');
|
|
52
|
+
return `<div class="df-row ${cls}">${cards}</div>`;
|
|
53
|
+
});
|
|
54
|
+
return `<div class="druck-front-page">${rendered.join('')}</div>`;
|
|
55
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { buildFrontPage, renderFrontPage } from './frontpage.js';
|
|
3
|
+
function item(n, extra = {}) {
|
|
4
|
+
return {
|
|
5
|
+
title: `Story ${n}`,
|
|
6
|
+
subtitle: `Subtitle ${n}`,
|
|
7
|
+
metaDescription: `Meta ${n}`,
|
|
8
|
+
slug: `story-${n}`,
|
|
9
|
+
format: 'wire',
|
|
10
|
+
category: 'ai',
|
|
11
|
+
publishedAt: 'Jun 10, 2026',
|
|
12
|
+
heroImage: `https://example.com/${n}.webp`,
|
|
13
|
+
shareUrl: `https://example.com/articles/story-${n}/`,
|
|
14
|
+
...extra,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const eleven = Array.from({ length: 11 }, (_, i) => item(i));
|
|
18
|
+
describe('buildFrontPage', () => {
|
|
19
|
+
it('produces hero, feature, triple, brief for 11 items', () => {
|
|
20
|
+
const rows = buildFrontPage(eleven);
|
|
21
|
+
expect(rows.map((r) => r.type)).toEqual(['hero', 'feature', 'triple', 'brief']);
|
|
22
|
+
expect(rows.map((r) => r.items.length)).toEqual([1, 2, 3, 5]);
|
|
23
|
+
});
|
|
24
|
+
it('is deterministic', () => {
|
|
25
|
+
expect(buildFrontPage(eleven)).toEqual(buildFrontPage(eleven));
|
|
26
|
+
});
|
|
27
|
+
it('promotes the first hot item to the hero', () => {
|
|
28
|
+
const items = [item(0), item(1), item(2, { hot: true })];
|
|
29
|
+
const rows = buildFrontPage(items);
|
|
30
|
+
expect(rows[0].items[0].slug).toBe('story-2');
|
|
31
|
+
});
|
|
32
|
+
it('degrades gracefully on sparse input', () => {
|
|
33
|
+
expect(buildFrontPage([])).toEqual([]);
|
|
34
|
+
expect(buildFrontPage([item(0)]).map((r) => r.type)).toEqual(['hero']);
|
|
35
|
+
expect(buildFrontPage([item(0), item(1)]).map((r) => r.type)).toEqual(['hero', 'brief']);
|
|
36
|
+
expect(buildFrontPage(eleven.slice(0, 3)).map((r) => r.type)).toEqual(['hero', 'feature']);
|
|
37
|
+
});
|
|
38
|
+
it('caps brief at 5 and ignores overflow', () => {
|
|
39
|
+
const twenty = Array.from({ length: 20 }, (_, i) => item(i));
|
|
40
|
+
const rows = buildFrontPage(twenty);
|
|
41
|
+
expect(rows.at(-1)?.items.length).toBe(5);
|
|
42
|
+
expect(rows.flatMap((r) => r.items).length).toBe(11);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe('renderFrontPage', () => {
|
|
46
|
+
it('renders row wrappers and a scrimmed hero card', () => {
|
|
47
|
+
const html = renderFrontPage(buildFrontPage(eleven));
|
|
48
|
+
expect(html).toContain('class="druck-front-page"');
|
|
49
|
+
expect(html).toContain('df-row--hero');
|
|
50
|
+
expect(html).toContain('df-row--feature');
|
|
51
|
+
expect(html).toContain('df-row--triple');
|
|
52
|
+
expect(html).toContain('df-row--brief');
|
|
53
|
+
expect(html).toContain('df-hero-scrim');
|
|
54
|
+
});
|
|
55
|
+
it('renders a HOT badge only for hot heroes', () => {
|
|
56
|
+
const hot = renderFrontPage(buildFrontPage([item(0, { hot: true })]));
|
|
57
|
+
const cold = renderFrontPage(buildFrontPage([item(0)]));
|
|
58
|
+
expect(hot).toContain('df-hot');
|
|
59
|
+
expect(cold).not.toContain('df-hot');
|
|
60
|
+
});
|
|
61
|
+
it('escapes content and drops unsafe URLs', () => {
|
|
62
|
+
const evil = item(0, {
|
|
63
|
+
title: '<script>x</script>',
|
|
64
|
+
shareUrl: 'javascript:alert(1)',
|
|
65
|
+
});
|
|
66
|
+
const html = renderFrontPage(buildFrontPage([evil]));
|
|
67
|
+
expect(html).not.toContain('<script>x');
|
|
68
|
+
expect(html).not.toContain('javascript:');
|
|
69
|
+
expect(html).toContain('href="#"');
|
|
70
|
+
});
|
|
71
|
+
it('guards an empty hero image src', () => {
|
|
72
|
+
const html = renderFrontPage(buildFrontPage([item(0, { heroImage: '' })]));
|
|
73
|
+
expect(html).not.toContain('src=""');
|
|
74
|
+
});
|
|
75
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { ArticleFormat, ArticleData, Category, Chapter, KeyPoint, StatAside, SourceQuote, Figure, KnowCard, RelatedArticle, WeeklyData, WeeklySection, WeeklySectionArticle, RenderOptions, } from './types.js';
|
|
2
|
+
export { renderArticle, renderCard } from './render.js';
|
|
3
|
+
export { transformInlineBlocks, sanitizeInline, escapeHtml, safeUrl } from './format.js';
|
|
4
|
+
export { buildFrontPage, renderFrontPage } from './frontpage.js';
|
|
5
|
+
export type { FrontPageRow, FrontPageRowType } from './frontpage.js';
|
package/dist/index.js
ADDED
package/dist/render.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ArticleData, RenderOptions, WeeklyData } from './types.js';
|
|
2
|
+
export declare function categoryClass(category: string): string;
|
|
3
|
+
export declare function renderArticle(data: ArticleData, opts?: RenderOptions): string;
|
|
4
|
+
export declare function renderWeekly(data: WeeklyData, opts?: RenderOptions): string;
|
|
5
|
+
export declare function renderCard(data: ArticleData, opts?: RenderOptions): string;
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { escapeHtml, sanitizeInline, transformInlineBlocks, safeUrl } from './format.js';
|
|
2
|
+
function safeDimension(value, fallback) {
|
|
3
|
+
return Number.isFinite(value) ? Math.trunc(value) : fallback;
|
|
4
|
+
}
|
|
5
|
+
function titleWithAccent(title, accentWord) {
|
|
6
|
+
const safe = escapeHtml(title);
|
|
7
|
+
if (!accentWord)
|
|
8
|
+
return safe;
|
|
9
|
+
const safeWord = escapeHtml(accentWord);
|
|
10
|
+
if (!safe.includes(safeWord))
|
|
11
|
+
return safe;
|
|
12
|
+
const idx = safe.indexOf(safeWord);
|
|
13
|
+
return safe.slice(0, idx) + `<em class="accent-word">${safeWord}</em>` + safe.slice(idx + safeWord.length);
|
|
14
|
+
}
|
|
15
|
+
function renderChapter(num, total, chapter) {
|
|
16
|
+
const titleHtml = titleWithAccent(chapter.title, chapter.titleAccentWord);
|
|
17
|
+
const body = transformInlineBlocks(chapter.bodyHtml);
|
|
18
|
+
return ('<section class="chapter-panel">' +
|
|
19
|
+
'<div class="chapter-head">' +
|
|
20
|
+
`<div class="chapter-num">Chapter ${escapeHtml(String(num))} · of ${String(total).padStart(2, '0')}</div>` +
|
|
21
|
+
`<h2 class="chapter-title">${titleHtml}</h2>` +
|
|
22
|
+
'</div>' +
|
|
23
|
+
`<div class="chapter-body">${body}</div>` +
|
|
24
|
+
'</section>');
|
|
25
|
+
}
|
|
26
|
+
function renderKeyPoints(points) {
|
|
27
|
+
if (!points?.length)
|
|
28
|
+
return '';
|
|
29
|
+
const items = points
|
|
30
|
+
.slice(0, 3)
|
|
31
|
+
.map((p, i) => `<div class="kp-item"><div class="n">${String(i + 1).padStart(2, '0')}</div><p>${escapeHtml(p.text)}</p></div>`)
|
|
32
|
+
.join('');
|
|
33
|
+
return ('<section class="key-points">' +
|
|
34
|
+
'<div class="lbl">Three things to know</div>' +
|
|
35
|
+
`<div class="key-points-grid">${items}</div>` +
|
|
36
|
+
'</section>');
|
|
37
|
+
}
|
|
38
|
+
function renderKnowCard(items, label, kind) {
|
|
39
|
+
const lis = items.map((x) => `<li>${escapeHtml(x)}</li>`).join('');
|
|
40
|
+
return (`<div class="know-card ${kind}">` +
|
|
41
|
+
`<div class="lbl">${label}</div>` +
|
|
42
|
+
`<ul>${lis || '<li>—</li>'}</ul>` +
|
|
43
|
+
'</div>');
|
|
44
|
+
}
|
|
45
|
+
function renderKnowCards(known, unknown) {
|
|
46
|
+
const k = known?.items ?? [];
|
|
47
|
+
const u = unknown?.items ?? [];
|
|
48
|
+
if (!k.length && !u.length)
|
|
49
|
+
return '';
|
|
50
|
+
return ('<aside class="know-cards" role="note">' +
|
|
51
|
+
renderKnowCard(k, 'What we know', 'yes') +
|
|
52
|
+
renderKnowCard(u, "What we don't", 'no') +
|
|
53
|
+
'</aside>');
|
|
54
|
+
}
|
|
55
|
+
function renderEditorsNote(angle, sourceCount) {
|
|
56
|
+
if (!angle)
|
|
57
|
+
return '';
|
|
58
|
+
const siteName = 'Druck';
|
|
59
|
+
return ('<aside class="editors-note" role="note">' +
|
|
60
|
+
"<span class=\"lbl\">Editor's note</span> " +
|
|
61
|
+
`Written by ${siteName}'s editorial agent from <b>${sourceCount ?? 1} sources</b>. ` +
|
|
62
|
+
`<b>${siteName}'s angle:</b> ${escapeHtml(angle)}` +
|
|
63
|
+
'</aside>');
|
|
64
|
+
}
|
|
65
|
+
function renderShareBar(title, url) {
|
|
66
|
+
const shareUrl = url ?? '#';
|
|
67
|
+
return ('<div class="article-share-bar">' +
|
|
68
|
+
'<span class="article-share-bar-label">Share this story</span>' +
|
|
69
|
+
'<div class="article-share-bar-actions">' +
|
|
70
|
+
`<button class="share-btn-pill" data-share-button data-share-title="${escapeHtml(title)}" data-share-url="${escapeHtml(shareUrl)}" aria-label="Share article">` +
|
|
71
|
+
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
72
|
+
'<line x1="22" y1="2" x2="11" y2="13"/>' +
|
|
73
|
+
'<polygon points="22 2 15 22 11 13 2 9 22 2"/>' +
|
|
74
|
+
'</svg>' +
|
|
75
|
+
'Share' +
|
|
76
|
+
'</button>' +
|
|
77
|
+
'</div>' +
|
|
78
|
+
'</div>');
|
|
79
|
+
}
|
|
80
|
+
export function categoryClass(category) {
|
|
81
|
+
return `cat-${category}`;
|
|
82
|
+
}
|
|
83
|
+
function formatLabel(format) {
|
|
84
|
+
if (format === 'quick_take')
|
|
85
|
+
return 'Quick Take';
|
|
86
|
+
if (format === 'feature')
|
|
87
|
+
return 'Feature';
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
function renderRelated(related) {
|
|
91
|
+
if (!related?.length)
|
|
92
|
+
return '';
|
|
93
|
+
const cards = related
|
|
94
|
+
.map((r) => {
|
|
95
|
+
const safeRelatedUrl = safeUrl(r.url);
|
|
96
|
+
if (!safeRelatedUrl)
|
|
97
|
+
return '';
|
|
98
|
+
return (`<a class="similar-card" href="${escapeHtml(safeRelatedUrl)}">` +
|
|
99
|
+
(r.image ? `<img class="sc-img" src="${escapeHtml(safeUrl(r.image))}" alt="" loading="lazy">` : '') +
|
|
100
|
+
`<div class="sc-text"><div class="sc-title">${escapeHtml(r.title)}</div>` +
|
|
101
|
+
(r.category ? `<div class="sc-cat">${escapeHtml(r.category)}</div>` : '') +
|
|
102
|
+
'</div></a>');
|
|
103
|
+
})
|
|
104
|
+
.join('');
|
|
105
|
+
return '<section class="similar-articles">' + cards + '</section>';
|
|
106
|
+
}
|
|
107
|
+
export function renderArticle(data, opts) {
|
|
108
|
+
const lang = opts?.lang ?? 'en';
|
|
109
|
+
const catClass = categoryClass(data.category);
|
|
110
|
+
const titleHtml = titleWithAccent(data.title, data.titleAccentWord);
|
|
111
|
+
const fLabel = formatLabel(data.format);
|
|
112
|
+
if (data.format === 'wire') {
|
|
113
|
+
return renderWireArticle(data, { ...opts, lang, catClass, titleHtml, fLabel });
|
|
114
|
+
}
|
|
115
|
+
return renderFeatureArticle(data, { ...opts, lang, catClass, titleHtml, fLabel });
|
|
116
|
+
}
|
|
117
|
+
function renderFeatureArticle(data, ctx) {
|
|
118
|
+
const chaptersHtml = (data.chapters ?? [])
|
|
119
|
+
.map((ch, i) => renderChapter(i + 1, data.chapters.length, ch))
|
|
120
|
+
.join('');
|
|
121
|
+
const bodyHtml = transformInlineBlocks(data.bodyHtml ?? chaptersHtml);
|
|
122
|
+
const readingSpan = data.readingTime ? `<span>${escapeHtml(data.readingTime)}</span>` : '';
|
|
123
|
+
const byline = data.byline
|
|
124
|
+
? `<span>${escapeHtml(data.byline.author)}</span><span>${escapeHtml(data.byline.date)}</span>${readingSpan}`
|
|
125
|
+
: `<span>By Editorial</span>${readingSpan}`;
|
|
126
|
+
return (`<article class="article-shell ${ctx.catClass}">` +
|
|
127
|
+
'<div class="article-progress" aria-hidden="true"><div class="fill"></div></div>' +
|
|
128
|
+
'<header class="article-hero">' +
|
|
129
|
+
'<div class="article-hero-inner">' +
|
|
130
|
+
`<div class="article-kicker">${escapeHtml(data.category)}${ctx.fLabel ? ` <span class="sep">·</span> ${ctx.fLabel}` : ''}</div>` +
|
|
131
|
+
`<h1 class="article-title">${ctx.titleHtml}</h1>` +
|
|
132
|
+
`<p class="article-deck">${escapeHtml(data.subtitle)}</p>` +
|
|
133
|
+
`<div class="article-byline">${byline}</div>` +
|
|
134
|
+
'</div>' +
|
|
135
|
+
'</header>' +
|
|
136
|
+
`<figure class="article-hero-img"><img src="${escapeHtml(safeUrl(data.heroImage))}" alt="${escapeHtml(data.heroImageAlt ?? data.title)}" loading="eager" fetchpriority="high" width="${safeDimension(data.heroImageWidth, 1920)}" height="${safeDimension(data.heroImageHeight, 1080)}"></figure>` +
|
|
137
|
+
renderEditorsNote(data.editorsNote, data.sourceCount) +
|
|
138
|
+
`<div class="article-body">${bodyHtml}</div>` +
|
|
139
|
+
renderKeyPoints(data.keyPoints) +
|
|
140
|
+
renderKnowCards(data.known, data.unknown) +
|
|
141
|
+
renderRelated(data.related) +
|
|
142
|
+
renderShareBar(data.title, data.shareUrl) +
|
|
143
|
+
'</article>');
|
|
144
|
+
}
|
|
145
|
+
function renderWireArticle(data, ctx) {
|
|
146
|
+
const bodyHtml = transformInlineBlocks(data.bodyHtml ?? '');
|
|
147
|
+
return (`<article class="post-simple ${ctx.catClass}">` +
|
|
148
|
+
'<header class="post-simple-head">' +
|
|
149
|
+
`<div class="post-simple-kicker">${escapeHtml(data.category)}</div>` +
|
|
150
|
+
`<h1 class="post-simple-title">${ctx.titleHtml}</h1>` +
|
|
151
|
+
`<div class="post-simple-meta"><span>${escapeHtml(data.publishedAt)}</span>${data.readingTime ? `<span>${escapeHtml(data.readingTime)}</span>` : ''}</div>` +
|
|
152
|
+
'</header>' +
|
|
153
|
+
`<figure class="post-simple-img"><img src="${escapeHtml(safeUrl(data.heroImage))}" alt="${escapeHtml(data.heroImageAlt ?? data.title)}" loading="eager" fetchpriority="high" decoding="async" width="${safeDimension(data.heroImageWidth, 1600)}" height="${safeDimension(data.heroImageHeight, 900)}"></figure>` +
|
|
154
|
+
`<div class="post-simple-body">${bodyHtml}</div>` +
|
|
155
|
+
(data.pullQuote
|
|
156
|
+
? `<figure class="source-quote"><p class="q">${sanitizeInline(data.pullQuote)}</p></figure>`
|
|
157
|
+
: '') +
|
|
158
|
+
(data.whyItMatters
|
|
159
|
+
? `<aside class="editors-note" role="note"><span class="lbl">Why it matters</span> ${escapeHtml(data.whyItMatters)}</aside>`
|
|
160
|
+
: '') +
|
|
161
|
+
renderRelated(data.related) +
|
|
162
|
+
renderShareBar(data.title, data.shareUrl) +
|
|
163
|
+
'</article>');
|
|
164
|
+
}
|
|
165
|
+
export function renderWeekly(data, opts) {
|
|
166
|
+
const sectionsHtml = data.sections
|
|
167
|
+
.map((s) => renderWeeklySection(s))
|
|
168
|
+
.join('');
|
|
169
|
+
return (`<article class="article-shell cat-weekly">` +
|
|
170
|
+
'<div class="article-progress" aria-hidden="true"><div class="fill"></div></div>' +
|
|
171
|
+
'<header class="article-hero">' +
|
|
172
|
+
'<div class="article-hero-inner">' +
|
|
173
|
+
'<div class="article-kicker">Weekly Recap</div>' +
|
|
174
|
+
`<h1 class="article-title">${escapeHtml(data.title)}</h1>` +
|
|
175
|
+
`<p class="article-deck">${escapeHtml(data.subtitle)}</p>` +
|
|
176
|
+
'</div>' +
|
|
177
|
+
'</header>' +
|
|
178
|
+
`<figure class="article-hero-img"><img src="${escapeHtml(safeUrl(data.heroImage))}" alt="${escapeHtml(data.title)}" loading="eager" width="1920" height="1080"></figure>` +
|
|
179
|
+
`<div class="recap-thesis">${escapeHtml(data.thesis)}</div>` +
|
|
180
|
+
`<div class="article-body">${sectionsHtml}</div>` +
|
|
181
|
+
renderKeyPoints(data.keyPoints) +
|
|
182
|
+
renderKnowCards(data.known, data.unknown) +
|
|
183
|
+
renderRelated(data.related) +
|
|
184
|
+
renderShareBar(data.title, data.shareUrl) +
|
|
185
|
+
'</article>');
|
|
186
|
+
}
|
|
187
|
+
export function renderCard(data, opts) {
|
|
188
|
+
const catClass = categoryClass(data.category);
|
|
189
|
+
const titleHtml = titleWithAccent(data.title, data.titleAccentWord);
|
|
190
|
+
const fLabel = formatLabel(data.format);
|
|
191
|
+
const rawHref = data.shareUrl ?? `#${data.slug}`;
|
|
192
|
+
const href = safeUrl(rawHref) || '#';
|
|
193
|
+
return (`<a class="druck-card ${catClass}" href="${escapeHtml(href)}">` +
|
|
194
|
+
`<figure class="card-thumb"><img src="${escapeHtml(safeUrl(data.heroImage))}" alt="${escapeHtml(data.heroImageAlt ?? data.title)}" loading="lazy" width="400" height="225"></figure>` +
|
|
195
|
+
`<div class="card-text">` +
|
|
196
|
+
`<div class="card-kicker">${escapeHtml(data.category)}${fLabel ? ` <span class="sep">·</span> ${fLabel}` : ''}</div>` +
|
|
197
|
+
`<h3 class="card-title">${titleHtml}</h3>` +
|
|
198
|
+
`<p class="card-subtitle">${escapeHtml(data.subtitle)}</p>` +
|
|
199
|
+
`<div class="card-meta"><time>${escapeHtml(data.publishedAt)}</time>${data.readingTime ? `<span>${escapeHtml(data.readingTime)}</span>` : ''}</div>` +
|
|
200
|
+
`</div>` +
|
|
201
|
+
`</a>`);
|
|
202
|
+
}
|
|
203
|
+
function renderWeeklySection(section) {
|
|
204
|
+
const narrativeHtml = transformInlineBlocks(section.narrative);
|
|
205
|
+
const keyPointsHtml = renderKeyPoints(section.keyPoints);
|
|
206
|
+
const articlesHtml = (section.articles ?? [])
|
|
207
|
+
.map((a) => {
|
|
208
|
+
const safeArticleUrl = safeUrl(a.url);
|
|
209
|
+
if (!safeArticleUrl)
|
|
210
|
+
return '';
|
|
211
|
+
return (`<a class="recap-article" href="${escapeHtml(safeArticleUrl)}">` +
|
|
212
|
+
(a.image ? `<img class="recap-article-thumb" src="${escapeHtml(safeUrl(a.image))}" alt="" loading="lazy">` : '') +
|
|
213
|
+
`<div class="recap-article-title">${escapeHtml(a.title)}</div>` +
|
|
214
|
+
(a.summary ? `<div class="recap-article-summary">${escapeHtml(a.summary)}</div>` : '') +
|
|
215
|
+
'</a>');
|
|
216
|
+
})
|
|
217
|
+
.join('');
|
|
218
|
+
return ('<section class="chapter-panel recap-section">' +
|
|
219
|
+
`<h2 class="recap-section-title">${escapeHtml(section.title)}</h2>` +
|
|
220
|
+
`<div class="recap-section-narrative">${narrativeHtml}</div>` +
|
|
221
|
+
keyPointsHtml +
|
|
222
|
+
(articlesHtml ? `<div class="recap-articles">${articlesHtml}</div>` : '') +
|
|
223
|
+
'</section>');
|
|
224
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { renderArticle, renderCard } from './render.js';
|
|
3
|
+
function buildArticle(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
title: 'Test Story',
|
|
6
|
+
subtitle: 'A subtitle',
|
|
7
|
+
metaDescription: 'meta',
|
|
8
|
+
slug: 'test-story',
|
|
9
|
+
format: 'feature',
|
|
10
|
+
category: 'ai',
|
|
11
|
+
publishedAt: '2026-06-09',
|
|
12
|
+
readingTime: '3 min read',
|
|
13
|
+
heroImage: '/img/test.webp',
|
|
14
|
+
chapters: [{ title: 'One', bodyHtml: '<p>Body</p>' }],
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('hero image attributes', () => {
|
|
19
|
+
test('emits provided alt text and dimensions on feature heroes', () => {
|
|
20
|
+
const html = renderArticle(buildArticle({ heroImageAlt: 'Abstract circuitry', heroImageWidth: 1920, heroImageHeight: 1047 }));
|
|
21
|
+
expect(html).toContain('alt="Abstract circuitry"');
|
|
22
|
+
expect(html).toContain('width="1920"');
|
|
23
|
+
expect(html).toContain('height="1047"');
|
|
24
|
+
});
|
|
25
|
+
test('falls back to title alt and template dimensions when fields absent', () => {
|
|
26
|
+
const html = renderArticle(buildArticle());
|
|
27
|
+
expect(html).toContain('alt="Test Story"');
|
|
28
|
+
expect(html).toContain('width="1920"');
|
|
29
|
+
expect(html).toContain('height="1080"');
|
|
30
|
+
});
|
|
31
|
+
test('emits provided dimensions on wire heroes', () => {
|
|
32
|
+
const html = renderArticle(buildArticle({ format: 'wire', bodyHtml: '<p>Wire body</p>', heroImageWidth: 1920, heroImageHeight: 1049 }));
|
|
33
|
+
expect(html).toContain('width="1920"');
|
|
34
|
+
expect(html).toContain('height="1049"');
|
|
35
|
+
});
|
|
36
|
+
test('escapes html-special characters in heroImageAlt', () => {
|
|
37
|
+
const html = renderArticle(buildArticle({ heroImageAlt: '"onload="alert(1)' }));
|
|
38
|
+
expect(html).not.toContain('"onload=');
|
|
39
|
+
expect(html).toContain('"');
|
|
40
|
+
});
|
|
41
|
+
test('rejects non-numeric hero dimensions to prevent attribute injection', () => {
|
|
42
|
+
const malicious = { heroImageWidth: '800" onload="alert(1)' };
|
|
43
|
+
const html = renderArticle(buildArticle(malicious));
|
|
44
|
+
expect(html).not.toContain('onload=');
|
|
45
|
+
expect(html).toContain('width="1920"');
|
|
46
|
+
});
|
|
47
|
+
test('falls back to wire template dimensions when fields absent', () => {
|
|
48
|
+
const html = renderArticle(buildArticle({ format: 'wire', bodyHtml: '<p>Wire body</p>', chapters: undefined }));
|
|
49
|
+
expect(html).toContain('width="1600"');
|
|
50
|
+
expect(html).toContain('height="900"');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('renderCard', () => {
|
|
54
|
+
test('emits card markup with category class and kicker', () => {
|
|
55
|
+
const html = renderCard(buildArticle({ format: 'quick_take' }));
|
|
56
|
+
expect(html).toContain('class="druck-card cat-ai"');
|
|
57
|
+
expect(html).toContain('class="card-thumb"');
|
|
58
|
+
expect(html).toContain('class="card-title"');
|
|
59
|
+
expect(html).toContain('ai');
|
|
60
|
+
expect(html).toContain('Quick Take');
|
|
61
|
+
expect(html).toContain('Test Story');
|
|
62
|
+
expect(html).toContain('A subtitle');
|
|
63
|
+
});
|
|
64
|
+
test('uses shareUrl as href when present', () => {
|
|
65
|
+
const html = renderCard(buildArticle({ shareUrl: '/articles/test/' }));
|
|
66
|
+
expect(html).toContain('href="/articles/test/"');
|
|
67
|
+
});
|
|
68
|
+
test('falls back to slug hash when shareUrl absent', () => {
|
|
69
|
+
const html = renderCard(buildArticle());
|
|
70
|
+
expect(html).toContain('href="#test-story"');
|
|
71
|
+
});
|
|
72
|
+
test('escapes title and subtitle in card output', () => {
|
|
73
|
+
const html = renderCard(buildArticle({ title: '<script>', subtitle: '"alert"' }));
|
|
74
|
+
expect(html).not.toContain('<script>');
|
|
75
|
+
expect(html).toContain('<script>');
|
|
76
|
+
expect(html).toContain('"alert"');
|
|
77
|
+
});
|
|
78
|
+
test('strips javascript: URLs from card href', () => {
|
|
79
|
+
const html = renderCard(buildArticle({ shareUrl: 'javascript:alert(1)' }));
|
|
80
|
+
expect(html).not.toContain('javascript:');
|
|
81
|
+
expect(html).toContain('href="\#"');
|
|
82
|
+
});
|
|
83
|
+
test('strips data: URLs from hero image src', () => {
|
|
84
|
+
const html = renderArticle(buildArticle({ heroImage: 'data:image/svg+xml,<svg></svg>' }));
|
|
85
|
+
expect(html).not.toContain('data:');
|
|
86
|
+
expect(html).toContain('src=""');
|
|
87
|
+
});
|
|
88
|
+
test('preserves safe relative and absolute URLs', () => {
|
|
89
|
+
const html = renderCard(buildArticle({ shareUrl: '/articles/safe/', heroImage: 'https://example.com/img.webp' }));
|
|
90
|
+
expect(html).toContain('href="/articles/safe/"');
|
|
91
|
+
expect(html).toContain('src="https://example.com/img.webp"');
|
|
92
|
+
});
|
|
93
|
+
test('omits the reading-time span when readingTime is absent', () => {
|
|
94
|
+
const html = renderCard(buildArticle({ readingTime: undefined }));
|
|
95
|
+
expect(html).not.toContain('<span></span>');
|
|
96
|
+
expect(html).toContain('<time>');
|
|
97
|
+
});
|
|
98
|
+
test('accepts the widened category union', () => {
|
|
99
|
+
const html = renderCard(buildArticle({ category: 'infrastructure' }));
|
|
100
|
+
expect(html).toContain('cat-infrastructure');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('renderArticle feature format', () => {
|
|
104
|
+
test('omits reading-time span in byline when readingTime is absent', () => {
|
|
105
|
+
const html = renderArticle(buildArticle({ format: 'feature', readingTime: undefined }));
|
|
106
|
+
expect(html).not.toContain('<span></span>');
|
|
107
|
+
expect(html).toContain('article-byline');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('renderArticle wire format', () => {
|
|
111
|
+
test('omits reading-time span in meta when readingTime is absent', () => {
|
|
112
|
+
const html = renderArticle(buildArticle({ format: 'wire', readingTime: undefined, bodyHtml: '<p>Wire body</p>' }));
|
|
113
|
+
expect(html).not.toContain('<span></span>');
|
|
114
|
+
expect(html).toContain('post-simple-meta');
|
|
115
|
+
});
|
|
116
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export type ArticleFormat = 'feature' | 'quick_take' | 'wire';
|
|
2
|
+
export type Category = 'ai' | 'dev' | 'dev-tools' | 'security' | 'infrastructure' | 'policy' | 'startup' | 'business' | 'science' | 'general' | 'weekly';
|
|
3
|
+
export interface Chapter {
|
|
4
|
+
title: string;
|
|
5
|
+
titleAccentWord?: string;
|
|
6
|
+
bodyHtml: string;
|
|
7
|
+
}
|
|
8
|
+
export interface KeyPoint {
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
export interface StatAside {
|
|
12
|
+
value: string;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SourceQuote {
|
|
16
|
+
text: string;
|
|
17
|
+
attribution?: string;
|
|
18
|
+
source?: string;
|
|
19
|
+
sourceUrl?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface Figure {
|
|
22
|
+
src: string;
|
|
23
|
+
alt: string;
|
|
24
|
+
caption?: string;
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
}
|
|
28
|
+
export interface KnowCard {
|
|
29
|
+
items: string[];
|
|
30
|
+
}
|
|
31
|
+
export interface ArticleData {
|
|
32
|
+
title: string;
|
|
33
|
+
titleAccentWord?: string;
|
|
34
|
+
subtitle: string;
|
|
35
|
+
metaDescription: string;
|
|
36
|
+
slug: string;
|
|
37
|
+
format: ArticleFormat;
|
|
38
|
+
hot?: boolean;
|
|
39
|
+
category: Category;
|
|
40
|
+
publishedAt: string;
|
|
41
|
+
readingTime?: string;
|
|
42
|
+
heroImage: string;
|
|
43
|
+
heroImageAlt?: string;
|
|
44
|
+
heroImageWidth?: number;
|
|
45
|
+
heroImageHeight?: number;
|
|
46
|
+
heroCaption?: string;
|
|
47
|
+
byline?: {
|
|
48
|
+
author: string;
|
|
49
|
+
date: string;
|
|
50
|
+
};
|
|
51
|
+
lens?: string;
|
|
52
|
+
lensRationale?: string;
|
|
53
|
+
chapters?: Chapter[];
|
|
54
|
+
keyPoints?: KeyPoint[];
|
|
55
|
+
known?: KnowCard;
|
|
56
|
+
unknown?: KnowCard;
|
|
57
|
+
editorsNote?: string;
|
|
58
|
+
sourceCount?: number;
|
|
59
|
+
stats?: StatAside[];
|
|
60
|
+
quotes?: SourceQuote[];
|
|
61
|
+
figures?: Figure[];
|
|
62
|
+
bodyHtml?: string;
|
|
63
|
+
pullQuote?: string;
|
|
64
|
+
whyItMatters?: string;
|
|
65
|
+
shareUrl?: string;
|
|
66
|
+
related?: RelatedArticle[];
|
|
67
|
+
}
|
|
68
|
+
export interface RelatedArticle {
|
|
69
|
+
title: string;
|
|
70
|
+
url: string;
|
|
71
|
+
category?: Category;
|
|
72
|
+
image?: string;
|
|
73
|
+
}
|
|
74
|
+
export interface WeeklyData {
|
|
75
|
+
title: string;
|
|
76
|
+
subtitle: string;
|
|
77
|
+
metaDescription: string;
|
|
78
|
+
slug: string;
|
|
79
|
+
date: string;
|
|
80
|
+
heroImage: string;
|
|
81
|
+
thesis: string;
|
|
82
|
+
sections: WeeklySection[];
|
|
83
|
+
keyPoints?: KeyPoint[];
|
|
84
|
+
known?: KnowCard;
|
|
85
|
+
unknown?: KnowCard;
|
|
86
|
+
shareUrl?: string;
|
|
87
|
+
related?: RelatedArticle[];
|
|
88
|
+
}
|
|
89
|
+
export interface WeeklySection {
|
|
90
|
+
title: string;
|
|
91
|
+
narrative: string;
|
|
92
|
+
keyPoints?: KeyPoint[];
|
|
93
|
+
articles?: WeeklySectionArticle[];
|
|
94
|
+
}
|
|
95
|
+
export interface WeeklySectionArticle {
|
|
96
|
+
title: string;
|
|
97
|
+
url: string;
|
|
98
|
+
category?: Category;
|
|
99
|
+
image?: string;
|
|
100
|
+
summary?: string;
|
|
101
|
+
}
|
|
102
|
+
export interface RenderOptions {
|
|
103
|
+
lang?: string;
|
|
104
|
+
theme?: 'light' | 'dark';
|
|
105
|
+
accentColor?: string;
|
|
106
|
+
siteName?: string;
|
|
107
|
+
siteUrl?: string;
|
|
108
|
+
canonicalUrl?: string;
|
|
109
|
+
ogImageUrl?: string;
|
|
110
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@druck-editorial/engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Editorial rendering engine: structured article JSON in, magazine-quality pages out.",
|
|
5
|
+
"author": "Artem Iagovdik <artyom.yagovdik@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/druck-editorial/druck.git",
|
|
23
|
+
"directory": "packages/druck-engine"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/druck-editorial/druck",
|
|
26
|
+
"keywords": [
|
|
27
|
+
"editorial",
|
|
28
|
+
"typography",
|
|
29
|
+
"rendering",
|
|
30
|
+
"magazine",
|
|
31
|
+
"web-components"
|
|
32
|
+
],
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "^5.8.0",
|
|
38
|
+
"vitest": "^3.2.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
}
|
|
45
|
+
}
|