From e8283fe3a43fe8f44532dacd7a213e89069d5405 Mon Sep 17 00:00:00 2001 From: Dominic Idagu Date: Sat, 10 Oct 2026 04:58:23 +0200 Subject: [PATCH 1/2] feat(shared): hashtag parser Co-Authored-By: Claude Opus 5.5 --- packages/shared/package.json | 16 + packages/shared/src/hashtags.test.ts | 132 +++++++ packages/shared/src/hashtags.ts | 47 +++ packages/shared/src/index.ts | 1 + packages/shared/tsconfig.json | 7 + pnpm-lock.yaml | 561 +++++++++++++++++++++++++++ 6 files changed, 764 insertions(+) create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/hashtags.test.ts create mode 100644 packages/shared/src/hashtags.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/tsconfig.json diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..33e44b5 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,16 @@ +{ + "name": "@cleanhashtags/shared", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^5.0.3" + } +} diff --git a/packages/shared/src/hashtags.test.ts b/packages/shared/src/hashtags.test.ts new file mode 100644 index 0000000..ef183ae --- /dev/null +++ b/packages/shared/src/hashtags.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_TAG_LENGTH, + MAX_TAGS, + isValidHashtag, + normalizeHashtag, + parseHashtags, +} from "./hashtags"; + +describe("parseHashtags", () => { + it("returns nothing for empty or blank input", () => { + expect(parseHashtags("")).toEqual({ tags: [], invalid: [] }); + expect(parseHashtags(" \n\t , ,")).toEqual({ tags: [], invalid: [] }); + }); + + it("splits on whitespace, commas and newlines", () => { + expect(parseHashtags("a b,c\nd\r\ne\tf, ,g").tags).toEqual([ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + ]); + }); + + it("strips one or more leading #", () => { + expect(parseHashtags("#travel ##food ###sun").tags).toEqual([ + "travel", + "food", + "sun", + ]); + }); + + it("ignores lone # tokens", () => { + expect(parseHashtags("# ## #ok")).toEqual({ tags: ["ok"], invalid: [] }); + }); + + it("lowercases and dedupes preserving first-seen order", () => { + expect(parseHashtags("#Sunset #beach #SUNSET sunset #Beach").tags).toEqual([ + "sunset", + "beach", + ]); + }); + + it("applies NFKC normalization before deduping", () => { + const fullWidth = "TAG"; + const decomposed = "café"; + expect(parseHashtags(`${fullWidth} tag ${decomposed} café`).tags).toEqual([ + "tag", + "café", + ]); + }); + + it("accepts unicode letters, digits and underscores", () => { + expect( + parseHashtags("#über #東京 #москва #नमस्ते #photo_of_the_day #2024vibes") + .tags, + ).toEqual([ + "über", + "東京", + "москва", + "नमस्ते", + "photo_of_the_day", + "2024vibes", + ]); + }); + + it("rejects emoji and punctuation, keeping the original token", () => { + expect(parseHashtags("#love❤️ #🔥 #rock&roll #hello-world #ok")).toEqual({ + tags: ["ok"], + invalid: ["#love❤️", "#🔥", "#rock&roll", "#hello-world"], + }); + }); + + it("rejects tags made only of digits", () => { + expect(parseHashtags("#2024 #123abc")).toEqual({ + tags: ["123abc"], + invalid: ["#2024"], + }); + }); + + it("rejects # inside a tag", () => { + expect(parseHashtags("#one#two").invalid).toEqual(["#one#two"]); + }); + + it("enforces the maximum tag length", () => { + const ok = "a".repeat(MAX_TAG_LENGTH); + const tooLong = "a".repeat(MAX_TAG_LENGTH + 1); + expect(parseHashtags(`${ok} ${tooLong}`)).toEqual({ + tags: [ok], + invalid: [tooLong], + }); + }); + + it("counts length in code points, not UTF-16 units", () => { + const astral = "\u{20000}".repeat(MAX_TAG_LENGTH); + expect(parseHashtags(astral).tags).toEqual([astral]); + }); + + it("dedupes invalid tokens", () => { + expect(parseHashtags("#a-b #a-b").invalid).toEqual(["#a-b"]); + }); + + it("does not cap the number of tags itself", () => { + const input = Array.from({ length: MAX_TAGS + 5 }, (_, i) => `t${i}`).join( + " ", + ); + expect(parseHashtags(input).tags).toHaveLength(MAX_TAGS + 5); + }); +}); + +describe("normalizeHashtag", () => { + it("strips leading # and lowercases", () => { + expect(normalizeHashtag("##InstaGood")).toBe("instagood"); + }); +}); + +describe("isValidHashtag", () => { + it.each(["a", "a_b", "_", "x1", "ñandú"])("accepts %s", (tag) => { + expect(isValidHashtag(tag)).toBe(true); + }); + + it.each(["", "1", "a b", "a.b", "a#b", "😀"])("rejects %j", (tag) => { + expect(isValidHashtag(tag)).toBe(false); + }); +}); + +it("exposes MAX_TAGS = 30", () => { + expect(MAX_TAGS).toBe(30); +}); diff --git a/packages/shared/src/hashtags.ts b/packages/shared/src/hashtags.ts new file mode 100644 index 0000000..008af73 --- /dev/null +++ b/packages/shared/src/hashtags.ts @@ -0,0 +1,47 @@ +export const MAX_TAGS = 30; +export const MAX_TAG_LENGTH = 100; + +export interface ParsedHashtags { + tags: string[]; + invalid: string[]; +} + +const SEPARATORS = /[\s,]+/u; +const VALID_TAG = /^[\p{L}\p{M}\p{Nd}_]+$/u; +const ALL_DIGITS = /^\p{Nd}+$/u; + +export function normalizeHashtag(raw: string): string { + return raw.replace(/^#+/u, "").normalize("NFKC").toLowerCase(); +} + +export function isValidHashtag(tag: string): boolean { + return ( + [...tag].length <= MAX_TAG_LENGTH && + VALID_TAG.test(tag) && + !ALL_DIGITS.test(tag) + ); +} + +export function parseHashtags(input: string): ParsedHashtags { + const tags: string[] = []; + const invalid: string[] = []; + const seenTags = new Set(); + const seenInvalid = new Set(); + + for (const token of input.split(SEPARATORS)) { + const tag = normalizeHashtag(token); + if (tag === "") continue; + + if (isValidHashtag(tag)) { + if (!seenTags.has(tag)) { + seenTags.add(tag); + tags.push(tag); + } + } else if (!seenInvalid.has(token)) { + seenInvalid.add(token); + invalid.push(token); + } + } + + return { tags, invalid }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..ccea877 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1 @@ +export * from "./hashtags"; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..b66432d --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": [] + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02db539..fdf6491 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,6 +191,12 @@ importers: specifier: ^8.71.1 version: 8.71.1(eslint@10.12.0)(typescript@6.0.3) + packages/shared: + devDependencies: + vitest: + specifier: ^5.0.3 + version: 5.0.3(vite@8.3.4) + packages: '@babel/code-frame@7.29.7': @@ -350,6 +356,114 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -418,6 +532,20 @@ packages: resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -431,6 +559,10 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -455,6 +587,10 @@ packages: caniuse-lite@1.0.30001815: resolution: {integrity: sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==} + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -474,9 +610,16 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + electron-to-chromium@1.5.452: resolution: {integrity: sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==} + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -535,10 +678,17 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -570,6 +720,11 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -647,6 +802,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -654,6 +883,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -661,6 +893,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -668,6 +905,10 @@ packages: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -695,6 +936,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -712,6 +957,11 @@ packages: resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} engines: {node: '>=20'} + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -729,6 +979,21 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + tinybench@6.2.1: + resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -764,11 +1029,100 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -980,6 +1334,62 @@ snapshots: '@keyv/serialize@1.1.1': {} + '@oxc-project/types@0.153.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -1077,6 +1487,17 @@ snapshots: '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 + '@vitest/mocker@5.0.3(vite@8.3.4)': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4 + + '@vitest/spy@5.0.3': {} + acorn-jsx@5.3.2(acorn@8.19.0): dependencies: acorn: 8.19.0 @@ -1090,6 +1511,8 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + assertion-error@2.0.1: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.11.28: {} @@ -1116,6 +1539,8 @@ snapshots: caniuse-lite@1.0.30001815: {} + chai@6.3.0: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -1130,8 +1555,12 @@ snapshots: deep-is@0.1.4: {} + detect-libc@2.1.2: {} + electron-to-chromium@1.5.452: {} + es-module-lexer@2.3.2: {} + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -1213,8 +1642,14 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -1242,6 +1677,9 @@ snapshots: flatted@3.4.4: {} + fsevents@2.3.3: + optional: true + gensync@1.0.0-beta.2: {} glob-parent@6.0.2: @@ -1297,6 +1735,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -1305,16 +1792,24 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + minimatch@10.2.6: dependencies: brace-expansion: 5.0.12 ms@2.1.3: {} + nanoid@3.3.20: {} + natural-compare@1.4.0: {} node-releases@2.0.58: {} + obug@2.2.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -1340,6 +1835,12 @@ snapshots: picomatch@4.0.7: {} + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + prelude-ls@1.2.1: {} prettier@3.9.9: {} @@ -1350,6 +1851,27 @@ snapshots: dependencies: hookified: 2.2.0 + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + semver@6.3.1: {} semver@7.8.5: {} @@ -1360,6 +1882,14 @@ snapshots: shebang-regex@3.0.0: {} + source-map-js@1.2.2: {} + + std-env@4.3.0: {} + + tinybench@6.2.1: {} + + tinyexec@1.3.1: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.7) @@ -1396,10 +1926,41 @@ snapshots: dependencies: punycode: 2.3.1 + vite@8.3.4: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@5.0.3(vite@8.3.4): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.4) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.1 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4 + why-is-node-running: 3.2.1 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@3.2.1: {} + word-wrap@1.2.5: {} yallist@3.1.1: {} From c3ed72ef7eeff14d9f8f4098ab8f1f739adec847 Mon Sep 17 00:00:00 2001 From: Dominic Idagu Date: Sat, 10 Oct 2026 04:58:55 +0200 Subject: [PATCH 2/2] feat(shared): check API schemas Co-Authored-By: Claude Opus 5.5 --- packages/shared/package.json | 3 + packages/shared/src/index.ts | 1 + packages/shared/src/schemas.test.ts | 135 ++++++++++++++++++++++++++++ packages/shared/src/schemas.ts | 58 ++++++++++++ pnpm-lock.yaml | 4 + 5 files changed, 201 insertions(+) create mode 100644 packages/shared/src/schemas.test.ts create mode 100644 packages/shared/src/schemas.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 33e44b5..7f3a3e6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,5 +12,8 @@ }, "devDependencies": { "vitest": "^5.0.3" + }, + "dependencies": { + "zod": "^4.6.5" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ccea877..375e95a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,2 @@ export * from "./hashtags"; +export * from "./schemas"; diff --git a/packages/shared/src/schemas.test.ts b/packages/shared/src/schemas.test.ts new file mode 100644 index 0000000..e8699c3 --- /dev/null +++ b/packages/shared/src/schemas.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { MAX_TAGS } from "./hashtags"; +import { + ApiError, + CheckRequest, + CheckResponse, + CheckResult, + HashtagStatus, +} from "./schemas"; + +describe("CheckRequest", () => { + it("accepts 1..MAX_TAGS hashtags", () => { + expect(CheckRequest.safeParse({ hashtags: ["#travel"] }).success).toBe( + true, + ); + const max = Array.from({ length: MAX_TAGS }, (_, i) => `t${i}`); + expect(CheckRequest.safeParse({ hashtags: max }).success).toBe(true); + }); + + it("trims each entry", () => { + expect(CheckRequest.parse({ hashtags: [" #a "] })).toEqual({ + hashtags: ["#a"], + }); + }); + + it.each([ + ["empty array", { hashtags: [] }], + [ + "too many tags", + { hashtags: Array.from({ length: MAX_TAGS + 1 }, (_, i) => `t${i}`) }, + ], + ["blank entry", { hashtags: [" "] }], + ["oversized entry", { hashtags: ["a".repeat(201)] }], + ["non-string entry", { hashtags: [42] }], + ["missing field", {}], + ["string instead of array", { hashtags: "#a #b" }], + ["null body", null], + ])("rejects %s", (_label, payload) => { + expect(CheckRequest.safeParse(payload).success).toBe(false); + }); +}); + +describe("HashtagStatus", () => { + it("lists every status", () => { + expect(HashtagStatus.options).toEqual([ + "banned", + "restricted", + "reported", + "clear", + "unknown", + ]); + }); + + it("rejects anything else", () => { + expect(HashtagStatus.safeParse("safe").success).toBe(false); + }); +}); + +describe("CheckResult", () => { + it("accepts a minimal result", () => { + expect( + CheckResult.safeParse({ hashtag: "dm", status: "unknown" }).success, + ).toBe(true); + }); + + it("accepts source and date or datetime lastVerifiedAt", () => { + for (const lastVerifiedAt of ["2026-10-10", "2026-10-10T12:00:00Z"]) { + expect( + CheckResult.safeParse({ + hashtag: "dm", + status: "reported", + source: "community-list-2020", + lastVerifiedAt, + }).success, + ).toBe(true); + } + }); + + it.each([ + ["bad status", { hashtag: "dm", status: "safe" }], + ["empty hashtag", { hashtag: "", status: "clear" }], + [ + "non-ISO date", + { hashtag: "dm", status: "clear", lastVerifiedAt: "10/10/2026" }, + ], + ["null source", { hashtag: "dm", status: "clear", source: null }], + ])("rejects %s", (_label, payload) => { + expect(CheckResult.safeParse(payload).success).toBe(false); + }); +}); + +describe("CheckResponse", () => { + it("accepts a valid response", () => { + const parsed = CheckResponse.safeParse({ + results: [{ hashtag: "dm", status: "reported" }], + checkedAt: "2026-10-10T12:00:00.000Z", + }); + expect(parsed.success).toBe(true); + }); + + it("rejects a missing checkedAt or invalid result", () => { + expect(CheckResponse.safeParse({ results: [] }).success).toBe(false); + expect( + CheckResponse.safeParse({ + results: [{ hashtag: "dm" }], + checkedAt: "2026-10-10T12:00:00Z", + }).success, + ).toBe(false); + }); +}); + +describe("ApiError", () => { + it("accepts an error with and without issues", () => { + expect( + ApiError.safeParse({ + error: { code: "rate_limited", message: "Too many requests" }, + }).success, + ).toBe(true); + expect( + ApiError.safeParse({ + error: { + code: "bad_request", + message: "Invalid body", + issues: [{ path: "hashtags", message: "Too small" }], + }, + }).success, + ).toBe(true); + }); + + it("rejects unknown codes", () => { + expect( + ApiError.safeParse({ error: { code: "teapot", message: "no" } }).success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts new file mode 100644 index 0000000..2c7d515 --- /dev/null +++ b/packages/shared/src/schemas.ts @@ -0,0 +1,58 @@ +import { z } from "zod"; +import { MAX_TAGS } from "./hashtags"; + +/** Raw input is normalized server-side, so allow some slack over MAX_TAG_LENGTH for `#` prefixes. */ +const MAX_RAW_HASHTAG_LENGTH = 200; + +export const CheckRequest = z.object({ + hashtags: z + .array(z.string().trim().min(1).max(MAX_RAW_HASHTAG_LENGTH)) + .min(1) + .max(MAX_TAGS), +}); +export type CheckRequest = z.infer; + +export const HashtagStatus = z.enum([ + "banned", + "restricted", + "reported", + "clear", + "unknown", +]); +export type HashtagStatus = z.infer; + +const IsoTimestamp = z.union([z.iso.date(), z.iso.datetime({ offset: true })]); + +export const CheckResult = z.object({ + hashtag: z.string().min(1), + status: HashtagStatus, + source: z.string().min(1).optional(), + lastVerifiedAt: IsoTimestamp.optional(), +}); +export type CheckResult = z.infer; + +export const CheckResponse = z.object({ + results: z.array(CheckResult), + checkedAt: z.iso.datetime({ offset: true }), +}); +export type CheckResponse = z.infer; + +export const ApiErrorCode = z.enum([ + "bad_request", + "not_found", + "payload_too_large", + "rate_limited", + "internal_error", +]); +export type ApiErrorCode = z.infer; + +export const ApiError = z.object({ + error: z.object({ + code: ApiErrorCode, + message: z.string(), + issues: z + .array(z.object({ path: z.string(), message: z.string() })) + .optional(), + }), +}); +export type ApiError = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdf6491..ae6f8d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,10 @@ importers: version: 8.71.1(eslint@10.12.0)(typescript@6.0.3) packages/shared: + dependencies: + zod: + specifier: ^4.6.5 + version: 4.6.5 devDependencies: vitest: specifier: ^5.0.3