diff --git a/README.md b/README.md
index eefe44e..8da9037 100644
--- a/README.md
+++ b/README.md
@@ -106,6 +106,8 @@ tags:
- "api"
websiteUrl: "https://coolapi.dev"
githubUrl: "https://github.com/cooldev/coolapi"
+# Optional: only include when explicitly supplied for announcement tagging.
+announcementXHandle: "CoolAPI_Team"
logoUrl: "https://www.google.com/s2/favicons?sz=64&domain_url=https://coolapi.dev"
ogImageUrl: "https://coolapi.dev/og-image.png"
logoWidth: 64
@@ -160,8 +162,9 @@ Required tool fields:
Optional tool fields:
+- `announcementXHandle` — an explicitly supplied X account to tag in a listing announcement, not GitHub submitter attribution. Accept a bare handle or one leading `@`, with outer whitespace trimmed. The canonical published value is bare and preserves case; it must match ASCII `[A-Za-z0-9_]{1,15}`. Omit it when no tag is requested; empty or whitespace-only strings also mean no tag. URLs (including X/Twitter profile URLs), `null`, numbers, booleans, arrays, objects, and other invalid values are rejected. Never infer it from `submittedBy`, `tool-submitters.json`, `githubUrl`, website social metadata, or a product name; do not backfill existing listings.
- `seoTitle`, `seoDescription`, and `agentSummary` are optional, non-empty plain-text strings. Contributors may suggest them; the editorial reviewer owns the final wording. Existing listings need no changes.
-- Surrounding whitespace is trimmed before validation and publishing; whitespace-only values are invalid.
+- For these three SEO fields, surrounding whitespace is trimmed before validation and publishing; whitespace-only values are invalid.
- `seoTitle` overrides only the tool page title; `seoDescription` overrides only its meta and social description. Each falls back independently to the current generated text when omitted. `agentSummary` adds a paragraph before the existing visible description and does not replace it.
- Draft a specific, factual title around the tool and its agent use (usually about 50–60 characters). Write a description that helps a reader decide whether to open the profile (usually about 140–160 characters). Summarize the practical agent-facing outcome in one short paragraph (roughly 40–70 words). These are editorial drafting targets, not hard validation limits or automatic truncation rules. Avoid unsupported claims, keyword repetition, promotional language, and markdown or HTML.
- `githubUrl`
@@ -181,6 +184,8 @@ Optional tool fields:
The [tool review guide](docs/tool-review-guide.md) gives the reviewer the acceptance checklist and rollout behavior for SEO suggestions.
+The announcement handle is retained by the source reader and published to D1 as nullable `announcement_x_handle`. Omission or blank input writes `NULL`, and removal clears any previous value. Existing attribution and asset generators preserve the authored field. This repository does not post announcements: a downstream announcement consumer must explicitly read this field, add `@` only when formatting a tag, and omit tagging when it is absent. Supplying it does not itself trigger or guarantee a post.
+
Do not add `contentModifiedAt` to a tool file. D1 sets it when the record is first published and advances it only when authored or visible data changes. `synced_at` is a separate operational timestamp that advances on every sync.
Valid `pricing` values:
@@ -261,6 +266,7 @@ What you need to provide:
- useful, specific tags
- `githubUrl` when the tool is open source
- `logoUrl` and `ogImageUrl` when you already have the canonical values, but these are optional
+- optionally, `announcementXHandle` if you explicitly want that X account tagged in a listing announcement; a handle is never required to submit a tool
What you do not need to worry about:
diff --git a/docs/d1-publish-pipeline.md b/docs/d1-publish-pipeline.md
index 4e461fd..a4c2ade 100644
--- a/docs/d1-publish-pipeline.md
+++ b/docs/d1-publish-pipeline.md
@@ -38,6 +38,10 @@ Schema migrations are staged before the full content sync. `0003_add_tool_classi
`0005_add_tool_seo_metadata.sql` adds nullable `seo_title`, `seo_description`, and `agent_summary` columns. Tool authors may propose these fields in frontmatter, but the editorial reviewer accepts or revises the public wording. Omission remains valid for existing tools. The full sync writes supplied values, clears removed values to `NULL`, and advances `content_modified_at` when any of the three changes. The site resolves title and description independently; `agent_summary` appears before the existing visible description.
+`0007_add_announcement_x_handle.sql` adds nullable `announcement_x_handle TEXT` with no default or backfill. Its filename and SQL match the companion website migration (the content repository has no `0006` migration); keep this shared migration identity aligned. Apply it before deploying the updated full-sync publisher. The publish workflow already applies migrations before sync. Do not apply the `ALTER TABLE` twice manually if the website has already applied this migration to the same D1 database; inspect migration history during rollout.
+
+Tool frontmatter may optionally supply `announcementXHandle`. The parser trims outer whitespace and removes one optional leading `@`, preserving case. The canonical nonblank value must match ASCII `[A-Za-z0-9_]{1,15}`; URLs and non-string values, including `null`, are invalid. Omission or blank strings publish `NULL`, and removing a value clears it. Insert, update, and change detection all include this column. GitHub `submittedBy`/`submitted_by_github` remains entirely separate: do not infer, backfill, or substitute it as an announcement handle. Source readers expose the canonical camelCase field to downstream announcement consumers, which must add `@` only for display and omit tagging when absent. Neither repository currently implements announcement posting; this persistence contract alone does not create or trigger a post.
+
See [tool review guide](tool-review-guide.md) for wording and acceptance criteria.
## CI And Merge Behavior
@@ -90,6 +94,7 @@ Validation must fail on any of the following:
- malformed evidence items or evidence without an access date
- invalid entity, verification, image-dimension, date, array, or indexability metadata
- an empty or non-string tool `seoTitle`, `seoDescription`, or `agentSummary` when supplied
+- an invalid tool `announcementXHandle` (only omitted/blank or a 1–15-character ASCII handle, optionally prefixed with `@`, is accepted; URLs and `null` are rejected)
- an author-supplied `contentModifiedAt` field
- a missing or unsupported redirect-manifest version
- invalid redirect status/path combinations, duplicate sources, chains, or cycles
@@ -114,6 +119,7 @@ The D1 sync step must:
- set `is_active = 0` for redirect rows missing from the current manifest
- publish authored category scope, evidence, tool entity, comparison, and provenance fields
- publish optional tool SEO title, SEO description, and agent summary, including removal of previously approved values
+- publish explicitly supplied `announcementXHandle` as nullable `announcement_x_handle`, clearing omitted/blank values and tracking edits independently of GitHub attribution
- initialize `content_modified_at` for a new or migrated record and advance it only when substantive fields change
- advance `synced_at` on every sync without using it as a public content modification date
@@ -176,6 +182,7 @@ tools(
pricing,
classification,
submitted_by_github,
+ announcement_x_handle,
logo_url,
og_image_url,
logo_width,
diff --git a/docs/tool-review-guide.md b/docs/tool-review-guide.md
index 9ad4128..5143e9e 100644
--- a/docs/tool-review-guide.md
+++ b/docs/tool-review-guide.md
@@ -7,3 +7,11 @@ Review each field against the tool's cited first-party evidence and the existing
`seoTitle` replaces the generated page title only. `seoDescription` replaces the generated meta, Open Graph, Twitter, and WebPage description only. These two fields fall back independently, so reviewers can approve one without the other. `agentSummary` appears as a paragraph before the existing short description; it does not replace the description used in tool entities, exports, feeds, or LLM summaries.
During rollout, approve these fields one tool at a time when the wording is ready. An omitted or removed field is published as `NULL`, and the site retains its established fallback or layout. A change to any field counts as a substantive tool edit for the content modification timestamp.
+
+## Optional announcement tag
+
+`announcementXHandle` is a separate, opt-in announcement-tagging value. Do not require it for acceptance or substitute a GitHub submitter, repository owner, product name, or scraped social account. Confirm it was explicitly supplied for tagging; existing tools need no backfill.
+
+Accept outer whitespace and one optional leading `@`; publish the bare, case-preserved handle matching ASCII `[A-Za-z0-9_]{1,15}`. Omission and empty/whitespace-only strings mean no tag. Reject URLs (even X/Twitter profile URLs), `null`, other non-string types, extra `@` signs, punctuation, embedded whitespace, non-ASCII characters, and overlength handles. A lone `@` is invalid, not blank. Quote numeric-looking handles in YAML to keep them strings.
+
+The D1 value is nullable `announcement_x_handle`; removing or blanking the source clears it to `NULL`. Changes, including case-only edits or removal, advance `content_modified_at`; equivalent `@`/bare inputs do not. Metadata generation must preserve the field independently of `submittedBy`. No announcement-posting implementation exists in this repository: downstream posting must consume this explicit field and omit a tag when it is absent, never fall back to GitHub attribution. This field does not itself authorize or trigger a post.
diff --git a/migrations/0007_add_announcement_x_handle.sql b/migrations/0007_add_announcement_x_handle.sql
new file mode 100644
index 0000000..459f51c
--- /dev/null
+++ b/migrations/0007_add_announcement_x_handle.sql
@@ -0,0 +1,3 @@
+-- Optional, explicitly supplied announcement tag; independent of GitHub attribution.
+-- Existing rows remain NULL (no announcement tag). Apply before the content publisher writes it.
+ALTER TABLE tools ADD COLUMN announcement_x_handle TEXT;
diff --git a/scripts/lib/content.mjs b/scripts/lib/content.mjs
index ec92741..1f0f0a8 100644
--- a/scripts/lib/content.mjs
+++ b/scripts/lib/content.mjs
@@ -281,6 +281,11 @@ export function parseToolFile(raw, sourcePath) {
data[fieldName] = data[fieldName].trim();
}
}
+ if (typeof data.announcementXHandle === "string") {
+ const trimmed = data.announcementXHandle.trim();
+ // Only an actually blank input is absent; a lone @ must fail validation.
+ data.announcementXHandle = trimmed === "" ? undefined : trimmed.replace(/^@/, "");
+ }
return {
...data,
body: parsed.content.trim(),
@@ -457,6 +462,14 @@ export async function validateContent(rootDir = process.cwd()) {
errors.push(`${tool.sourcePath}: sortOrder must be an integer when present`);
}
+ if (tool.announcementXHandle !== undefined
+ && (typeof tool.announcementXHandle !== "string"
+ || !/^[A-Za-z0-9_]{1,15}$/.test(tool.announcementXHandle))) {
+ errors.push(
+ `${tool.sourcePath}: announcementXHandle must be 1–15 ASCII letters, digits, or underscores, optionally prefixed with @; URLs are not supported`,
+ );
+ }
+
for (const fieldName of [
"seoTitle",
"seoDescription",
diff --git a/scripts/lib/sync-sql.mjs b/scripts/lib/sync-sql.mjs
index 8935f4a..102e31d 100644
--- a/scripts/lib/sync-sql.mjs
+++ b/scripts/lib/sync-sql.mjs
@@ -82,6 +82,7 @@ const TOOL_CONTENT_COLUMNS = [
"pricing",
"classification",
"submitted_by_github",
+ "announcement_x_handle",
"logo_url",
"og_image_url",
"logo_width",
@@ -193,7 +194,7 @@ ON CONFLICT(slug) DO UPDATE SET
INSERT INTO tools (
slug, name, description, seo_title, seo_description, agent_summary,
body_md, category_slug, tags_json, website_url,
- github_url, pricing, classification, submitted_by_github, logo_url, og_image_url,
+ github_url, pricing, classification, submitted_by_github, announcement_x_handle, logo_url, og_image_url,
logo_width, logo_height, og_image_width, og_image_height, entity_type,
developer_name, docs_url, pricing_url, license_url, interfaces_json,
deployment_modes_json, evidence_json, verification_level,
@@ -208,7 +209,7 @@ VALUES (
${sqlString(tool.body)},
${sqlString(tool.category)}, ${sqlJson(tool.tags)}, ${sqlString(tool.websiteUrl)},
${sqlString(tool.githubUrl)}, ${sqlString(tool.pricing)}, ${sqlString(tool.classification)},
- ${sqlString(tool.submittedBy)}, ${sqlString(tool.logoUrl)}, ${sqlString(tool.ogImageUrl)},
+ ${sqlString(tool.submittedBy)}, ${sqlString(tool.announcementXHandle)}, ${sqlString(tool.logoUrl)}, ${sqlString(tool.ogImageUrl)},
${sqlInteger(tool.logoWidth)}, ${sqlInteger(tool.logoHeight)}, ${sqlInteger(tool.ogImageWidth)},
${sqlInteger(tool.ogImageHeight)}, ${sqlString(tool.entityType)}, ${sqlString(tool.developerName)},
${sqlString(tool.docsUrl)}, ${sqlString(tool.pricingUrl)}, ${sqlString(tool.licenseUrl)},
@@ -234,6 +235,7 @@ ON CONFLICT(slug) DO UPDATE SET
pricing = excluded.pricing,
classification = excluded.classification,
submitted_by_github = excluded.submitted_by_github,
+ announcement_x_handle = excluded.announcement_x_handle,
logo_url = excluded.logo_url,
og_image_url = excluded.og_image_url,
logo_width = excluded.logo_width,
diff --git a/test/announcement-x-handle.test.mjs b/test/announcement-x-handle.test.mjs
new file mode 100644
index 0000000..c04322b
--- /dev/null
+++ b/test/announcement-x-handle.test.mjs
@@ -0,0 +1,239 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { cp, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { DatabaseSync } from "node:sqlite";
+import { promisify } from "node:util";
+import matter from "gray-matter";
+import { loadContent, parseToolFile, readTools, validateContent } from "../scripts/lib/content.mjs";
+import { enrichOgImages, enrichToolAssets } from "../scripts/lib/og-images.mjs";
+import { generateSyncSql } from "../scripts/lib/sync-sql.mjs";
+
+const execFileAsync = promisify(execFile);
+const fixtureRoot = path.resolve("test/fixtures/valid");
+const toolPath = "tools/paperclip.md";
+
+async function withFixture(run) {
+ const root = await mkdtemp(path.join(os.tmpdir(), "agentfirst-x-handle-"));
+ try {
+ await cp(fixtureRoot, root, { recursive: true });
+ return await run(root);
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+}
+
+async function setHandle(root, value) {
+ const file = path.join(root, toolPath);
+ const parsed = matter(await readFile(file, "utf8"));
+ // gray-matter caches parsed data by source; never mutate a cached fixture object.
+ const data = { ...parsed.data };
+ if (value === undefined) delete data.announcementXHandle;
+ else data.announcementXHandle = value;
+ await writeFile(file, matter.stringify(parsed.content, data));
+}
+
+async function migratedDatabase({ beforeAnnouncement } = {}) {
+ const db = new DatabaseSync(":memory:");
+ for (const file of (await readdir("migrations")).filter((name) => name.endsWith(".sql")).sort()) {
+ if (file === "0007_add_announcement_x_handle.sql") beforeAnnouncement?.(db);
+ db.exec(await readFile(path.resolve("migrations", file), "utf8"));
+ }
+ return db;
+}
+
+for (const [value, expected] of [
+ [undefined, undefined], ["", undefined], [" \t\n ", undefined],
+ ["Mixed_Case9", "Mixed_Case9"], [" @Mixed_Case9 \t", "Mixed_Case9"],
+ ["a", "a"], ["@_", "_"], ["123456789012345", "123456789012345"],
+ ["Abcde12345_6789", "Abcde12345_6789"],
+ ["@Abcde12345_6789", "Abcde12345_6789"],
+]) {
+ test(`announcement handle accepts ${JSON.stringify(value)} without changing case or GitHub attribution`, async () => {
+ await withFixture(async (root) => {
+ await setHandle(root, value);
+ const result = await validateContent(root);
+ assert.deepEqual(result.errors, []);
+ const tool = result.tools.find((tool) => tool.slug === "paperclip");
+ assert.equal(tool.announcementXHandle, expected);
+ assert.equal(tool.submittedBy, "bradvin");
+ const source = (await readTools(root)).find((tool) => tool.slug === "paperclip");
+ assert.equal(source.announcementXHandle, expected, "announcement-facing source must be canonical too");
+ assert.equal(source.submittedBy, undefined);
+ });
+ });
+}
+
+for (const value of [
+ null, false, true, 42, 0, [], {}, ["valid"], { handle: "valid" },
+ "@", "@@valid", "@ valid", "with space", "two\nlines", "has-dash", "has.dot",
+ "é", "Foo", "name!", "1234567890123456", "@1234567890123456",
+ "https://x.com/Valid", "http://twitter.com/Valid", "https://www.x.com/Valid?x=1",
+ "x.com/Valid", "twitter.com/Valid", "//x.com/Valid", "mailto:Valid", "https://example.com",
+ "x.com", "@valid/", "name\u200b", "foo'bar",
+]) {
+ test(`announcement handle rejects ${JSON.stringify(value)}`, async () => {
+ await withFixture(async (root) => {
+ await setHandle(root, value);
+ const result = await validateContent(root);
+ assert(result.errors.some((error) => error.startsWith(`${toolPath}: announcementXHandle `)),
+ `expected announcementXHandle error, got ${JSON.stringify(result.errors)}`);
+ await assert.rejects(generateSyncSql(root), /Content validation failed/);
+ });
+ });
+}
+
+test("announcement handle is never inferred from or used as submitter attribution", async () => {
+ await withFixture(async (root) => {
+ await setHandle(root, "@Different_X");
+ await rm(path.join(root, "tool-submitters.json"));
+ const { tools } = await loadContent(root);
+ assert.equal(tools.find((tool) => tool.slug === "paperclip").submittedBy, undefined);
+ assert.equal(tools.find((tool) => tool.slug === "paperclip").announcementXHandle, "Different_X");
+ assert.equal(tools.find((tool) => tool.slug === "vapi").announcementXHandle, undefined);
+ await assert.rejects(generateSyncSql(root), (error) =>
+ error.validationErrors.includes(`${toolPath}: submitter must be set in tool-submitters.json`));
+ });
+});
+
+test("announcement handle migration adds nullable TEXT with no default or backfill", async () => {
+ const db = await migratedDatabase({
+ beforeAnnouncement(db) {
+ db.exec(`
+ INSERT INTO categories (slug, label, source_path) VALUES ('existing', 'Existing', 'existing.json');
+ INSERT INTO tools (slug, name, description, body_md, category_slug, tags_json,
+ website_url, pricing, source_path, submitted_by_github)
+ VALUES ('existing', 'Existing', 'Description', 'Body', 'existing', '[]',
+ 'https://example.com', 'free', 'tools/existing.md', 'OriginalGitHub');
+ `);
+ },
+ });
+ try {
+ const existing = db.prepare("SELECT announcement_x_handle, submitted_by_github FROM tools WHERE slug = 'existing'").get();
+ assert.deepEqual({ ...existing }, { announcement_x_handle: null, submitted_by_github: "OriginalGitHub" });
+ const column = db.prepare("PRAGMA table_info(tools)").all()
+ .find((column) => column.name === "announcement_x_handle");
+ assert(column, "announcement_x_handle must exist");
+ assert.deepEqual({ type: column.type, notnull: column.notnull, defaultValue: column.dflt_value },
+ { type: "TEXT", notnull: 0, defaultValue: null });
+ db.exec(await generateSyncSql(fixtureRoot));
+ assert(db.prepare("SELECT announcement_x_handle FROM tools").all()
+ .every((row) => row.announcement_x_handle === null));
+ } finally {
+ db.close();
+ }
+});
+
+async function assertSqlRoundTrip(db, root) {
+ const read = () => db.prepare(`SELECT announcement_x_handle, submitted_by_github, content_modified_at
+ FROM tools WHERE slug = 'paperclip'`).get();
+ const old = "2000-01-01 00:00:00";
+ const resetTime = () => db.prepare("UPDATE tools SET content_modified_at = ? WHERE slug = 'paperclip'").run(old);
+
+ // Supplied on the initial insert, not only on subsequent updates.
+ await setHandle(root, " @Mixed_Case9 \t");
+ db.exec(await generateSyncSql(root));
+ assert.equal(read().announcement_x_handle, "Mixed_Case9");
+ assert.equal(read().submitted_by_github, "bradvin");
+ assert.equal(db.prepare("SELECT announcement_x_handle FROM tools WHERE slug = 'vapi'").get().announcement_x_handle, null);
+
+ resetTime();
+ await setHandle(root, "Mixed_Case9");
+ db.exec(await generateSyncSql(root));
+ assert.equal(read().content_modified_at, old, "equivalent canonical input is a no-op");
+
+ resetTime();
+ await setHandle(root, "mixed_Case9");
+ db.exec(await generateSyncSql(root));
+ assert.equal(read().announcement_x_handle, "mixed_Case9");
+ assert.notEqual(read().content_modified_at, old, "case-only edit is tracked");
+
+ for (const value of [" ", undefined]) {
+ await setHandle(root, "@Another_X");
+ db.exec(await generateSyncSql(root));
+ resetTime();
+ await setHandle(root, value);
+ db.exec(await generateSyncSql(root));
+ assert.equal(read().announcement_x_handle, null, "blank or removal clears to NULL");
+ assert.equal(read().submitted_by_github, "bradvin");
+ assert.notEqual(read().content_modified_at, old, "clearing is tracked");
+ resetTime();
+ db.exec(await generateSyncSql(root));
+ assert.equal(read().content_modified_at, old, "absent handle remains a no-op");
+ }
+}
+
+test("announcement handle SQL inserts, updates, clears and tracks changes without attribution conflation", async () => {
+ const db = await migratedDatabase();
+ try {
+ await withFixture((root) => assertSqlRoundTrip(db, root));
+ } finally {
+ db.close();
+ }
+});
+
+test("content SQL round-trips against the website migration when its checkout is available", async (t) => {
+ const migrationDir = path.resolve("../agentfirst.directory-site-x-announcement/migrations");
+ try {
+ await readFile(path.join(migrationDir, "0007_add_announcement_x_handle.sql"), "utf8");
+ } catch (error) {
+ if (error.code !== "ENOENT") throw error;
+ t.skip("companion website migration checkout is not available");
+ return;
+ }
+ const db = new DatabaseSync(":memory:");
+ try {
+ for (const file of (await readdir(migrationDir)).filter((name) => name.endsWith(".sql")).sort()) {
+ db.exec(await readFile(path.join(migrationDir, file), "utf8"));
+ }
+ await withFixture((root) => assertSqlRoundTrip(db, root));
+ } finally {
+ db.close();
+ }
+});
+
+for (const enrich of [enrichOgImages, enrichToolAssets]) {
+ test(`${enrich.name} preserves authored announcement handle and does not infer it from social metadata`, async () => {
+ await withFixture(async (root) => {
+ await setHandle(root, " @Mixed_Case9 ");
+ const before = await readFile(path.join(root, toolPath), "utf8");
+ const authoredLine = before.split("\n").find((line) => line.startsWith("announcementXHandle:"));
+ const summary = await enrich({
+ rootDir: root, write: true, refresh: true, refreshOg: true, refreshLogo: true,
+ fetchImpl: async (url) => ({
+ ok: true, url, headers: new Headers({ "content-type": "text/html" }),
+ async text() {
+ return '';
+ },
+ }),
+ logger: { log() {}, error() {} },
+ });
+ assert.equal(summary.failed, 0);
+ const after = await readFile(path.join(root, toolPath), "utf8");
+ assert(after.includes(`${authoredLine}\n`), "generator must retain authored bytes");
+ assert.equal(parseToolFile(after, toolPath).announcementXHandle, "Mixed_Case9");
+ const result = await validateContent(root);
+ assert.deepEqual(result.errors, []);
+ assert.equal(result.tools.find((tool) => tool.slug === "vapi").announcementXHandle, undefined);
+ });
+ });
+}
+
+test("submitter metadata generator preserves the announcement handle while stripping submittedBy", async () => {
+ await withFixture(async (root) => {
+ await setHandle(root, "@Mixed_Case9");
+ const file = path.join(root, toolPath);
+ const original = await readFile(file, "utf8");
+ await writeFile(file, original.replace("---\n", '---\nsubmittedBy: "spoofed"\n'));
+ await execFileAsync(process.execPath, [path.resolve("scripts/sync-tool-submitters.mjs"),
+ "--root-dir", root, "--base-root-dir", fixtureRoot,
+ "--submitted-by", "different-pr-author", "--slug", "paperclip", "--slug", "vapi"]);
+ assert.equal(await readFile(file, "utf8"), original);
+ const { tools } = await loadContent(root);
+ assert.equal(tools.find((tool) => tool.slug === "paperclip").announcementXHandle, "Mixed_Case9");
+ assert.equal(tools.find((tool) => tool.slug === "paperclip").submittedBy, "bradvin");
+ assert.equal(tools.find((tool) => tool.slug === "vapi").announcementXHandle, undefined);
+ });
+});
diff --git a/test/redirects.test.mjs b/test/redirects.test.mjs
index 4bd845c..66d4d06 100644
--- a/test/redirects.test.mjs
+++ b/test/redirects.test.mjs
@@ -80,6 +80,7 @@ async function applyMigrations(db) {
"0003_add_tool_classification.sql",
"0004_add_editorial_seo_metadata.sql",
"0005_add_tool_seo_metadata.sql",
+ "0007_add_announcement_x_handle.sql",
]) {
db.exec(await readMigration(migration));
}
diff --git a/test/sync-sql.test.mjs b/test/sync-sql.test.mjs
index 5060229..9709f17 100644
--- a/test/sync-sql.test.mjs
+++ b/test/sync-sql.test.mjs
@@ -137,6 +137,7 @@ test("sync SQL upserts repo content and retires missing rows", async () => {
db.exec(await readMigration("0003_add_tool_classification.sql"));
db.exec(await readMigration("0004_add_editorial_seo_metadata.sql"));
db.exec(await readMigration("0005_add_tool_seo_metadata.sql"));
+ db.exec(await readMigration("0007_add_announcement_x_handle.sql"));
db.exec(`
INSERT INTO categories (slug, label, sort_order, source_path, is_active, synced_at)
@@ -292,6 +293,7 @@ test("no-op sync preserves content modification timestamps while visible edits a
"0003_add_tool_classification.sql",
"0004_add_editorial_seo_metadata.sql",
"0005_add_tool_seo_metadata.sql",
+ "0007_add_announcement_x_handle.sql",
]) {
db.exec(await readMigration(migration));
}
@@ -352,6 +354,7 @@ test("tool SEO sync inserts, escapes, updates, clears, and tracks changes", asyn
"0003_add_tool_classification.sql",
"0004_add_editorial_seo_metadata.sql",
"0005_add_tool_seo_metadata.sql",
+ "0007_add_announcement_x_handle.sql",
]) {
db.exec(await readMigration(migration));
}
@@ -404,6 +407,7 @@ test("sync SQL publishes optional provenance without synthesizing review dates",
"0003_add_tool_classification.sql",
"0004_add_editorial_seo_metadata.sql",
"0005_add_tool_seo_metadata.sql",
+ "0007_add_announcement_x_handle.sql",
]) {
db.exec(await readMigration(migration));
}