@sellable/mcp 0.1.263 → 0.1.265

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 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, renderLinkedInPostPreviewTool, saveHookResearchTool, savePostDraftTool, updatePostDraftTool, updatePublishedPostMetricsTool, } from "./tools/content-posts.js";
12
+ import { calculateLinkedInHookPreviewTool, 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 "calculate_linkedin_hook_preview":
248
+ result = calculateLinkedInHookPreviewTool(args);
249
+ break;
247
250
  case "render_linkedin_post_preview":
248
251
  result = renderLinkedInPostPreviewTool(args);
249
252
  break;
@@ -95,8 +95,15 @@ export declare const contentPostToolDefinitions: ({
95
95
  validationReceipt?: undefined;
96
96
  status?: undefined;
97
97
  postText?: undefined;
98
- renderId?: undefined;
99
98
  sourceLabel?: undefined;
99
+ measurementMode?: undefined;
100
+ requireBrowserMeasurement?: undefined;
101
+ clampLines?: undefined;
102
+ mobileClampLines?: undefined;
103
+ desktopClampLines?: undefined;
104
+ mobileTextWidthPx?: undefined;
105
+ desktopTextWidthPx?: undefined;
106
+ renderId?: undefined;
100
107
  renderScreenshots?: undefined;
101
108
  requireScreenshots?: undefined;
102
109
  reviewClampLines?: undefined;
@@ -200,8 +207,15 @@ export declare const contentPostToolDefinitions: ({
200
207
  validationReceipt?: undefined;
201
208
  status?: undefined;
202
209
  postText?: undefined;
203
- renderId?: undefined;
204
210
  sourceLabel?: undefined;
211
+ measurementMode?: undefined;
212
+ requireBrowserMeasurement?: undefined;
213
+ clampLines?: undefined;
214
+ mobileClampLines?: undefined;
215
+ desktopClampLines?: undefined;
216
+ mobileTextWidthPx?: undefined;
217
+ desktopTextWidthPx?: undefined;
218
+ renderId?: undefined;
205
219
  renderScreenshots?: undefined;
206
220
  requireScreenshots?: undefined;
207
221
  reviewClampLines?: undefined;
@@ -273,8 +287,15 @@ export declare const contentPostToolDefinitions: ({
273
287
  previewBudget?: undefined;
274
288
  notes?: undefined;
275
289
  postText?: undefined;
276
- renderId?: undefined;
277
290
  sourceLabel?: undefined;
291
+ measurementMode?: undefined;
292
+ requireBrowserMeasurement?: undefined;
293
+ clampLines?: undefined;
294
+ mobileClampLines?: undefined;
295
+ desktopClampLines?: undefined;
296
+ mobileTextWidthPx?: undefined;
297
+ desktopTextWidthPx?: undefined;
298
+ renderId?: undefined;
278
299
  renderScreenshots?: undefined;
279
300
  requireScreenshots?: undefined;
280
301
  reviewClampLines?: undefined;
@@ -292,6 +313,91 @@ export declare const contentPostToolDefinitions: ({
292
313
  required: string[];
293
314
  additionalProperties: boolean;
294
315
  };
316
+ } | {
317
+ name: string;
318
+ description: string;
319
+ inputSchema: {
320
+ type: string;
321
+ properties: {
322
+ postText: {
323
+ type: string;
324
+ description: string;
325
+ };
326
+ draftId: {
327
+ type: string;
328
+ description: string;
329
+ };
330
+ sourceLabel: {
331
+ type: string;
332
+ };
333
+ measurementMode: {
334
+ type: string;
335
+ enum: string[];
336
+ description: string;
337
+ };
338
+ requireBrowserMeasurement: {
339
+ type: string;
340
+ description: string;
341
+ };
342
+ clampLines: {
343
+ type: string;
344
+ description: string;
345
+ };
346
+ mobileClampLines: {
347
+ type: string;
348
+ description: string;
349
+ };
350
+ desktopClampLines: {
351
+ type: string;
352
+ description: string;
353
+ };
354
+ mobileTextWidthPx: {
355
+ type: string;
356
+ description: string;
357
+ };
358
+ desktopTextWidthPx: {
359
+ type: string;
360
+ description: string;
361
+ };
362
+ rawSource?: undefined;
363
+ title?: undefined;
364
+ distilledBrief?: undefined;
365
+ ideaId?: undefined;
366
+ sourceType?: undefined;
367
+ sourceUrl?: undefined;
368
+ capturedAt?: undefined;
369
+ researchId?: undefined;
370
+ topic?: undefined;
371
+ keywords?: undefined;
372
+ sourcePosts?: undefined;
373
+ selectedPatterns?: undefined;
374
+ previewBudget?: undefined;
375
+ notes?: undefined;
376
+ createdAt?: undefined;
377
+ hookResearchId?: undefined;
378
+ priorDraftId?: undefined;
379
+ iteration?: undefined;
380
+ body?: undefined;
381
+ validationReceipt?: undefined;
382
+ status?: undefined;
383
+ renderId?: undefined;
384
+ renderScreenshots?: undefined;
385
+ requireScreenshots?: undefined;
386
+ reviewClampLines?: undefined;
387
+ updatedAt?: undefined;
388
+ publishUrl?: undefined;
389
+ activityId?: undefined;
390
+ publishedAt?: undefined;
391
+ finalText?: undefined;
392
+ updateDraftStatus?: undefined;
393
+ publishedPostId?: undefined;
394
+ year?: undefined;
395
+ metrics?: undefined;
396
+ note?: undefined;
397
+ };
398
+ required: never[];
399
+ additionalProperties: boolean;
400
+ };
295
401
  } | {
296
402
  name: string;
297
403
  description: string;
@@ -348,6 +454,13 @@ export declare const contentPostToolDefinitions: ({
348
454
  body?: undefined;
349
455
  validationReceipt?: undefined;
350
456
  status?: undefined;
457
+ measurementMode?: undefined;
458
+ requireBrowserMeasurement?: undefined;
459
+ clampLines?: undefined;
460
+ mobileClampLines?: undefined;
461
+ desktopClampLines?: undefined;
462
+ mobileTextWidthPx?: undefined;
463
+ desktopTextWidthPx?: undefined;
351
464
  updatedAt?: undefined;
352
465
  publishUrl?: undefined;
353
466
  activityId?: undefined;
@@ -414,8 +527,15 @@ export declare const contentPostToolDefinitions: ({
414
527
  notes?: undefined;
415
528
  createdAt?: undefined;
416
529
  postText?: undefined;
417
- renderId?: undefined;
418
530
  sourceLabel?: undefined;
531
+ measurementMode?: undefined;
532
+ requireBrowserMeasurement?: undefined;
533
+ clampLines?: undefined;
534
+ mobileClampLines?: undefined;
535
+ desktopClampLines?: undefined;
536
+ mobileTextWidthPx?: undefined;
537
+ desktopTextWidthPx?: undefined;
538
+ renderId?: undefined;
419
539
  renderScreenshots?: undefined;
420
540
  requireScreenshots?: undefined;
421
541
  reviewClampLines?: undefined;
@@ -482,8 +602,15 @@ export declare const contentPostToolDefinitions: ({
482
602
  validationReceipt?: undefined;
483
603
  status?: undefined;
484
604
  postText?: undefined;
485
- renderId?: undefined;
486
605
  sourceLabel?: undefined;
606
+ measurementMode?: undefined;
607
+ requireBrowserMeasurement?: undefined;
608
+ clampLines?: undefined;
609
+ mobileClampLines?: undefined;
610
+ desktopClampLines?: undefined;
611
+ mobileTextWidthPx?: undefined;
612
+ desktopTextWidthPx?: undefined;
613
+ renderId?: undefined;
487
614
  renderScreenshots?: undefined;
488
615
  requireScreenshots?: undefined;
489
616
  reviewClampLines?: undefined;
@@ -540,8 +667,15 @@ export declare const contentPostToolDefinitions: ({
540
667
  validationReceipt?: undefined;
541
668
  status?: undefined;
542
669
  postText?: undefined;
543
- renderId?: undefined;
544
670
  sourceLabel?: undefined;
671
+ measurementMode?: undefined;
672
+ requireBrowserMeasurement?: undefined;
673
+ clampLines?: undefined;
674
+ mobileClampLines?: undefined;
675
+ desktopClampLines?: undefined;
676
+ mobileTextWidthPx?: undefined;
677
+ desktopTextWidthPx?: undefined;
678
+ renderId?: undefined;
545
679
  renderScreenshots?: undefined;
546
680
  requireScreenshots?: undefined;
547
681
  reviewClampLines?: undefined;
@@ -639,6 +773,71 @@ export declare function savePostDraftTool(input: {
639
773
  updatedAt: string;
640
774
  preview: string;
641
775
  };
776
+ type LinkedInHookPreviewSurface = {
777
+ label: "mobile" | "desktop";
778
+ textWidthPx: number;
779
+ fontSizePx: number;
780
+ lineHeightPx: number;
781
+ clampLines: number;
782
+ charGuardrail: number;
783
+ renderedLines: string[];
784
+ visibleLines: string[];
785
+ visibleTextBlock: string;
786
+ visibleTextBlockCharCount: number;
787
+ withinCharGuardrail: boolean;
788
+ lineCountBeforeClamp: number;
789
+ blankLinesBeforeClamp: number;
790
+ truncated: boolean;
791
+ seeMoreWouldShow: boolean;
792
+ hiddenTextStartsWith?: string;
793
+ measurementBasis: "local_chrome_browser_layout" | "estimated_width_model";
794
+ measurementWarning?: string;
795
+ chromePath?: string;
796
+ };
797
+ export declare function calculateLinkedInHookPreviewTool(input: {
798
+ postText?: string;
799
+ draftId?: string;
800
+ sourceLabel?: string;
801
+ measurementMode?: "browser" | "estimated";
802
+ requireBrowserMeasurement?: boolean;
803
+ clampLines?: number;
804
+ mobileClampLines?: number;
805
+ desktopClampLines?: number;
806
+ mobileTextWidthPx?: number;
807
+ desktopTextWidthPx?: number;
808
+ }): {
809
+ basis: string;
810
+ ruleConfidence: string;
811
+ cssContractVersion: string;
812
+ sourceLabel: string | undefined;
813
+ draftId: string | undefined;
814
+ ruleSummary: string;
815
+ rules: {
816
+ visibleUnit: string;
817
+ defaultClampLines: number;
818
+ fontSizePx: number;
819
+ lineHeightPx: number;
820
+ whiteSpace: string;
821
+ overflowWrap: string;
822
+ desktopCharGuardrail: number;
823
+ mobileCharGuardrail: number;
824
+ characterCountsAreGuardrailsOnly: boolean;
825
+ notOfficialLinkedInApi: boolean;
826
+ strongestEvidence: string;
827
+ };
828
+ mobile: LinkedInHookPreviewSurface;
829
+ desktop: LinkedInHookPreviewSurface;
830
+ diagnostics: {
831
+ charCount: number;
832
+ charCountIncludingNewlines: number;
833
+ firstLineChars: number;
834
+ firstTwoPhysicalLinesChars: number;
835
+ physicalLineCount: number;
836
+ contentLineCount: number;
837
+ longestNonblankLineChars: number;
838
+ blankLineCount: number;
839
+ };
840
+ };
642
841
  export declare function renderLinkedInPostPreviewTool(input: {
643
842
  postText?: string;
644
843
  draftId?: string;
@@ -5,6 +5,24 @@ import { execFileSync } from "node:child_process";
5
5
  import { pathToFileURL } from "node:url";
6
6
  const CONTENT_ROOT_ENV = "SELLABLE_CONTENT_DIR";
7
7
  const DEFAULT_PREVIEW_CHARS = 220;
8
+ const LINKEDIN_PREVIEW_CONTRACT_VERSION = "linkedin-preview-rendering/v2";
9
+ const LINKEDIN_PREVIEW_FONT_SIZE_PX = 14;
10
+ const LINKEDIN_PREVIEW_LINE_HEIGHT_PX = 21;
11
+ const LINKEDIN_DEFAULT_CLAMP_LINES = 3;
12
+ const LINKEDIN_MOBILE_CHAR_GUARDRAIL = 140;
13
+ const LINKEDIN_DESKTOP_CHAR_GUARDRAIL = 210;
14
+ const LINKEDIN_PREVIEW_SURFACES = {
15
+ mobile: {
16
+ textWidthPx: 308,
17
+ viewportWidthPx: 390,
18
+ viewportHeightPx: 760,
19
+ },
20
+ desktop: {
21
+ textWidthPx: 582,
22
+ viewportWidthPx: 744,
23
+ viewportHeightPx: 760,
24
+ },
25
+ };
8
26
  const RELATIVE_DIRS = {
9
27
  ideas: "linkedin/ideas",
10
28
  hookResearch: "linkedin/research/hooks",
@@ -107,6 +125,55 @@ export const contentPostToolDefinitions = [
107
125
  additionalProperties: false,
108
126
  },
109
127
  },
128
+ {
129
+ name: "calculate_linkedin_hook_preview",
130
+ description: "Functionally calculate the LinkedIn pre-see-more hook for mobile and desktop. Returns rendered visible lines and rule diagnostics without writing files.",
131
+ inputSchema: {
132
+ type: "object",
133
+ properties: {
134
+ postText: {
135
+ type: "string",
136
+ description: "Full post text or hook block to calculate. Provide either postText or draftId.",
137
+ },
138
+ draftId: {
139
+ type: "string",
140
+ description: "Optional saved draft ID. When postText is omitted, the tool calculates this draft's Draft Body.",
141
+ },
142
+ sourceLabel: { type: "string" },
143
+ measurementMode: {
144
+ type: "string",
145
+ enum: ["browser", "estimated"],
146
+ description: "Defaults to browser. Browser mode uses local Chrome layout measurement when available; estimated mode uses the deterministic width model.",
147
+ },
148
+ requireBrowserMeasurement: {
149
+ type: "boolean",
150
+ description: "Defaults to false. When true, fail if local Chrome cannot measure rendered lines.",
151
+ },
152
+ clampLines: {
153
+ type: "number",
154
+ description: "Visible rendered lines before see-more for both surfaces. Defaults to 3.",
155
+ },
156
+ mobileClampLines: {
157
+ type: "number",
158
+ description: "Optional mobile-specific visible rendered line count. Defaults to clampLines.",
159
+ },
160
+ desktopClampLines: {
161
+ type: "number",
162
+ description: "Optional desktop-specific visible rendered line count. Defaults to clampLines.",
163
+ },
164
+ mobileTextWidthPx: {
165
+ type: "number",
166
+ description: "Optional mobile text width override. Defaults to 308px.",
167
+ },
168
+ desktopTextWidthPx: {
169
+ type: "number",
170
+ description: "Optional desktop text width override. Defaults to 582px.",
171
+ },
172
+ },
173
+ required: [],
174
+ additionalProperties: false,
175
+ },
176
+ },
110
177
  {
111
178
  name: "render_linkedin_post_preview",
112
179
  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.",
@@ -429,16 +496,54 @@ export function savePostDraftTool(input) {
429
496
  preview: sanitizedPreview(input.body),
430
497
  };
431
498
  }
499
+ export function calculateLinkedInHookPreviewTool(input) {
500
+ const { postText, draftId } = resolveLinkedInPreviewPostText(input);
501
+ const defaultClampLines = boundedWholeNumber(input.clampLines, LINKEDIN_DEFAULT_CLAMP_LINES, 1, 8);
502
+ const measurementMode = input.measurementMode ?? "browser";
503
+ const mobile = calculateLinkedInHookPreviewSurface({
504
+ label: "mobile",
505
+ text: postText,
506
+ textWidthPx: boundedWholeNumber(input.mobileTextWidthPx, LINKEDIN_PREVIEW_SURFACES.mobile.textWidthPx, 160, 900),
507
+ clampLines: boundedWholeNumber(input.mobileClampLines, defaultClampLines, 1, 8),
508
+ measurementMode,
509
+ requireBrowserMeasurement: input.requireBrowserMeasurement,
510
+ });
511
+ const desktop = calculateLinkedInHookPreviewSurface({
512
+ label: "desktop",
513
+ text: postText,
514
+ textWidthPx: boundedWholeNumber(input.desktopTextWidthPx, LINKEDIN_PREVIEW_SURFACES.desktop.textWidthPx, 260, 1200),
515
+ clampLines: boundedWholeNumber(input.desktopClampLines, defaultClampLines, 1, 8),
516
+ measurementMode,
517
+ requireBrowserMeasurement: input.requireBrowserMeasurement,
518
+ });
519
+ return {
520
+ basis: "linkedin_rendering_rule_function",
521
+ ruleConfidence: "calibrated_from_observed_linkedin_feed_screenshots_and_public_line_clamp_behavior",
522
+ cssContractVersion: LINKEDIN_PREVIEW_CONTRACT_VERSION,
523
+ sourceLabel: input.sourceLabel || draftId,
524
+ draftId,
525
+ ruleSummary: "The hook is the text visible before see more. The review gate is rendered line count, not a fixed character count. Blank lines consume visible lines.",
526
+ rules: {
527
+ visibleUnit: "rendered_visual_line",
528
+ defaultClampLines: LINKEDIN_DEFAULT_CLAMP_LINES,
529
+ fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
530
+ lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
531
+ whiteSpace: "pre-wrap",
532
+ overflowWrap: "break-word",
533
+ desktopCharGuardrail: LINKEDIN_DESKTOP_CHAR_GUARDRAIL,
534
+ mobileCharGuardrail: LINKEDIN_MOBILE_CHAR_GUARDRAIL,
535
+ characterCountsAreGuardrailsOnly: true,
536
+ notOfficialLinkedInApi: true,
537
+ strongestEvidence: "authenticated LinkedIn feed screenshot on the target surface",
538
+ },
539
+ mobile,
540
+ desktop,
541
+ diagnostics: previewDiagnostics(postText),
542
+ };
543
+ }
432
544
  export function renderLinkedInPostPreviewTool(input) {
433
545
  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");
546
+ const { postText, draftId } = resolveLinkedInPreviewPostText(input);
442
547
  const reviewClampLines = typeof input.reviewClampLines === "number" &&
443
548
  Number.isFinite(input.reviewClampLines)
444
549
  ? Math.max(1, Math.min(Math.floor(input.reviewClampLines), 8))
@@ -453,18 +558,18 @@ export function renderLinkedInPostPreviewTool(input) {
453
558
  label: "mobile",
454
559
  text: postText,
455
560
  baseDir,
456
- textWidthPx: 308,
457
- viewportWidthPx: 390,
458
- viewportHeightPx: 760,
561
+ textWidthPx: LINKEDIN_PREVIEW_SURFACES.mobile.textWidthPx,
562
+ viewportWidthPx: LINKEDIN_PREVIEW_SURFACES.mobile.viewportWidthPx,
563
+ viewportHeightPx: LINKEDIN_PREVIEW_SURFACES.mobile.viewportHeightPx,
459
564
  reviewClampLines,
460
565
  });
461
566
  const desktop = buildPreviewSurface({
462
567
  label: "desktop",
463
568
  text: postText,
464
569
  baseDir,
465
- textWidthPx: 582,
466
- viewportWidthPx: 744,
467
- viewportHeightPx: 760,
570
+ textWidthPx: LINKEDIN_PREVIEW_SURFACES.desktop.textWidthPx,
571
+ viewportWidthPx: LINKEDIN_PREVIEW_SURFACES.desktop.viewportWidthPx,
572
+ viewportHeightPx: LINKEDIN_PREVIEW_SURFACES.desktop.viewportHeightPx,
468
573
  reviewClampLines,
469
574
  });
470
575
  const combinedHtmlRelativePath = `${baseDir}/preview.html`;
@@ -491,7 +596,7 @@ export function renderLinkedInPostPreviewTool(input) {
491
596
  basis: screenshotResult.status === "rendered"
492
597
  ? "local_chrome_headless_screenshot"
493
598
  : "linkedin_css_contract_html",
494
- cssContractVersion: "linkedin-preview-rendering/v1",
599
+ cssContractVersion: LINKEDIN_PREVIEW_CONTRACT_VERSION,
495
600
  generatedAt: now,
496
601
  sourceLabel: title,
497
602
  mobile: surfaceRecord(mobile),
@@ -760,6 +865,199 @@ export function listPublishedPostsTool(input) {
760
865
  }
761
866
  return summarizeArtifacts(artifacts, input?.limit);
762
867
  }
868
+ function resolveLinkedInPreviewPostText(input) {
869
+ const draftId = input.draftId
870
+ ? normalizeArtifactId(input.draftId, "draftId")
871
+ : undefined;
872
+ const draftBody = draftId
873
+ ? extractMarkdownSection(getArtifact(RELATIVE_DIRS.drafts, draftId, "draftId").markdown, "Draft Body")
874
+ : undefined;
875
+ const postText = input.postText ?? draftBody;
876
+ requireString(postText, "postText");
877
+ return { postText, draftId };
878
+ }
879
+ function boundedWholeNumber(value, fallback, min, max) {
880
+ if (typeof value !== "number" || !Number.isFinite(value))
881
+ return fallback;
882
+ return Math.max(min, Math.min(Math.floor(value), max));
883
+ }
884
+ function calculateLinkedInHookPreviewSurface(input) {
885
+ const measurement = measureLinkedInRenderedLines({
886
+ text: input.text,
887
+ textWidthPx: input.textWidthPx,
888
+ measurementMode: input.measurementMode,
889
+ requireBrowserMeasurement: input.requireBrowserMeasurement,
890
+ });
891
+ const visibleLines = measurement.lines.slice(0, input.clampLines);
892
+ const hiddenLines = measurement.lines.slice(input.clampLines);
893
+ const visibleTextBlock = visibleLines.join("\n");
894
+ const charGuardrail = input.label === "mobile"
895
+ ? LINKEDIN_MOBILE_CHAR_GUARDRAIL
896
+ : LINKEDIN_DESKTOP_CHAR_GUARDRAIL;
897
+ return {
898
+ label: input.label,
899
+ textWidthPx: input.textWidthPx,
900
+ fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
901
+ lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
902
+ clampLines: input.clampLines,
903
+ charGuardrail,
904
+ renderedLines: measurement.lines,
905
+ visibleLines,
906
+ visibleTextBlock,
907
+ visibleTextBlockCharCount: visibleTextBlock.length,
908
+ withinCharGuardrail: visibleTextBlock.length <= charGuardrail,
909
+ lineCountBeforeClamp: measurement.lines.length,
910
+ blankLinesBeforeClamp: visibleLines.filter((line) => line.trim().length === 0).length,
911
+ truncated: hiddenLines.length > 0,
912
+ seeMoreWouldShow: hiddenLines.length > 0,
913
+ hiddenTextStartsWith: hiddenLines.find((line) => line.trim().length > 0),
914
+ measurementBasis: measurement.basis,
915
+ measurementWarning: measurement.warning,
916
+ chromePath: measurement.chromePath,
917
+ };
918
+ }
919
+ function measureLinkedInRenderedLines(input) {
920
+ if (input.measurementMode === "browser") {
921
+ const browserMeasurement = measureLinkedInRenderedLinesWithChrome(input.text, input.textWidthPx);
922
+ if (browserMeasurement)
923
+ return browserMeasurement;
924
+ if (input.requireBrowserMeasurement) {
925
+ throw new Error("Chrome executable not found or browser line measurement failed. Set CHROME_PATH or use measurementMode=estimated.");
926
+ }
927
+ }
928
+ return {
929
+ lines: estimateRenderedLines(input.text, input.textWidthPx),
930
+ basis: "estimated_width_model",
931
+ warning: input.measurementMode === "browser"
932
+ ? "local Chrome measurement unavailable; used deterministic width estimate"
933
+ : undefined,
934
+ };
935
+ }
936
+ function measureLinkedInRenderedLinesWithChrome(text, textWidthPx) {
937
+ const chromePath = findChromeExecutable();
938
+ if (!chromePath)
939
+ return null;
940
+ const htmlPath = path.join(os.tmpdir(), `sellable-linkedin-hook-preview-${process.pid}-${Date.now()}-${Math.random()
941
+ .toString(36)
942
+ .slice(2)}.html`);
943
+ try {
944
+ fs.writeFileSync(htmlPath, buildLineMeasurementHtml(text, textWidthPx), "utf8");
945
+ const dom = execFileSync(chromePath, [
946
+ "--headless=new",
947
+ "--disable-gpu",
948
+ "--no-sandbox",
949
+ "--disable-dev-shm-usage",
950
+ "--hide-scrollbars",
951
+ "--dump-dom",
952
+ pathToFileURL(htmlPath).href,
953
+ ], { encoding: "utf8", timeout: 20000, stdio: ["ignore", "pipe", "ignore"] });
954
+ const encoded = dom.match(/data-linkedin-lines="([^"]+)"/)?.[1];
955
+ if (!encoded)
956
+ return null;
957
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
958
+ const parsed = JSON.parse(decoded);
959
+ if (!Array.isArray(parsed) ||
960
+ parsed.some((line) => typeof line !== "string")) {
961
+ return null;
962
+ }
963
+ return {
964
+ lines: parsed,
965
+ basis: "local_chrome_browser_layout",
966
+ chromePath,
967
+ };
968
+ }
969
+ catch {
970
+ return null;
971
+ }
972
+ finally {
973
+ fs.rmSync(htmlPath, { force: true });
974
+ }
975
+ }
976
+ function buildLineMeasurementHtml(text, textWidthPx) {
977
+ return [
978
+ "<!doctype html>",
979
+ '<html lang="en">',
980
+ "<head>",
981
+ '<meta charset="utf-8">',
982
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
983
+ "</head>",
984
+ "<body>",
985
+ "<script>",
986
+ `const text = ${jsonForInlineScript(text)};`,
987
+ `const textWidthPx = ${textWidthPx};`,
988
+ `const fontSizePx = ${LINKEDIN_PREVIEW_FONT_SIZE_PX};`,
989
+ `const lineHeightPx = ${LINKEDIN_PREVIEW_LINE_HEIGHT_PX};`,
990
+ `
991
+ const div = document.createElement("div");
992
+ div.style.cssText = [
993
+ "position:absolute",
994
+ "left:0",
995
+ "top:0",
996
+ "width:" + textWidthPx + "px",
997
+ "font-family:-apple-system,system-ui,'Segoe UI',Roboto,'Helvetica Neue','Fira Sans',Ubuntu,'Oxygen Sans',Cantarell,'Droid Sans','Lucida Grande',Helvetica,Arial,sans-serif",
998
+ "font-size:" + fontSizePx + "px",
999
+ "line-height:" + lineHeightPx + "px",
1000
+ "font-weight:400",
1001
+ "letter-spacing:normal",
1002
+ "white-space:pre-wrap",
1003
+ "overflow-wrap:break-word",
1004
+ "color:#000"
1005
+ ].join(";");
1006
+ Array.from(text).forEach((char, index) => {
1007
+ if (char === "\\n") {
1008
+ div.appendChild(document.createTextNode("\\n"));
1009
+ return;
1010
+ }
1011
+ const span = document.createElement("span");
1012
+ span.dataset.index = String(index);
1013
+ span.textContent = char;
1014
+ div.appendChild(span);
1015
+ });
1016
+ document.body.appendChild(div);
1017
+
1018
+ const lines = [];
1019
+ let currentText = null;
1020
+ let currentTop = null;
1021
+ let previousTop = null;
1022
+ function flushLine() {
1023
+ if (currentText !== null) {
1024
+ lines.push(currentText.trimEnd());
1025
+ currentText = null;
1026
+ currentTop = null;
1027
+ }
1028
+ }
1029
+
1030
+ for (const span of div.querySelectorAll("span")) {
1031
+ const rect = span.getClientRects()[0];
1032
+ if (!rect) continue;
1033
+ const top = Math.round(rect.top * 10) / 10;
1034
+ if (currentTop === null || Math.abs(top - currentTop) > 2) {
1035
+ flushLine();
1036
+ if (previousTop !== null) {
1037
+ const blankCount = Math.max(0, Math.round((top - previousTop) / lineHeightPx) - 1);
1038
+ for (let index = 0; index < blankCount; index += 1) lines.push("");
1039
+ }
1040
+ currentText = "";
1041
+ currentTop = top;
1042
+ previousTop = top;
1043
+ }
1044
+ currentText += span.textContent;
1045
+ }
1046
+ flushLine();
1047
+ document.documentElement.setAttribute(
1048
+ "data-linkedin-lines",
1049
+ btoa(unescape(encodeURIComponent(JSON.stringify(lines))))
1050
+ );
1051
+ `,
1052
+ "</script>",
1053
+ "</body>",
1054
+ "</html>",
1055
+ "",
1056
+ ].join("\n");
1057
+ }
1058
+ function jsonForInlineScript(value) {
1059
+ return JSON.stringify(value).replace(/</g, "\\u003c");
1060
+ }
763
1061
  function buildPreviewSurface(input) {
764
1062
  const estimatedLines = estimateRenderedLines(input.text, input.textWidthPx);
765
1063
  const htmlPath = `${input.baseDir}/${input.label}.html`;
@@ -768,8 +1066,8 @@ function buildPreviewSurface(input) {
768
1066
  textWidthPx: input.textWidthPx,
769
1067
  viewportWidthPx: input.viewportWidthPx,
770
1068
  viewportHeightPx: input.viewportHeightPx,
771
- fontSizePx: 14,
772
- lineHeightPx: 21,
1069
+ fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
1070
+ lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
773
1071
  reviewClampLines: input.reviewClampLines,
774
1072
  estimatedLines,
775
1073
  estimatedVisibleTextBlock: estimatedLines