@druck-editorial/engine 0.1.1 → 0.2.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.
@@ -1,8 +1,19 @@
1
1
  import type { ArticleData, RenderOptions } from './types.js';
2
2
  export type FrontPageRowType = 'hero' | 'feature' | 'triple' | 'brief';
3
+ export interface FrontPageItem extends ArticleData {
4
+ role: 'lead' | 'feature' | 'brief';
5
+ hasImage: boolean;
6
+ }
3
7
  export interface FrontPageRow {
4
8
  type: FrontPageRowType;
5
- items: ArticleData[];
9
+ items: FrontPageItem[];
6
10
  }
7
11
  export declare function buildFrontPage(items: ArticleData[]): FrontPageRow[];
12
+ export type FrontPageComposer = (rows: FrontPageRow[], opts?: RenderOptions) => string;
13
+ export interface FrontPagePartition {
14
+ lead?: FrontPageItem;
15
+ cells: FrontPageItem[];
16
+ brief: FrontPageItem[];
17
+ }
18
+ export declare function partitionRows(rows: FrontPageRow[]): FrontPagePartition;
8
19
  export declare function renderFrontPage(rows: FrontPageRow[], opts?: RenderOptions): string;
package/dist/frontpage.js CHANGED
@@ -3,6 +3,15 @@
3
3
  import { escapeHtml, safeUrl } from './format.js';
4
4
  import { categoryClass, renderCard } from './render.js';
5
5
  const BRIEF_MAX = 5;
