@druck-editorial/engine 0.1.0 → 0.1.1

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 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
- return bodyHtml
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
@@ -1,3 +1,5 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 Artem Iagovdik <artyom.yagovdik@gmail.com>
1
3
  import { escapeHtml, safeUrl } from './format.js';
2
4
  import { categoryClass, renderCard } from './render.js';
3
5
  const BRIEF_MAX = 5;
package/dist/render.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { ArticleData, RenderOptions, WeeklyData } from './types.js';
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
@@ -78,7 +78,7 @@ function renderShareBar(title, url) {
78
78
  '</div>');
79
79
  }
80
80
  export function categoryClass(category) {
81
- return `cat-${category}`;
81
+ return `cat-${escapeHtml(category)}`;
82
82
  }
83
83
  function formatLabel(format) {
84
84
  if (format === 'quick_take')
@@ -133,7 +133,9 @@ function renderFeatureArticle(data, ctx) {
133
133
  `<div class="article-byline">${byline}</div>` +
134
134
  '</div>' +
135
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>` +
136
+ (data.heroImage
137
+ ? `<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>`
138
+ : '') +
137
139
  renderEditorsNote(data.editorsNote, data.sourceCount) +
138
140
  `<div class="article-body">${bodyHtml}</div>` +
139
141
  renderKeyPoints(data.keyPoints) +
@@ -150,7 +152,9 @@ function renderWireArticle(data, ctx) {
150
152
  `<h1 class="post-simple-title">${ctx.titleHtml}</h1>` +
151
153
  `<div class="post-simple-meta"><span>${escapeHtml(data.publishedAt)}</span>${data.readingTime ? `<span>${escapeHtml(data.readingTime)}</span>` : ''}</div>` +
152
154
  '</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>` +
155
+ (data.heroImage
156
+ ? `<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>`
157
+ : '') +
154
158
  `<div class="post-simple-body">${bodyHtml}</div>` +
155
159
  (data.pullQuote
156
160
  ? `<figure class="source-quote"><p class="q">${sanitizeInline(data.pullQuote)}</p></figure>`
@@ -162,28 +166,6 @@ function renderWireArticle(data, ctx) {
162
166
  renderShareBar(data.title, data.shareUrl) +
163
167
  '</article>');
164
168
  }
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
169
  export function renderCard(data, opts) {
188
170
  const catClass = categoryClass(data.category);
189
171
  const titleHtml = titleWithAccent(data.title, data.titleAccentWord);
@@ -191,7 +173,9 @@ export function renderCard(data, opts) {
191
173
  const rawHref = data.shareUrl ?? `#${data.slug}`;
192
174
  const href = safeUrl(rawHref) || '#';
193
175
  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>` +
176
+ (data.heroImage
177
+ ? `<figure class="card-thumb"><img src="${escapeHtml(safeUrl(data.heroImage))}" alt="${escapeHtml(data.heroImageAlt ?? data.title)}" loading="lazy" width="400" height="225"></figure>`
178
+ : `<figure class="card-thumb card-thumb--placeholder" aria-hidden="true"><span class="thumb-mark">${escapeHtml((data.title.trim()[0] ?? '').toUpperCase())}</span></figure>`) +
195
179
  `<div class="card-text">` +
196
180
  `<div class="card-kicker">${escapeHtml(data.category)}${fLabel ? ` <span class="sep">&middot;</span> ${fLabel}` : ''}</div>` +
197
181
  `<h3 class="card-title">${titleHtml}</h3>` +
@@ -200,25 +184,3 @@ export function renderCard(data, opts) {
200
184
  `</div>` +
201
185
  `</a>`);
202
186
  }
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
@@ -39,7 +39,7 @@ export interface ArticleData {
39
39
  category: Category;
40
40
  publishedAt: string;
41
41
  readingTime?: string;
42
- heroImage: string;
42
+ heroImage?: string;
43
43
  heroImageAlt?: string;
44
44
  heroImageWidth?: number;
45
45
  heroImageHeight?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@druck-editorial/engine",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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
  }
@@ -1 +0,0 @@
1
- export {};
@@ -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
- });
@@ -1 +0,0 @@
1
- export {};
@@ -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('&quot;');
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('&lt;script&gt;');
76
- expect(html).toContain('&quot;alert&quot;');
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
- });