diff --git a/skills/typefully/CHANGELOG.md b/skills/typefully/CHANGELOG.md index f06641d..250eb72 100644 --- a/skills/typefully/CHANGELOG.md +++ b/skills/typefully/CHANGELOG.md @@ -4,6 +4,13 @@ All notable user-facing changes to the Typefully skill and its CLI are documente The format is based on Keep a Changelog. +## 2026-07-27 + +### Added + +- Link-preview cards are now documented for LinkedIn, Threads, and Bluesky: when a post contains a URL, Typefully automatically fetches Open Graph metadata and publishes a rich preview card — no flag needed. +- New `--hide-link-preview` flag (also `--hide_link_preview`) on `drafts:create` and `drafts:update` publishes the URL as plain text with no card. Supported on LinkedIn and Threads only, matching the Typefully editor; the flag errors when neither platform is targeted. On `drafts:update` it works without `--text` to hide the card on an existing draft. + ## 2026-07-09 ### Changed diff --git a/skills/typefully/SKILL.md b/skills/typefully/SKILL.md index 77c5357..933d3f3 100644 --- a/skills/typefully/SKILL.md +++ b/skills/typefully/SKILL.md @@ -104,6 +104,17 @@ When the user asks to add notes, ideas, or context to a draft, use `--scratchpad ./scripts/typefully.js drafts:create --text "My post" --scratchpad "Ideas: 1) Add stats 2) Include quote" ``` +### Link previews + +When a post contains a URL, Typefully automatically fetches Open Graph metadata for the last URL in the text and publishes a rich link-preview card on **LinkedIn, Threads, and Bluesky**. No flag is needed — it just works. X and Mastodon unfurl links themselves after publishing. + +To publish the URL as plain text with no card, pass `--hide-link-preview`. Suppression is supported on **LinkedIn and Threads only** (matching the Typefully editor); the flag errors if neither platform is targeted, and is ignored for other platforms in a mixed-platform draft: + +```bash +./scripts/typefully.js drafts:create --platform linkedin,threads --text "Read this https://example.com" --hide-link-preview +./scripts/typefully.js drafts:update draft-123 --hide-link-preview --use-default # hide the card on an existing draft +``` + --- ## 3. Schedule & publish @@ -183,6 +194,7 @@ Add any of these flags to a `drafts:create` or `drafts:update` command. The **Ap | `--scratchpad ""` | Attach internal notes (see [Scratchpad notes](#scratchpad-notes)) | create, update | | `--share` | Generate a public share URL | create, update | | `--schedule ` | Schedule or reschedule the draft | create, update | +| `--hide-link-preview` | Suppress the link-preview card (LinkedIn/Threads only — see [Link previews](#link-previews)) | create, update | | `--exclude-comment-markers` | Render response without anchors (display only; validation still applies) | update | | `--force-overwrite-comments` | Destructive last resort — see [`comments.md`](references/comments.md) | update | diff --git a/skills/typefully/scripts/typefully.js b/skills/typefully/scripts/typefully.js index 9c18cea..0c14f7e 100755 --- a/skills/typefully/scripts/typefully.js +++ b/skills/typefully/scripts/typefully.js @@ -471,9 +471,9 @@ function sanitizePostForPlatform(post, platform) { if (reshareTarget) { clean.linkedin_reshare_target = reshareTarget; } - if (post.hide_link_preview) { - clean.hide_link_preview = true; - } + } + if (HIDE_LINK_PREVIEW_PLATFORMS.includes(platform) && post.hide_link_preview) { + clean.hide_link_preview = true; } return clean; } @@ -512,6 +512,24 @@ function validateXOnlyPostOptions(platformList, { quotePostUrl, disclosures }) { } } +// Platforms where Typefully lets you suppress the link-preview card (matches the web editor). +const HIDE_LINK_PREVIEW_PLATFORMS = ['linkedin', 'threads']; + +function getHideLinkPreviewFromParsed(parsed) { + return Boolean(parsed['hide-link-preview'] || parsed.hide_link_preview); +} + +function addHideLinkPreview(posts, hideLinkPreview) { + if (!hideLinkPreview) return posts; + return posts.map(post => ({ ...post, hide_link_preview: true })); +} + +function validateHideLinkPreviewOption(platformList, hideLinkPreview) { + if (hideLinkPreview && !platformList.some(p => HIDE_LINK_PREVIEW_PLATFORMS.includes(p))) { + error('--hide-link-preview is only supported for LinkedIn and Threads posts. Include linkedin or threads in --platform or remove the flag.'); + } +} + function hasParsedArg(parsed, key) { return Object.prototype.hasOwnProperty.call(parsed, key); } @@ -1260,10 +1278,13 @@ async function cmdDraftsCreate(args) { paid_partnership: 'boolean', 'made-with-ai': 'boolean', made_with_ai: 'boolean', + 'hide-link-preview': 'boolean', + hide_link_preview: 'boolean', }); const socialSetId = resolveSocialSetIdFromParsed(parsed, parsed._positional[0]); const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); + const hideLinkPreview = getHideLinkPreviewFromParsed(parsed); // Determine platform(s) let platforms = parsed.platform; @@ -1312,6 +1333,7 @@ async function cmdDraftsCreate(args) { quotePostUrl, disclosures: xContentDisclosures, }); + validateHideLinkPreviewOption(platformList, hideLinkPreview); // Split text into posts (thread support) const posts = splitThreadText(text); @@ -1330,9 +1352,12 @@ async function cmdDraftsCreate(args) { }); for (const platform of platformList) { - const postsArray = platform === 'x' + let postsArray = platform === 'x' ? addXContentDisclosures(addQuotePostUrl(basePostsArray, quotePostUrl), xContentDisclosures) : basePostsArray; + if (HIDE_LINK_PREVIEW_PLATFORMS.includes(platform)) { + postsArray = addHideLinkPreview(postsArray, hideLinkPreview); + } const platformConfig = { enabled: true, posts: postsArray, @@ -1392,10 +1417,13 @@ async function cmdDraftsUpdate(args) { exclude_comment_markers: 'boolean', 'force-overwrite-comments': 'boolean', force_overwrite_comments: 'boolean', + 'hide-link-preview': 'boolean', + hide_link_preview: 'boolean', }); const { socialSetId, draftId } = resolveDraftTargetFromParsed(parsed, 'drafts:update'); const quotePostUrl = getQuotePostUrlFromParsed(parsed); const xContentDisclosures = getXContentDisclosuresFromParsed(parsed); + const hideLinkPreview = getHideLinkPreviewFromParsed(parsed); const body = {}; const explicitPlatformList = parsed.platform @@ -1428,13 +1456,16 @@ async function cmdDraftsUpdate(args) { text = getPostTextFromParsed(parsed); } - const shouldUpdatePosts = Boolean(!shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny)); + const shouldUpdatePosts = Boolean( + !shouldUpdateArticle && (text || quotePostUrl || xContentDisclosures.hasAny || hideLinkPreview) + ); if (shouldUpdatePosts) { if (explicitPlatformList) { validateXOnlyPostOptions(explicitPlatformList, { quotePostUrl, disclosures: xContentDisclosures, }); + validateHideLinkPreviewOption(explicitPlatformList, hideLinkPreview); } // Parse media IDs @@ -1469,6 +1500,7 @@ async function cmdDraftsUpdate(args) { quotePostUrl, disclosures: xContentDisclosures, }); + validateHideLinkPreviewOption(platformList, hideLinkPreview); let postsArray; @@ -1500,8 +1532,11 @@ async function cmdDraftsUpdate(args) { return post; }); } - } else { + } else if (quotePostUrl || xContentDisclosures.hasAny) { // X-only metadata update: preserve existing X posts and add quote/disclosure attrs. + if (hideLinkPreview) { + error('Cannot combine --hide-link-preview with X-only flags unless --text is provided'); + } const existingXPosts = existing.platforms?.x?.posts; if (!Array.isArray(existingXPosts) || existingXPosts.length === 0) { if (quotePostUrl && !xContentDisclosures.hasAny) { @@ -1511,15 +1546,31 @@ async function cmdDraftsUpdate(args) { } postsArray = existingXPosts; platformList = ['x']; + } else { + // --hide-link-preview only: preserve existing posts on platforms that support suppression. + const targets = platformList.filter(p => + HIDE_LINK_PREVIEW_PLATFORMS.includes(p) && + Array.isArray(existing.platforms?.[p]?.posts) && + existing.platforms[p].posts.length > 0 + ); + if (targets.length === 0) { + error('Cannot apply --hide-link-preview because this draft has no existing LinkedIn or Threads posts'); + } + postsArray = null; + platformList = targets; } // Build platforms object const platformsObj = {}; for (const p of platformList) { - const sanitizedPosts = postsArray.map(post => sanitizePostForPlatform(post, p)); - const platformPosts = p === 'x' + const sourcePosts = postsArray ?? existing.platforms?.[p]?.posts ?? []; + const sanitizedPosts = sourcePosts.map(post => sanitizePostForPlatform(post, p)); + let platformPosts = p === 'x' ? addXContentDisclosures(addQuotePostUrl(sanitizedPosts, quotePostUrl), xContentDisclosures) : sanitizedPosts; + if (HIDE_LINK_PREVIEW_PLATFORMS.includes(p)) { + platformPosts = addHideLinkPreview(platformPosts, hideLinkPreview); + } platformsObj[p] = { enabled: true, posts: platformPosts, @@ -1553,7 +1604,7 @@ async function cmdDraftsUpdate(args) { } if (Object.keys(body).length === 0) { - error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required'); + error('At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required'); } const params = new URLSearchParams(); @@ -2154,6 +2205,8 @@ COMMANDS: --quote-post-url, --quote-url Quote an X post URL (X only) --paid-partnership, --paid_partnership Label X posts as paid partnership --made-with-ai, --made_with_ai Label X posts as made with AI + --hide-link-preview Suppress the link-preview card (LinkedIn/Threads only). + Also accepts: --hide_link_preview --share Generate a public share URL for the draft --notes, --scratchpad Internal notes/scratchpad for the draft @@ -2172,6 +2225,8 @@ COMMANDS: --quote-post-url, --quote-url Quote an X post URL (X only) --paid-partnership, --paid_partnership Label X posts as paid partnership --made-with-ai, --made_with_ai Label X posts as made with AI + --hide-link-preview Suppress the link-preview card (LinkedIn/Threads only). + Also accepts: --hide_link_preview --share Generate a public share URL for the draft --notes, --scratchpad Internal notes/scratchpad for the draft --exclude-comment-markers Render response posts[*].text without diff --git a/tests/drafts.test.js b/tests/drafts.test.js index 3d31812..94df7e1 100644 --- a/tests/drafts.test.js +++ b/tests/drafts.test.js @@ -426,7 +426,7 @@ describe('drafts', () => { ); assert.equal(result.code, 1); assert.deepEqual(parseJsonOrNull(result.stdout), { - error: 'At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, or --force-overwrite-comments is required', + error: 'At least one of --text, --file, --content-markdown, --cover-media-id, --title, --schedule, --share, --notes, --tags, --quote-post-url, --paid-partnership, --made-with-ai, --hide-link-preview, or --force-overwrite-comments is required', }); assert.equal(server.requests.length, 0); })); @@ -732,6 +732,54 @@ describe('drafts', () => { assert.equal(server.requests.length, 0); })); + it('drafts:create applies --hide-link-preview only to LinkedIn and Threads posts', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + server.expect('POST', '/v2/social-sets/9/drafts', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + linkedin: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: true }], + }, + threads: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: true }], + }, + bluesky: { + enabled: true, + posts: [{ text: 'Read this https://example.com' }], + }, + }, + }); + }, + json: { id: 'd1' }, + }); + const result = await runCli( + ['drafts:create', '9', '--platform', 'linkedin,threads,bluesky', '--text', 'Read this https://example.com', '--hide-link-preview'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(result.code, 0); + assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1' }); + server.assertNoPendingExpectations(); + })); + + it('drafts:create errors when --hide-link-preview is used without LinkedIn or Threads', withCliHarness(async ({ + sandbox, server + }) => { + const result = await runCli( + ['drafts:create', '9', '--platform', 'bluesky', '--text', 'Read this https://example.com', '--hide-link-preview'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_KEY: 'typ_test_key' } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--hide-link-preview is only supported for LinkedIn and Threads posts. Include linkedin or threads in --platform or remove the flag.', + }); + assert.equal(server.requests.length, 0); + })); + it('drafts:create surfaces API VALIDATION_ERROR messages for quote URLs', withCliHarness(async ({ sandbox, server, baseUrl, apiKey }) => { @@ -1305,6 +1353,81 @@ describe('drafts', () => { server.assertNoPendingExpectations(); })); + it('drafts:update can hide the link preview without changing text', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + server.expect('GET', '/v2/social-sets/9/drafts/d1', { + assert: authAssertFactory(apiKey), + json: { + id: 'd1', + platforms: { + linkedin: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: false }], + }, + threads: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: false }], + }, + bluesky: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: false }], + }, + }, + }, + }); + + server.expect('PATCH', '/v2/social-sets/9/drafts/d1', { + assert: (req) => { + authAssertFactory(apiKey)(req); + assert.deepEqual(req.bodyJson, { + platforms: { + linkedin: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: true }], + }, + threads: { + enabled: true, + posts: [{ text: 'Read this https://example.com', hide_link_preview: true }], + }, + }, + }); + }, + json: { id: 'd1', ok: true }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--hide-link-preview'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(result.code, 0); + assert.deepEqual(parseJsonOrNull(result.stdout), { id: 'd1', ok: true }); + server.assertNoPendingExpectations(); + })); + + it('drafts:update errors on --hide-link-preview when the draft has no LinkedIn or Threads posts', withCliHarness(async ({ + sandbox, server, baseUrl, apiKey + }) => { + server.expect('GET', '/v2/social-sets/9/drafts/d1', { + assert: authAssertFactory(apiKey), + json: { + id: 'd1', + platforms: { + bluesky: { enabled: true, posts: [{ text: 'Read this https://example.com' }] }, + }, + }, + }); + const result = await runCli( + ['drafts:update', '9', 'd1', '--hide-link-preview'], + { cwd: sandbox.cwd, env: { HOME: sandbox.home, TYPEFULLY_API_BASE: baseUrl, TYPEFULLY_API_KEY: apiKey } } + ); + assert.equal(result.code, 1); + assert.deepEqual(parseJsonOrNull(result.stdout), { + error: '--hide-link-preview is only supported for LinkedIn and Threads posts. Include linkedin or threads in --platform or remove the flag.', + }); + assert.equal(server.requests.length, 1); + server.assertNoPendingExpectations(); + })); + it('drafts:update can set X content disclosures without changing text', withCliHarness(async ({ sandbox, server, baseUrl, apiKey }) => {