diff --git a/apps/api/src/modules/website/domains.ts b/apps/api/src/modules/website/domains.ts index 9ef570990..2429ced53 100644 --- a/apps/api/src/modules/website/domains.ts +++ b/apps/api/src/modules/website/domains.ts @@ -24,6 +24,7 @@ import { requireTier, type AuthVariables } from '@mana/shared-hono'; import { errorResponse, validationError } from '../../lib/responses'; import { websiteDomainVerifyTotal } from '../../lib/metrics'; import { db, customDomains } from './schema'; +import { isValidHostname } from './reserved-slugs'; const routes = new Hono<{ Variables: AuthVariables }>(); @@ -36,36 +37,6 @@ routes.use('/sites/*/domains/*', requireTier('founder')); const DNS_TARGET = process.env.WEBSITE_DNS_TARGET ?? 'custom.mana.how'; const CHALLENGE_PREFIX = '_mana-challenge'; -// Conservative hostname whitelist — lowercase letters, digits, dots, -// hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal -// names. Not a full RFC-compliant parser — just a sanity check before -// we hand the string to dns.resolve. -const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; - -// Reserved hostnames that must not be user-bound. `mana.how` itself + -// every app subdomain (todo, chat, …) lives here so a user can't point -// their CNAME at the app's root. -const RESERVED_HOSTNAMES = new Set([ - 'mana.how', - 'www.mana.how', - 'api.mana.how', - 'auth.mana.how', - 'app.mana.how', - 'admin.mana.how', - 'custom.mana.how', - 'events.mana.how', - 'research.mana.how', -]); - -function isValidHostname(raw: string): boolean { - if (!raw) return false; - const lower = raw.toLowerCase().trim(); - if (!HOSTNAME_RE.test(lower)) return false; - if (RESERVED_HOSTNAMES.has(lower)) return false; - if (lower.endsWith('.mana.how')) return false; // reserved root - return true; -} - function randomToken(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); return Array.from(bytes) diff --git a/apps/api/src/modules/website/reserved-slugs.test.ts b/apps/api/src/modules/website/reserved-slugs.test.ts new file mode 100644 index 000000000..38acb975b --- /dev/null +++ b/apps/api/src/modules/website/reserved-slugs.test.ts @@ -0,0 +1,100 @@ +/** + * Slug + hostname validator tests. Pure — no DB, no DNS. + */ + +import { describe, it, expect } from 'vitest'; +import { isValidSlug, RESERVED_SLUGS, isValidHostname, RESERVED_HOSTNAMES } from './reserved-slugs'; + +describe('isValidSlug (server)', () => { + it('accepts 2-40 lowercase alphanumerics + hyphens', () => { + for (const s of ['ab', 'hello', 'hello-world', '1234', 'a'.repeat(40)]) { + expect(isValidSlug(s), `"${s}"`).toBe(true); + } + }); + + it('rejects single chars, too-long, uppercase, edge hyphens, non-ASCII', () => { + for (const s of ['', 'a', 'a'.repeat(41), 'Hello', '-x', 'x-', 'a b', 'ä']) { + expect(isValidSlug(s), `"${s}"`).toBe(false); + } + }); + + it('rejects every entry in RESERVED_SLUGS', () => { + for (const slug of RESERVED_SLUGS) { + expect(isValidSlug(slug), `reserved "${slug}"`).toBe(false); + } + }); + + it('reserved-list covers the minimum app routes', () => { + for (const must of ['app', 'api', 'auth', 'admin', 's', 'www']) { + expect(RESERVED_SLUGS.includes(must), `${must} must be reserved`).toBe(true); + } + }); +}); + +describe('isValidHostname', () => { + it('accepts reasonable external domains', () => { + const valid = [ + 'example.com', + 'portfolio.example.com', + 'my-site.deine-domain.de', + 'a.b.c.d.example.com', // deep subdomain + 'abc.io', + ]; + for (const h of valid) { + expect(isValidHostname(h), `"${h}"`).toBe(true); + } + }); + + it('rejects malformed hostnames', () => { + const invalid = [ + '', + 'localhost', // no dot + 'example', // no TLD + '.example.com', // leading dot + 'example..com', // double dot + 'exa mple.com', // space + 'example.com.', // trailing dot + '-example.com', // leading hyphen + 'example-.com', // trailing hyphen on label + 'EXAMPLE.COM', // uppercase — we lowercase but still must match; let's verify current behaviour + '192.168.1.1', // IP rejected (TLD must be letters) + 'foo.1', // TLD needs 2+ letters + ]; + for (const h of invalid) { + // EXAMPLE.COM currently passes because isValidHostname lowercases + // before regex. That's intentional. + if (h === 'EXAMPLE.COM') { + expect(isValidHostname(h)).toBe(true); + continue; + } + expect(isValidHostname(h), `"${h}"`).toBe(false); + } + }); + + it('rejects anything under .mana.how', () => { + for (const h of [ + 'mana.how', + 'www.mana.how', + 'todo.mana.how', + 'random-user.mana.how', + 'a.b.c.mana.how', + ]) { + expect(isValidHostname(h), `"${h}"`).toBe(false); + } + }); + + it('rejects every RESERVED_HOSTNAMES entry', () => { + for (const h of RESERVED_HOSTNAMES) { + expect(isValidHostname(h), `reserved "${h}"`).toBe(false); + } + }); + + it('trims whitespace before validating', () => { + expect(isValidHostname(' example.com ')).toBe(true); + }); + + it('respects length limit (253 chars RFC)', () => { + const tooLong = 'a.'.repeat(130) + 'com'; // > 253 + expect(isValidHostname(tooLong)).toBe(false); + }); +}); diff --git a/apps/api/src/modules/website/reserved-slugs.ts b/apps/api/src/modules/website/reserved-slugs.ts index 65d3e57bb..bc1de9024 100644 --- a/apps/api/src/modules/website/reserved-slugs.ts +++ b/apps/api/src/modules/website/reserved-slugs.ts @@ -1,14 +1,20 @@ /** - * Reserved slugs — server-authoritative list. The client carries a - * mirror copy in `apps/mana/apps/web/src/lib/modules/website/constants.ts` - * for fast pre-flight UX, but this list is the one that matters at - * publish time. + * Reserved slugs + hostname validation — server-authoritative lists. + * The client carries mirror copies for fast pre-flight UX, but these + * are the ones that matter at publish time. * - * Rule: any slug that would shadow a SvelteKit route or collide with - * a well-known subdomain goes here. When a new top-level route is - * added, append its segment here in the same PR. + * Rule (slugs): any slug that would shadow a SvelteKit route or + * collide with a well-known subdomain goes in RESERVED_SLUGS. When a + * new top-level route is added, append its segment here in the same + * PR. + * + * Rule (hostnames): only hostnames the user genuinely owns should be + * bindable. Anything ending in `.mana.how`, or the well-known CF / + * auth / app endpoints, stays reserved. */ +// ── Slugs ─────────────────────────────────────────────── + export const RESERVED_SLUGS: readonly string[] = [ 'app', 'api', @@ -33,10 +39,52 @@ export const RESERVED_SLUGS: readonly string[] = [ ]; /** Same regex as the client uses — 2-40 lowercase alphanumerics + hyphens. */ -const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; +const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/; export function isValidSlug(slug: string): boolean { if (!SLUG_REGEX.test(slug)) return false; if (RESERVED_SLUGS.includes(slug.toLowerCase())) return false; return true; } + +// ── Hostnames ─────────────────────────────────────────── + +/** + * Conservative hostname regex — lowercase letters, digits, dots, + * hyphens. Length 4-253 per RFC. Rejects `localhost`, IPs, internal + * names. Not a full RFC-compliant parser — a sanity check before we + * hand the string to dns.resolve. + */ +const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/; + +/** + * Reserved hostnames that must not be user-bound. mana.how itself + + * every app subdomain (todo, chat, …) lives here so a user can't + * point their CNAME at the app's root. + */ +export const RESERVED_HOSTNAMES: ReadonlySet = new Set([ + 'mana.how', + 'www.mana.how', + 'api.mana.how', + 'auth.mana.how', + 'app.mana.how', + 'admin.mana.how', + 'custom.mana.how', + 'events.mana.how', + 'research.mana.how', +]); + +/** + * True if `raw` is a syntactically valid hostname that isn't reserved + * and doesn't live under `.mana.how` (which is ours end-to-end — + * subdomain-publish handles `{slug}.mana.how` specifically, users + * should bring an external domain to the custom-domain flow). + */ +export function isValidHostname(raw: string): boolean { + if (!raw) return false; + const lower = raw.toLowerCase().trim(); + if (!HOSTNAME_RE.test(lower)) return false; + if (RESERVED_HOSTNAMES.has(lower)) return false; + if (lower.endsWith('.mana.how')) return false; + return true; +} diff --git a/apps/mana/apps/web/src/lib/modules/website/constants.test.ts b/apps/mana/apps/web/src/lib/modules/website/constants.test.ts new file mode 100644 index 000000000..3673d9db2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/constants.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for the slug / path validators. Pure regex — no I/O. + */ + +import { describe, it, expect } from 'vitest'; +import { isValidSlug, isReservedSlug, SLUG_REGEX, RESERVED_SLUGS } from './constants'; +import { isValidPath } from './stores/pages.svelte'; + +describe('slug validation', () => { + it('accepts valid slugs', () => { + const valid = [ + 'ab', // minimum length + 'hello', + 'hello-world', + 'portfolio-2026', + 'a-b-c', + '1234', + 'a'.repeat(40), // maximum length + ]; + for (const s of valid) { + expect(isValidSlug(s), `"${s}" should be valid`).toBe(true); + expect(SLUG_REGEX.test(s), `"${s}" should match regex`).toBe(true); + } + }); + + it('rejects invalid formats', () => { + const invalid = [ + '', // empty + 'a', // too short + 'a'.repeat(41), // too long + 'Hello', // uppercase + '-abc', // leading hyphen + 'abc-', // trailing hyphen + 'ab--cd', // double hyphen is technically OK by regex — sanity-check inverted expectation below + 'ab cd', // space + 'ab.cd', // dot + 'ab_cd', // underscore + 'ab/cd', // slash + 'üöä', // non-ASCII + '🌟', // emoji + ]; + // Double-hyphen: the plan's regex ALLOWS it (no explicit ban). + // If we ever forbid it, remove from this list and move to valid. + const expected = invalid.filter((s) => s !== 'ab--cd'); + for (const s of expected) { + expect(isValidSlug(s), `"${s}" should be invalid`).toBe(false); + } + expect(isValidSlug('ab--cd'), 'double-hyphen currently allowed').toBe(true); + }); + + it('rejects reserved slugs even if format is valid', () => { + for (const reserved of RESERVED_SLUGS) { + expect(isReservedSlug(reserved)).toBe(true); + expect(isValidSlug(reserved), `"${reserved}" is reserved`).toBe(false); + } + }); + + it('reserved-slug check is case-insensitive', () => { + expect(isReservedSlug('API')).toBe(true); + expect(isReservedSlug('Api')).toBe(true); + expect(isReservedSlug('admin')).toBe(true); + }); + + it('covers the minimum reserved names that would shadow app routes', () => { + const mustHave = ['app', 'api', 'auth', 'admin', 's', 'www']; + for (const name of mustHave) { + expect(RESERVED_SLUGS.includes(name), `${name} must be reserved`).toBe(true); + } + }); +}); + +describe('page path validation', () => { + it('accepts valid paths', () => { + const valid = [ + '/', + '/about', + '/about-us', + '/docs/getting-started', + '/blog/2026/04/hello', + '/a', + '/a/b/c/d', + ]; + for (const p of valid) { + expect(isValidPath(p), `"${p}" should be valid`).toBe(true); + } + }); + + it('rejects invalid paths', () => { + const invalid = [ + '', // must start with / + 'about', // no leading slash + '/about/', // trailing slash (per current regex) + '/About', // uppercase + '/foo bar', // space + '/foo.html', // dot + '/foo//bar', // double slash + '//', // just slashes + '/ä', // non-ASCII + ]; + for (const p of invalid) { + expect(isValidPath(p), `"${p}" should be invalid`).toBe(false); + } + }); + + it('root path "/" is always valid', () => { + expect(isValidPath('/')).toBe(true); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/website/constants.ts b/apps/mana/apps/web/src/lib/modules/website/constants.ts index 8d74315c7..c31b6d18e 100644 --- a/apps/mana/apps/web/src/lib/modules/website/constants.ts +++ b/apps/mana/apps/web/src/lib/modules/website/constants.ts @@ -36,8 +36,12 @@ export function isReservedSlug(slug: string): boolean { /** * Slug regex — lowercase alphanumerics + hyphens, 2-40 chars, no leading * or trailing hyphen. Mirrored in the backend for authoritative checks. + * + * Single-char slugs are forbidden: hard to read, reserved-list + TLD- + * collision risk scales with shortness, and `a.mana.how` is the kind + * of thing every app would need a special exception for. Min 2. */ -export const SLUG_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; +export const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,38}[a-z0-9]$/; export function isValidSlug(slug: string): boolean { return SLUG_REGEX.test(slug) && !isReservedSlug(slug); diff --git a/apps/mana/apps/web/src/lib/modules/website/publish.test.ts b/apps/mana/apps/web/src/lib/modules/website/publish.test.ts index aa8ec7d47..e90de2dec 100644 --- a/apps/mana/apps/web/src/lib/modules/website/publish.test.ts +++ b/apps/mana/apps/web/src/lib/modules/website/publish.test.ts @@ -156,4 +156,49 @@ describe('buildBlockTree — orphan handling', () => { const tree = buildBlockTree(blocks); expect(tree[0]!.children.map((c) => c.id)).toEqual(['child-a', 'child-b', 'child-c']); }); + + it('drops self-parent blocks (cycle of length 1)', () => { + const blocks: LocalWebsiteBlock[] = [ + localBlock('ok', 1024), + localBlock('loop', 2048, { parentBlockId: 'loop' }), + ]; + // `loop` references itself as parent. byId.has('loop') is true, so + // the orphan-drop doesn't catch it; children-walk from `loop` + // recurses forever IF we don't guard. Current implementation + // should either drop it or keep a cycle-free view — verify no + // stack overflow + only the well-formed block lands at root. + const tree = buildBlockTree(blocks); + // The self-referencing block will be reachable as a child of + // itself; what we assert is that the walk doesn't infinite-loop + // and the `ok` block is always at top-level. + expect(tree.find((b) => b.id === 'ok')).toBeDefined(); + }); + + it('handles 3-level nesting without losing ancestors', () => { + const blocks: LocalWebsiteBlock[] = [ + localBlock('grandparent', 1024), + localBlock('parent', 2048, { parentBlockId: 'grandparent' }), + localBlock('child', 3072, { parentBlockId: 'parent' }), + ]; + const tree = buildBlockTree(blocks); + expect(tree.length).toBe(1); + expect(tree[0]!.id).toBe('grandparent'); + expect(tree[0]!.children.length).toBe(1); + expect(tree[0]!.children[0]!.id).toBe('parent'); + expect(tree[0]!.children[0]!.children.length).toBe(1); + expect(tree[0]!.children[0]!.children[0]!.id).toBe('child'); + }); + + it('returns an empty array when all blocks are orphans', () => { + const blocks: LocalWebsiteBlock[] = [ + localBlock('o1', 1024, { parentBlockId: 'nope' }), + localBlock('o2', 2048, { parentBlockId: 'also-nope' }), + ]; + const tree = buildBlockTree(blocks); + expect(tree).toEqual([]); + }); + + it('returns an empty array for no input', () => { + expect(buildBlockTree([])).toEqual([]); + }); }); diff --git a/apps/mana/apps/web/src/lib/modules/website/templates.test.ts b/apps/mana/apps/web/src/lib/modules/website/templates.test.ts new file mode 100644 index 000000000..7a404892e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/website/templates.test.ts @@ -0,0 +1,109 @@ +/** + * Template apply + snapshot tests. + * + * Verifies that each of the 4 bundled templates: + * 1. clones into a fresh site with new UUIDs (no template-localId + * leaks into Dexie rows) + * 2. produces the expected page + block counts + * 3. preserves parentBlockId chains when a template uses containers + * 4. populates navConfig with template page titles + paths + */ + +import 'fake-indexeddb/auto'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); +vi.mock('./embeds', () => ({ + resolveEmbed: vi.fn(async () => ({ items: [], resolvedAt: '2026-04-23T00:00:00.000Z' })), +})); + +import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections'; +import { sitesStore } from './stores/sites.svelte'; +import { SITE_TEMPLATES } from './templates'; + +describe('sitesStore.applyTemplate — per-template round-trip', () => { + beforeEach(async () => { + await websitesTable.clear(); + await websitePagesTable.clear(); + await websiteBlocksTable.clear(); + }); + + it.each(SITE_TEMPLATES.map((t) => [t.id] as const))( + 'clones "%s" into a fresh site with new UUIDs', + async (templateId) => { + const tpl = SITE_TEMPLATES.find((t) => t.id === templateId)!; + const expectedBlockCount = tpl.pages.reduce((sum, p) => sum + p.blocks.length, 0); + + const { siteId, homePageId } = await sitesStore.applyTemplate(templateId, { + slug: `test-${templateId}`, + name: `Test ${templateId}`, + }); + + // Site row exists with fresh id. + const site = await websitesTable.get(siteId); + expect(site).toBeDefined(); + expect(site!.slug).toBe(`test-${templateId}`); + expect(site!.publishedVersion).toBeNull(); + + // Pages: one per template page. + const pages = await websitePagesTable.where('siteId').equals(siteId).toArray(); + expect(pages.length).toBe(tpl.pages.length); + + // Home page id matches the first page (path '/') when present. + const home = pages.find((p) => p.path === '/'); + expect(home?.id).toBe(homePageId); + + // Blocks: total across all pages matches template total. + const pageIds = pages.map((p) => p.id); + const blocks = await websiteBlocksTable.where('pageId').anyOf(pageIds).toArray(); + expect(blocks.length).toBe(expectedBlockCount); + + // Every block has a real UUID (not a template localId). + for (const block of blocks) { + expect(block.id).toMatch(/^[0-9a-f-]{36}$/i); + // No template 'localId' fields leak into the row. + expect((block as unknown as { localId?: string }).localId).toBeUndefined(); + } + + // navConfig gets populated from template pages. + expect(site!.navConfig.items.length).toBe(tpl.pages.length); + expect(site!.navConfig.items.map((i) => i.pagePath).sort()).toEqual( + tpl.pages.map((p) => p.path).sort() + ); + } + ); + + // NOTE: the parentLocalId → parentBlockId remap logic inside + // applyTemplate is exercised only when a bundled template uses a + // container. None of the four shipped templates (portfolio, + // linktree, event, blank) do yet. Once we add smb-corporate + // (columns-heavy), add a parent-chain assertion to the .each loop + // above. Until then, the behaviour is covered by the manual smoke + // test in docs/plans/website-builder-smoketest.md §2 (Columns). + + it('rejects an unknown template id', async () => { + await expect( + sitesStore.applyTemplate('does-not-exist', { slug: 'oops', name: 'Oops' }) + ).rejects.toThrow(/Unknown template/); + }); + + it('rejects duplicate slugs in the same space', async () => { + await sitesStore.applyTemplate('blank', { slug: 'dup', name: 'First' }); + await expect( + sitesStore.applyTemplate('blank', { slug: 'dup', name: 'Second' }) + ).rejects.toThrow(/already exists/); + }); + + it('rejects invalid slug format', async () => { + await expect( + sitesStore.applyTemplate('blank', { slug: 'HAS UPPERCASE', name: 'Bad' }) + ).rejects.toThrow(); + await expect( + sitesStore.applyTemplate('blank', { slug: 'a', name: 'Too short' }) + ).rejects.toThrow(); + }); +}); diff --git a/packages/website-blocks/src/schemas.test.ts b/packages/website-blocks/src/schemas.test.ts new file mode 100644 index 000000000..1e0470bc7 --- /dev/null +++ b/packages/website-blocks/src/schemas.test.ts @@ -0,0 +1,239 @@ +/** + * Block-schema tests. Pure Zod — no Svelte runtime needed, runs under + * plain vitest in this package. + * + * Every block gets: + * - sanity: defaults pass the schema + * - a known-valid input + * - a known-invalid input (catches regex / enum / min / max drifts) + */ + +import { describe, it, expect } from 'vitest'; +import { BLOCK_SCHEMAS, BLOCK_DEFAULTS, safeValidateSchema } from './schemas'; + +describe('registry shape', () => { + it('has the expected 11 block types', () => { + const types = Object.keys(BLOCK_SCHEMAS).sort(); + expect(types).toEqual([ + 'analytics', + 'columns', + 'cta', + 'faq', + 'form', + 'gallery', + 'hero', + 'image', + 'moduleEmbed', + 'richText', + 'spacer', + ]); + }); + + it('every block has matching defaults', () => { + const schemaTypes = Object.keys(BLOCK_SCHEMAS).sort(); + const defaultTypes = Object.keys(BLOCK_DEFAULTS).sort(); + expect(schemaTypes).toEqual(defaultTypes); + }); + + it('every block-default passes its own schema', () => { + for (const [type, defaults] of Object.entries(BLOCK_DEFAULTS)) { + const result = safeValidateSchema(type, defaults); + expect(result.success, `defaults for ${type}`).toBe(true); + } + }); + + it('safeValidateSchema returns error for unknown type', () => { + const result = safeValidateSchema('not-a-block', {}); + expect(result.success).toBe(false); + }); +}); + +describe('hero', () => { + it('accepts minimal valid props', () => { + expect(safeValidateSchema('hero', { title: 'Hello' }).success).toBe(true); + }); + it('requires non-empty title', () => { + expect(safeValidateSchema('hero', { title: '' }).success).toBe(false); + }); + it('rejects invalid align enum', () => { + expect(safeValidateSchema('hero', { title: 'Hi', align: 'justify' }).success).toBe(false); + }); + it('caps title length at 240', () => { + expect(safeValidateSchema('hero', { title: 'a'.repeat(241) }).success).toBe(false); + }); +}); + +describe('richText', () => { + it('accepts empty content (renders placeholder in edit)', () => { + expect(safeValidateSchema('richText', { content: '' }).success).toBe(true); + }); + it('caps content at 10k chars', () => { + expect(safeValidateSchema('richText', { content: 'x'.repeat(10_001) }).success).toBe(false); + }); + it('rejects invalid size', () => { + expect(safeValidateSchema('richText', { size: 'huge' }).success).toBe(false); + }); +}); + +describe('cta', () => { + it('requires non-empty buttonLabel', () => { + expect(safeValidateSchema('cta', { buttonLabel: '' }).success).toBe(false); + }); + it('accepts all variants', () => { + for (const variant of ['primary', 'secondary', 'ghost']) { + expect(safeValidateSchema('cta', { buttonLabel: 'Go', variant }).success).toBe(true); + } + }); +}); + +describe('image', () => { + it('accepts empty url (placeholder in edit)', () => { + expect(safeValidateSchema('image', {}).success).toBe(true); + }); + it('caps url at 1024', () => { + expect(safeValidateSchema('image', { url: 'x'.repeat(1025) }).success).toBe(false); + }); + it('accepts every declared aspectRatio', () => { + for (const aspectRatio of ['auto', '16:9', '4:3', '1:1', '21:9']) { + expect(safeValidateSchema('image', { aspectRatio }).success).toBe(true); + } + }); +}); + +describe('gallery', () => { + it('accepts empty image list', () => { + expect(safeValidateSchema('gallery', { images: [] }).success).toBe(true); + }); + it('caps at 60 images', () => { + const images = Array.from({ length: 61 }, () => ({ url: 'https://x' })); + expect(safeValidateSchema('gallery', { images }).success).toBe(false); + }); + it('requires url on each image', () => { + const result = safeValidateSchema('gallery', { images: [{ altText: 'x' }] }); + expect(result.success).toBe(false); + }); + it('accepts only columns 2/3/4', () => { + for (const columns of [2, 3, 4]) { + expect(safeValidateSchema('gallery', { columns }).success).toBe(true); + } + expect(safeValidateSchema('gallery', { columns: 1 }).success).toBe(false); + expect(safeValidateSchema('gallery', { columns: 5 }).success).toBe(false); + }); +}); + +describe('faq', () => { + it('accepts items with question + answer', () => { + const result = safeValidateSchema('faq', { + items: [{ question: 'Q?', answer: 'A.' }], + }); + expect(result.success).toBe(true); + }); + it('rejects empty question', () => { + const result = safeValidateSchema('faq', { + items: [{ question: '', answer: 'A.' }], + }); + expect(result.success).toBe(false); + }); + it('caps answer at 2000 chars', () => { + const result = safeValidateSchema('faq', { + items: [{ question: 'Q', answer: 'a'.repeat(2001) }], + }); + expect(result.success).toBe(false); + }); +}); + +describe('form', () => { + function validField(overrides: Record = {}) { + return { + name: 'name', + label: 'Name', + type: 'text', + required: true, + placeholder: '', + helpText: '', + maxLength: 100, + ...overrides, + }; + } + + it('requires at least 1 field', () => { + expect(safeValidateSchema('form', { fields: [] }).success).toBe(false); + }); + it('caps at 20 fields', () => { + const fields = Array.from({ length: 21 }, (_, i) => validField({ name: `f${i}` })); + expect(safeValidateSchema('form', { fields }).success).toBe(false); + }); + it('rejects field name with hyphen', () => { + expect( + safeValidateSchema('form', { fields: [validField({ name: 'has-hyphen' })] }).success + ).toBe(false); + }); + it('rejects field name starting with digit', () => { + expect(safeValidateSchema('form', { fields: [validField({ name: '1foo' })] }).success).toBe( + false + ); + }); + it('accepts all declared field types', () => { + for (const type of ['text', 'email', 'tel', 'url', 'textarea', 'number']) { + expect( + safeValidateSchema('form', { fields: [validField({ type })] }).success, + `type ${type}` + ).toBe(true); + } + }); +}); + +describe('moduleEmbed', () => { + it('accepts declared sources', () => { + for (const source of ['picture.board', 'library.entries']) { + expect(safeValidateSchema('moduleEmbed', { source }).success).toBe(true); + } + }); + it('rejects unknown source', () => { + expect(safeValidateSchema('moduleEmbed', { source: 'spotify' }).success).toBe(false); + }); + it('accepts maxItems 1..48', () => { + expect(safeValidateSchema('moduleEmbed', { maxItems: 1 }).success).toBe(true); + expect(safeValidateSchema('moduleEmbed', { maxItems: 48 }).success).toBe(true); + expect(safeValidateSchema('moduleEmbed', { maxItems: 0 }).success).toBe(false); + expect(safeValidateSchema('moduleEmbed', { maxItems: 49 }).success).toBe(false); + }); +}); + +describe('analytics', () => { + it('accepts both providers', () => { + for (const provider of ['plausible', 'umami']) { + expect(safeValidateSchema('analytics', { provider }).success).toBe(true); + } + }); + it('rejects unknown provider', () => { + expect(safeValidateSchema('analytics', { provider: 'ga4' }).success).toBe(false); + }); +}); + +describe('columns', () => { + it('accepts count 2 and 3', () => { + expect(safeValidateSchema('columns', { count: 2 }).success).toBe(true); + expect(safeValidateSchema('columns', { count: 3 }).success).toBe(true); + }); + it('rejects other counts', () => { + expect(safeValidateSchema('columns', { count: 1 }).success).toBe(false); + expect(safeValidateSchema('columns', { count: 4 }).success).toBe(false); + }); + it('accepts all align enum values', () => { + for (const align of ['start', 'center', 'stretch']) { + expect(safeValidateSchema('columns', { align }).success).toBe(true); + } + }); +}); + +describe('spacer', () => { + it('accepts all sizes', () => { + for (const size of ['sm', 'md', 'lg', 'xl']) { + expect(safeValidateSchema('spacer', { size }).success).toBe(true); + } + }); + it('rejects invalid size', () => { + expect(safeValidateSchema('spacer', { size: 'xxl' }).success).toBe(false); + }); +}); diff --git a/packages/website-blocks/src/schemas.ts b/packages/website-blocks/src/schemas.ts new file mode 100644 index 000000000..955c9a01a --- /dev/null +++ b/packages/website-blocks/src/schemas.ts @@ -0,0 +1,62 @@ +/** + * Pure-Zod schema aggregation — no Svelte components imported here. + * + * This side-channel lets tests + server-side validators pull every + * block's Zod schema without triggering the .svelte imports that + * `registry.ts` needs (which drag in Svelte runtime and trip up + * plain node/bun vitest runners). + * + * Keep the two in lockstep: a new block goes into both. + */ + +import { HeroSchema, HERO_DEFAULTS } from './hero/schema'; +import { RichTextSchema, RICH_TEXT_DEFAULTS } from './richText/schema'; +import { CtaSchema, CTA_DEFAULTS } from './cta/schema'; +import { ImageSchema, IMAGE_DEFAULTS } from './image/schema'; +import { GallerySchema, GALLERY_DEFAULTS } from './gallery/schema'; +import { FaqSchema, FAQ_DEFAULTS } from './faq/schema'; +import { FormSchema, FORM_DEFAULTS } from './form/schema'; +import { ModuleEmbedSchema, MODULE_EMBED_DEFAULTS } from './moduleEmbed/schema'; +import { AnalyticsSchema, ANALYTICS_DEFAULTS } from './analytics/schema'; +import { ColumnsSchema, COLUMNS_DEFAULTS } from './columns/schema'; +import { SpacerSchema, SPACER_DEFAULTS } from './spacer/schema'; +import type { ZodTypeAny } from 'zod'; + +export const BLOCK_SCHEMAS: Record = { + hero: HeroSchema, + richText: RichTextSchema, + cta: CtaSchema, + image: ImageSchema, + gallery: GallerySchema, + faq: FaqSchema, + form: FormSchema, + moduleEmbed: ModuleEmbedSchema, + analytics: AnalyticsSchema, + columns: ColumnsSchema, + spacer: SpacerSchema, +}; + +export const BLOCK_DEFAULTS: Record = { + hero: HERO_DEFAULTS, + richText: RICH_TEXT_DEFAULTS, + cta: CTA_DEFAULTS, + image: IMAGE_DEFAULTS, + gallery: GALLERY_DEFAULTS, + faq: FAQ_DEFAULTS, + form: FORM_DEFAULTS, + moduleEmbed: MODULE_EMBED_DEFAULTS, + analytics: ANALYTICS_DEFAULTS, + columns: COLUMNS_DEFAULTS, + spacer: SPACER_DEFAULTS, +}; + +export function safeValidateSchema( + type: string, + props: unknown +): { success: true; data: unknown } | { success: false; error: unknown } { + const schema = BLOCK_SCHEMAS[type]; + if (!schema) return { success: false, error: new Error(`Unknown block type "${type}"`) }; + const parsed = schema.safeParse(props); + if (parsed.success) return { success: true, data: parsed.data }; + return { success: false, error: parsed.error }; +} diff --git a/packages/website-blocks/src/themes/themes.test.ts b/packages/website-blocks/src/themes/themes.test.ts new file mode 100644 index 000000000..61c546cb0 --- /dev/null +++ b/packages/website-blocks/src/themes/themes.test.ts @@ -0,0 +1,119 @@ +/** + * Theme resolver + CSS-var serializer tests. + */ + +import { describe, it, expect } from 'vitest'; +import { + THEME_PRESETS, + PRESET_LABELS, + CLASSIC_LIGHT, + MODERN_DARK, + WARM, + resolveTheme, + themeCssVars, + type ThemePreset, +} from './index'; + +describe('THEME_PRESETS', () => { + it('has exactly classic / modern / warm', () => { + expect(Object.keys(THEME_PRESETS).sort()).toEqual(['classic', 'modern', 'warm']); + }); + + it('exposes PRESET_LABELS for every preset', () => { + for (const preset of Object.keys(THEME_PRESETS) as ThemePreset[]) { + expect(PRESET_LABELS[preset]).toBeTruthy(); + } + }); + + it('each preset has all required tokens', () => { + const required = [ + 'primary', + 'primaryFg', + 'background', + 'foreground', + 'surface', + 'border', + 'muted', + 'fontFamily', + 'headingFontFamily', + 'radius', + ]; + for (const [name, tokens] of Object.entries(THEME_PRESETS)) { + for (const key of required) { + expect((tokens as Record)[key], `${name}.${key}`).toBeTypeOf('string'); + } + } + }); + + it('classic / modern / warm share the same set of keys (parity check)', () => { + const keys = (o: object) => Object.keys(o).sort().join(','); + expect(keys(CLASSIC_LIGHT)).toBe(keys(MODERN_DARK)); + expect(keys(CLASSIC_LIGHT)).toBe(keys(WARM)); + }); +}); + +describe('resolveTheme', () => { + it('returns preset tokens as-is when no overrides given', () => { + const resolved = resolveTheme('classic'); + expect(resolved).toEqual(CLASSIC_LIGHT); + }); + + it('returns preset tokens when overrides is an empty object', () => { + const resolved = resolveTheme('modern', {}); + expect(resolved).toEqual(MODERN_DARK); + }); + + it('overrides primary without touching the rest', () => { + const resolved = resolveTheme('classic', { primary: '#ff0080' }); + expect(resolved.primary).toBe('#ff0080'); + expect(resolved.background).toBe(CLASSIC_LIGHT.background); + expect(resolved.foreground).toBe(CLASSIC_LIGHT.foreground); + }); + + it('overrides multiple tokens simultaneously', () => { + const resolved = resolveTheme('warm', { + primary: '#111', + background: '#222', + foreground: '#333', + }); + expect(resolved.primary).toBe('#111'); + expect(resolved.background).toBe('#222'); + expect(resolved.foreground).toBe('#333'); + expect(resolved.surface).toBe(WARM.surface); // untouched + }); +}); + +describe('themeCssVars', () => { + it('serializes every token as a CSS custom property', () => { + const css = themeCssVars(CLASSIC_LIGHT); + expect(css).toContain('--wb-primary:'); + expect(css).toContain('--wb-primary-fg:'); + expect(css).toContain('--wb-bg:'); + expect(css).toContain('--wb-fg:'); + expect(css).toContain('--wb-surface:'); + expect(css).toContain('--wb-border:'); + expect(css).toContain('--wb-muted:'); + expect(css).toContain('--wb-font:'); + expect(css).toContain('--wb-font-heading:'); + expect(css).toContain('--wb-radius:'); + }); + + it('substitutes the actual token values', () => { + const css = themeCssVars(CLASSIC_LIGHT); + expect(css).toContain(`--wb-primary:${CLASSIC_LIGHT.primary}`); + expect(css).toContain(`--wb-bg:${CLASSIC_LIGHT.background}`); + }); + + it('falls back to body font for headings when headingFontFamily is empty', () => { + const tokens = { ...CLASSIC_LIGHT, headingFontFamily: '' }; + const css = themeCssVars(tokens); + expect(css).toContain(`--wb-font-heading:${CLASSIC_LIGHT.fontFamily}`); + }); + + it('separates declarations with semicolons (valid inline CSS)', () => { + const css = themeCssVars(MODERN_DARK); + // All 10 declarations → 9 semicolons between + 0 trailing. + const semicolonCount = (css.match(/;/g) ?? []).length; + expect(semicolonCount).toBe(9); + }); +});