claude-plugin-wordpress-manager 2.12.2 → 2.14.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.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +8 -3
  2. package/CHANGELOG.md +94 -2
  3. package/agents/wp-accessibility-auditor.md +1 -1
  4. package/agents/wp-content-strategist.md +2 -2
  5. package/agents/wp-deployment-engineer.md +1 -1
  6. package/agents/wp-distribution-manager.md +1 -1
  7. package/agents/wp-monitoring-agent.md +1 -1
  8. package/agents/wp-performance-optimizer.md +1 -1
  9. package/agents/wp-security-auditor.md +1 -1
  10. package/agents/wp-site-manager.md +3 -3
  11. package/commands/wp-setup.md +2 -2
  12. package/docs/GUIDE.md +260 -21
  13. package/docs/VALIDATION.md +341 -0
  14. package/docs/guides/wp-ecommerce.md +4 -4
  15. package/docs/plans/2026-03-01-tier3-wcop-implementation.md +1 -1
  16. package/docs/plans/2026-03-01-tier4-5-implementation.md +1 -1
  17. package/docs/plans/2026-03-02-content-framework-architecture.md +612 -0
  18. package/docs/plans/2026-03-02-content-framework-strategic-reflections.md +228 -0
  19. package/docs/plans/2026-03-02-content-intelligence-phase2.md +560 -0
  20. package/docs/plans/2026-03-02-content-pipeline-phase1.md +456 -0
  21. package/docs/plans/2026-03-02-dashboard-kanban-design.md +761 -0
  22. package/docs/plans/2026-03-02-dashboard-kanban-implementation.md +598 -0
  23. package/docs/plans/2026-03-02-dashboard-strategy.md +363 -0
  24. package/docs/plans/2026-03-02-editorial-calendar-phase3.md +490 -0
  25. package/docs/validation/.gitkeep +0 -0
  26. package/docs/validation/dashboard.html +286 -0
  27. package/docs/validation/results.json +1705 -0
  28. package/package.json +16 -3
  29. package/scripts/context-scanner.mjs +446 -0
  30. package/scripts/dashboard-renderer.mjs +553 -0
  31. package/scripts/run-validation.mjs +1132 -0
  32. package/servers/wp-rest-bridge/build/server.js +17 -6
  33. package/servers/wp-rest-bridge/build/tools/index.js +0 -9
  34. package/servers/wp-rest-bridge/build/tools/plugin-repository.js +23 -31
  35. package/servers/wp-rest-bridge/build/tools/schema.js +10 -2
  36. package/servers/wp-rest-bridge/build/tools/unified-content.js +10 -2
  37. package/servers/wp-rest-bridge/build/wordpress.d.ts +0 -3
  38. package/servers/wp-rest-bridge/build/wordpress.js +16 -98
  39. package/servers/wp-rest-bridge/package.json +1 -0
  40. package/skills/wp-analytics/SKILL.md +153 -0
  41. package/skills/wp-analytics/references/signals-feed-schema.md +417 -0
  42. package/skills/wp-content/references/content-templates.md +1 -1
  43. package/skills/wp-content/references/seo-optimization.md +8 -8
  44. package/skills/wp-content-attribution/references/roi-calculation.md +1 -1
  45. package/skills/wp-content-attribution/references/utm-tracking-setup.md +5 -5
  46. package/skills/wp-content-generation/references/generation-workflow.md +2 -2
  47. package/skills/wp-content-pipeline/SKILL.md +461 -0
  48. package/skills/wp-content-pipeline/references/content-brief-schema.md +377 -0
  49. package/skills/wp-content-pipeline/references/site-config-schema.md +431 -0
  50. package/skills/wp-content-repurposing/references/auto-transform-pipeline.md +1 -1
  51. package/skills/wp-content-repurposing/references/email-newsletter.md +1 -1
  52. package/skills/wp-content-repurposing/references/platform-specs.md +2 -2
  53. package/skills/wp-content-repurposing/references/transform-templates.md +27 -27
  54. package/skills/wp-dashboard/SKILL.md +121 -0
  55. package/skills/wp-deploy/references/ssh-deploy.md +2 -2
  56. package/skills/wp-editorial-planner/SKILL.md +262 -0
  57. package/skills/wp-editorial-planner/references/editorial-schema.md +268 -0
  58. package/skills/wp-multilang-network/references/content-sync.md +3 -3
  59. package/skills/wp-multilang-network/references/network-architecture.md +1 -1
  60. package/skills/wp-multilang-network/references/seo-international.md +7 -7
  61. package/skills/wp-structured-data/references/schema-types.md +4 -4
  62. package/skills/wp-webhooks/references/payload-formats.md +3 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-plugin-wordpress-manager",
