5a657129f0
Reorganizes the flat `sections/stepper/` (30 files — two distinct steppers plus shared code) into two product-feature-style sections, and moves the shared code to the shared layers. - **`sections/home-stepper/`** — the home-page stepper. Renamed `Stepper` → `HomeStepper` (and the home components → `HomeStepperLottie` / `HomeStepperSteps` / `HomeStepperVisualFrame`) for symmetry with `ProductStepper`. Shell at the root + `components/`/`data/`/`utils/` + barrel. - **`sections/product-stepper/`** — the product-page stepper, same structure. The 3 files both steppers shared can't live in a shared *section* — `check-conventions` forbids a section importing another section. So they moved to the shared layers: - `StepperProgressRail`, `StepperSwipeDeck` → `ui/` - `useBreakpointStepSync` → `platform/motion` Both consumer pages repointed (`@/sections/home-stepper`, `@/sections/product-stepper`); the row-gap allowlist in `check-conventions.mjs` updated to the new paths; explanatory comments stripped across the moved files (CSS-in-template comments and `'use client'` kept). Pure reorganization — no behavior change. typecheck + lint + build all green.
424 lines
16 KiB
JavaScript
424 lines
16 KiB
JavaScript
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
|
|
// <img> 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/product-stepper/ProductStepper.tsx',
|
|
'sections/home-stepper/HomeStepper.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 <section>: it is where vertical
|
|
// rhythm and surface schemes live, so no other file may create one.
|
|
if (
|
|
relativePath !== path.join('ui', 'SectionShell.tsx') &&
|
|
/<section[\s>]|styled\.section/.test(content)
|
|
) {
|
|
failures.push(
|
|
`src/${relativePath}: <section> 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');
|