Loading files
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.
// Shared input rules for the community — used by both the client forms and the
// API routes (the API is the authority; client checks are UX only).
export const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
export const PASSWORD_MIN = 8;
export const TITLE_MAX = 80;
export const DESCRIPTION_MAX = 2000;
export const CODE_MAX = 100_000; // ~100KB of pattern source
export const CPP_MAX = 200_000; // headers carry baked LUT tables, so allow more
export const COMMENT_MAX = 2000;
export function cleanTitle(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const title = raw.trim();
if (title.length === 0 || title.length > TITLE_MAX) return null;
return title;
}
export function cleanDescription(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null) return null;
if (typeof raw !== "string") return undefined; // invalid
const description = raw.trim();
if (description.length > DESCRIPTION_MAX) return undefined;
return description.length === 0 ? null : description;
}
export function cleanCode(raw: unknown): string | null {
if (typeof raw !== "string") return null;
if (raw.trim().length === 0 || raw.length > CODE_MAX) return null;
return raw;
}
/**
* Firmware header check. We cannot compile ESP32 C++ here, so this is a shape
* check only — enough to reject a pasted JS file or truncated output, not a
* guarantee the header builds. The UI says so; the author's own hardware test
* is the real verification.
*
* Returns the cleaned header, `null` to CLEAR the field, or `undefined` when
* the input is invalid.
*/
export function cleanCpp(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null) return null;
if (typeof raw !== "string") return undefined;
const cpp = raw.trim();
if (cpp.length === 0) return null; // explicit clear
if (cpp.length > CPP_MAX) return undefined;
if (!/^\s*#pragma\s+once\b/m.test(cpp)) return undefined;
return cpp;
}
/**
* "Made on" date: YYYY-MM-DD, a real calendar day, not in the future.
* Returns the value, `null` to clear it, or `undefined` when invalid.
*/
export function cleanMadeOn(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null) return null;
if (typeof raw !== "string") return undefined;
const value = raw.trim();
if (value.length === 0) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const parsed = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(parsed.getTime())) return undefined;
// Round-trip catches days that don't exist, e.g. 2026-02-31 rolling over.
if (parsed.toISOString().slice(0, 10) !== value) return undefined;
// A day of slack so a timezone ahead of the server isn't rejected.
if (parsed.getTime() > Date.now() + 86_400_000) return undefined;
return value;
}
/**
* Thread body. A thread in the workshop is a build log — what you tried, what
* it measured, what broke — so it gets room a comment does not.
*
* Stored exactly as typed. It renders as a closed subset of markdown (see
* PostBody), and NOTHING is stripped on the way in: the text somebody wrote is
* the text that comes back out of the database, so changing what renders never
* silently rewrites what was already posted.
*/
export const POST_BODY_MAX = 20_000;
export function cleanPostBody(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const body = raw.trim();
if (body.length === 0 || body.length > POST_BODY_MAX) return null;
return body;
}
export function cleanComment(raw: unknown): string | null {
if (typeof raw !== "string") return null;
const body = raw.trim();
if (body.length === 0 || body.length > COMMENT_MAX) return null;
return body;
}
/**
* A moderator's reason for taking something off the wall. Optional — and on a
* pattern it is posted as a comment in the moderator's name, so it is held to
* a comment's length. `null` for none, `undefined` when it cannot be used.
*/
export function cleanModerationReason(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null) return null;
if (typeof raw !== "string") return undefined;
const reason = raw.trim();
if (reason.length > COMMENT_MAX) return undefined;
return reason.length === 0 ? null : reason;
}
// ── Provenance ───────────────────────────────────────────────────────────────
/**
* How a pattern was made, declared by its author at publication.
*
* Three options, not a slider: the honest answer to "did a person write this"
* is one of these three, and more granularity would only invite guessing.
* Nothing is hidden or penalised — Pattern Lab's whole point is AI generation
* with a human hand on the output, and saying so plainly is worth more than a
* field everyone leaves blank.
*/
export const MADE_HOW_VALUES = ["hand", "ai-assisted", "ai-generated"] as const;
export type MadeHow = (typeof MADE_HOW_VALUES)[number];
export const MADE_HOW_LABELS: Record<MadeHow, string> = {
hand: "Written by hand",
"ai-assisted": "AI-assisted, then edited",
"ai-generated": "AI-generated, then edited",
};
/** Shown on the pattern page, so it has to say something a reader can use. */
export const MADE_HOW_BLURBS: Record<MadeHow, string> = {
hand: "The author wrote this code themselves.",
"ai-assisted": "Written with AI help and reworked by the author.",
"ai-generated": "Generated by AI, then selected and reworked by the author.",
};
export function cleanMadeHow(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null) return null;
if (typeof raw !== "string") return undefined;
const value = raw.trim();
if (value.length === 0) return null;
return MADE_HOW_VALUES.includes(value as MadeHow) ? value : undefined;
}
// ── Reports ──────────────────────────────────────────────────────────────────
export const REPORT_TARGET_TYPES = ["pattern", "post", "comment", "deck"] as const;
export type ReportTargetType = (typeof REPORT_TARGET_TYPES)[number];
export function isReportTargetType(raw: unknown): raw is ReportTargetType {
return typeof raw === "string" && REPORT_TARGET_TYPES.includes(raw as ReportTargetType);
}
/**
* Why something was reported. "malicious" is on the list because a pattern is
* executable code that other people run in their browser and flash to their
* board — a category the usual report menus have no word for.
*/
/** Reasons, in the order they are offered.
*
* Strobing leads: it is the only one where the harm is to the person looking
* at the page RIGHT NOW rather than to an author or to the site, and a
* photosensitive visitor should not have to read past three property disputes
* to find it. The rest keep their existing values — reports already filed
* carry them, and the moderation queue renders from this map. */
export const REPORT_REASONS = [
"strobing",
"copyright",
"malicious",
"inappropriate",
"spam",
"other",
] as const;
export type ReportReason = (typeof REPORT_REASONS)[number];
export const REPORT_REASON_LABELS: Record<ReportReason, string> = {
strobing: "Strobing / photosensitive risk",
copyright: "Stolen work / missing credit",
malicious: "Malicious code",
inappropriate: "Inappropriate or abusive content",
spam: "Spam or junk",
other: "Something else",
};
export const REPORT_DETAIL_MAX = 2000;
export function cleanReportDetail(raw: unknown): string | null | undefined {
if (raw === undefined || raw === null) return null;
if (typeof raw !== "string") return undefined;
const detail = raw.trim();
if (detail.length > REPORT_DETAIL_MAX) return undefined;
return detail.length === 0 ? null : detail;
}