import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const sourceRoot = path.join( path.dirname(fileURLToPath(import.meta.url)), '..', 'src', ); // Next.js route files are framework contracts: they require default exports // and may export route config alongside (metadata, generateStaticParams...). const NEXT_CONTRACT_FILES = new Set([ 'default.tsx', 'error.tsx', 'forbidden.tsx', 'global-error.tsx', 'layout.tsx', 'loading.tsx', 'manifest.ts', 'not-found.tsx', 'opengraph-image.tsx', 'page.tsx', 'robots.ts', 'route.ts', 'sitemap.ts', 'template.tsx', 'unauthorized.tsx', ]); const VALUE_EXPORT_PATTERN = /^export (?:const|let|function|async function|class) /gm; const DEFAULT_EXPORT_PATTERN = /^export default /m; const REEXPORT_STATEMENT_PATTERN = /export (?:type )?\{[\s\S]*?\} from '[^']+';|export \* from '[^']+';/g; const failures = []; // Locale rewrites run BEFORE the filesystem: every top-level public/ dir // must be a reserved prefix or its assets 404 under /fr/* style rewrites. // This bug class shipped three times (models, halftone, lottie) before // this check existed. { const patternsSource = fs.readFileSync( 'src/platform/routing/locale-rewrite-patterns.ts', 'utf8', ); const publicDirectories = fs .readdirSync('public', { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name); for (const directory of publicDirectories) { if (!new RegExp(`'${directory}'`).test(patternsSource)) { failures.push( `public/${directory}/ is not in RESERVED_PREFIXES (locale-rewrite-patterns.ts) — its assets 404 under locale rewrites.`, ); } } } // Color and easing literals live only in src/tokens (comments stripped // before matching). Authored one-offs are allowlisted with their reason. const LITERAL_ALLOWLIST = new Set([]); // Files allowed to set the new-tab security attributes themselves. const EXTERNAL_LINK_OWNERS = new Set([ 'src/ui/ExternalLink.tsx', 'src/ui/Button.tsx', ]); // Owned vector glyphs are React components in src/icons — never .svg // files in public/. Files here are third-party brand assets the site can // only serve by URL (plus twenty.svg, the data layer's static export of // src/icons/twenty-logo.tsx for the mockup's brand-image-by-URL path). const PUBLIC_SVG_BRAND_FILES = new Set([ 'public/images/logo-bar/otiima.svg', 'public/images/logo-bar/civicactions.svg', 'public/images/logo-bar/fora.svg', 'public/images/logo-bar/wazoku.svg', 'public/images/shared/companies/logos/linear.svg', 'public/images/shared/companies/logos/twenty.svg', // The halftone studio's default image input: fetched at runtime as an // and fed through the halftone shader, not rendered as an icon glyph. 'public/images/shared/halftone/twenty-logo.svg', ]); // Root README documentation assets: the repo's top-level README.md embeds // these SVGs by URL. They are not site UI (so not src/icons components) nor // third-party brand marks — they live here only so the README's relative // paths keep resolving once this package takes over the twenty-website // public/ path. A trailing slash matches a whole directory. const PUBLIC_SVG_README_DOC_PATHS = [ 'public/images/core/logo.svg', 'public/images/readme/', ]; // Vertical rhythm rides margins ('& > * + *'), not row-gap: gap breaks // silently when a wrapper changes the child list. row-gap is allowed only // where layout is genuinely multi-axis (wrapping rows, multi-column // tracks) — listed here explicitly. const ROW_GAP_MULTI_AXIS_FILES = new Set([ 'sections/case-study-detail/CaseStudyHero.tsx', 'sections/faq/Faq.tsx', 'sections/faq/FaqItems.tsx', 'sections/pricing-plans/PricingBoard.tsx', 'sections/problem/Problem.tsx', 'sections/releases-feed/ReleasesFeed.tsx', 'sections/stepper/ProductStepper.tsx', 'sections/stepper/Stepper.tsx', 'sections/testimonials/PartnerTestimonialsCarousel.tsx', 'sections/testimonials/TestimonialsCarousel.tsx', 'sections/trusted-by/TrustedBy.tsx', 'sections/why-twenty-editorial/Editorial.tsx', ]); const LITERAL_PATTERNS = [ [/#[0-9a-fA-F]{3,8}\b/, 'hex color literal'], [/rgba?\(/, 'rgb/rgba literal'], [/cubic-bezier\(/, 'cubic-bezier literal'], ]; function walk(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const fullPath = path.join(directory, entry.name); if (entry.isDirectory()) { // oxfmt silently ignores directories named "lib" (build-output // convention), so a lib/ directory would dodge formatting forever. // src/locales/generated is the one sanctioned exception: it is the // lingui compile output the shared CI regenerates, and oxfmt ignoring // machine-generated catalogs is exactly what we want there. const isLocalesGenerated = entry.name === 'generated' && path.basename(directory) === 'locales'; if ( entry.name === 'lib' || (entry.name === 'generated' && !isLocalesGenerated) ) { failures.push( `${fullPath}: directories named "lib" or "generated" are forbidden (oxfmt ignores them).`, ); } walk(fullPath); continue; } if (!/\.(ts|tsx)$/.test(entry.name)) continue; const content = fs.readFileSync(fullPath, 'utf8'); const relativePath = path.relative(sourceRoot, fullPath); const posixPath = relativePath.split(path.sep).join('/'); if ( (posixPath.startsWith('ui/') || posixPath.startsWith('sections/')) && /row-gap:/.test(content) && !ROW_GAP_MULTI_AXIS_FILES.has(posixPath) ) { failures.push( `src/${relativePath}: row-gap in a flow stack — use '& > * + * { margin-top: … }' (mind: unlike gap, the margin shifts absolutely-positioned non-first children; allowlist the file if the layout is genuinely multi-axis).`, ); } // The global * reset (layout.tsx) already zeroes every element's margin, // so a component's own 'margin: 0' is redundant — and it ties with the // owl rhythm ('& > * + * { margin-top }', equal specificity), silently // collapsing the gap by source order (this broke a heading once). Cancel // an owl gap deliberately with the specific 'margin-top: 0' instead. if ( posixPath !== 'app/[locale]/layout.tsx' && // The /halftone generator bakes standalone HTML whose own '* { margin: 0 }' // reset is required — the downloaded file has no global reset to inherit. !posixPath.startsWith('platform/visuals/halftone-studio/') && !relativePath.includes('.test.') && /^[ \t]*margin:[ \t]*0;[ \t]*$/m.test(content) ) { failures.push( `src/${relativePath}: redundant 'margin: 0' — the global * reset zeroes margins and it ties with the owl rhythm; remove it (use 'margin-top: 0' to deliberately cancel an owl gap).`, ); } if ( !relativePath.startsWith('tokens' + path.sep) && // The /halftone generator is a standalone color/shader tool: hex + rgba // colors and cubic-bezier eases are its domain values (and what it // exports), not design-system tokens. !posixPath.startsWith('platform/visuals/halftone-studio/') && !relativePath.includes('.test.') && !LITERAL_ALLOWLIST.has(`src/${relativePath}`) ) { const withoutComments = content .split('\n') .map((line) => line.replace(/\/\/.*$/, '')) .join('\n') .replace(/\/\*[\s\S]*?\*\//g, ''); for (const [pattern, label] of LITERAL_PATTERNS) { if (pattern.test(withoutComments)) { failures.push( `src/${relativePath}: ${label} outside src/tokens — use a token.`, ); } } } // Breakpoints exist only through mediaUp(); a raw width query bypasses // the breakpoint tokens (reduced-motion and print queries are fine). if ( !relativePath.startsWith('tokens' + path.sep) && /@media \((?:min|max)-width/.test(content) ) { failures.push( `src/${relativePath}: raw width @media query — use mediaUp().`, ); } if ( !EXTERNAL_LINK_OWNERS.has(`src/${relativePath}`) && /target="_blank"|noopener/.test(content) ) { failures.push( `src/${relativePath}: new-tab attributes belong to ui/ExternalLink — compose it.`, ); } // Screen-reader strings are user-facing: a11y attributes must be // localized, never string literals. if ( (relativePath.startsWith('sections' + path.sep) || relativePath.startsWith('case-studies' + path.sep) || relativePath.startsWith('app-preview' + path.sep) || relativePath.startsWith('contact-cal' + path.sep) || relativePath.startsWith('partner-application' + path.sep) || relativePath.startsWith('partners-marketplace' + path.sep) || relativePath.startsWith('pricing-state' + path.sep)) && /(?:aria-label|ariaLabel|aria-roledescription|placeholder|alt)="[A-Za-z]/.test( content, ) ) { failures.push( `src/${relativePath}: untranslated a11y string literal — wrap in i18n._(msg\`...\`).`, ); } // Sections are islands: importing another section couples compositions // that must evolve independently. Shared shapes live in ui/icons/platform. if (relativePath.startsWith('sections' + path.sep)) { const ownSection = relativePath.split(path.sep)[1]; const crossImport = [...content.matchAll(/from '@\/sections\/([a-z-]+)/g)] .map((m) => m[1]) .find((section) => section !== ownSection); if (crossImport) { failures.push( `src/${relativePath}: imports from sections/${crossImport} — sections may not import each other.`, ); } } // Shared composite layers (the product mockup, the contact modal) sit // between sections and primitives: multiple sections consume them, so // they may reach only the pure and platform layers, never sections. const sharedLayer = [ 'app-preview', 'case-studies', 'contact-cal', 'partner-application', 'partners-marketplace', 'pricing-state', ].find((layer) => relativePath.startsWith(layer + path.sep)); if (sharedLayer) { const allowedLayers = new Set([ 'tokens', 'icons', 'ui', 'platform', sharedLayer, ]); const forbiddenLayer = [...content.matchAll(/from '@\/([a-z-]+)/g)] .map((m) => m[1]) .find((layer) => !allowedLayers.has(layer)); if (forbiddenLayer) { failures.push( `src/${relativePath}: ${sharedLayer} may import only tokens/icons/ui/platform, found @/${forbiddenLayer}.`, ); } } // twenty-ui's theme is pure data, baked by Linaria at build time — consume // it directly so the mockups can't drift from the product. Its components // are React runtime (+ react-tooltip): importing them would weigh down the // marketing bundle, so the mockups stay on lean primitives built against // the theme. const badTwentyUiSubpath = [ ...content.matchAll(/from 'twenty-ui(\/[a-z-]+)?'/g), ] .map((match) => match[1] ?? '') .find( (subpath) => subpath !== '/theme' && subpath !== '/theme-constants', ); if (badTwentyUiSubpath !== undefined) { failures.push( `src/${relativePath}: only twenty-ui/theme is importable (pure data, baked at build); twenty-ui${badTwentyUiSubpath} pulls React runtime into the bundle — build a lean primitive instead.`, ); } // three is heavy (~150KB gz): only the visuals heavy zones may value- // import it, reached exclusively via the rigs' dynamic imports — the // bundle boundary as a build invariant. (halftone-studio is the standalone // /halftone generator tool, dynamic-imported on its own code-split route.) if ( !/^platform\/visuals\/(three-runtime|halftone|halftone-studio)\//.test( relativePath.split(path.sep).join('/'), ) && /^import (?!type )[^;]*from 'three/m.test(content) ) { failures.push( `src/${relativePath}: value-imports three outside platform/visuals heavy zones (use "import type" for types).`, ); } // tokens and icons are pure: no client runtime. if ( (relativePath.startsWith('tokens' + path.sep) || relativePath.startsWith('icons' + path.sep)) && content.includes("'use client'") ) { failures.push(`src/${relativePath}: 'use client' in a pure layer.`); } // .tsx files are PascalCase (named after their React component); .ts // files are kebab-case. Test files mirror their subject's name, so the // .test infix is stripped before the casing rule (TagInput.test.tsx, // partner-fields.test.ts) — matching the .test-only exemptions elsewhere. // Next.js route files (page/layout/...) and the compiled locale catalogs // are exempt. if ( !NEXT_CONTRACT_FILES.has(entry.name) && !relativePath.startsWith('locales' + path.sep) ) { const nameForCasing = entry.name.replace(/\.test(?=\.[tj]sx?$)/, ''); if (nameForCasing.endsWith('.tsx')) { if (!/^[A-Z][A-Za-z0-9]*\.tsx$/.test(nameForCasing)) { failures.push(`src/${relativePath}: .tsx filenames are PascalCase.`); } } else if (/[A-Z]/.test(nameForCasing)) { failures.push(`src/${relativePath}: .ts filenames are kebab-case.`); } } // SectionShell is the only owner of
: it is where vertical // rhythm and surface schemes live, so no other file may create one. if ( relativePath !== path.join('ui', 'SectionShell.tsx') && /]|styled\.section/.test(content) ) { failures.push( `src/${relativePath}:
may only be rendered by ui/SectionShell.tsx.`, ); } const isNextContractFile = relativePath.startsWith('app' + path.sep) && NEXT_CONTRACT_FILES.has(entry.name); if (isNextContractFile) continue; // The module-shape rules are line-anchored and must not read inside // template literals (mock source-code fiction contains export lines). const withoutTemplateLiterals = content.replace(/`[\s\S]*?`/g, '``'); if (DEFAULT_EXPORT_PATTERN.test(withoutTemplateLiterals)) { failures.push( `src/${relativePath}: default export outside a Next.js route file (use named exports).`, ); } if (entry.name === 'index.ts') { const withoutReexports = content .replace(REEXPORT_STATEMENT_PATTERN, '') .replace(/\/\/[^\n]*/g, ''); if (/\S/.test(withoutReexports)) { failures.push( `src/${relativePath}: barrels may only re-export (found: ${withoutReexports.trim().split('\n')[0]}).`, ); } continue; } const valueExportCount = ( withoutTemplateLiterals.match(VALUE_EXPORT_PATTERN) ?? [] ).length; if (valueExportCount > 1) { failures.push( `src/${relativePath}: ${valueExportCount} value exports (limit is one per file).`, ); } } } walk(sourceRoot); // Public SVG audit: any .svg outside the brand-file allowlist means an // owned glyph leaked out of src/icons. const publicSvgs = []; const walkPublic = (dir) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) walkPublic(fullPath); else if (entry.name.endsWith('.svg')) publicSvgs.push(fullPath); } }; walkPublic('public'); for (const svgPath of publicSvgs) { const isReadmeDocAsset = PUBLIC_SVG_README_DOC_PATHS.some((allowed) => allowed.endsWith('/') ? svgPath.startsWith(allowed) : svgPath === allowed, ); if (!PUBLIC_SVG_BRAND_FILES.has(svgPath) && !isReadmeDocAsset) { failures.push( `${svgPath}: owned vector glyphs are components in src/icons — public/ svg files are third-party brand assets only (or add to PUBLIC_SVG_BRAND_FILES with a reason).`, ); } } if (failures.length > 0) { console.error('check-conventions: FAILED'); for (const failure of failures) console.error(` ${failure}`); process.exit(1); } console.log('check-conventions: OK');