@sellable/mcp 0.1.262 → 0.1.263
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/server.js +4 -1
- package/dist/tools/content-posts.d.ts +222 -1
- package/dist/tools/content-posts.js +504 -0
- package/dist/tools/registry.d.ts +33 -0
- package/package.json +1 -1
- package/skills/create-post/SKILL.md +30 -11
- package/skills/create-post/references/hook-research-playbook.md +27 -11
- package/skills/create-post/references/linkedin-preview-rendering.md +51 -10
- package/skills/create-post/references/post-validation.md +27 -9
package/dist/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { getCampaignTableSchema, queueCampaignCells, reviseMessageTemplateAndRer
|
|
|
9
9
|
import { createCampaign, duplicateCampaign, getCampaign, getCampaignMessagesPreview, getCampaigns, pauseCampaign, startCampaign, updateCampaign, updateCampaignBrief, } from "./tools/campaigns.js";
|
|
10
10
|
import { queueCells, updateCell } from "./tools/cells.js";
|
|
11
11
|
import { handleStartCliLogin, handleWaitForCliLogin, } from "./tools/cli-login.js";
|
|
12
|
-
import { capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
|
|
12
|
+
import { capturePostIdeaTool, getPostDraftTool, getPostIdeaTool, getPublishedPostTool, listPostDraftIterationsTool, listPostDraftsTool, listPostIdeasTool, listPublishedPostsTool, markPostPublishedTool, renderLinkedInPostPreviewTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
|
|
13
13
|
import { getCampaignContext, hydrateCampaignContextFromCampaign, markCampaignContextDirty, } from "./tools/context.js";
|
|
14
14
|
import { addToCommentCampaign, addToConnectionCampaign, addToInmailCampaign, getEngagedPosts, getOrCreateDirectCampaignTable, pauseDirectCampaign, startDirectCampaign, } from "./tools/direct-campaigns.js";
|
|
15
15
|
import { bootstrapEngage, bootstrapEngageMulti, } from "./tools/engage-bootstrap.js";
|
|
@@ -244,6 +244,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
244
244
|
case "save_post_draft":
|
|
245
245
|
result = savePostDraftTool(args);
|
|
246
246
|
break;
|
|
247
|
+
case "render_linkedin_post_preview":
|
|
248
|
+
result = renderLinkedInPostPreviewTool(args);
|
|
249
|
+
break;
|
|
247
250
|
case "update_post_draft":
|
|
248
251
|
result = updatePostDraftTool(args);
|
|
249
252
|
break;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type ContentKind = "idea" | "hook_research" | "draft" | "published_post";
|
|
1
|
+
type ContentKind = "idea" | "hook_research" | "draft" | "published_post" | "linkedin_preview";
|
|
2
2
|
type ArtifactMetadata = {
|
|
3
3
|
id: string;
|
|
4
4
|
type: ContentKind;
|
|
@@ -94,6 +94,12 @@ export declare const contentPostToolDefinitions: ({
|
|
|
94
94
|
body?: undefined;
|
|
95
95
|
validationReceipt?: undefined;
|
|
96
96
|
status?: undefined;
|
|
97
|
+
postText?: undefined;
|
|
98
|
+
renderId?: undefined;
|
|
99
|
+
sourceLabel?: undefined;
|
|
100
|
+
renderScreenshots?: undefined;
|
|
101
|
+
requireScreenshots?: undefined;
|
|
102
|
+
reviewClampLines?: undefined;
|
|
97
103
|
updatedAt?: undefined;
|
|
98
104
|
publishUrl?: undefined;
|
|
99
105
|
activityId?: undefined;
|
|
@@ -193,6 +199,12 @@ export declare const contentPostToolDefinitions: ({
|
|
|
193
199
|
body?: undefined;
|
|
194
200
|
validationReceipt?: undefined;
|
|
195
201
|
status?: undefined;
|
|
202
|
+
postText?: undefined;
|
|
203
|
+
renderId?: undefined;
|
|
204
|
+
sourceLabel?: undefined;
|
|
205
|
+
renderScreenshots?: undefined;
|
|
206
|
+
requireScreenshots?: undefined;
|
|
207
|
+
reviewClampLines?: undefined;
|
|
196
208
|
updatedAt?: undefined;
|
|
197
209
|
publishUrl?: undefined;
|
|
198
210
|
activityId?: undefined;
|
|
@@ -215,6 +227,7 @@ export declare const contentPostToolDefinitions: ({
|
|
|
215
227
|
properties: {
|
|
216
228
|
draftId: {
|
|
217
229
|
type: string;
|
|
230
|
+
description?: undefined;
|
|
218
231
|
};
|
|
219
232
|
ideaId: {
|
|
220
233
|
type: string;
|
|
@@ -259,6 +272,12 @@ export declare const contentPostToolDefinitions: ({
|
|
|
259
272
|
selectedPatterns?: undefined;
|
|
260
273
|
previewBudget?: undefined;
|
|
261
274
|
notes?: undefined;
|
|
275
|
+
postText?: undefined;
|
|
276
|
+
renderId?: undefined;
|
|
277
|
+
sourceLabel?: undefined;
|
|
278
|
+
renderScreenshots?: undefined;
|
|
279
|
+
requireScreenshots?: undefined;
|
|
280
|
+
reviewClampLines?: undefined;
|
|
262
281
|
updatedAt?: undefined;
|
|
263
282
|
publishUrl?: undefined;
|
|
264
283
|
activityId?: undefined;
|
|
@@ -279,8 +298,79 @@ export declare const contentPostToolDefinitions: ({
|
|
|
279
298
|
inputSchema: {
|
|
280
299
|
type: string;
|
|
281
300
|
properties: {
|
|
301
|
+
postText: {
|
|
302
|
+
type: string;
|
|
303
|
+
description: string;
|
|
304
|
+
};
|
|
282
305
|
draftId: {
|
|
283
306
|
type: string;
|
|
307
|
+
description: string;
|
|
308
|
+
};
|
|
309
|
+
renderId: {
|
|
310
|
+
type: string;
|
|
311
|
+
description: string;
|
|
312
|
+
};
|
|
313
|
+
sourceLabel: {
|
|
314
|
+
type: string;
|
|
315
|
+
};
|
|
316
|
+
createdAt: {
|
|
317
|
+
type: string;
|
|
318
|
+
};
|
|
319
|
+
renderScreenshots: {
|
|
320
|
+
type: string;
|
|
321
|
+
description: string;
|
|
322
|
+
};
|
|
323
|
+
requireScreenshots: {
|
|
324
|
+
type: string;
|
|
325
|
+
description: string;
|
|
326
|
+
};
|
|
327
|
+
reviewClampLines: {
|
|
328
|
+
type: string;
|
|
329
|
+
description: string;
|
|
330
|
+
};
|
|
331
|
+
rawSource?: undefined;
|
|
332
|
+
title?: undefined;
|
|
333
|
+
distilledBrief?: undefined;
|
|
334
|
+
ideaId?: undefined;
|
|
335
|
+
sourceType?: undefined;
|
|
336
|
+
sourceUrl?: undefined;
|
|
337
|
+
capturedAt?: undefined;
|
|
338
|
+
researchId?: undefined;
|
|
339
|
+
topic?: undefined;
|
|
340
|
+
keywords?: undefined;
|
|
341
|
+
sourcePosts?: undefined;
|
|
342
|
+
selectedPatterns?: undefined;
|
|
343
|
+
previewBudget?: undefined;
|
|
344
|
+
notes?: undefined;
|
|
345
|
+
hookResearchId?: undefined;
|
|
346
|
+
priorDraftId?: undefined;
|
|
347
|
+
iteration?: undefined;
|
|
348
|
+
body?: undefined;
|
|
349
|
+
validationReceipt?: undefined;
|
|
350
|
+
status?: undefined;
|
|
351
|
+
updatedAt?: undefined;
|
|
352
|
+
publishUrl?: undefined;
|
|
353
|
+
activityId?: undefined;
|
|
354
|
+
publishedAt?: undefined;
|
|
355
|
+
finalText?: undefined;
|
|
356
|
+
updateDraftStatus?: undefined;
|
|
357
|
+
publishedPostId?: undefined;
|
|
358
|
+
year?: undefined;
|
|
359
|
+
metrics?: undefined;
|
|
360
|
+
note?: undefined;
|
|
361
|
+
};
|
|
362
|
+
required: never[];
|
|
363
|
+
additionalProperties: boolean;
|
|
364
|
+
};
|
|
365
|
+
} | {
|
|
366
|
+
name: string;
|
|
367
|
+
description: string;
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: string;
|
|
370
|
+
properties: {
|
|
371
|
+
draftId: {
|
|
372
|
+
type: string;
|
|
373
|
+
description?: undefined;
|
|
284
374
|
};
|
|
285
375
|
hookResearchId: {
|
|
286
376
|
type: string;
|
|
@@ -323,6 +413,12 @@ export declare const contentPostToolDefinitions: ({
|
|
|
323
413
|
previewBudget?: undefined;
|
|
324
414
|
notes?: undefined;
|
|
325
415
|
createdAt?: undefined;
|
|
416
|
+
postText?: undefined;
|
|
417
|
+
renderId?: undefined;
|
|
418
|
+
sourceLabel?: undefined;
|
|
419
|
+
renderScreenshots?: undefined;
|
|
420
|
+
requireScreenshots?: undefined;
|
|
421
|
+
reviewClampLines?: undefined;
|
|
326
422
|
publishUrl?: undefined;
|
|
327
423
|
activityId?: undefined;
|
|
328
424
|
publishedAt?: undefined;
|
|
@@ -344,6 +440,7 @@ export declare const contentPostToolDefinitions: ({
|
|
|
344
440
|
properties: {
|
|
345
441
|
draftId: {
|
|
346
442
|
type: string;
|
|
443
|
+
description?: undefined;
|
|
347
444
|
};
|
|
348
445
|
publishUrl: {
|
|
349
446
|
type: string;
|
|
@@ -384,6 +481,12 @@ export declare const contentPostToolDefinitions: ({
|
|
|
384
481
|
body?: undefined;
|
|
385
482
|
validationReceipt?: undefined;
|
|
386
483
|
status?: undefined;
|
|
484
|
+
postText?: undefined;
|
|
485
|
+
renderId?: undefined;
|
|
486
|
+
sourceLabel?: undefined;
|
|
487
|
+
renderScreenshots?: undefined;
|
|
488
|
+
requireScreenshots?: undefined;
|
|
489
|
+
reviewClampLines?: undefined;
|
|
387
490
|
updatedAt?: undefined;
|
|
388
491
|
publishedPostId?: undefined;
|
|
389
492
|
year?: undefined;
|
|
@@ -436,6 +539,12 @@ export declare const contentPostToolDefinitions: ({
|
|
|
436
539
|
body?: undefined;
|
|
437
540
|
validationReceipt?: undefined;
|
|
438
541
|
status?: undefined;
|
|
542
|
+
postText?: undefined;
|
|
543
|
+
renderId?: undefined;
|
|
544
|
+
sourceLabel?: undefined;
|
|
545
|
+
renderScreenshots?: undefined;
|
|
546
|
+
requireScreenshots?: undefined;
|
|
547
|
+
reviewClampLines?: undefined;
|
|
439
548
|
updatedAt?: undefined;
|
|
440
549
|
publishUrl?: undefined;
|
|
441
550
|
activityId?: undefined;
|
|
@@ -455,6 +564,7 @@ export declare function ensureContentLayout(root?: string): {
|
|
|
455
564
|
hookResearch: "linkedin/research/hooks";
|
|
456
565
|
drafts: "linkedin/drafts";
|
|
457
566
|
published: "linkedin/published";
|
|
567
|
+
previews: "linkedin/previews";
|
|
458
568
|
commentLibrary: "linkedin/comments/library";
|
|
459
569
|
};
|
|
460
570
|
};
|
|
@@ -529,6 +639,117 @@ export declare function savePostDraftTool(input: {
|
|
|
529
639
|
updatedAt: string;
|
|
530
640
|
preview: string;
|
|
531
641
|
};
|
|
642
|
+
export declare function renderLinkedInPostPreviewTool(input: {
|
|
643
|
+
postText?: string;
|
|
644
|
+
draftId?: string;
|
|
645
|
+
renderId?: string;
|
|
646
|
+
sourceLabel?: string;
|
|
647
|
+
createdAt?: string;
|
|
648
|
+
renderScreenshots?: boolean;
|
|
649
|
+
requireScreenshots?: boolean;
|
|
650
|
+
reviewClampLines?: number;
|
|
651
|
+
}): {
|
|
652
|
+
id: string;
|
|
653
|
+
path: string;
|
|
654
|
+
artifactDir: string;
|
|
655
|
+
absoluteArtifactDir: string;
|
|
656
|
+
combinedHtmlPath: string;
|
|
657
|
+
absoluteCombinedHtmlPath: string;
|
|
658
|
+
mobile: {
|
|
659
|
+
absoluteHtmlPath: string;
|
|
660
|
+
absoluteScreenshotPath: string | undefined;
|
|
661
|
+
textWidthPx: number;
|
|
662
|
+
viewportWidthPx: number;
|
|
663
|
+
fontSizePx: number;
|
|
664
|
+
lineHeightPx: number;
|
|
665
|
+
reviewClampLines: number;
|
|
666
|
+
estimatedVisibleTextBlock: string;
|
|
667
|
+
estimatedRenderedLines: string[];
|
|
668
|
+
estimatedLineCountBeforeClamp: number;
|
|
669
|
+
estimatedBlankLinesBeforeClamp: number;
|
|
670
|
+
htmlPath: string;
|
|
671
|
+
screenshotPath: string | undefined;
|
|
672
|
+
};
|
|
673
|
+
desktop: {
|
|
674
|
+
absoluteHtmlPath: string;
|
|
675
|
+
absoluteScreenshotPath: string | undefined;
|
|
676
|
+
textWidthPx: number;
|
|
677
|
+
viewportWidthPx: number;
|
|
678
|
+
fontSizePx: number;
|
|
679
|
+
lineHeightPx: number;
|
|
680
|
+
reviewClampLines: number;
|
|
681
|
+
estimatedVisibleTextBlock: string;
|
|
682
|
+
estimatedRenderedLines: string[];
|
|
683
|
+
estimatedLineCountBeforeClamp: number;
|
|
684
|
+
estimatedBlankLinesBeforeClamp: number;
|
|
685
|
+
htmlPath: string;
|
|
686
|
+
screenshotPath: string | undefined;
|
|
687
|
+
};
|
|
688
|
+
screenshotStatus: {
|
|
689
|
+
status: "rendered";
|
|
690
|
+
chromePath: string;
|
|
691
|
+
} | {
|
|
692
|
+
status: "skipped";
|
|
693
|
+
reason: string;
|
|
694
|
+
} | {
|
|
695
|
+
status: "failed";
|
|
696
|
+
reason: string;
|
|
697
|
+
chromePath?: string;
|
|
698
|
+
};
|
|
699
|
+
renderedPreview: {
|
|
700
|
+
basis: string;
|
|
701
|
+
cssContractVersion: string;
|
|
702
|
+
generatedAt: string;
|
|
703
|
+
sourceLabel: string;
|
|
704
|
+
mobile: {
|
|
705
|
+
textWidthPx: number;
|
|
706
|
+
viewportWidthPx: number;
|
|
707
|
+
fontSizePx: number;
|
|
708
|
+
lineHeightPx: number;
|
|
709
|
+
reviewClampLines: number;
|
|
710
|
+
estimatedVisibleTextBlock: string;
|
|
711
|
+
estimatedRenderedLines: string[];
|
|
712
|
+
estimatedLineCountBeforeClamp: number;
|
|
713
|
+
estimatedBlankLinesBeforeClamp: number;
|
|
714
|
+
htmlPath: string;
|
|
715
|
+
screenshotPath: string | undefined;
|
|
716
|
+
};
|
|
717
|
+
desktop: {
|
|
718
|
+
textWidthPx: number;
|
|
719
|
+
viewportWidthPx: number;
|
|
720
|
+
fontSizePx: number;
|
|
721
|
+
lineHeightPx: number;
|
|
722
|
+
reviewClampLines: number;
|
|
723
|
+
estimatedVisibleTextBlock: string;
|
|
724
|
+
estimatedRenderedLines: string[];
|
|
725
|
+
estimatedLineCountBeforeClamp: number;
|
|
726
|
+
estimatedBlankLinesBeforeClamp: number;
|
|
727
|
+
htmlPath: string;
|
|
728
|
+
screenshotPath: string | undefined;
|
|
729
|
+
};
|
|
730
|
+
diagnostics: {
|
|
731
|
+
charCount: number;
|
|
732
|
+
charCountIncludingNewlines: number;
|
|
733
|
+
firstLineChars: number;
|
|
734
|
+
firstTwoPhysicalLinesChars: number;
|
|
735
|
+
physicalLineCount: number;
|
|
736
|
+
contentLineCount: number;
|
|
737
|
+
longestNonblankLineChars: number;
|
|
738
|
+
blankLineCount: number;
|
|
739
|
+
};
|
|
740
|
+
screenshotStatus: {
|
|
741
|
+
status: "rendered";
|
|
742
|
+
chromePath: string;
|
|
743
|
+
} | {
|
|
744
|
+
status: "skipped";
|
|
745
|
+
reason: string;
|
|
746
|
+
} | {
|
|
747
|
+
status: "failed";
|
|
748
|
+
reason: string;
|
|
749
|
+
chromePath?: string;
|
|
750
|
+
};
|
|
751
|
+
};
|
|
752
|
+
};
|
|
532
753
|
export declare function updatePostDraftTool(input: {
|
|
533
754
|
draftId: string;
|
|
534
755
|
hookResearchId?: string;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
4
6
|
const CONTENT_ROOT_ENV = "SELLABLE_CONTENT_DIR";
|
|
5
7
|
const DEFAULT_PREVIEW_CHARS = 220;
|
|
6
8
|
const RELATIVE_DIRS = {
|
|
@@ -8,6 +10,7 @@ const RELATIVE_DIRS = {
|
|
|
8
10
|
hookResearch: "linkedin/research/hooks",
|
|
9
11
|
drafts: "linkedin/drafts",
|
|
10
12
|
published: "linkedin/published",
|
|
13
|
+
previews: "linkedin/previews",
|
|
11
14
|
commentLibrary: "linkedin/comments/library",
|
|
12
15
|
};
|
|
13
16
|
export const contentPostToolDefinitions = [
|
|
@@ -104,6 +107,43 @@ export const contentPostToolDefinitions = [
|
|
|
104
107
|
additionalProperties: false,
|
|
105
108
|
},
|
|
106
109
|
},
|
|
110
|
+
{
|
|
111
|
+
name: "render_linkedin_post_preview",
|
|
112
|
+
description: "Render any LinkedIn post text through the Sellable LinkedIn preview CSS contract for mobile and desktop. Writes HTML artifacts and, when local Chrome is available, browser screenshots under ~/.sellable/content/linkedin/previews.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: {
|
|
116
|
+
postText: {
|
|
117
|
+
type: "string",
|
|
118
|
+
description: "Full post text to render. Provide either postText or draftId.",
|
|
119
|
+
},
|
|
120
|
+
draftId: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Optional saved draft ID. When postText is omitted, the tool renders this draft's Draft Body.",
|
|
123
|
+
},
|
|
124
|
+
renderId: {
|
|
125
|
+
type: "string",
|
|
126
|
+
description: "Optional stable render ID. Must be a safe filename without slashes.",
|
|
127
|
+
},
|
|
128
|
+
sourceLabel: { type: "string" },
|
|
129
|
+
createdAt: { type: "string" },
|
|
130
|
+
renderScreenshots: {
|
|
131
|
+
type: "boolean",
|
|
132
|
+
description: "Defaults to true. When true, tries to use local Chrome headless to write PNG screenshots.",
|
|
133
|
+
},
|
|
134
|
+
requireScreenshots: {
|
|
135
|
+
type: "boolean",
|
|
136
|
+
description: "Defaults to false. When true, fail if local Chrome cannot produce PNG screenshots.",
|
|
137
|
+
},
|
|
138
|
+
reviewClampLines: {
|
|
139
|
+
type: "number",
|
|
140
|
+
description: "Visible review lines before see-more. Defaults to 3.",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
required: [],
|
|
144
|
+
additionalProperties: false,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
107
147
|
{
|
|
108
148
|
name: "update_post_draft",
|
|
109
149
|
description: "Update an existing LinkedIn post draft in place while preserving omitted fields. Useful for adding iteration receipts, status changes, or final copy edits without rewriting the whole artifact.",
|
|
@@ -389,6 +429,109 @@ export function savePostDraftTool(input) {
|
|
|
389
429
|
preview: sanitizedPreview(input.body),
|
|
390
430
|
};
|
|
391
431
|
}
|
|
432
|
+
export function renderLinkedInPostPreviewTool(input) {
|
|
433
|
+
const now = normalizeDate(input.createdAt);
|
|
434
|
+
const draftId = input.draftId
|
|
435
|
+
? normalizeArtifactId(input.draftId, "draftId")
|
|
436
|
+
: undefined;
|
|
437
|
+
const draftBody = draftId
|
|
438
|
+
? extractMarkdownSection(getArtifact(RELATIVE_DIRS.drafts, draftId, "draftId").markdown, "Draft Body")
|
|
439
|
+
: undefined;
|
|
440
|
+
const postText = input.postText ?? draftBody;
|
|
441
|
+
requireString(postText, "postText");
|
|
442
|
+
const reviewClampLines = typeof input.reviewClampLines === "number" &&
|
|
443
|
+
Number.isFinite(input.reviewClampLines)
|
|
444
|
+
? Math.max(1, Math.min(Math.floor(input.reviewClampLines), 8))
|
|
445
|
+
: 3;
|
|
446
|
+
const id = input.renderId ??
|
|
447
|
+
`preview_${dateStamp(now)}_${slugify(input.sourceLabel || draftId || postText)}`;
|
|
448
|
+
const safeId = normalizeArtifactId(id, "renderId");
|
|
449
|
+
const baseDir = `${RELATIVE_DIRS.previews}/${safeId}`;
|
|
450
|
+
const title = input.sourceLabel || draftId || safeId;
|
|
451
|
+
const renderScreenshots = input.renderScreenshots !== false;
|
|
452
|
+
const mobile = buildPreviewSurface({
|
|
453
|
+
label: "mobile",
|
|
454
|
+
text: postText,
|
|
455
|
+
baseDir,
|
|
456
|
+
textWidthPx: 308,
|
|
457
|
+
viewportWidthPx: 390,
|
|
458
|
+
viewportHeightPx: 760,
|
|
459
|
+
reviewClampLines,
|
|
460
|
+
});
|
|
461
|
+
const desktop = buildPreviewSurface({
|
|
462
|
+
label: "desktop",
|
|
463
|
+
text: postText,
|
|
464
|
+
baseDir,
|
|
465
|
+
textWidthPx: 582,
|
|
466
|
+
viewportWidthPx: 744,
|
|
467
|
+
viewportHeightPx: 760,
|
|
468
|
+
reviewClampLines,
|
|
469
|
+
});
|
|
470
|
+
const combinedHtmlRelativePath = `${baseDir}/preview.html`;
|
|
471
|
+
const combinedHtml = buildCombinedPreviewHtml({
|
|
472
|
+
title,
|
|
473
|
+
postText,
|
|
474
|
+
mobile,
|
|
475
|
+
desktop,
|
|
476
|
+
});
|
|
477
|
+
writeContentFile(combinedHtmlRelativePath, combinedHtml);
|
|
478
|
+
const screenshotResult = renderScreenshots
|
|
479
|
+
? renderPreviewScreenshots([mobile, desktop], input.requireScreenshots)
|
|
480
|
+
: { status: "skipped", reason: "renderScreenshots=false" };
|
|
481
|
+
const metadata = {
|
|
482
|
+
id: safeId,
|
|
483
|
+
type: "linkedin_preview",
|
|
484
|
+
status: screenshotResult.status === "rendered" ? "rendered" : "html_rendered",
|
|
485
|
+
title,
|
|
486
|
+
draftId,
|
|
487
|
+
createdAt: now,
|
|
488
|
+
updatedAt: now,
|
|
489
|
+
};
|
|
490
|
+
const renderedPreview = {
|
|
491
|
+
basis: screenshotResult.status === "rendered"
|
|
492
|
+
? "local_chrome_headless_screenshot"
|
|
493
|
+
: "linkedin_css_contract_html",
|
|
494
|
+
cssContractVersion: "linkedin-preview-rendering/v1",
|
|
495
|
+
generatedAt: now,
|
|
496
|
+
sourceLabel: title,
|
|
497
|
+
mobile: surfaceRecord(mobile),
|
|
498
|
+
desktop: surfaceRecord(desktop),
|
|
499
|
+
diagnostics: previewDiagnostics(postText),
|
|
500
|
+
screenshotStatus: screenshotResult,
|
|
501
|
+
};
|
|
502
|
+
const recordRelativePath = `${baseDir}/render.md`;
|
|
503
|
+
const metadataJsonRelativePath = `${baseDir}/metadata.json`;
|
|
504
|
+
writeContentFile(metadataJsonRelativePath, `${JSON.stringify(renderedPreview, null, 2)}\n`);
|
|
505
|
+
writeArtifact(recordRelativePath, buildMarkdown(metadata, [
|
|
506
|
+
["Rendered Preview Record", jsonBlock(renderedPreview)],
|
|
507
|
+
[
|
|
508
|
+
"Artifacts",
|
|
509
|
+
[
|
|
510
|
+
`- combinedHtml: ${combinedHtmlRelativePath}`,
|
|
511
|
+
`- mobileHtml: ${mobile.htmlPath}`,
|
|
512
|
+
mobile.screenshotPath
|
|
513
|
+
? `- mobileScreenshot: ${mobile.screenshotPath}`
|
|
514
|
+
: "- mobileScreenshot: none",
|
|
515
|
+
`- desktopHtml: ${desktop.htmlPath}`,
|
|
516
|
+
desktop.screenshotPath
|
|
517
|
+
? `- desktopScreenshot: ${desktop.screenshotPath}`
|
|
518
|
+
: "- desktopScreenshot: none",
|
|
519
|
+
].join("\n"),
|
|
520
|
+
],
|
|
521
|
+
]));
|
|
522
|
+
return {
|
|
523
|
+
id: safeId,
|
|
524
|
+
path: recordRelativePath,
|
|
525
|
+
artifactDir: baseDir,
|
|
526
|
+
absoluteArtifactDir: safePath(resolveContentRoot(), baseDir),
|
|
527
|
+
combinedHtmlPath: combinedHtmlRelativePath,
|
|
528
|
+
absoluteCombinedHtmlPath: safePath(resolveContentRoot(), combinedHtmlRelativePath),
|
|
529
|
+
mobile: surfaceReturnRecord(mobile),
|
|
530
|
+
desktop: surfaceReturnRecord(desktop),
|
|
531
|
+
screenshotStatus: screenshotResult,
|
|
532
|
+
renderedPreview,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
392
535
|
export function updatePostDraftTool(input) {
|
|
393
536
|
const safeDraftId = normalizeArtifactId(input.draftId, "draftId");
|
|
394
537
|
const relativePath = `${RELATIVE_DIRS.drafts}/${safeDraftId}.md`;
|
|
@@ -617,6 +760,362 @@ export function listPublishedPostsTool(input) {
|
|
|
617
760
|
}
|
|
618
761
|
return summarizeArtifacts(artifacts, input?.limit);
|
|
619
762
|
}
|
|
763
|
+
function buildPreviewSurface(input) {
|
|
764
|
+
const estimatedLines = estimateRenderedLines(input.text, input.textWidthPx);
|
|
765
|
+
const htmlPath = `${input.baseDir}/${input.label}.html`;
|
|
766
|
+
const surface = {
|
|
767
|
+
label: input.label,
|
|
768
|
+
textWidthPx: input.textWidthPx,
|
|
769
|
+
viewportWidthPx: input.viewportWidthPx,
|
|
770
|
+
viewportHeightPx: input.viewportHeightPx,
|
|
771
|
+
fontSizePx: 14,
|
|
772
|
+
lineHeightPx: 21,
|
|
773
|
+
reviewClampLines: input.reviewClampLines,
|
|
774
|
+
estimatedLines,
|
|
775
|
+
estimatedVisibleTextBlock: estimatedLines
|
|
776
|
+
.slice(0, input.reviewClampLines)
|
|
777
|
+
.join("\n"),
|
|
778
|
+
estimatedLineCountBeforeClamp: estimatedLines.length,
|
|
779
|
+
estimatedBlankLinesBeforeClamp: estimatedLines
|
|
780
|
+
.slice(0, input.reviewClampLines)
|
|
781
|
+
.filter((line) => line.trim().length === 0).length,
|
|
782
|
+
htmlPath,
|
|
783
|
+
absoluteHtmlPath: safePath(resolveContentRoot(), htmlPath),
|
|
784
|
+
};
|
|
785
|
+
writeContentFile(htmlPath, buildSinglePreviewHtml({
|
|
786
|
+
title: `${input.label} LinkedIn preview`,
|
|
787
|
+
postText: input.text,
|
|
788
|
+
surface,
|
|
789
|
+
}));
|
|
790
|
+
return surface;
|
|
791
|
+
}
|
|
792
|
+
function buildSinglePreviewHtml(input) {
|
|
793
|
+
return htmlDocument({
|
|
794
|
+
title: input.title,
|
|
795
|
+
bodyClass: input.surface.label,
|
|
796
|
+
body: linkedInCardHtml(input.postText, input.surface),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
function buildCombinedPreviewHtml(input) {
|
|
800
|
+
return htmlDocument({
|
|
801
|
+
title: `LinkedIn preview: ${input.title}`,
|
|
802
|
+
bodyClass: "combined",
|
|
803
|
+
body: [
|
|
804
|
+
`<main class="combined-grid">`,
|
|
805
|
+
`<section><h1>Mobile preview</h1>${linkedInCardHtml(input.postText, input.mobile)}</section>`,
|
|
806
|
+
`<section><h1>Desktop preview</h1>${linkedInCardHtml(input.postText, input.desktop)}</section>`,
|
|
807
|
+
`</main>`,
|
|
808
|
+
].join("\n"),
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
function htmlDocument(input) {
|
|
812
|
+
return [
|
|
813
|
+
"<!doctype html>",
|
|
814
|
+
'<html lang="en">',
|
|
815
|
+
"<head>",
|
|
816
|
+
'<meta charset="utf-8">',
|
|
817
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
818
|
+
`<title>${escapeHtml(input.title)}</title>`,
|
|
819
|
+
"<style>",
|
|
820
|
+
linkedinPreviewCss(),
|
|
821
|
+
"</style>",
|
|
822
|
+
"</head>",
|
|
823
|
+
`<body class="${escapeHtml(input.bodyClass)}">`,
|
|
824
|
+
input.body,
|
|
825
|
+
"</body>",
|
|
826
|
+
"</html>",
|
|
827
|
+
"",
|
|
828
|
+
].join("\n");
|
|
829
|
+
}
|
|
830
|
+
function linkedInCardHtml(postText, surface) {
|
|
831
|
+
const escapedText = escapeHtml(postText);
|
|
832
|
+
const cardWidth = surface.textWidthPx + 32;
|
|
833
|
+
return [
|
|
834
|
+
`<article class="linkedin-card" data-surface="${surface.label}" style="width:${cardWidth}px">`,
|
|
835
|
+
'<header class="post-header" aria-hidden="true">',
|
|
836
|
+
'<div class="avatar"></div>',
|
|
837
|
+
'<div class="byline"><div class="name">Preview Author</div><div class="meta">LinkedIn preview contract</div></div>',
|
|
838
|
+
"</header>",
|
|
839
|
+
`<div class="post-text" style="width:${surface.textWidthPx}px;-webkit-line-clamp:${surface.reviewClampLines}">${escapedText}</div>`,
|
|
840
|
+
surface.estimatedLineCountBeforeClamp > surface.reviewClampLines
|
|
841
|
+
? '<div class="see-more">...see more</div>'
|
|
842
|
+
: "",
|
|
843
|
+
'<div class="post-actions" aria-hidden="true"><span>Like</span><span>Comment</span><span>Repost</span></div>',
|
|
844
|
+
"</article>",
|
|
845
|
+
].join("\n");
|
|
846
|
+
}
|
|
847
|
+
function linkedinPreviewCss() {
|
|
848
|
+
return `
|
|
849
|
+
:root {
|
|
850
|
+
color-scheme: light;
|
|
851
|
+
--linkedin-text: rgba(0, 0, 0, 0.9);
|
|
852
|
+
--linkedin-muted: rgba(0, 0, 0, 0.6);
|
|
853
|
+
--linkedin-border: #e0dfdc;
|
|
854
|
+
--linkedin-bg: #f3f2ef;
|
|
855
|
+
}
|
|
856
|
+
* { box-sizing: border-box; }
|
|
857
|
+
body {
|
|
858
|
+
margin: 0;
|
|
859
|
+
min-height: 100vh;
|
|
860
|
+
background: var(--linkedin-bg);
|
|
861
|
+
font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue",
|
|
862
|
+
"Fira Sans", Ubuntu, "Oxygen Sans", Cantarell, "Droid Sans",
|
|
863
|
+
"Lucida Grande", Helvetica, Arial, sans-serif;
|
|
864
|
+
}
|
|
865
|
+
body.mobile, body.desktop {
|
|
866
|
+
display: flex;
|
|
867
|
+
align-items: flex-start;
|
|
868
|
+
justify-content: center;
|
|
869
|
+
padding: 20px;
|
|
870
|
+
}
|
|
871
|
+
.combined-grid {
|
|
872
|
+
display: grid;
|
|
873
|
+
grid-template-columns: max-content max-content;
|
|
874
|
+
gap: 28px;
|
|
875
|
+
align-items: start;
|
|
876
|
+
padding: 20px;
|
|
877
|
+
}
|
|
878
|
+
h1 {
|
|
879
|
+
margin: 0 0 10px;
|
|
880
|
+
font-size: 14px;
|
|
881
|
+
line-height: 20px;
|
|
882
|
+
font-weight: 600;
|
|
883
|
+
color: var(--linkedin-muted);
|
|
884
|
+
}
|
|
885
|
+
.linkedin-card {
|
|
886
|
+
background: #fff;
|
|
887
|
+
border: 1px solid var(--linkedin-border);
|
|
888
|
+
border-radius: 8px;
|
|
889
|
+
padding: 12px 16px 10px;
|
|
890
|
+
color: var(--linkedin-text);
|
|
891
|
+
}
|
|
892
|
+
.post-header {
|
|
893
|
+
display: flex;
|
|
894
|
+
align-items: center;
|
|
895
|
+
gap: 8px;
|
|
896
|
+
margin-bottom: 8px;
|
|
897
|
+
}
|
|
898
|
+
.avatar {
|
|
899
|
+
width: 40px;
|
|
900
|
+
height: 40px;
|
|
901
|
+
border-radius: 50%;
|
|
902
|
+
background: linear-gradient(135deg, #d7dce0, #eef0f2);
|
|
903
|
+
}
|
|
904
|
+
.byline { min-width: 0; }
|
|
905
|
+
.name {
|
|
906
|
+
height: 16px;
|
|
907
|
+
font-size: 14px;
|
|
908
|
+
line-height: 16px;
|
|
909
|
+
font-weight: 600;
|
|
910
|
+
}
|
|
911
|
+
.meta {
|
|
912
|
+
font-size: 12px;
|
|
913
|
+
line-height: 14px;
|
|
914
|
+
color: var(--linkedin-muted);
|
|
915
|
+
}
|
|
916
|
+
.post-text {
|
|
917
|
+
font-size: 14px;
|
|
918
|
+
line-height: 21px;
|
|
919
|
+
font-weight: 400;
|
|
920
|
+
letter-spacing: normal;
|
|
921
|
+
white-space: pre-wrap;
|
|
922
|
+
overflow-wrap: break-word;
|
|
923
|
+
color: var(--linkedin-text);
|
|
924
|
+
overflow: hidden;
|
|
925
|
+
display: -webkit-box;
|
|
926
|
+
-webkit-box-orient: vertical;
|
|
927
|
+
}
|
|
928
|
+
.see-more {
|
|
929
|
+
margin-top: 2px;
|
|
930
|
+
font-size: 14px;
|
|
931
|
+
line-height: 21px;
|
|
932
|
+
color: var(--linkedin-muted);
|
|
933
|
+
}
|
|
934
|
+
.post-actions {
|
|
935
|
+
display: flex;
|
|
936
|
+
gap: 18px;
|
|
937
|
+
border-top: 1px solid var(--linkedin-border);
|
|
938
|
+
margin-top: 10px;
|
|
939
|
+
padding-top: 8px;
|
|
940
|
+
color: var(--linkedin-muted);
|
|
941
|
+
font-size: 13px;
|
|
942
|
+
line-height: 18px;
|
|
943
|
+
}
|
|
944
|
+
`;
|
|
945
|
+
}
|
|
946
|
+
function renderPreviewScreenshots(surfaces, requireScreenshots) {
|
|
947
|
+
const chromePath = findChromeExecutable();
|
|
948
|
+
if (!chromePath) {
|
|
949
|
+
if (requireScreenshots) {
|
|
950
|
+
throw new Error("Chrome executable not found. Set CHROME_PATH or install Google Chrome to render LinkedIn preview screenshots.");
|
|
951
|
+
}
|
|
952
|
+
return { status: "skipped", reason: "chrome_not_found" };
|
|
953
|
+
}
|
|
954
|
+
try {
|
|
955
|
+
for (const surface of surfaces) {
|
|
956
|
+
const screenshotPath = surface.htmlPath.replace(/\.html$/, ".png");
|
|
957
|
+
const absoluteScreenshotPath = safePath(resolveContentRoot(), screenshotPath);
|
|
958
|
+
execFileSync(chromePath, [
|
|
959
|
+
"--headless=new",
|
|
960
|
+
"--disable-gpu",
|
|
961
|
+
"--no-sandbox",
|
|
962
|
+
"--disable-dev-shm-usage",
|
|
963
|
+
"--hide-scrollbars",
|
|
964
|
+
`--screenshot=${absoluteScreenshotPath}`,
|
|
965
|
+
`--window-size=${surface.viewportWidthPx},${surface.viewportHeightPx}`,
|
|
966
|
+
pathToFileURL(surface.absoluteHtmlPath).href,
|
|
967
|
+
], { stdio: "ignore", timeout: 20000 });
|
|
968
|
+
surface.screenshotPath = screenshotPath;
|
|
969
|
+
surface.absoluteScreenshotPath = absoluteScreenshotPath;
|
|
970
|
+
}
|
|
971
|
+
return { status: "rendered", chromePath };
|
|
972
|
+
}
|
|
973
|
+
catch (error) {
|
|
974
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
975
|
+
if (requireScreenshots) {
|
|
976
|
+
throw new Error(`Chrome screenshot render failed: ${reason}`);
|
|
977
|
+
}
|
|
978
|
+
return { status: "failed", reason, chromePath };
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
function findChromeExecutable() {
|
|
982
|
+
const candidates = [
|
|
983
|
+
process.env.CHROME_PATH,
|
|
984
|
+
process.env.GOOGLE_CHROME_BIN,
|
|
985
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
986
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
987
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
988
|
+
"/usr/bin/google-chrome",
|
|
989
|
+
"/usr/bin/google-chrome-stable",
|
|
990
|
+
"/usr/bin/chromium",
|
|
991
|
+
"/usr/bin/chromium-browser",
|
|
992
|
+
"/snap/bin/chromium",
|
|
993
|
+
].filter(Boolean);
|
|
994
|
+
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
995
|
+
}
|
|
996
|
+
function surfaceRecord(surface) {
|
|
997
|
+
return {
|
|
998
|
+
textWidthPx: surface.textWidthPx,
|
|
999
|
+
viewportWidthPx: surface.viewportWidthPx,
|
|
1000
|
+
fontSizePx: surface.fontSizePx,
|
|
1001
|
+
lineHeightPx: surface.lineHeightPx,
|
|
1002
|
+
reviewClampLines: surface.reviewClampLines,
|
|
1003
|
+
estimatedVisibleTextBlock: surface.estimatedVisibleTextBlock,
|
|
1004
|
+
estimatedRenderedLines: surface.estimatedLines.slice(0, surface.reviewClampLines),
|
|
1005
|
+
estimatedLineCountBeforeClamp: surface.estimatedLineCountBeforeClamp,
|
|
1006
|
+
estimatedBlankLinesBeforeClamp: surface.estimatedBlankLinesBeforeClamp,
|
|
1007
|
+
htmlPath: surface.htmlPath,
|
|
1008
|
+
screenshotPath: surface.screenshotPath,
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function surfaceReturnRecord(surface) {
|
|
1012
|
+
return {
|
|
1013
|
+
...surfaceRecord(surface),
|
|
1014
|
+
absoluteHtmlPath: surface.absoluteHtmlPath,
|
|
1015
|
+
absoluteScreenshotPath: surface.absoluteScreenshotPath,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
function previewDiagnostics(text) {
|
|
1019
|
+
const physicalLines = text.split("\n");
|
|
1020
|
+
const nonblankLines = physicalLines.filter((line) => line.trim().length > 0);
|
|
1021
|
+
return {
|
|
1022
|
+
charCount: text.replace(/\s/g, "").length,
|
|
1023
|
+
charCountIncludingNewlines: text.length,
|
|
1024
|
+
firstLineChars: physicalLines[0]?.length ?? 0,
|
|
1025
|
+
firstTwoPhysicalLinesChars: physicalLines.slice(0, 2).join("\n").length,
|
|
1026
|
+
physicalLineCount: physicalLines.length,
|
|
1027
|
+
contentLineCount: nonblankLines.length,
|
|
1028
|
+
longestNonblankLineChars: nonblankLines.reduce((max, line) => Math.max(max, line.length), 0),
|
|
1029
|
+
blankLineCount: physicalLines.length - nonblankLines.length,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function estimateRenderedLines(text, textWidthPx) {
|
|
1033
|
+
const physicalLines = text.split("\n");
|
|
1034
|
+
const rendered = [];
|
|
1035
|
+
for (const physicalLine of physicalLines) {
|
|
1036
|
+
if (physicalLine.length === 0) {
|
|
1037
|
+
rendered.push("");
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
rendered.push(...wrapPhysicalLine(physicalLine, textWidthPx));
|
|
1041
|
+
}
|
|
1042
|
+
return rendered;
|
|
1043
|
+
}
|
|
1044
|
+
function wrapPhysicalLine(line, textWidthPx) {
|
|
1045
|
+
const tokens = line.match(/\S+\s*/g) ?? [line];
|
|
1046
|
+
const wrapped = [];
|
|
1047
|
+
let current = "";
|
|
1048
|
+
for (const token of tokens) {
|
|
1049
|
+
const candidate = `${current}${token}`;
|
|
1050
|
+
if (!current || measureTextPx(candidate) <= textWidthPx) {
|
|
1051
|
+
current = candidate;
|
|
1052
|
+
continue;
|
|
1053
|
+
}
|
|
1054
|
+
wrapped.push(current.trimEnd());
|
|
1055
|
+
if (measureTextPx(token) <= textWidthPx) {
|
|
1056
|
+
current = token;
|
|
1057
|
+
}
|
|
1058
|
+
else {
|
|
1059
|
+
const broken = breakLongToken(token.trimEnd(), textWidthPx);
|
|
1060
|
+
wrapped.push(...broken.slice(0, -1));
|
|
1061
|
+
current = broken.at(-1) ?? "";
|
|
1062
|
+
if (token.endsWith(" "))
|
|
1063
|
+
current += " ";
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (current.length > 0)
|
|
1067
|
+
wrapped.push(current.trimEnd());
|
|
1068
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
1069
|
+
}
|
|
1070
|
+
function breakLongToken(token, textWidthPx) {
|
|
1071
|
+
const parts = [];
|
|
1072
|
+
let current = "";
|
|
1073
|
+
for (const char of [...token]) {
|
|
1074
|
+
const candidate = `${current}${char}`;
|
|
1075
|
+
if (!current || measureTextPx(candidate) <= textWidthPx) {
|
|
1076
|
+
current = candidate;
|
|
1077
|
+
}
|
|
1078
|
+
else {
|
|
1079
|
+
parts.push(current);
|
|
1080
|
+
current = char;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
if (current)
|
|
1084
|
+
parts.push(current);
|
|
1085
|
+
return parts;
|
|
1086
|
+
}
|
|
1087
|
+
function measureTextPx(value) {
|
|
1088
|
+
let width = 0;
|
|
1089
|
+
for (const char of [...value]) {
|
|
1090
|
+
width += estimateCharWidthPx(char);
|
|
1091
|
+
}
|
|
1092
|
+
return width;
|
|
1093
|
+
}
|
|
1094
|
+
function estimateCharWidthPx(char) {
|
|
1095
|
+
if (char === " ")
|
|
1096
|
+
return 3.6;
|
|
1097
|
+
if (/[ilI|.,:;!'`]/.test(char))
|
|
1098
|
+
return 3.8;
|
|
1099
|
+
if (/[fjrt()[\]{}]/.test(char))
|
|
1100
|
+
return 5.2;
|
|
1101
|
+
if (/[mwMW@#%&]/.test(char))
|
|
1102
|
+
return 10.2;
|
|
1103
|
+
if (/[A-Z]/.test(char))
|
|
1104
|
+
return 8.2;
|
|
1105
|
+
if (/[0-9]/.test(char))
|
|
1106
|
+
return 7.3;
|
|
1107
|
+
if (/[^\x00-\x7F]/.test(char))
|
|
1108
|
+
return 8.4;
|
|
1109
|
+
return 7.0;
|
|
1110
|
+
}
|
|
1111
|
+
function escapeHtml(value) {
|
|
1112
|
+
return value
|
|
1113
|
+
.replace(/&/g, "&")
|
|
1114
|
+
.replace(/</g, "<")
|
|
1115
|
+
.replace(/>/g, ">")
|
|
1116
|
+
.replace(/"/g, """)
|
|
1117
|
+
.replace(/'/g, "'");
|
|
1118
|
+
}
|
|
620
1119
|
function getArtifact(relativeDir, id, label) {
|
|
621
1120
|
const safeId = normalizeArtifactId(id, label);
|
|
622
1121
|
const relativePath = `${relativeDir}/${safeId}.md`;
|
|
@@ -810,6 +1309,11 @@ function writeArtifact(relativePath, markdown) {
|
|
|
810
1309
|
const filePath = safeWritablePath(root, relativePath);
|
|
811
1310
|
fs.writeFileSync(filePath, markdown);
|
|
812
1311
|
}
|
|
1312
|
+
function writeContentFile(relativePath, contents) {
|
|
1313
|
+
const root = resolveContentRoot();
|
|
1314
|
+
const filePath = safeWritablePath(root, relativePath);
|
|
1315
|
+
fs.writeFileSync(filePath, contents);
|
|
1316
|
+
}
|
|
813
1317
|
function safePath(root, relativePath) {
|
|
814
1318
|
validateRelativePath(relativePath);
|
|
815
1319
|
const fullPath = path.resolve(root, ...relativePath.split("/"));
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -1260,6 +1260,12 @@ export declare const allTools: ({
|
|
|
1260
1260
|
body?: undefined;
|
|
1261
1261
|
validationReceipt?: undefined;
|
|
1262
1262
|
status?: undefined;
|
|
1263
|
+
postText?: undefined;
|
|
1264
|
+
renderId?: undefined;
|
|
1265
|
+
sourceLabel?: undefined;
|
|
1266
|
+
renderScreenshots?: undefined;
|
|
1267
|
+
requireScreenshots?: undefined;
|
|
1268
|
+
reviewClampLines?: undefined;
|
|
1263
1269
|
updatedAt?: undefined;
|
|
1264
1270
|
publishUrl?: undefined;
|
|
1265
1271
|
activityId?: undefined;
|
|
@@ -1295,6 +1301,7 @@ export declare const allTools: ({
|
|
|
1295
1301
|
properties: {
|
|
1296
1302
|
draftId: {
|
|
1297
1303
|
type: string;
|
|
1304
|
+
description?: undefined;
|
|
1298
1305
|
};
|
|
1299
1306
|
ideaId: {
|
|
1300
1307
|
type: string;
|
|
@@ -1339,6 +1346,12 @@ export declare const allTools: ({
|
|
|
1339
1346
|
selectedPatterns?: undefined;
|
|
1340
1347
|
previewBudget?: undefined;
|
|
1341
1348
|
notes?: undefined;
|
|
1349
|
+
postText?: undefined;
|
|
1350
|
+
renderId?: undefined;
|
|
1351
|
+
sourceLabel?: undefined;
|
|
1352
|
+
renderScreenshots?: undefined;
|
|
1353
|
+
requireScreenshots?: undefined;
|
|
1354
|
+
reviewClampLines?: undefined;
|
|
1342
1355
|
updatedAt?: undefined;
|
|
1343
1356
|
publishUrl?: undefined;
|
|
1344
1357
|
activityId?: undefined;
|
|
@@ -1361,6 +1374,7 @@ export declare const allTools: ({
|
|
|
1361
1374
|
properties: {
|
|
1362
1375
|
draftId: {
|
|
1363
1376
|
type: string;
|
|
1377
|
+
description?: undefined;
|
|
1364
1378
|
};
|
|
1365
1379
|
hookResearchId: {
|
|
1366
1380
|
type: string;
|
|
@@ -1403,6 +1417,12 @@ export declare const allTools: ({
|
|
|
1403
1417
|
previewBudget?: undefined;
|
|
1404
1418
|
notes?: undefined;
|
|
1405
1419
|
createdAt?: undefined;
|
|
1420
|
+
postText?: undefined;
|
|
1421
|
+
renderId?: undefined;
|
|
1422
|
+
sourceLabel?: undefined;
|
|
1423
|
+
renderScreenshots?: undefined;
|
|
1424
|
+
requireScreenshots?: undefined;
|
|
1425
|
+
reviewClampLines?: undefined;
|
|
1406
1426
|
publishUrl?: undefined;
|
|
1407
1427
|
activityId?: undefined;
|
|
1408
1428
|
publishedAt?: undefined;
|
|
@@ -1424,6 +1444,7 @@ export declare const allTools: ({
|
|
|
1424
1444
|
properties: {
|
|
1425
1445
|
draftId: {
|
|
1426
1446
|
type: string;
|
|
1447
|
+
description?: undefined;
|
|
1427
1448
|
};
|
|
1428
1449
|
publishUrl: {
|
|
1429
1450
|
type: string;
|
|
@@ -1464,6 +1485,12 @@ export declare const allTools: ({
|
|
|
1464
1485
|
body?: undefined;
|
|
1465
1486
|
validationReceipt?: undefined;
|
|
1466
1487
|
status?: undefined;
|
|
1488
|
+
postText?: undefined;
|
|
1489
|
+
renderId?: undefined;
|
|
1490
|
+
sourceLabel?: undefined;
|
|
1491
|
+
renderScreenshots?: undefined;
|
|
1492
|
+
requireScreenshots?: undefined;
|
|
1493
|
+
reviewClampLines?: undefined;
|
|
1467
1494
|
updatedAt?: undefined;
|
|
1468
1495
|
publishedPostId?: undefined;
|
|
1469
1496
|
year?: undefined;
|
|
@@ -1516,6 +1543,12 @@ export declare const allTools: ({
|
|
|
1516
1543
|
body?: undefined;
|
|
1517
1544
|
validationReceipt?: undefined;
|
|
1518
1545
|
status?: undefined;
|
|
1546
|
+
postText?: undefined;
|
|
1547
|
+
renderId?: undefined;
|
|
1548
|
+
sourceLabel?: undefined;
|
|
1549
|
+
renderScreenshots?: undefined;
|
|
1550
|
+
requireScreenshots?: undefined;
|
|
1551
|
+
reviewClampLines?: undefined;
|
|
1519
1552
|
updatedAt?: undefined;
|
|
1520
1553
|
publishUrl?: undefined;
|
|
1521
1554
|
activityId?: undefined;
|
package/package.json
CHANGED
|
@@ -108,6 +108,7 @@ Use these MCP tools when available:
|
|
|
108
108
|
- `mcp__sellable__get_post_idea`
|
|
109
109
|
- `mcp__sellable__list_post_ideas`
|
|
110
110
|
- `mcp__sellable__save_hook_research`
|
|
111
|
+
- `mcp__sellable__render_linkedin_post_preview`
|
|
111
112
|
- `mcp__sellable__save_post_draft`
|
|
112
113
|
- `mcp__sellable__update_post_draft`
|
|
113
114
|
- `mcp__sellable__list_post_draft_iterations`
|
|
@@ -342,7 +343,8 @@ Visible Flow Trace
|
|
|
342
343
|
|
|
343
344
|
4. Hook Autopsies
|
|
344
345
|
- source hook:
|
|
345
|
-
- rendered/mobile preview or
|
|
346
|
+
- rendered/mobile preview artifact from `render_linkedin_post_preview` or
|
|
347
|
+
authenticated LinkedIn screenshot:
|
|
346
348
|
- see-more tension:
|
|
347
349
|
- curiosity debt:
|
|
348
350
|
- body promise:
|
|
@@ -402,7 +404,7 @@ Visible Flow Trace
|
|
|
402
404
|
|
|
403
405
|
11. Hook Lab
|
|
404
406
|
- at least 12 hooks:
|
|
405
|
-
- rendered preview
|
|
407
|
+
- rendered preview artifact from `render_linkedin_post_preview`:
|
|
406
408
|
- hook-to-body promise:
|
|
407
409
|
- score:
|
|
408
410
|
- selected hook:
|
|
@@ -829,17 +831,34 @@ Each hook must include:
|
|
|
829
831
|
|
|
830
832
|
Do not copy source wording. Copy only the structure.
|
|
831
833
|
|
|
832
|
-
Use
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
834
|
+
Use the rendered-preview contract from
|
|
835
|
+
`references/linkedin-preview-rendering.md`. LinkedIn does not publish exact
|
|
836
|
+
"see more" cutoff rules, and rendering varies by device, app version, font,
|
|
837
|
+
media, and line break. Treat character counts as diagnostics only, not as proof
|
|
838
|
+
that the hook will render before "see more."
|
|
839
|
+
|
|
840
|
+
The selected hook and top candidates must include literal mobile and desktop
|
|
841
|
+
visible blocks. Current third-party preview tools and 2026 creator references
|
|
842
|
+
generally cluster around mobile showing about 2-3 text lines and desktop showing
|
|
843
|
+
about 3-4 text lines before "see more," with media attachments sometimes showing
|
|
844
|
+
less. Blank lines and `--` separators consume visible preview lines.
|
|
845
|
+
|
|
846
|
+
Use:
|
|
847
|
+
|
|
848
|
+
- `pass`: rendered mobile preview shows the pain, proof, or curiosity by the end
|
|
849
|
+
of the first 3 rendered lines, and either the core point is visible or a
|
|
850
|
+
specific intentional open loop is visible with immediate body payoff planned
|
|
851
|
+
- `warn`: rendered mobile preview creates useful curiosity but wrapping,
|
|
852
|
+
blank-line rhythm, media risk, or one missing context word weakens it; include
|
|
853
|
+
a compact fallback
|
|
854
|
+
- `fail`: the hook's real point appears after the rendered mobile review clamp,
|
|
855
|
+
the visible open loop is vague, blank lines consume the preview before the
|
|
856
|
+
point, or desktop fit is the only reason it looks good
|
|
840
857
|
|
|
841
858
|
Desktop preview usually has more room. Still record `desktopPreviewFit`, but
|
|
842
|
-
never let desktop fit compensate for a mobile `fail`.
|
|
859
|
+
never let desktop fit compensate for a mobile `fail`. Do not tell the user "we
|
|
860
|
+
know" how LinkedIn will render unless there is an authenticated LinkedIn
|
|
861
|
+
screenshot; say it passes the renderer and show the visible blocks.
|
|
843
862
|
|
|
844
863
|
If a hook's point depends on text after the likely preview, rewrite it before
|
|
845
864
|
selecting it. A selected hook may carry a `warn` only when the warning is about
|
|
@@ -256,19 +256,33 @@ Measure the visible opening for every shortlisted source post before extracting
|
|
|
256
256
|
the hook pattern. This makes the study useful for LinkedIn, not just generally
|
|
257
257
|
"good writing."
|
|
258
258
|
|
|
259
|
-
LinkedIn does not publish exact "see more" cutoff rules. Treat
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
-
|
|
268
|
-
|
|
259
|
+
LinkedIn does not publish exact "see more" cutoff rules. Treat preview fit as a
|
|
260
|
+
rendered-line problem first, not a fixed character-count rule. Current
|
|
261
|
+
third-party preview tools and 2026 creator references generally cluster around:
|
|
262
|
+
|
|
263
|
+
- mobile feed: about 2-3 visible text lines, often around 140 characters when
|
|
264
|
+
there is no media
|
|
265
|
+
- desktop feed: about 3-4 visible text lines, often around 210 characters when
|
|
266
|
+
there is no media
|
|
267
|
+
- media posts can show fewer text lines before truncation
|
|
268
|
+
- blank lines and `--` style separators consume visible preview lines
|
|
269
|
+
|
|
270
|
+
Use `references/linkedin-preview-rendering.md` as the required gate. Character
|
|
271
|
+
budgets are diagnostics only:
|
|
272
|
+
|
|
273
|
+
- `pass`: rendered mobile preview shows the pain, proof, or curiosity by the end
|
|
274
|
+
of the first 3 rendered lines, and the hook creates a specific click reason
|
|
275
|
+
- `warn`: rendered mobile preview creates useful curiosity but loses one useful
|
|
276
|
+
context word, wraps awkwardly, or spends a visible line on blank space or a
|
|
277
|
+
separator; include a compact fallback
|
|
278
|
+
- `fail`: the hook's real point appears after the rendered mobile review clamp,
|
|
279
|
+
the visible open loop is vague, blank lines consume the preview before the
|
|
280
|
+
point, or desktop fit is the only reason it looks good
|
|
269
281
|
|
|
270
282
|
Desktop preview has more room, so record it separately, but never let desktop
|
|
271
|
-
fit compensate for a mobile `fail`.
|
|
283
|
+
fit compensate for a mobile `fail`. Never say "we know" how LinkedIn will render
|
|
284
|
+
the hook unless there is an authenticated LinkedIn screenshot. Say the hook
|
|
285
|
+
`passes the renderer` and show the mobile/desktop visible blocks.
|
|
272
286
|
|
|
273
287
|
For each source, record:
|
|
274
288
|
|
|
@@ -282,6 +296,8 @@ For each source, record:
|
|
|
282
296
|
- `firstTwoContentLinesChars`
|
|
283
297
|
- `longestNonblankLineChars`
|
|
284
298
|
- `blankLineCountBeforeFold`
|
|
299
|
+
- `renderedPreview`: literal mobile and desktop visible blocks from
|
|
300
|
+
`references/linkedin-preview-rendering.md`
|
|
285
301
|
- `mobilePreviewBudget`: `pass`, `warn`, or `fail`
|
|
286
302
|
- `desktopPreviewBudget`: `pass`, `warn`, or `fail`
|
|
287
303
|
- `blankLineVisualRisk`
|
|
@@ -5,8 +5,14 @@ research, hook candidate generation, gold-standard decomposition, and draft
|
|
|
5
5
|
validation.
|
|
6
6
|
|
|
7
7
|
Character budgets are only diagnostics. They are not the preview gate. A hook is
|
|
8
|
-
not studied, selected, or ready until it has been rendered through
|
|
9
|
-
or through a stricter
|
|
8
|
+
not studied, selected, or ready until it has been rendered through
|
|
9
|
+
`mcp__sellable__render_linkedin_post_preview` or through a stricter
|
|
10
|
+
authenticated LinkedIn screenshot.
|
|
11
|
+
|
|
12
|
+
Do not let the LLM imagine wrapping. Character counts and hand-written line
|
|
13
|
+
blocks are only fallback diagnostics. The normal path is tool-rendered mobile
|
|
14
|
+
and desktop artifacts: HTML always, and PNG screenshots whenever local Chrome is
|
|
15
|
+
available.
|
|
10
16
|
|
|
11
17
|
## Rendering Basis
|
|
12
18
|
|
|
@@ -14,6 +20,38 @@ LinkedIn does not publish exact feed truncation rules, and rendering can vary by
|
|
|
14
20
|
surface, app version, device, media attachment, and account state. Use this
|
|
15
21
|
deterministic renderer as the MCP review gate for unpublished drafts.
|
|
16
22
|
|
|
23
|
+
Call:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
mcp__sellable__render_linkedin_post_preview({
|
|
27
|
+
"postText": "<full post text or candidate hook block>",
|
|
28
|
+
"sourceLabel": "<draft id, hook id, or source label>",
|
|
29
|
+
"renderScreenshots": true
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
When screenshots are produced, the PNG files are the review artifact. When
|
|
34
|
+
screenshots cannot be produced, the HTML artifact is still deterministic and
|
|
35
|
+
must be opened or explicitly marked as lower confidence.
|
|
36
|
+
|
|
37
|
+
Treat "see more" as a rendered-line problem first and a character-count problem
|
|
38
|
+
second. Current third-party preview tools and 2026 creator references generally
|
|
39
|
+
cluster around these working assumptions:
|
|
40
|
+
|
|
41
|
+
- mobile feed: about 2-3 visible text lines, often around 140 characters when
|
|
42
|
+
there is no media
|
|
43
|
+
- desktop feed: about 3-4 visible text lines, often around 210 characters when
|
|
44
|
+
there is no media
|
|
45
|
+
- posts with media may show fewer text lines before truncation, sometimes only
|
|
46
|
+
1-2 lines
|
|
47
|
+
- blank lines and intentional separators consume visible preview lines
|
|
48
|
+
- device width, font scaling, app version, browser, and profile/page context can
|
|
49
|
+
move the cutoff
|
|
50
|
+
|
|
51
|
+
Because of that variation, never say a hook is guaranteed to render before
|
|
52
|
+
"see more." Say `pass`, `warn`, or `fail` under the rendered-preview contract,
|
|
53
|
+
and show the visible mobile and desktop blocks.
|
|
54
|
+
|
|
17
55
|
When an authenticated LinkedIn feed/composer/browser screenshot is available,
|
|
18
56
|
that screenshot is the strongest evidence. Still record the fields below so
|
|
19
57
|
future agents can compare candidates without redoing the visual inspection.
|
|
@@ -48,13 +86,13 @@ selected hook must include a `renderedPreview` record:
|
|
|
48
86
|
|
|
49
87
|
```text
|
|
50
88
|
renderedPreview:
|
|
51
|
-
basis:
|
|
89
|
+
basis: local_chrome_headless_screenshot | linkedin_css_contract_html | authenticated_linkedin_screenshot | manual_user_source
|
|
52
90
|
cssContractVersion: linkedin-preview-rendering/v1
|
|
53
91
|
mobile:
|
|
54
92
|
textWidthPx: 308
|
|
55
93
|
fontSizePx: 14
|
|
56
94
|
lineHeightPx: 21
|
|
57
|
-
visibleTextBlock: <literal first rendered review-clamp block>
|
|
95
|
+
visibleTextBlock: <literal first rendered review-clamp block or tool estimated block when screenshot is the authority>
|
|
58
96
|
renderedLines:
|
|
59
97
|
- <line 1 exactly as wrapped>
|
|
60
98
|
- <line 2 exactly as wrapped>
|
|
@@ -68,7 +106,8 @@ renderedPreview:
|
|
|
68
106
|
payoffPlannedImmediatelyAfterClamp: true | false
|
|
69
107
|
seeMoreClickReason: <why a reader would click see more>
|
|
70
108
|
seeMoreRisk: pass | warn | fail
|
|
71
|
-
|
|
109
|
+
htmlPath: <local path>
|
|
110
|
+
screenshotPath: <local path when Chrome rendered PNG>
|
|
72
111
|
desktop:
|
|
73
112
|
textWidthPx: 582
|
|
74
113
|
fontSizePx: 14
|
|
@@ -83,7 +122,8 @@ renderedPreview:
|
|
|
83
122
|
corePainProofOrCuriosityVisible: true | false
|
|
84
123
|
corePointVisible: true | false
|
|
85
124
|
seeMoreRisk: pass | warn | fail
|
|
86
|
-
|
|
125
|
+
htmlPath: <local path>
|
|
126
|
+
screenshotPath: <local path when Chrome rendered PNG>
|
|
87
127
|
diagnostics:
|
|
88
128
|
charCount: <number>
|
|
89
129
|
charCountIncludingNewlines: <number>
|
|
@@ -96,10 +136,11 @@ renderedPreview:
|
|
|
96
136
|
rewriteIfTruncated: <short fallback>
|
|
97
137
|
```
|
|
98
138
|
|
|
99
|
-
If a host cannot produce screenshots, it must still produce
|
|
100
|
-
|
|
101
|
-
literal line
|
|
102
|
-
passed preview validation from character
|
|
139
|
+
If a host cannot produce screenshots, it must still produce and preserve the
|
|
140
|
+
HTML artifacts from `render_linkedin_post_preview`. If it cannot produce
|
|
141
|
+
screenshots, HTML artifacts, or literal wrapped line blocks, return `blocked` or
|
|
142
|
+
`needs_revision`; do not claim the hook passed preview validation from character
|
|
143
|
+
counts alone.
|
|
103
144
|
|
|
104
145
|
## Study Rules
|
|
105
146
|
|
|
@@ -320,22 +320,38 @@ proof from another creator, save as `needs_revision`.
|
|
|
320
320
|
|
|
321
321
|
## LinkedIn Preview Audit
|
|
322
322
|
|
|
323
|
-
Audit the selected hook and top candidates against
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
is a mobile-first safety gate, not a claim about an
|
|
323
|
+
Audit the selected hook and top candidates against the rendered-preview
|
|
324
|
+
contract. LinkedIn does not publish exact "see more" cutoff rules, and rendering
|
|
325
|
+
varies by device, app version, font, media, line break, and whether the post has
|
|
326
|
+
media attached. This audit is a mobile-first safety gate, not a claim about an
|
|
327
|
+
official LinkedIn limit.
|
|
328
|
+
|
|
329
|
+
Treat character counts as diagnostics only. The gate is the literal rendered
|
|
330
|
+
mobile and desktop visible block from `references/linkedin-preview-rendering.md`
|
|
331
|
+
or an authenticated LinkedIn screenshot.
|
|
327
332
|
|
|
328
333
|
Use:
|
|
329
334
|
|
|
330
|
-
- `pass`:
|
|
331
|
-
|
|
332
|
-
|
|
335
|
+
- `pass`: the rendered mobile preview shows the pain, proof, or curiosity by the
|
|
336
|
+
end of the first 3 rendered lines, and either the core point is visible or a
|
|
337
|
+
specific intentional open loop is visible with immediate body payoff planned
|
|
338
|
+
- `warn`: the rendered mobile preview creates useful curiosity but wrapping,
|
|
339
|
+
blank-line rhythm, media risk, or one missing context word weakens it; include
|
|
340
|
+
a compact fallback
|
|
341
|
+
- `fail`: the hook's real point appears after the rendered mobile review clamp,
|
|
342
|
+
the visible open loop is vague, blank lines consume the preview before the
|
|
343
|
+
point, or desktop fit is the only reason it looks good
|
|
333
344
|
|
|
334
345
|
Desktop preview usually has more room. Still record desktop fit, but never let
|
|
335
346
|
desktop fit compensate for a mobile `fail`.
|
|
336
347
|
|
|
337
348
|
Record:
|
|
338
349
|
|
|
350
|
+
- `renderedPreview`
|
|
351
|
+
- `renderedPreviewBasis`: `linkedin_css_contract` |
|
|
352
|
+
`authenticated_linkedin_screenshot` | `manual_user_source`
|
|
353
|
+
- literal mobile visible block
|
|
354
|
+
- literal desktop visible block
|
|
339
355
|
- `charCount`
|
|
340
356
|
- `charCountIncludingNewlines`
|
|
341
357
|
- `firstLineChars`
|
|
@@ -355,9 +371,11 @@ Record:
|
|
|
355
371
|
- `compactFallback` when `previewBudgetStatus` is `warn`
|
|
356
372
|
|
|
357
373
|
If the hook only works after likely truncation, rewrite it. A draft cannot be
|
|
358
|
-
`ready` with `previewBudgetStatus: fail
|
|
374
|
+
`ready` with `previewBudgetStatus: fail`, with no rendered preview block, or
|
|
375
|
+
with only a character-count claim. A draft may be `ready` with
|
|
359
376
|
`previewBudgetStatus: warn` only when the warning is explicit, usually because
|
|
360
|
-
the user prefers blank-line rhythm,
|
|
377
|
+
the user prefers blank-line rhythm, separators, or a deliberate open loop, and
|
|
378
|
+
the receipt includes a compact fallback.
|
|
361
379
|
|
|
362
380
|
## Simplifier / Concrete-Language Audit
|
|
363
381
|
|