@druck-editorial/engine 0.1.0 → 0.1.2
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/dist/format.d.ts +1 -1
- package/dist/format.js +33 -2
- package/dist/frontpage.js +2 -0
- package/dist/render.d.ts +1 -2
- package/dist/render.js +62 -69
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
- package/dist/frontpage.test.d.ts +0 -1
- package/dist/frontpage.test.js +0 -75
- package/dist/render.test.d.ts +0 -1
- package/dist/render.test.js +0 -116
package/dist/format.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
declare function escapeHtml(s: string): string;
|
|
2
2
|
declare function sanitizeInline(html: string): string;
|
|
3
3
|
declare function transformInlineBlocks(bodyHtml: string): string;
|
|
4
|
-
declare function safeUrl(url: string): string;
|
|
4
|
+
declare function safeUrl(url: string | undefined): string;
|
|
5
5
|
export { escapeHtml, sanitizeInline, transformInlineBlocks, safeUrl };
|
package/dist/format.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Copyright (c) 2026 Artem Iagovdik <artyom.yagovdik@gmail.com>
|
|
1
3
|
const INLINE_ALLOWED_TAGS = new Set(['strong', 'em', 'b', 'i', 'span', 'a']);
|
|
2
4
|
const INLINE_ALLOWED_ATTRS = {
|
|
3
5
|
a: new Set(['href']),
|
|
@@ -76,11 +78,40 @@ function sanitizeInline(html) {
|
|
|
76
78
|
const STAT_RE = /<aside\s+data-stat="(?<value>[^"]+)">(?<label>.*?)<\/aside>/gis;
|
|
77
79
|
const QUOTE_RE = /<blockquote\s+(?:data-source="(?<src>[^"]*)"\s*)?(?:data-source-url="(?<url>[^"]*)"\s*)?(?:data-attr="(?<attr>[^"]*)"\s*)?>(?<text>.*?)<\/blockquote>/gis;
|
|
78
80
|
const TAG_STRIP_RE = /<[^>]+>/g;
|
|
81
|
+
const SCRIPT_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
|
|
82
|
+
const SCRIPT_OPEN_RE = /<script\b[^>]*>/gi;
|
|
83
|
+
const ON_ATTR_RE = /[\s/]+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
|
|
84
|
+
const URL_ATTR_RE = /[\s/]+(?:href|src|xlink:href|formaction|poster)\s*=\s*(?:"([^"]*)"|'([^']*)')/gi;
|
|
85
|
+
const DANGEROUS_SCHEME_RE = /^(?:javascript|vbscript|data)\s*:/i;
|
|
86
|
+
const DANGEROUS_TAG_RE = /<\/?(?:script|style|iframe|object|embed|form|input|button|textarea|select|meta|link|base|svg|math)\b[^>]*>/gi;
|
|
87
|
+
function fromCodePoint(code) {
|
|
88
|
+
return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '';
|
|
89
|
+
}
|
|
90
|
+
function decodeEntities(value) {
|
|
91
|
+
return value
|
|
92
|
+
.replace(/&#x([0-9a-f]+);?/gi, (_m, hex) => fromCodePoint(parseInt(hex, 16)))
|
|
93
|
+
.replace(/&#(\d+);?/g, (_m, dec) => fromCodePoint(parseInt(dec, 10)));
|
|
94
|
+
}
|
|
95
|
+
function stripDangerousUrlAttrs(html) {
|
|
96
|
+
return html.replace(URL_ATTR_RE, (match, doubleQuoted, singleQuoted) => {
|
|
97
|
+
const raw = doubleQuoted ?? singleQuoted ?? '';
|
|
98
|
+
const normalized = decodeEntities(raw).replace(/[\s\u0000-\u001f]+/g, '');
|
|
99
|
+
return DANGEROUS_SCHEME_RE.test(normalized) ? '' : match;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function sanitizeBody(html) {
|
|
103
|
+
return stripDangerousUrlAttrs(html
|
|
104
|
+
.replace(SCRIPT_RE, '')
|
|
105
|
+
.replace(SCRIPT_OPEN_RE, '')
|
|
106
|
+
.replace(DANGEROUS_TAG_RE, '')
|
|
107
|
+
.replace(ON_ATTR_RE, '')).replace(/javascript\s*:/gi, '');
|
|
108
|
+
}
|
|
79
109
|
function plainStatValue(raw) {
|
|
80
110
|
return raw.replace(TAG_STRIP_RE, '').trim();
|
|
81
111
|
}
|
|
82
112
|
function transformInlineBlocks(bodyHtml) {
|
|
83
|
-
|
|
113
|
+
const safe = sanitizeBody(bodyHtml);
|
|
114
|
+
return safe
|
|
84
115
|
.replace(STAT_RE, (_match, _value, _label, offset, str, groups) => {
|
|
85
116
|
const value = plainStatValue(groups?.value ?? _value);
|
|
86
117
|
const label = (groups?.label ?? _label ?? '').trim();
|
|
@@ -110,6 +141,6 @@ function transformInlineBlocks(bodyHtml) {
|
|
|
110
141
|
});
|
|
111
142
|
}
|
|
112
143
|
function safeUrl(url) {
|
|
113
|
-
return SAFE_HREF_RE.test(url) ? url : '';
|
|
144
|
+
return url && SAFE_HREF_RE.test(url) ? url : '';
|
|
114
145
|
}
|
|
115
146
|
export { escapeHtml, sanitizeInline, transformInlineBlocks, safeUrl };
|
package/dist/frontpage.js
CHANGED
package/dist/render.d.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import type { ArticleData, RenderOptions
|
|
1
|
+
import type { ArticleData, RenderOptions } from './types.js';
|
|
2
2
|
export declare function categoryClass(category: string): string;
|
|
3
3
|
export declare function renderArticle(data: ArticleData, opts?: RenderOptions): string;
|
|
4
|
-
export declare function renderWeekly(data: WeeklyData, opts?: RenderOptions): string;
|
|
5
4
|
export declare function renderCard(data: ArticleData, opts?: RenderOptions): string;
|
package/dist/render.js
CHANGED
|
@@ -12,18 +12,19 @@ function titleWithAccent(title, accentWord) {
|
|
|
12
12
|
const idx = safe.indexOf(safeWord);
|
|
13
13
|
return safe.slice(0, idx) + `<em class="accent-word">${safeWord}</em>` + safe.slice(idx + safeWord.length);
|
|
14
14
|
}
|
|
15
|
-
function renderChapter(num, total, chapter) {
|
|
15
|
+
function renderChapter(num, total, chapter, lang) {
|
|
16
16
|
const titleHtml = titleWithAccent(chapter.title, chapter.titleAccentWord);
|
|
17
17
|
const body = transformInlineBlocks(chapter.bodyHtml);
|
|
18
|
+
const labels = uiLabels(lang);
|
|
18
19
|
return ('<section class="chapter-panel">' +
|
|
19
20
|
'<div class="chapter-head">' +
|
|
20
|
-
`<div class="chapter-num"
|
|
21
|
+
`<div class="chapter-num">${labels.chapter} ${escapeHtml(String(num))} · ${labels.chapterOf} ${String(total).padStart(2, '0')}</div>` +
|
|
21
22
|
`<h2 class="chapter-title">${titleHtml}</h2>` +
|
|
22
23
|
'</div>' +
|
|
23
24
|
`<div class="chapter-body">${body}</div>` +
|
|
24
25
|
'</section>');
|
|
25
26
|
}
|
|
26
|
-
function renderKeyPoints(points) {
|
|
27
|
+
function renderKeyPoints(points, lang) {
|
|
27
28
|
if (!points?.length)
|
|
28
29
|
return '';
|
|
29
30
|
const items = points
|
|
@@ -31,7 +32,7 @@ function renderKeyPoints(points) {
|
|
|
31
32
|
.map((p, i) => `<div class="kp-item"><div class="n">${String(i + 1).padStart(2, '0')}</div><p>${escapeHtml(p.text)}</p></div>`)
|
|
32
33
|
.join('');
|
|
33
34
|
return ('<section class="key-points">' +
|
|
34
|
-
|
|
35
|
+
`<div class="lbl">${uiLabels(lang).threeThings}</div>` +
|
|
35
36
|
`<div class="key-points-grid">${items}</div>` +
|
|
36
37
|
'</section>');
|
|
37
38
|
}
|
|
@@ -42,30 +43,60 @@ function renderKnowCard(items, label, kind) {
|
|
|
42
43
|
`<ul>${lis || '<li>—</li>'}</ul>` +
|
|
43
44
|
'</div>');
|
|
44
45
|
}
|
|
45
|
-
function renderKnowCards(known, unknown) {
|
|
46
|
+
function renderKnowCards(known, unknown, lang) {
|
|
46
47
|
const k = known?.items ?? [];
|
|
47
48
|
const u = unknown?.items ?? [];
|
|
48
49
|
if (!k.length && !u.length)
|
|
49
50
|
return '';
|
|
51
|
+
const labels = uiLabels(lang);
|
|
50
52
|
return ('<aside class="know-cards" role="note">' +
|
|
51
|
-
renderKnowCard(k,
|
|
52
|
-
renderKnowCard(u,
|
|
53
|
+
renderKnowCard(k, labels.knowYes, 'yes') +
|
|
54
|
+
renderKnowCard(u, labels.knowNo, 'no') +
|
|
53
55
|
'</aside>');
|
|
54
56
|
}
|
|
55
|
-
function renderEditorsNote(angle, sourceCount) {
|
|
57
|
+
function renderEditorsNote(angle, sourceCount, lang) {
|
|
56
58
|
if (!angle)
|
|
57
59
|
return '';
|
|
58
|
-
const
|
|
60
|
+
const labels = uiLabels(lang);
|
|
59
61
|
return ('<aside class="editors-note" role="note">' +
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`<b>${
|
|
62
|
+
`<span class="lbl">${labels.editorsNote}</span> ` +
|
|
63
|
+
`${labels.writtenBy(sourceCount ?? 1)} ` +
|
|
64
|
+
`<b>${labels.angle}</b> ${escapeHtml(angle)}` +
|
|
63
65
|
'</aside>');
|
|
64
66
|
}
|
|
65
|
-
|
|
67
|
+
const UI_LABELS = {
|
|
68
|
+
en: {
|
|
69
|
+
share: 'Share this story',
|
|
70
|
+
byEditorial: 'By Editorial',
|
|
71
|
+
chapter: 'Chapter',
|
|
72
|
+
chapterOf: 'of',
|
|
73
|
+
threeThings: 'Three things to know',
|
|
74
|
+
knowYes: 'What we know',
|
|
75
|
+
knowNo: "What we don't",
|
|
76
|
+
editorsNote: "Editor's note",
|
|
77
|
+
writtenBy: (n) => `Written by Druck's editorial agent from <b>${n} sources</b>.`,
|
|
78
|
+
angle: "Druck's angle:",
|
|
79
|
+
},
|
|
80
|
+
de: {
|
|
81
|
+
share: 'Diesen Artikel teilen',
|
|
82
|
+
byEditorial: 'Von der Redaktion',
|
|
83
|
+
chapter: 'Kapitel',
|
|
84
|
+
chapterOf: 'von',
|
|
85
|
+
threeThings: 'Drei Dinge vorab',
|
|
86
|
+
knowYes: 'Was wir wissen',
|
|
87
|
+
knowNo: 'Was wir nicht wissen',
|
|
88
|
+
editorsNote: 'Anmerkung der Redaktion',
|
|
89
|
+
writtenBy: (n) => `Von Drucks Redaktions-KI aus <b>${n} Quellen</b> geschrieben.`,
|
|
90
|
+
angle: 'Drucks Blickwinkel:',
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
function uiLabels(lang) {
|
|
94
|
+
return UI_LABELS[lang] ?? UI_LABELS.en;
|
|
95
|
+
}
|
|
96
|
+
function renderShareBar(title, url, lang) {
|
|
66
97
|
const shareUrl = url ?? '#';
|
|
67
98
|
return ('<div class="article-share-bar">' +
|
|
68
|
-
|
|
99
|
+
`<span class="article-share-bar-label">${uiLabels(lang).share}</span>` +
|
|
69
100
|
'<div class="article-share-bar-actions">' +
|
|
70
101
|
`<button class="share-btn-pill" data-share-button data-share-title="${escapeHtml(title)}" data-share-url="${escapeHtml(shareUrl)}" aria-label="Share article">` +
|
|
71
102
|
'<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">' +
|
|
@@ -78,7 +109,7 @@ function renderShareBar(title, url) {
|
|
|
78
109
|
'</div>');
|
|
79
110
|
}
|
|
80
111
|
export function categoryClass(category) {
|
|
81
|
-
return `cat-${category}`;
|
|
112
|
+
return `cat-${escapeHtml(category)}`;
|
|
82
113
|
}
|
|
83
114
|
function formatLabel(format) {
|
|
84
115
|
if (format === 'quick_take')
|
|
@@ -116,13 +147,13 @@ export function renderArticle(data, opts) {
|
|
|
116
147
|
}
|
|
117
148
|
function renderFeatureArticle(data, ctx) {
|
|
118
149
|
const chaptersHtml = (data.chapters ?? [])
|
|
119
|
-
.map((ch, i) => renderChapter(i + 1, data.chapters.length, ch))
|
|
150
|
+
.map((ch, i) => renderChapter(i + 1, data.chapters.length, ch, ctx.lang))
|
|
120
151
|
.join('');
|
|
121
152
|
const bodyHtml = transformInlineBlocks(data.bodyHtml ?? chaptersHtml);
|
|
122
153
|
const readingSpan = data.readingTime ? `<span>${escapeHtml(data.readingTime)}</span>` : '';
|
|
123
154
|
const byline = data.byline
|
|
124
155
|
? `<span>${escapeHtml(data.byline.author)}</span><span>${escapeHtml(data.byline.date)}</span>${readingSpan}`
|
|
125
|
-
: `<span
|
|
156
|
+
: `<span>${uiLabels(ctx.lang).byEditorial}</span>${readingSpan}`;
|
|
126
157
|
return (`<article class="article-shell ${ctx.catClass}">` +
|
|
127
158
|
'<div class="article-progress" aria-hidden="true"><div class="fill"></div></div>' +
|
|
128
159
|
'<header class="article-hero">' +
|
|
@@ -133,13 +164,15 @@ function renderFeatureArticle(data, ctx) {
|
|
|
133
164
|
`<div class="article-byline">${byline}</div>` +
|
|
134
165
|
'</div>' +
|
|
135
166
|
'</header>' +
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
(data.heroImage
|
|
168
|
+
? `<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>`
|
|
169
|
+
: '') +
|
|
170
|
+
renderEditorsNote(data.editorsNote, data.sourceCount, ctx.lang) +
|
|
138
171
|
`<div class="article-body">${bodyHtml}</div>` +
|
|
139
|
-
renderKeyPoints(data.keyPoints) +
|
|
140
|
-
renderKnowCards(data.known, data.unknown) +
|
|
172
|
+
renderKeyPoints(data.keyPoints, ctx.lang) +
|
|
173
|
+
renderKnowCards(data.known, data.unknown, ctx.lang) +
|
|
141
174
|
renderRelated(data.related) +
|
|
142
|
-
renderShareBar(data.title, data.shareUrl) +
|
|
175
|
+
renderShareBar(data.title, data.shareUrl, ctx.lang) +
|
|
143
176
|
'</article>');
|
|
144
177
|
}
|
|
145
178
|
function renderWireArticle(data, ctx) {
|
|
@@ -150,7 +183,9 @@ function renderWireArticle(data, ctx) {
|
|
|
150
183
|
`<h1 class="post-simple-title">${ctx.titleHtml}</h1>` +
|
|
151
184
|
`<div class="post-simple-meta"><span>${escapeHtml(data.publishedAt)}</span>${data.readingTime ? `<span>${escapeHtml(data.readingTime)}</span>` : ''}</div>` +
|
|
152
185
|
'</header>' +
|
|
153
|
-
|
|
186
|
+
(data.heroImage
|
|
187
|
+
? `<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>`
|
|
188
|
+
: '') +
|
|
154
189
|
`<div class="post-simple-body">${bodyHtml}</div>` +
|
|
155
190
|
(data.pullQuote
|
|
156
191
|
? `<figure class="source-quote"><p class="q">${sanitizeInline(data.pullQuote)}</p></figure>`
|
|
@@ -159,29 +194,7 @@ function renderWireArticle(data, ctx) {
|
|
|
159
194
|
? `<aside class="editors-note" role="note"><span class="lbl">Why it matters</span> ${escapeHtml(data.whyItMatters)}</aside>`
|
|
160
195
|
: '') +
|
|
161
196
|
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) +
|
|
197
|
+
renderShareBar(data.title, data.shareUrl, ctx.lang) +
|
|
185
198
|
'</article>');
|
|
186
199
|
}
|
|
187
200
|
export function renderCard(data, opts) {
|
|
@@ -191,7 +204,9 @@ export function renderCard(data, opts) {
|
|
|
191
204
|
const rawHref = data.shareUrl ?? `#${data.slug}`;
|
|
192
205
|
const href = safeUrl(rawHref) || '#';
|
|
193
206
|
return (`<a class="druck-card ${catClass}" href="${escapeHtml(href)}">` +
|
|
194
|
-
|
|
207
|
+
(data.heroImage
|
|
208
|
+
? `<figure class="card-thumb"><img src="${escapeHtml(safeUrl(data.heroImage))}" alt="${escapeHtml(data.heroImageAlt ?? data.title)}" loading="lazy" width="400" height="225"></figure>`
|
|
209
|
+
: `<figure class="card-thumb card-thumb--placeholder" aria-hidden="true"><span class="thumb-mark">${escapeHtml((data.title.trim()[0] ?? '').toUpperCase())}</span></figure>`) +
|
|
195
210
|
`<div class="card-text">` +
|
|
196
211
|
`<div class="card-kicker">${escapeHtml(data.category)}${fLabel ? ` <span class="sep">·</span> ${fLabel}` : ''}</div>` +
|
|
197
212
|
`<h3 class="card-title">${titleHtml}</h3>` +
|
|
@@ -200,25 +215,3 @@ export function renderCard(data, opts) {
|
|
|
200
215
|
`</div>` +
|
|
201
216
|
`</a>`);
|
|
202
217
|
}
|
|
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
|
-
}
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@druck-editorial/engine",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Editorial rendering engine: structured article JSON in, magazine-quality pages out.",
|
|
5
5
|
"author": "Artem Iagovdik <artyom.yagovdik@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"vitest": "^3.2.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
|
-
"build": "tsc",
|
|
41
|
+
"build": "rm -rf dist && tsc",
|
|
42
42
|
"typecheck": "tsc --noEmit",
|
|
43
43
|
"test": "vitest run"
|
|
44
44
|
}
|
package/dist/frontpage.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/frontpage.test.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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/render.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/render.test.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
});
|