Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"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"
},
"dependencies": {
"zod": "^4.6.5"
}
}
132 changes: 132 additions & 0 deletions packages/shared/src/hashtags.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
47 changes: 47 additions & 0 deletions packages/shared/src/hashtags.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
const seenInvalid = new Set<string>();

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 };
}
2 changes: 2 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./hashtags";
export * from "./schemas";
135 changes: 135 additions & 0 deletions packages/shared/src/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
58 changes: 58 additions & 0 deletions packages/shared/src/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CheckRequest>;

export const HashtagStatus = z.enum([
"banned",
"restricted",
"reported",
"clear",
"unknown",
]);
export type HashtagStatus = z.infer<typeof HashtagStatus>;

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<typeof CheckResult>;

export const CheckResponse = z.object({
results: z.array(CheckResult),
checkedAt: z.iso.datetime({ offset: true }),
});
export type CheckResponse = z.infer<typeof CheckResponse>;

export const ApiErrorCode = z.enum([
"bad_request",
"not_found",
"payload_too_large",
"rate_limited",
"internal_error",
]);
export type ApiErrorCode = z.infer<typeof ApiErrorCode>;

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<typeof ApiError>;
Loading
Loading