6
+ const ROLE_BY_TYPE = {
7
+ hero: 'lead',
8
+ feature: 'feature',
9
+ triple: 'feature',
10
+ brief: 'brief',
11
+ };
12
+ function enrichItem(data, role) {
13
+ return { ...data, role, hasImage: Boolean(safeUrl(data.heroImage)) };
14
+ }
6
15
  export function buildFrontPage(items) {
7
16
  if (!items.length)
8
17
  return [];
@@ -10,15 +19,20 @@ export function buildFrontPage(items) {
10
19
  const hotIdx = pool.findIndex((entry) => entry.hot);
11
20
  if (hotIdx > 0)
12
21
  pool.unshift(pool.splice(hotIdx, 1)[0]);
13
- const rows = [{ type: 'hero', items: pool.splice(0, 1) }];
22
+ const raw = [
23
+ { type: 'hero', items: pool.splice(0, 1) },
24
+ ];
14
25
  if (pool.length >= 2)
15
- rows.push({ type: 'feature', items: pool.splice(0, 2) });
26
+ raw.push({ type: 'feature', items: pool.splice(0, 2) });
16
27
  if (pool.length >= 3)
17
- rows.push({ type: 'triple', items: pool.splice(0, 3) });
28
+ raw.push({ type: 'triple', items: pool.splice(0, 3) });
18
29
  const brief = pool.splice(0, BRIEF_MAX);
19
30
  if (brief.length)
20
- rows.push({ type: 'brief', items: brief });
21
- return rows;
31
+ raw.push({ type: 'brief', items: brief });
32
+ return raw.map((row) => ({
33
+ type: row.type,
34
+ items: row.items.map((data) => enrichItem(data, ROLE_BY_TYPE[row.type])),
35
+ }));
22
36
  }
23
37
  function renderHeroCard(data) {
24
38
  const href = safeUrl(data.shareUrl ?? '') || '#';
@@ -40,8 +54,9 @@ function renderBriefItem(data) {
40
54
  `<time>${escapeHtml(data.publishedAt)}</time>` +
41
55
  '</a></li>');
42
56
  }
43
- export function renderFrontPage(rows, opts) {
44
- const rendered = rows.map((row) => {
57
+ function composeClassic(rows, opts) {
58
+ return rows
59
+ .map((row) => {
45
60
  if (row.type === 'hero') {
46
61
  return `<div class="df-row df-row--hero">${renderHeroCard(row.items[0])}</div>`;
47
62
  }
@@ -52,6 +67,136 @@ export function renderFrontPage(rows, opts) {
52
67
  const cls = row.type === 'feature' ? 'df-row--feature' : 'df-row--triple';
53
68
  const cards = row.items.map((entry) => renderCard(entry, opts)).join('');
54
69
  return `<div class="df-row ${cls}">${cards}</div>`;
70
+ })
71
+ .join('');
72
+ }
73
+ export function partitionRows(rows) {
74
+ return {
75
+ lead: rows.find((r) => r.type === 'hero')?.items[0],
76
+ cells: rows.filter((r) => r.type === 'feature' || r.type === 'triple').flatMap((r) => r.items),
77
+ brief: rows.find((r) => r.type === 'brief')?.items ?? [],
78
+ };
79
+ }
80
+ function safeHref(item) {
81
+ return escapeHtml(safeUrl(item.shareUrl ?? '') || '#');
82
+ }
83
+ function safeImg(item) {
84
+ return escapeHtml(safeUrl(item.heroImage) || 'data:,');
85
+ }
86
+ function brutalistLead(item) {
87
+ const kicker = `${item.category}${item.hot ? ' / Hot' : ''}`;
88
+ return ('<div class="dfb-lead">' +
89
+ '<div class="dfb-head">' +
90
+ `<span class="dfb-kicker">${escapeHtml(kicker)}</span>` +
91
+ `<a class="dfb-title" href="${safeHref(item)}"><h2>${escapeHtml(item.title)}</h2></a>` +
92
+ '</div>' +
93
+ `<a class="dfb-img" href="${safeHref(item)}">` +
94
+ `<img src="${safeImg(item)}" alt="${escapeHtml(item.heroImageAlt ?? item.title)}" loading="lazy" width="1200" height="675">` +
95
+ '</a>' +
96
+ '</div>');
97
+ }
98
+ function brutalistCell(item, n) {
99
+ return (`<a class="dfb-cell" href="${safeHref(item)}">` +
100
+ `<span class="dfb-n">${String(n).padStart(2, '0')}</span>` +
101
+ `<span class="dfb-ck">${escapeHtml(item.category)}</span>` +
102
+ `<h3>${escapeHtml(item.title)}</h3>` +
103
+ '</a>');
104
+ }
105
+ function brutalistBriefItem(item) {
106
+ return (`<li><a href="${safeHref(item)}">` +
107
+ `<span class="dfb-bt">${escapeHtml(item.title)}</span>` +
108
+ `<time>${escapeHtml(item.publishedAt)}</time>` +
109
+ '</a></li>');
110
+ }
111
+ function composeBrutalist(rows, _opts) {
112
+ const { lead, cells, brief } = partitionRows(rows);
113
+ const parts = [
114
+ '<div class="dfb-mast"><span class="dfb-wm">Druck</span></div><div class="dfb-rule"></div>',
115
+ ];
116
+ if (lead)
117
+ parts.push(brutalistLead(lead));
118
+ if (cells.length) {
119
+ parts.push(`<div class="dfb-grid">${cells.map((c, i) => brutalistCell(c, i + 2)).join('')}</div>`);
120
+ }
121
+ if (brief.length) {
122
+ parts.push(`<ol class="dfb-brief">${brief.map(brutalistBriefItem).join('')}</ol>`);
123
+ }
124
+ return parts.join('');
125
+ }
126
+ function composeBroadsheet(rows, _opts) {
127
+ const { lead, cells, brief } = partitionRows(rows);
128
+ const parts = ['<div class="dfbr-mast">The Druck</div><div class="dfbr-rule"></div>'];
129
+ if (lead) {
130
+ parts.push(`<div class="dfbr-date">${escapeHtml(lead.publishedAt)}</div>`);
131
+ parts.push(`<a class="dfbr-leadtitle" href="${safeHref(lead)}"><h2>${escapeHtml(lead.title)}</h2></a>`);
132
+ parts.push(`<p class="dfbr-sub">${escapeHtml(lead.subtitle)}</p>`);
133
+ }
134
+ const stories = [...cells, ...brief];
135
+ if (stories.length) {
136
+ parts.push('<div class="dfbr-cols">' + stories.map((s, i) => `<div class="dfbr-story${i === 0 ? ' dfbr-drop' : ''}"><h3>${escapeHtml(s.category)}</h3>` +
137
+ `<a href="${safeHref(s)}"><b>${escapeHtml(s.title)}</b></a> ${escapeHtml(s.subtitle)}</div>`).join('') + '</div>');
138
+ }
139
+ return parts.join('');
140
+ }
141
+ function composeHeroBlocks(rows, prefix) {
142
+ const { lead, cells, brief } = partitionRows(rows);
143
+ const parts = [];
144
+ if (lead) {
145
+ parts.push(`<a class="${prefix}-hero" href="${safeHref(lead)}">` +
146
+ `<img src="${safeImg(lead)}" alt="${escapeHtml(lead.heroImageAlt ?? lead.title)}" loading="lazy" width="1600" height="900">` +
147
+ `<span class="${prefix}-scrim"></span>` +
148
+ `<span class="${prefix}-htext"><span class="${prefix}-kick">${escapeHtml(lead.category)}</span>` +
149
+ `<h2>${escapeHtml(lead.title)}</h2></span></a>`);
150
+ }
151
+ const stories = [...cells, ...brief].slice(0, 6);
152
+ if (stories.length) {
153
+ parts.push(`<div class="${prefix}-body">` + stories.map((s) => `<a class="${prefix}-story" href="${safeHref(s)}"><span class="${prefix}-k">${escapeHtml(s.category)}</span>` +
154
+ `<h3>${escapeHtml(s.title)}</h3><p>${escapeHtml(s.subtitle)}</p></a>`).join('') + '</div>');
155
+ }
156
+ return parts.join('');
157
+ }
158
+ function composeLuxury(rows, _opts) {
159
+ return composeHeroBlocks(rows, 'dflx');
160
+ }
161
+ function composeNoir(rows, _opts) {
162
+ return composeHeroBlocks(rows, 'dfnr');
163
+ }
164
+ function composeBento(rows, _opts) {
165
+ const { lead, cells, brief } = partitionRows(rows);
166
+ const tiles = [];
167
+ if (lead) {
168
+ tiles.push(`<a class="dfbn-tile dfbn-hero" href="${safeHref(lead)}">` +
169
+ `<img src="${safeImg(lead)}" alt="${escapeHtml(lead.heroImageAlt ?? lead.title)}" loading="lazy" width="1200" height="800">` +
170
+ '<span class="dfbn-ov"></span>' +
171
+ `<span class="dfbn-tag">${escapeHtml(lead.category)}</span>` +
172
+ `<h3>${escapeHtml(lead.title)}</h3></a>`);
173
+ }
174
+ cells.forEach((c) => {
175
+ const media = c.hasImage
176
+ ? `<img src="${safeImg(c)}" alt="${escapeHtml(c.heroImageAlt ?? c.title)}" loading="lazy" width="800" height="600"><span class="dfbn-ov"></span>`
177
+ : '';
178
+ tiles.push(`<a class="dfbn-tile${c.hasImage ? ' dfbn-img' : ''}" href="${safeHref(c)}">${media}` +
179
+ `<span class="dfbn-tag">${escapeHtml(c.category)}</span>` +
180
+ `<h4>${escapeHtml(c.title)}</h4></a>`);
181
+ });
182
+ brief.slice(0, 3).forEach((b) => {
183
+ tiles.push(`<a class="dfbn-tile dfbn-mini" href="${safeHref(b)}"><span class="dfbn-tag">${escapeHtml(b.category)}</span>` +
184
+ `<h4>${escapeHtml(b.title)}</h4></a>`);
55
185
  });
56
- return `<div class="druck-front-page">${rendered.join('')}</div>`;
186
+ return `<div class="dfbn-head"><span class="dfbn-wm">Druck</span></div><div class="dfbn-grid">${tiles.join('')}</div>`;
187
+ }
188
+ const COMPOSERS = {
189
+ classic: composeClassic,
190
+ brutalist: composeBrutalist,
191
+ broadsheet: composeBroadsheet,
192
+ luxury: composeLuxury,
193
+ noir: composeNoir,
194
+ bento: composeBento,
195
+ };
196
+ export function renderFrontPage(rows, opts) {
197
+ const requested = opts?.look ?? 'classic';
198
+ const compose = COMPOSERS[requested] ?? composeClassic;
199
+ const look = COMPOSERS[requested] ? requested : 'classic';
200
+ const lookClass = look === 'classic' ? '' : ` druck-front-page--${look}`;
201
+ return `<div class="druck-front-page${lookClass}">${compose(rows, opts)}</div>`;
57
202
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { ArticleFormat, ArticleData, Category, Chapter, KeyPoint, StatAside, SourceQuote, Figure, KnowCard, RelatedArticle, WeeklyData, WeeklySection, WeeklySectionArticle, RenderOptions, } from './types.js';
1
+ export type { ArticleFormat, ArticleData, Category, Chapter, KeyPoint, StatAside, SourceQuote, Figure, KnowCard, RelatedArticle, WeeklyData, WeeklySection, WeeklySectionArticle, RenderOptions, FrontPageLook, } from './types.js';
2
2
  export { renderArticle, renderCard } from './render.js';
3
3
  export { transformInlineBlocks, sanitizeInline, escapeHtml, safeUrl } from './format.js';
4
4
  export { buildFrontPage, renderFrontPage } from './frontpage.js';
5
- export type { FrontPageRow, FrontPageRowType } from './frontpage.js';
5
+ export type { FrontPageRow, FrontPageRowType, FrontPageItem } from './frontpage.js';
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">Chapter ${escapeHtml(String(num))} &middot; of ${String(total).padStart(2, '0')}</div>` +
21
+ `<div class="chapter-num">${labels.chapter} ${escapeHtml(String(num))} &middot; ${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
- '<div class="lbl">Three things to know</div>' +
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>&mdash;</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, 'What we know', 'yes') +
52
- renderKnowCard(u, "What we don't", 'no') +
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 siteName = 'Druck';
60
+ const labels = uiLabels(lang);
59
61
  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)}` +
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
- function renderShareBar(title, url) {
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
- '<span class="article-share-bar-label">Share this story</span>' +
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">' +
@@ -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>By Editorial</span>${readingSpan}`;
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">' +
@@ -136,12 +167,12 @@ function renderFeatureArticle(data, ctx) {
136
167
  (data.heroImage
137
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>`
138
169
  : '') +
139
- renderEditorsNote(data.editorsNote, data.sourceCount) +
170
+ renderEditorsNote(data.editorsNote, data.sourceCount, ctx.lang) +
140
171
  `<div class="article-body">${bodyHtml}</div>` +
141
- renderKeyPoints(data.keyPoints) +
142
- renderKnowCards(data.known, data.unknown) +
172
+ renderKeyPoints(data.keyPoints, ctx.lang) +
173
+ renderKnowCards(data.known, data.unknown, ctx.lang) +
143
174
  renderRelated(data.related) +
144
- renderShareBar(data.title, data.shareUrl) +
175
+ renderShareBar(data.title, data.shareUrl, ctx.lang) +
145
176
  '</article>');
146
177
  }
147
178
  function renderWireArticle(data, ctx) {
@@ -163,7 +194,7 @@ function renderWireArticle(data, ctx) {
163
194
  ? `<aside class="editors-note" role="note"><span class="lbl">Why it matters</span> ${escapeHtml(data.whyItMatters)}</aside>`
164
195
  : '') +
165
196
  renderRelated(data.related) +
166
- renderShareBar(data.title, data.shareUrl) +
197
+ renderShareBar(data.title, data.shareUrl, ctx.lang) +
167
198
  '</article>');
168
199
  }
169
200
  export function renderCard(data, opts) {
package/dist/types.d.ts CHANGED
@@ -99,6 +99,7 @@ export interface WeeklySectionArticle {
99
99
  image?: string;
100
100
  summary?: string;
101
101
  }
102
+ export type FrontPageLook = 'classic' | 'broadsheet' | 'brutalist' | 'luxury' | 'noir' | 'bento';
102
103
  export interface RenderOptions {
103
104
  lang?: string;
104
105
  theme?: 'light' | 'dark';
@@ -107,4 +108,5 @@ export interface RenderOptions {
107
108
  siteUrl?: string;
108
109
  canonicalUrl?: string;
109
110
  ogImageUrl?: string;
111
+ look?: FrontPageLook;
110
112
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@druck-editorial/engine",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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",