@sellable/mcp 0.1.267 → 0.1.269
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/index-dev.js +0 -0
- package/dist/index.js +0 -0
- package/dist/server.js +8 -7
- package/dist/tools/content-posts.d.ts +1 -421
- package/dist/tools/content-posts.js +0 -802
- package/dist/tools/engage-discovery.d.ts +0 -24
- package/dist/tools/engage-discovery.js +9 -114
- package/dist/tools/harvest-jobs.d.ts +182 -0
- package/dist/tools/harvest-jobs.js +429 -0
- package/dist/tools/leads.js +1 -1
- package/dist/tools/registry.d.ts +47 -76
- package/dist/tools/registry.js +2 -0
- package/package.json +1 -1
- package/skills/create-campaign/SKILL.md +10 -0
- package/skills/create-campaign/core/providers/prospeo.json +5 -2
- package/skills/create-post/SKILL.md +32 -605
- package/skills/create-post/references/hook-research-playbook.md +31 -460
- package/skills/create-post/references/post-file-contract.md +0 -36
- package/skills/create-post/references/post-validation.md +27 -258
- package/skills/create-post/references/premise-development.md +7 -298
- package/skills/providers/prospeo.md +21 -0
- package/skills/create-post/references/linkedin-preview-rendering.md +0 -221
- package/skills/research/config.json +0 -9
|
@@ -1,34 +1,13 @@
|
|
|
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";
|
|
6
4
|
const CONTENT_ROOT_ENV = "SELLABLE_CONTENT_DIR";
|
|
7
5
|
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
|
-
};
|
|
26
6
|
const RELATIVE_DIRS = {
|
|
27
7
|
ideas: "linkedin/ideas",
|
|
28
8
|
hookResearch: "linkedin/research/hooks",
|
|
29
9
|
drafts: "linkedin/drafts",
|
|
30
10
|
published: "linkedin/published",
|
|
31
|
-
previews: "linkedin/previews",
|
|
32
11
|
commentLibrary: "linkedin/comments/library",
|
|
33
12
|
};
|
|
34
13
|
export const contentPostToolDefinitions = [
|
|
@@ -125,92 +104,6 @@ export const contentPostToolDefinitions = [
|
|
|
125
104
|
additionalProperties: false,
|
|
126
105
|
},
|
|
127
106
|
},
|
|
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
|
-
},
|
|
177
|
-
{
|
|
178
|
-
name: "render_linkedin_post_preview",
|
|
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.",
|
|
180
|
-
inputSchema: {
|
|
181
|
-
type: "object",
|
|
182
|
-
properties: {
|
|
183
|
-
postText: {
|
|
184
|
-
type: "string",
|
|
185
|
-
description: "Full post text to render. Provide either postText or draftId.",
|
|
186
|
-
},
|
|
187
|
-
draftId: {
|
|
188
|
-
type: "string",
|
|
189
|
-
description: "Optional saved draft ID. When postText is omitted, the tool renders this draft's Draft Body.",
|
|
190
|
-
},
|
|
191
|
-
renderId: {
|
|
192
|
-
type: "string",
|
|
193
|
-
description: "Optional stable render ID. Must be a safe filename without slashes.",
|
|
194
|
-
},
|
|
195
|
-
sourceLabel: { type: "string" },
|
|
196
|
-
createdAt: { type: "string" },
|
|
197
|
-
renderScreenshots: {
|
|
198
|
-
type: "boolean",
|
|
199
|
-
description: "Defaults to true. When true, tries to use local Chrome headless to write PNG screenshots.",
|
|
200
|
-
},
|
|
201
|
-
requireScreenshots: {
|
|
202
|
-
type: "boolean",
|
|
203
|
-
description: "Defaults to false. When true, fail if local Chrome cannot produce PNG screenshots.",
|
|
204
|
-
},
|
|
205
|
-
reviewClampLines: {
|
|
206
|
-
type: "number",
|
|
207
|
-
description: "Visible review lines before see-more. Defaults to 3.",
|
|
208
|
-
},
|
|
209
|
-
},
|
|
210
|
-
required: [],
|
|
211
|
-
additionalProperties: false,
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
107
|
{
|
|
215
108
|
name: "update_post_draft",
|
|
216
109
|
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.",
|
|
@@ -496,147 +389,6 @@ export function savePostDraftTool(input) {
|
|
|
496
389
|
preview: sanitizedPreview(input.body),
|
|
497
390
|
};
|
|
498
391
|
}
|
|
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
|
-
}
|
|
544
|
-
export function renderLinkedInPostPreviewTool(input) {
|
|
545
|
-
const now = normalizeDate(input.createdAt);
|
|
546
|
-
const { postText, draftId } = resolveLinkedInPreviewPostText(input);
|
|
547
|
-
const reviewClampLines = typeof input.reviewClampLines === "number" &&
|
|
548
|
-
Number.isFinite(input.reviewClampLines)
|
|
549
|
-
? Math.max(1, Math.min(Math.floor(input.reviewClampLines), 8))
|
|
550
|
-
: 3;
|
|
551
|
-
const id = input.renderId ??
|
|
552
|
-
`preview_${dateStamp(now)}_${slugify(input.sourceLabel || draftId || postText)}`;
|
|
553
|
-
const safeId = normalizeArtifactId(id, "renderId");
|
|
554
|
-
const baseDir = `${RELATIVE_DIRS.previews}/${safeId}`;
|
|
555
|
-
const title = input.sourceLabel || draftId || safeId;
|
|
556
|
-
const renderScreenshots = input.renderScreenshots !== false;
|
|
557
|
-
const mobile = buildPreviewSurface({
|
|
558
|
-
label: "mobile",
|
|
559
|
-
text: postText,
|
|
560
|
-
baseDir,
|
|
561
|
-
textWidthPx: LINKEDIN_PREVIEW_SURFACES.mobile.textWidthPx,
|
|
562
|
-
viewportWidthPx: LINKEDIN_PREVIEW_SURFACES.mobile.viewportWidthPx,
|
|
563
|
-
viewportHeightPx: LINKEDIN_PREVIEW_SURFACES.mobile.viewportHeightPx,
|
|
564
|
-
reviewClampLines,
|
|
565
|
-
});
|
|
566
|
-
const desktop = buildPreviewSurface({
|
|
567
|
-
label: "desktop",
|
|
568
|
-
text: postText,
|
|
569
|
-
baseDir,
|
|
570
|
-
textWidthPx: LINKEDIN_PREVIEW_SURFACES.desktop.textWidthPx,
|
|
571
|
-
viewportWidthPx: LINKEDIN_PREVIEW_SURFACES.desktop.viewportWidthPx,
|
|
572
|
-
viewportHeightPx: LINKEDIN_PREVIEW_SURFACES.desktop.viewportHeightPx,
|
|
573
|
-
reviewClampLines,
|
|
574
|
-
});
|
|
575
|
-
const combinedHtmlRelativePath = `${baseDir}/preview.html`;
|
|
576
|
-
const combinedHtml = buildCombinedPreviewHtml({
|
|
577
|
-
title,
|
|
578
|
-
postText,
|
|
579
|
-
mobile,
|
|
580
|
-
desktop,
|
|
581
|
-
});
|
|
582
|
-
writeContentFile(combinedHtmlRelativePath, combinedHtml);
|
|
583
|
-
const screenshotResult = renderScreenshots
|
|
584
|
-
? renderPreviewScreenshots([mobile, desktop], input.requireScreenshots)
|
|
585
|
-
: { status: "skipped", reason: "renderScreenshots=false" };
|
|
586
|
-
const metadata = {
|
|
587
|
-
id: safeId,
|
|
588
|
-
type: "linkedin_preview",
|
|
589
|
-
status: screenshotResult.status === "rendered" ? "rendered" : "html_rendered",
|
|
590
|
-
title,
|
|
591
|
-
draftId,
|
|
592
|
-
createdAt: now,
|
|
593
|
-
updatedAt: now,
|
|
594
|
-
};
|
|
595
|
-
const renderedPreview = {
|
|
596
|
-
basis: screenshotResult.status === "rendered"
|
|
597
|
-
? "local_chrome_headless_screenshot"
|
|
598
|
-
: "linkedin_css_contract_html",
|
|
599
|
-
cssContractVersion: LINKEDIN_PREVIEW_CONTRACT_VERSION,
|
|
600
|
-
generatedAt: now,
|
|
601
|
-
sourceLabel: title,
|
|
602
|
-
mobile: surfaceRecord(mobile),
|
|
603
|
-
desktop: surfaceRecord(desktop),
|
|
604
|
-
diagnostics: previewDiagnostics(postText),
|
|
605
|
-
screenshotStatus: screenshotResult,
|
|
606
|
-
};
|
|
607
|
-
const recordRelativePath = `${baseDir}/render.md`;
|
|
608
|
-
const metadataJsonRelativePath = `${baseDir}/metadata.json`;
|
|
609
|
-
writeContentFile(metadataJsonRelativePath, `${JSON.stringify(renderedPreview, null, 2)}\n`);
|
|
610
|
-
writeArtifact(recordRelativePath, buildMarkdown(metadata, [
|
|
611
|
-
["Rendered Preview Record", jsonBlock(renderedPreview)],
|
|
612
|
-
[
|
|
613
|
-
"Artifacts",
|
|
614
|
-
[
|
|
615
|
-
`- combinedHtml: ${combinedHtmlRelativePath}`,
|
|
616
|
-
`- mobileHtml: ${mobile.htmlPath}`,
|
|
617
|
-
mobile.screenshotPath
|
|
618
|
-
? `- mobileScreenshot: ${mobile.screenshotPath}`
|
|
619
|
-
: "- mobileScreenshot: none",
|
|
620
|
-
`- desktopHtml: ${desktop.htmlPath}`,
|
|
621
|
-
desktop.screenshotPath
|
|
622
|
-
? `- desktopScreenshot: ${desktop.screenshotPath}`
|
|
623
|
-
: "- desktopScreenshot: none",
|
|
624
|
-
].join("\n"),
|
|
625
|
-
],
|
|
626
|
-
]));
|
|
627
|
-
return {
|
|
628
|
-
id: safeId,
|
|
629
|
-
path: recordRelativePath,
|
|
630
|
-
artifactDir: baseDir,
|
|
631
|
-
absoluteArtifactDir: safePath(resolveContentRoot(), baseDir),
|
|
632
|
-
combinedHtmlPath: combinedHtmlRelativePath,
|
|
633
|
-
absoluteCombinedHtmlPath: safePath(resolveContentRoot(), combinedHtmlRelativePath),
|
|
634
|
-
mobile: surfaceReturnRecord(mobile),
|
|
635
|
-
desktop: surfaceReturnRecord(desktop),
|
|
636
|
-
screenshotStatus: screenshotResult,
|
|
637
|
-
renderedPreview,
|
|
638
|
-
};
|
|
639
|
-
}
|
|
640
392
|
export function updatePostDraftTool(input) {
|
|
641
393
|
const safeDraftId = normalizeArtifactId(input.draftId, "draftId");
|
|
642
394
|
const relativePath = `${RELATIVE_DIRS.drafts}/${safeDraftId}.md`;
|
|
@@ -865,555 +617,6 @@ export function listPublishedPostsTool(input) {
|
|
|
865
617
|
}
|
|
866
618
|
return summarizeArtifacts(artifacts, input?.limit);
|
|
867
619
|
}
|
|
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
|
-
}
|
|
1061
|
-
function buildPreviewSurface(input) {
|
|
1062
|
-
const estimatedLines = estimateRenderedLines(input.text, input.textWidthPx);
|
|
1063
|
-
const htmlPath = `${input.baseDir}/${input.label}.html`;
|
|
1064
|
-
const surface = {
|
|
1065
|
-
label: input.label,
|
|
1066
|
-
textWidthPx: input.textWidthPx,
|
|
1067
|
-
viewportWidthPx: input.viewportWidthPx,
|
|
1068
|
-
viewportHeightPx: input.viewportHeightPx,
|
|
1069
|
-
fontSizePx: LINKEDIN_PREVIEW_FONT_SIZE_PX,
|
|
1070
|
-
lineHeightPx: LINKEDIN_PREVIEW_LINE_HEIGHT_PX,
|
|
1071
|
-
reviewClampLines: input.reviewClampLines,
|
|
1072
|
-
estimatedLines,
|
|
1073
|
-
estimatedVisibleTextBlock: estimatedLines
|
|
1074
|
-
.slice(0, input.reviewClampLines)
|
|
1075
|
-
.join("\n"),
|
|
1076
|
-
estimatedLineCountBeforeClamp: estimatedLines.length,
|
|
1077
|
-
estimatedBlankLinesBeforeClamp: estimatedLines
|
|
1078
|
-
.slice(0, input.reviewClampLines)
|
|
1079
|
-
.filter((line) => line.trim().length === 0).length,
|
|
1080
|
-
htmlPath,
|
|
1081
|
-
absoluteHtmlPath: safePath(resolveContentRoot(), htmlPath),
|
|
1082
|
-
};
|
|
1083
|
-
writeContentFile(htmlPath, buildSinglePreviewHtml({
|
|
1084
|
-
title: `${input.label} LinkedIn preview`,
|
|
1085
|
-
postText: input.text,
|
|
1086
|
-
surface,
|
|
1087
|
-
}));
|
|
1088
|
-
return surface;
|
|
1089
|
-
}
|
|
1090
|
-
function buildSinglePreviewHtml(input) {
|
|
1091
|
-
return htmlDocument({
|
|
1092
|
-
title: input.title,
|
|
1093
|
-
bodyClass: input.surface.label,
|
|
1094
|
-
body: linkedInCardHtml(input.postText, input.surface),
|
|
1095
|
-
});
|
|
1096
|
-
}
|
|
1097
|
-
function buildCombinedPreviewHtml(input) {
|
|
1098
|
-
return htmlDocument({
|
|
1099
|
-
title: `LinkedIn preview: ${input.title}`,
|
|
1100
|
-
bodyClass: "combined",
|
|
1101
|
-
body: [
|
|
1102
|
-
`<main class="combined-grid">`,
|
|
1103
|
-
`<section><h1>Mobile preview</h1>${linkedInCardHtml(input.postText, input.mobile)}</section>`,
|
|
1104
|
-
`<section><h1>Desktop preview</h1>${linkedInCardHtml(input.postText, input.desktop)}</section>`,
|
|
1105
|
-
`</main>`,
|
|
1106
|
-
].join("\n"),
|
|
1107
|
-
});
|
|
1108
|
-
}
|
|
1109
|
-
function htmlDocument(input) {
|
|
1110
|
-
return [
|
|
1111
|
-
"<!doctype html>",
|
|
1112
|
-
'<html lang="en">',
|
|
1113
|
-
"<head>",
|
|
1114
|
-
'<meta charset="utf-8">',
|
|
1115
|
-
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
1116
|
-
`<title>${escapeHtml(input.title)}</title>`,
|
|
1117
|
-
"<style>",
|
|
1118
|
-
linkedinPreviewCss(),
|
|
1119
|
-
"</style>",
|
|
1120
|
-
"</head>",
|
|
1121
|
-
`<body class="${escapeHtml(input.bodyClass)}">`,
|
|
1122
|
-
input.body,
|
|
1123
|
-
"</body>",
|
|
1124
|
-
"</html>",
|
|
1125
|
-
"",
|
|
1126
|
-
].join("\n");
|
|
1127
|
-
}
|
|
1128
|
-
function linkedInCardHtml(postText, surface) {
|
|
1129
|
-
const escapedText = escapeHtml(postText);
|
|
1130
|
-
const cardWidth = surface.textWidthPx + 32;
|
|
1131
|
-
return [
|
|
1132
|
-
`<article class="linkedin-card" data-surface="${surface.label}" style="width:${cardWidth}px">`,
|
|
1133
|
-
'<header class="post-header" aria-hidden="true">',
|
|
1134
|
-
'<div class="avatar"></div>',
|
|
1135
|
-
'<div class="byline"><div class="name">Preview Author</div><div class="meta">LinkedIn preview contract</div></div>',
|
|
1136
|
-
"</header>",
|
|
1137
|
-
`<div class="post-text" style="width:${surface.textWidthPx}px;-webkit-line-clamp:${surface.reviewClampLines}">${escapedText}</div>`,
|
|
1138
|
-
surface.estimatedLineCountBeforeClamp > surface.reviewClampLines
|
|
1139
|
-
? '<div class="see-more">...see more</div>'
|
|
1140
|
-
: "",
|
|
1141
|
-
'<div class="post-actions" aria-hidden="true"><span>Like</span><span>Comment</span><span>Repost</span></div>',
|
|
1142
|
-
"</article>",
|
|
1143
|
-
].join("\n");
|
|
1144
|
-
}
|
|
1145
|
-
function linkedinPreviewCss() {
|
|
1146
|
-
return `
|
|
1147
|
-
:root {
|
|
1148
|
-
color-scheme: light;
|
|
1149
|
-
--linkedin-text: rgba(0, 0, 0, 0.9);
|
|
1150
|
-
--linkedin-muted: rgba(0, 0, 0, 0.6);
|
|
1151
|
-
--linkedin-border: #e0dfdc;
|
|
1152
|
-
--linkedin-bg: #f3f2ef;
|
|
1153
|
-
}
|
|
1154
|
-
* { box-sizing: border-box; }
|
|
1155
|
-
body {
|
|
1156
|
-
margin: 0;
|
|
1157
|
-
min-height: 100vh;
|
|
1158
|
-
background: var(--linkedin-bg);
|
|
1159
|
-
font-family: -apple-system, system-ui, "Segoe UI", Roboto, "Helvetica Neue",
|
|
1160
|
-
"Fira Sans", Ubuntu, "Oxygen Sans", Cantarell, "Droid Sans",
|
|
1161
|
-
"Lucida Grande", Helvetica, Arial, sans-serif;
|
|
1162
|
-
}
|
|
1163
|
-
body.mobile, body.desktop {
|
|
1164
|
-
display: flex;
|
|
1165
|
-
align-items: flex-start;
|
|
1166
|
-
justify-content: center;
|
|
1167
|
-
padding: 20px;
|
|
1168
|
-
}
|
|
1169
|
-
.combined-grid {
|
|
1170
|
-
display: grid;
|
|
1171
|
-
grid-template-columns: max-content max-content;
|
|
1172
|
-
gap: 28px;
|
|
1173
|
-
align-items: start;
|
|
1174
|
-
padding: 20px;
|
|
1175
|
-
}
|
|
1176
|
-
h1 {
|
|
1177
|
-
margin: 0 0 10px;
|
|
1178
|
-
font-size: 14px;
|
|
1179
|
-
line-height: 20px;
|
|
1180
|
-
font-weight: 600;
|
|
1181
|
-
color: var(--linkedin-muted);
|
|
1182
|
-
}
|
|
1183
|
-
.linkedin-card {
|
|
1184
|
-
background: #fff;
|
|
1185
|
-
border: 1px solid var(--linkedin-border);
|
|
1186
|
-
border-radius: 8px;
|
|
1187
|
-
padding: 12px 16px 10px;
|
|
1188
|
-
color: var(--linkedin-text);
|
|
1189
|
-
}
|
|
1190
|
-
.post-header {
|
|
1191
|
-
display: flex;
|
|
1192
|
-
align-items: center;
|
|
1193
|
-
gap: 8px;
|
|
1194
|
-
margin-bottom: 8px;
|
|
1195
|
-
}
|
|
1196
|
-
.avatar {
|
|
1197
|
-
width: 40px;
|
|
1198
|
-
height: 40px;
|
|
1199
|
-
border-radius: 50%;
|
|
1200
|
-
background: linear-gradient(135deg, #d7dce0, #eef0f2);
|
|
1201
|
-
}
|
|
1202
|
-
.byline { min-width: 0; }
|
|
1203
|
-
.name {
|
|
1204
|
-
height: 16px;
|
|
1205
|
-
font-size: 14px;
|
|
1206
|
-
line-height: 16px;
|
|
1207
|
-
font-weight: 600;
|
|
1208
|
-
}
|
|
1209
|
-
.meta {
|
|
1210
|
-
font-size: 12px;
|
|
1211
|
-
line-height: 14px;
|
|
1212
|
-
color: var(--linkedin-muted);
|
|
1213
|
-
}
|
|
1214
|
-
.post-text {
|
|
1215
|
-
font-size: 14px;
|
|
1216
|
-
line-height: 21px;
|
|
1217
|
-
font-weight: 400;
|
|
1218
|
-
letter-spacing: normal;
|
|
1219
|
-
white-space: pre-wrap;
|
|
1220
|
-
overflow-wrap: break-word;
|
|
1221
|
-
color: var(--linkedin-text);
|
|
1222
|
-
overflow: hidden;
|
|
1223
|
-
display: -webkit-box;
|
|
1224
|
-
-webkit-box-orient: vertical;
|
|
1225
|
-
}
|
|
1226
|
-
.see-more {
|
|
1227
|
-
margin-top: 2px;
|
|
1228
|
-
font-size: 14px;
|
|
1229
|
-
line-height: 21px;
|
|
1230
|
-
color: var(--linkedin-muted);
|
|
1231
|
-
}
|
|
1232
|
-
.post-actions {
|
|
1233
|
-
display: flex;
|
|
1234
|
-
gap: 18px;
|
|
1235
|
-
border-top: 1px solid var(--linkedin-border);
|
|
1236
|
-
margin-top: 10px;
|
|
1237
|
-
padding-top: 8px;
|
|
1238
|
-
color: var(--linkedin-muted);
|
|
1239
|
-
font-size: 13px;
|
|
1240
|
-
line-height: 18px;
|
|
1241
|
-
}
|
|
1242
|
-
`;
|
|
1243
|
-
}
|
|
1244
|
-
function renderPreviewScreenshots(surfaces, requireScreenshots) {
|
|
1245
|
-
const chromePath = findChromeExecutable();
|
|
1246
|
-
if (!chromePath) {
|
|
1247
|
-
if (requireScreenshots) {
|
|
1248
|
-
throw new Error("Chrome executable not found. Set CHROME_PATH or install Google Chrome to render LinkedIn preview screenshots.");
|
|
1249
|
-
}
|
|
1250
|
-
return { status: "skipped", reason: "chrome_not_found" };
|
|
1251
|
-
}
|
|
1252
|
-
try {
|
|
1253
|
-
for (const surface of surfaces) {
|
|
1254
|
-
const screenshotPath = surface.htmlPath.replace(/\.html$/, ".png");
|
|
1255
|
-
const absoluteScreenshotPath = safePath(resolveContentRoot(), screenshotPath);
|
|
1256
|
-
execFileSync(chromePath, [
|
|
1257
|
-
"--headless=new",
|
|
1258
|
-
"--disable-gpu",
|
|
1259
|
-
"--no-sandbox",
|
|
1260
|
-
"--disable-dev-shm-usage",
|
|
1261
|
-
"--hide-scrollbars",
|
|
1262
|
-
`--screenshot=${absoluteScreenshotPath}`,
|
|
1263
|
-
`--window-size=${surface.viewportWidthPx},${surface.viewportHeightPx}`,
|
|
1264
|
-
pathToFileURL(surface.absoluteHtmlPath).href,
|
|
1265
|
-
], { stdio: "ignore", timeout: 20000 });
|
|
1266
|
-
surface.screenshotPath = screenshotPath;
|
|
1267
|
-
surface.absoluteScreenshotPath = absoluteScreenshotPath;
|
|
1268
|
-
}
|
|
1269
|
-
return { status: "rendered", chromePath };
|
|
1270
|
-
}
|
|
1271
|
-
catch (error) {
|
|
1272
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
1273
|
-
if (requireScreenshots) {
|
|
1274
|
-
throw new Error(`Chrome screenshot render failed: ${reason}`);
|
|
1275
|
-
}
|
|
1276
|
-
return { status: "failed", reason, chromePath };
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
function findChromeExecutable() {
|
|
1280
|
-
const candidates = [
|
|
1281
|
-
process.env.CHROME_PATH,
|
|
1282
|
-
process.env.GOOGLE_CHROME_BIN,
|
|
1283
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
1284
|
-
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
1285
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
1286
|
-
"/usr/bin/google-chrome",
|
|
1287
|
-
"/usr/bin/google-chrome-stable",
|
|
1288
|
-
"/usr/bin/chromium",
|
|
1289
|
-
"/usr/bin/chromium-browser",
|
|
1290
|
-
"/snap/bin/chromium",
|
|
1291
|
-
].filter(Boolean);
|
|
1292
|
-
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
|
1293
|
-
}
|
|
1294
|
-
function surfaceRecord(surface) {
|
|
1295
|
-
return {
|
|
1296
|
-
textWidthPx: surface.textWidthPx,
|
|
1297
|
-
viewportWidthPx: surface.viewportWidthPx,
|
|
1298
|
-
fontSizePx: surface.fontSizePx,
|
|
1299
|
-
lineHeightPx: surface.lineHeightPx,
|
|
1300
|
-
reviewClampLines: surface.reviewClampLines,
|
|
1301
|
-
estimatedVisibleTextBlock: surface.estimatedVisibleTextBlock,
|
|
1302
|
-
estimatedRenderedLines: surface.estimatedLines.slice(0, surface.reviewClampLines),
|
|
1303
|
-
estimatedLineCountBeforeClamp: surface.estimatedLineCountBeforeClamp,
|
|
1304
|
-
estimatedBlankLinesBeforeClamp: surface.estimatedBlankLinesBeforeClamp,
|
|
1305
|
-
htmlPath: surface.htmlPath,
|
|
1306
|
-
screenshotPath: surface.screenshotPath,
|
|
1307
|
-
};
|
|
1308
|
-
}
|
|
1309
|
-
function surfaceReturnRecord(surface) {
|
|
1310
|
-
return {
|
|
1311
|
-
...surfaceRecord(surface),
|
|
1312
|
-
absoluteHtmlPath: surface.absoluteHtmlPath,
|
|
1313
|
-
absoluteScreenshotPath: surface.absoluteScreenshotPath,
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
function previewDiagnostics(text) {
|
|
1317
|
-
const physicalLines = text.split("\n");
|
|
1318
|
-
const nonblankLines = physicalLines.filter((line) => line.trim().length > 0);
|
|
1319
|
-
return {
|
|
1320
|
-
charCount: text.replace(/\s/g, "").length,
|
|
1321
|
-
charCountIncludingNewlines: text.length,
|
|
1322
|
-
firstLineChars: physicalLines[0]?.length ?? 0,
|
|
1323
|
-
firstTwoPhysicalLinesChars: physicalLines.slice(0, 2).join("\n").length,
|
|
1324
|
-
physicalLineCount: physicalLines.length,
|
|
1325
|
-
contentLineCount: nonblankLines.length,
|
|
1326
|
-
longestNonblankLineChars: nonblankLines.reduce((max, line) => Math.max(max, line.length), 0),
|
|
1327
|
-
blankLineCount: physicalLines.length - nonblankLines.length,
|
|
1328
|
-
};
|
|
1329
|
-
}
|
|
1330
|
-
function estimateRenderedLines(text, textWidthPx) {
|
|
1331
|
-
const physicalLines = text.split("\n");
|
|
1332
|
-
const rendered = [];
|
|
1333
|
-
for (const physicalLine of physicalLines) {
|
|
1334
|
-
if (physicalLine.length === 0) {
|
|
1335
|
-
rendered.push("");
|
|
1336
|
-
continue;
|
|
1337
|
-
}
|
|
1338
|
-
rendered.push(...wrapPhysicalLine(physicalLine, textWidthPx));
|
|
1339
|
-
}
|
|
1340
|
-
return rendered;
|
|
1341
|
-
}
|
|
1342
|
-
function wrapPhysicalLine(line, textWidthPx) {
|
|
1343
|
-
const tokens = line.match(/\S+\s*/g) ?? [line];
|
|
1344
|
-
const wrapped = [];
|
|
1345
|
-
let current = "";
|
|
1346
|
-
for (const token of tokens) {
|
|
1347
|
-
const candidate = `${current}${token}`;
|
|
1348
|
-
if (!current || measureTextPx(candidate) <= textWidthPx) {
|
|
1349
|
-
current = candidate;
|
|
1350
|
-
continue;
|
|
1351
|
-
}
|
|
1352
|
-
wrapped.push(current.trimEnd());
|
|
1353
|
-
if (measureTextPx(token) <= textWidthPx) {
|
|
1354
|
-
current = token;
|
|
1355
|
-
}
|
|
1356
|
-
else {
|
|
1357
|
-
const broken = breakLongToken(token.trimEnd(), textWidthPx);
|
|
1358
|
-
wrapped.push(...broken.slice(0, -1));
|
|
1359
|
-
current = broken.at(-1) ?? "";
|
|
1360
|
-
if (token.endsWith(" "))
|
|
1361
|
-
current += " ";
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
if (current.length > 0)
|
|
1365
|
-
wrapped.push(current.trimEnd());
|
|
1366
|
-
return wrapped.length > 0 ? wrapped : [""];
|
|
1367
|
-
}
|
|
1368
|
-
function breakLongToken(token, textWidthPx) {
|
|
1369
|
-
const parts = [];
|
|
1370
|
-
let current = "";
|
|
1371
|
-
for (const char of [...token]) {
|
|
1372
|
-
const candidate = `${current}${char}`;
|
|
1373
|
-
if (!current || measureTextPx(candidate) <= textWidthPx) {
|
|
1374
|
-
current = candidate;
|
|
1375
|
-
}
|
|
1376
|
-
else {
|
|
1377
|
-
parts.push(current);
|
|
1378
|
-
current = char;
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
if (current)
|
|
1382
|
-
parts.push(current);
|
|
1383
|
-
return parts;
|
|
1384
|
-
}
|
|
1385
|
-
function measureTextPx(value) {
|
|
1386
|
-
let width = 0;
|
|
1387
|
-
for (const char of [...value]) {
|
|
1388
|
-
width += estimateCharWidthPx(char);
|
|
1389
|
-
}
|
|
1390
|
-
return width;
|
|
1391
|
-
}
|
|
1392
|
-
function estimateCharWidthPx(char) {
|
|
1393
|
-
if (char === " ")
|
|
1394
|
-
return 3.6;
|
|
1395
|
-
if (/[ilI|.,:;!'`]/.test(char))
|
|
1396
|
-
return 3.8;
|
|
1397
|
-
if (/[fjrt()[\]{}]/.test(char))
|
|
1398
|
-
return 5.2;
|
|
1399
|
-
if (/[mwMW@#%&]/.test(char))
|
|
1400
|
-
return 10.2;
|
|
1401
|
-
if (/[A-Z]/.test(char))
|
|
1402
|
-
return 8.2;
|
|
1403
|
-
if (/[0-9]/.test(char))
|
|
1404
|
-
return 7.3;
|
|
1405
|
-
if (/[^\x00-\x7F]/.test(char))
|
|
1406
|
-
return 8.4;
|
|
1407
|
-
return 7.0;
|
|
1408
|
-
}
|
|
1409
|
-
function escapeHtml(value) {
|
|
1410
|
-
return value
|
|
1411
|
-
.replace(/&/g, "&")
|
|
1412
|
-
.replace(/</g, "<")
|
|
1413
|
-
.replace(/>/g, ">")
|
|
1414
|
-
.replace(/"/g, """)
|
|
1415
|
-
.replace(/'/g, "'");
|
|
1416
|
-
}
|
|
1417
620
|
function getArtifact(relativeDir, id, label) {
|
|
1418
621
|
const safeId = normalizeArtifactId(id, label);
|
|
1419
622
|
const relativePath = `${relativeDir}/${safeId}.md`;
|
|
@@ -1607,11 +810,6 @@ function writeArtifact(relativePath, markdown) {
|
|
|
1607
810
|
const filePath = safeWritablePath(root, relativePath);
|
|
1608
811
|
fs.writeFileSync(filePath, markdown);
|
|
1609
812
|
}
|
|
1610
|
-
function writeContentFile(relativePath, contents) {
|
|
1611
|
-
const root = resolveContentRoot();
|
|
1612
|
-
const filePath = safeWritablePath(root, relativePath);
|
|
1613
|
-
fs.writeFileSync(filePath, contents);
|
|
1614
|
-
}
|
|
1615
813
|
function safePath(root, relativePath) {
|
|
1616
814
|
validateRelativePath(relativePath);
|
|
1617
815
|
const fullPath = path.resolve(root, ...relativePath.split("/"));
|