3
- "version": "2.12.2",
4
- "description": "Unified WordPress management and development plugin for Claude Code. Orchestrates Hostinger MCP, WP REST API bridge (148 tools incl. 30 WooCommerce + 10 Multisite + 4 Webhooks + 7 Mailchimp + 5 Buffer + 6 SendGrid + 8 GSC + 6 GA4 + 4 Plausible + 4 CWV + 3 Slack + 4 Workflows + 5 LinkedIn + 5 Twitter + 3 Schema), and WordPress.com MCP with 43 skills, 12 agents, and security hooks. v2.12.0 completes WCOP Tier 6+7 with content generation + structured data.",
3
+ "version": "2.14.0",
4
+ "description": "Unified WordPress management and development plugin for Claude Code. Orchestrates Hostinger MCP, WP REST API bridge (148 tools incl. 30 WooCommerce + 10 Multisite + 4 Webhooks + 7 Mailchimp + 5 Buffer + 6 SendGrid + 8 GSC + 6 GA4 + 4 Plausible + 4 CWV + 3 Slack + 4 Workflows + 5 LinkedIn + 5 Twitter + 3 Schema), and WordPress.com MCP with 46 skills, 12 agents, and security hooks. v2.14.0 adds Editorial Kanban Dashboard: self-contained HTML report generated from .content-state/ files.",
5
5
  "author": {
6
6
  "name": "vinmor",
7
7
  "email": "morreale.v@gmail.com"
@@ -66,7 +66,14 @@
66
66
  "cron-triggers",
67
67
  "linkedin",
68
68
  "twitter",
69
- "direct-social"
69
+ "direct-social",
70
+ "content-framework",
71
+ "content-pipeline",
72
+ "editorial-calendar",
73
+ "signals-intelligence",
74
+ "dashboard",
75
+ "kanban",
76
+ "editorial-dashboard"
70
77
  ],
71
78
  "repository": {
72
79
  "type": "git",
@@ -76,6 +83,12 @@
76
83
  "bugs": {
77
84
  "url": "https://github.com/morrealev/wordpress-manager/issues"
78
85
  },
86
+ "scripts": {
87
+ "validate": "node scripts/run-validation.mjs",
88
+ "validate:module": "node scripts/run-validation.mjs --module",
89
+ "validate:all": "node scripts/run-validation.mjs --include-writes",
90
+ "dashboard": "node scripts/dashboard-renderer.mjs"
91
+ },
79
92
  "files": [
80
93
  ".claude-plugin/",
81
94
  ".mcp.json",
@@ -0,0 +1,446 @@
1
+ #!/usr/bin/env node
2
+ // scripts/context-scanner.mjs — Shared SCAN + AGGREGATE module for dashboard system
3
+ // Reads .content-state/ files, parses YAML frontmatter and editorial tables,
4
+ // computes aggregate metrics for rendering.
5
+ //
6
+ // Exports:
7
+ // scanContentState(contentStatePath, siteId, month?)
8
+ // aggregateMetrics(rawData, viewType)
9
+ // renderContextSnippet(metrics, sliceType)
10
+ // parseFrontmatter(content)
11
+ // parseEditorialTable(markdownBody, calendarPeriod)
12
+
13
+ import { readFile, readdir } from 'node:fs/promises';
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const PROJECT_ROOT = resolve(__dirname, '..');
19
+
20
+ // ── YAML Frontmatter Parser ────────────────────────────────────────
21
+ // Handles the subset of YAML used in .content-state/ files:
22
+ // scalars, inline arrays [a, b], multi-line arrays (- item), nested objects (1-2 levels)
23
+
24
+ function parseYamlValue(raw) {
25
+ const trimmed = raw.trim();
26
+ if (trimmed === '' || trimmed === 'null' || trimmed === '~') return null;
27
+ if (trimmed === 'true') return true;
28
+ if (trimmed === 'false') return false;
29
+ if (/^-?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
30
+ if (/^-?\d+\.\d+$/.test(trimmed)) return parseFloat(trimmed);
31
+ // Inline array: [a, b, c]
32
+ if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
33
+ const inner = trimmed.slice(1, -1).trim();
34
+ if (inner === '') return [];
35
+ return inner.split(',').map(s => parseYamlValue(s));
36
+ }
37
+ // Quoted string
38
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
39
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
40
+ return trimmed.slice(1, -1);
41
+ }
42
+ return trimmed;
43
+ }
44
+
45
+ function parseYamlBlock(lines) {
46
+ const result = {};
47
+ let i = 0;
48
+
49
+ while (i < lines.length) {
50
+ const line = lines[i];
51
+
52
+ // Skip empty lines and comments
53
+ if (line.trim() === '' || line.trim().startsWith('#')) { i++; continue; }
54
+
55
+ // Detect indentation level
56
+ const indent = line.search(/\S/);
57
+
58
+ // Top-level key: value
59
+ const kvMatch = line.match(/^(\w[\w_-]*):\s*(.*)/);
60
+ if (!kvMatch) { i++; continue; }
61
+
62
+ const key = kvMatch[1];
63
+ const valueStr = kvMatch[2].trim();
64
+
65
+ // Multi-line block scalar (|)
66
+ if (valueStr === '|') {
67
+ const blockLines = [];
68
+ i++;
69
+ while (i < lines.length) {
70
+ const nextIndent = lines[i].search(/\S/);
71
+ if (nextIndent <= indent && lines[i].trim() !== '') break;
72
+ blockLines.push(lines[i].trimStart());
73
+ i++;
74
+ }
75
+ result[key] = blockLines.join('\n').trimEnd();
76
+ continue;
77
+ }
78
+
79
+ // Value on same line
80
+ if (valueStr !== '') {
81
+ result[key] = parseYamlValue(valueStr);
82
+ i++;
83
+ continue;
84
+ }
85
+
86
+ // Value is on next lines (nested object or multi-line array)
87
+ i++;
88
+ const children = [];
89
+ while (i < lines.length) {
90
+ if (lines[i].trim() === '' || lines[i].trim().startsWith('#')) { i++; continue; }
91
+ const nextIndent = lines[i].search(/\S/);
92
+ if (nextIndent <= indent) break;
93
+ children.push(lines[i]);
94
+ i++;
95
+ }
96
+
97
+ if (children.length === 0) {
98
+ result[key] = null;
99
+ continue;
100
+ }
101
+
102
+ // Multi-line array (- item)
103
+ if (children[0].trim().startsWith('- ')) {
104
+ result[key] = children
105
+ .filter(c => c.trim().startsWith('- '))
106
+ .map(c => parseYamlValue(c.trim().slice(2)));
107
+ continue;
108
+ }
109
+
110
+ // Nested object — recurse with dedented lines
111
+ const minIndent = children[0].search(/\S/);
112
+ const dedented = children.map(c => c.slice(minIndent));
113
+ result[key] = parseYamlBlock(dedented);
114
+ }
115
+
116
+ return result;
117
+ }
118
+
119
+ export function parseFrontmatter(content) {
120
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
121
+ if (!match) return { frontmatter: {}, body: content };
122
+
123
+ const yamlStr = match[1];
124
+ const body = content.slice(match[0].length).trim();
125
+ const lines = yamlStr.split('\n');
126
+ const frontmatter = parseYamlBlock(lines);
127
+
128
+ return { frontmatter, body };
129
+ }
130
+
131
+ // ── Editorial Table Parser ─────────────────────────────────────────
132
+ // Parses Markdown tables from editorial calendar .state.md files.
133
+ // Tables have fixed columns: | Data | Titolo | Tipo | Status | Brief ID | Post ID | Canali |
134
+
135
+ const MONTH_MAP = {
136
+ 'gen': '01', 'feb': '02', 'mar': '03', 'apr': '04', 'mag': '05', 'giu': '06',
137
+ 'lug': '07', 'ago': '08', 'set': '09', 'ott': '10', 'nov': '11', 'dic': '12',
138
+ 'jan': '01', 'feb': '02', 'mar': '03', 'apr': '04', 'may': '05', 'jun': '06',
139
+ 'jul': '07', 'aug': '08', 'sep': '09', 'oct': '10', 'nov': '11', 'dec': '12'
140
+ };
141
+
142
+ function resolveDate(dateStr, calendarPeriod) {
143
+ // dateStr: "Mar 18", "Apr 3", etc.
144
+ // calendarPeriod: "2026-03-01..2026-03-31"
145
+ if (!dateStr || !calendarPeriod) return null;
146
+
147
+ const year = calendarPeriod.slice(0, 4);
148
+ const parts = dateStr.trim().split(/\s+/);
149
+ if (parts.length < 2) return null;
150
+
151
+ const monthKey = parts[0].toLowerCase().slice(0, 3);
152
+ const day = parseInt(parts[1], 10);
153
+ const month = MONTH_MAP[monthKey];
154
+ if (!month || isNaN(day)) return null;
155
+
156
+ return `${year}-${month}-${String(day).padStart(2, '0')}`;
157
+ }
158
+
159
+ function parseCellValue(cell) {
160
+ const trimmed = cell.trim();
161
+ if (trimmed === '' || trimmed === '—' || trimmed === '-') return null;
162
+ return trimmed;
163
+ }
164
+
165
+ export function parseEditorialTable(markdownBody, calendarPeriod) {
166
+ const entries = [];
167
+ const lines = markdownBody.split('\n');
168
+
169
+ for (const line of lines) {
170
+ // Only process table data rows (start with |, contain data)
171
+ if (!line.trim().startsWith('|')) continue;
172
+
173
+ const cells = line.split('|').map(c => c.trim()).filter(c => c !== '');
174
+
175
+ // Skip header rows and separator rows
176
+ if (cells.length < 7) continue;
177
+ if (cells[0] === 'Data' || cells[0].startsWith('---') || cells[0].match(/^-+$/)) continue;
178
+ if (cells.every(c => c.match(/^-+$/))) continue;
179
+
180
+ const title = parseCellValue(cells[1]);
181
+ const channels = parseCellValue(cells[6]);
182
+
183
+ entries.push({
184
+ date: resolveDate(cells[0], calendarPeriod),
185
+ title: title === '[da assegnare]' ? null : title,
186
+ type: parseCellValue(cells[2]) || 'post',
187
+ status: parseCellValue(cells[3]) || 'planned',
188
+ briefId: parseCellValue(cells[4]),
189
+ postId: parseCellValue(cells[5]) ? parseInt(cells[5], 10) || parseCellValue(cells[5]) : null,
190
+ channels: channels ? channels.split(',').map(c => c.trim()).filter(Boolean) : []
191
+ });
192
+ }
193
+
194
+ return entries;
195
+ }
196
+
197
+ // ── Anomaly Table Parser ───────────────────────────────────────────
198
+ // Parses the Anomalies & Patterns table from signals-feed.md
199
+ // | Entity | Metric | Delta | Pattern Match | Action |
200
+
201
+ function parseAnomalyTable(markdownBody) {
202
+ const anomalies = [];
203
+ const lines = markdownBody.split('\n');
204
+
205
+ for (const line of lines) {
206
+ if (!line.trim().startsWith('|')) continue;
207
+
208
+ const cells = line.split('|').map(c => c.trim()).filter(c => c !== '');
209
+ if (cells.length < 5) continue;
210
+ if (cells[0] === 'Entity' || cells[0].match(/^-+$/)) continue;
211
+ if (cells.every(c => c.match(/^-+$/))) continue;
212
+
213
+ anomalies.push({
214
+ entity: cells[0],
215
+ metric: cells[1],
216
+ delta: cells[2],
217
+ pattern: cells[3],
218
+ action: cells[4]
219
+ });
220
+ }
221
+
222
+ return anomalies;
223
+ }
224
+
225
+ // ── File Glob Helper ───────────────────────────────────────────────
226
+
227
+ async function globFiles(dirPath, suffix) {
228
+ try {
229
+ const files = await readdir(dirPath);
230
+ return files.filter(f => f.endsWith(suffix)).sort();
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ // ── SCAN: scanContentState ─────────────────────────────────────────
237
+
238
+ export async function scanContentState(contentStatePath, siteId, month) {
239
+ const absPath = resolve(PROJECT_ROOT, contentStatePath);
240
+
241
+ // 1. Read site config
242
+ const configPath = join(absPath, `${siteId}.config.md`);
243
+ let site;
244
+ try {
245
+ const configContent = await readFile(configPath, 'utf8');
246
+ const { frontmatter, body } = parseFrontmatter(configContent);
247
+ site = {
248
+ id: frontmatter.site_id || siteId,
249
+ url: frontmatter.site_url || null,
250
+ brand: frontmatter.brand || {},
251
+ defaults: frontmatter.defaults || {},
252
+ channels: frontmatter.channels || {},
253
+ seo: frontmatter.seo || {},
254
+ cadence: frontmatter.cadence || {},
255
+ brandContext: body
256
+ };
257
+ } catch (err) {
258
+ if (err.code === 'ENOENT') {
259
+ throw new Error(`Site config not found: ${configPath}. Run wp-editorial-planner first.`);
260
+ }
261
+ throw err;
262
+ }
263
+
264
+ // 2. Find and read editorial calendar
265
+ let calendar = null;
266
+ const calendarFiles = await globFiles(absPath, '-editorial.state.md');
267
+
268
+ if (calendarFiles.length > 0) {
269
+ // If month specified, find matching file; otherwise use most recent
270
+ let calFile;
271
+ if (month) {
272
+ calFile = calendarFiles.find(f => f.includes(month));
273
+ }
274
+ if (!calFile) {
275
+ calFile = calendarFiles[calendarFiles.length - 1]; // most recent by name sort
276
+ }
277
+
278
+ const calContent = await readFile(join(absPath, calFile), 'utf8');
279
+ const { frontmatter, body } = parseFrontmatter(calContent);
280
+ const entries = parseEditorialTable(body, frontmatter.period);
281
+
282
+ calendar = {
283
+ id: frontmatter.calendar_id,
284
+ period: frontmatter.period,
285
+ goals: frontmatter.goals || {},
286
+ status: frontmatter.status,
287
+ entries
288
+ };
289
+ }
290
+
291
+ // 3. Read active briefs
292
+ const activeBriefs = [];
293
+ const activeDir = join(absPath, 'pipeline-active');
294
+ const activeFiles = await globFiles(activeDir, '.brief.md');
295
+ for (const file of activeFiles) {
296
+ const content = await readFile(join(activeDir, file), 'utf8');
297
+ const { frontmatter } = parseFrontmatter(content);
298
+ activeBriefs.push({
299
+ briefId: frontmatter.brief_id,
300
+ status: frontmatter.status,
301
+ title: frontmatter.content?.title || null,
302
+ siteId: frontmatter.target?.site_id || null,
303
+ channels: frontmatter.distribution?.channels || [],
304
+ signalRef: frontmatter.source?.signal_ref || null,
305
+ postId: frontmatter.post_id || null,
306
+ postUrl: frontmatter.post_url || null
307
+ });
308
+ }
309
+
310
+ // 4. Read archived briefs
311
+ const archivedBriefs = [];
312
+ const archiveDir = join(absPath, 'pipeline-archive');
313
+ const archiveFiles = await globFiles(archiveDir, '.brief.md');
314
+ for (const file of archiveFiles) {
315
+ const content = await readFile(join(archiveDir, file), 'utf8');
316
+ const { frontmatter } = parseFrontmatter(content);
317
+ archivedBriefs.push({
318
+ briefId: frontmatter.brief_id,
319
+ status: frontmatter.status,
320
+ title: frontmatter.content?.title || null,
321
+ postId: frontmatter.post_id || null,
322
+ postUrl: frontmatter.post_url || null
323
+ });
324
+ }
325
+
326
+ // 5. Read signals feed
327
+ let signals = null;
328
+ const signalsPath = join(absPath, 'signals-feed.md');
329
+ try {
330
+ const sigContent = await readFile(signalsPath, 'utf8');
331
+ const { frontmatter, body } = parseFrontmatter(sigContent);
332
+ const anomalies = parseAnomalyTable(body);
333
+ signals = {
334
+ feedId: frontmatter.feed_id,
335
+ period: frontmatter.period,
336
+ anomalies
337
+ };
338
+ } catch (err) {
339
+ if (err.code !== 'ENOENT') throw err;
340
+ // signals-feed.md not found — signals stays null
341
+ }
342
+
343
+ return {
344
+ site,
345
+ calendar,
346
+ briefs: { active: activeBriefs, archived: archivedBriefs },
347
+ signals
348
+ };
349
+ }
350
+
351
+ // ── AGGREGATE: aggregateMetrics ────────────────────────────────────
352
+
353
+ export function aggregateMetrics(rawData, viewType = 'kanban') {
354
+ const { site, calendar, briefs, signals } = rawData;
355
+
356
+ const entries = calendar?.entries || [];
357
+ const postsTarget = calendar?.goals?.posts_target || entries.length || 0;
358
+
359
+ // Column counts
360
+ const columns = { planned: 0, draft: 0, ready: 0, scheduled: 0, published: 0 };
361
+ for (const entry of entries) {
362
+ if (columns[entry.status] !== undefined) {
363
+ columns[entry.status]++;
364
+ }
365
+ }
366
+
367
+ const postsPublished = columns.published;
368
+ const progressPercent = postsTarget > 0 ? Math.round((postsPublished / postsTarget) * 100) : 0;
369
+
370
+ // Next deadline: first non-published entry with a title, sorted by date
371
+ const upcoming = entries
372
+ .filter(e => e.status !== 'published' && e.title && e.date)
373
+ .sort((a, b) => a.date.localeCompare(b.date));
374
+ const today = new Date().toISOString().slice(0, 10);
375
+ const nextDeadline = upcoming.length > 0 ? {
376
+ date: upcoming[0].date,
377
+ title: upcoming[0].title,
378
+ status: upcoming[0].status,
379
+ daysFromNow: Math.ceil((new Date(upcoming[0].date) - new Date(today)) / 86400000)
380
+ } : null;
381
+
382
+ // Channel usage
383
+ const channelUsage = {};
384
+ for (const entry of entries) {
385
+ for (const ch of entry.channels) {
386
+ channelUsage[ch] = (channelUsage[ch] || 0) + 1;
387
+ }
388
+ }
389
+
390
+ // Fill rate: entries with title / total entries
391
+ const withTitle = entries.filter(e => e.title !== null).length;
392
+ const fillRate = entries.length > 0 ? Math.round((withTitle / entries.length) * 100 * 10) / 10 : 0;
393
+
394
+ // Signals summary
395
+ const anomalies = signals?.anomalies || [];
396
+ const signalsCount = anomalies.length;
397
+ const signalsHighest = anomalies.length > 0
398
+ ? anomalies.reduce((best, a) => {
399
+ const delta = parseFloat(a.delta) || 0;
400
+ const bestDelta = parseFloat(best.delta) || 0;
401
+ return Math.abs(delta) > Math.abs(bestDelta) ? a : best;
402
+ })
403
+ : null;
404
+
405
+ return {
406
+ siteId: site.id,
407
+ siteUrl: site.url,
408
+ calendarId: calendar?.id || null,
409
+ calendarPeriod: calendar?.period || null,
410
+ postsPublished,
411
+ postsTarget,
412
+ progressPercent,
413
+ columns,
414
+ nextDeadline,
415
+ channelUsage,
416
+ fillRate,
417
+ signalsCount,
418
+ signalsHighest,
419
+ generatedAt: new Date().toISOString(),
420
+ generatorVersion: '1.0.0'
421
+ };
422
+ }
423
+
424
+ // ── RENDER: Context Snippet (Fase B stub) ──────────────────────────
425
+
426
+ export function renderContextSnippet(metrics, sliceType = 'pipeline') {
427
+ const m = metrics;
428
+ const period = m.calendarPeriod ? m.calendarPeriod.replace('..', ' → ') : 'no calendar';
429
+ const lines = [
430
+ `── Editorial Context ──────────────────────`,
431
+ ` ${m.siteId || '?'} | ${period}`,
432
+ ` Pipeline: ${m.columns?.draft ?? 0} draft → ${m.columns?.ready ?? 0} ready → ${m.columns?.scheduled ?? 0} scheduled`,
433
+ ` Posts: ${m.postsPublished ?? 0}/${m.postsTarget ?? '?'} pubblicati`,
434
+ `───────────────────────────────────────────`,
435
+ ];
436
+
437
+ if (sliceType === 'calendar' && m.nextDeadline) {
438
+ lines.splice(3, 0, ` Next: ${m.nextDeadline.date} — "${m.nextDeadline.title?.substring(0, 40)}..."`);
439
+ }
440
+
441
+ if (sliceType === 'signals' && m.signalsCount > 0) {
442
+ lines.splice(3, 0, ` Signals: ${m.signalsCount} anomalie | Top: ${m.signalsHighest?.entity} ${m.signalsHighest?.delta}`);
443
+ }
444
+
445
+ return lines.join('\n');
446
+ }