Files
twenty/packages/twenty-website-new/scripts/check-section-shape.mjs
T
Abdullah.andGitHub 47710908a8 [Website] Architecture, hardening, and perf pass. (#20020)
This PR is the result of a critical architectural review of
`twenty-website-new` and the
follow-on cleanup work. It does not touch any other package. Scope is
everything except
the enterprise/billing routes (deferred to a separate PR) and CI wiring
(also deferred).

### Why

The package had grown organically: ad-hoc scroll/motion/halftone code
per section,
WebGL renderers instantiated raw, sections without a shared shape
contract, route-scoped
files leaking across layers, public API routes without rate limiting /
timeouts /
schema validation, unbounded module-level caches in visual code,
drag/resize handlers
re-rendering React 60x/sec, no security headers, and a Lottie
scroll-mapping that would
silently drift if the asset got re-exported. We needed contracts and
primitives in
place before further work (i18n, decomposition of the giant visual
files, MDX migration
of customer/legal pages) is safe to do.

### What changed

**Layering and contracts (now enforced, not just documented).**
- New layering rule: `app → sections → lib → design-system / theme /
icons`.
Cross-section reuse goes through `lib/`. Flipped the design-system
import rule
  from warn to error.
- Extracted shared primitives into `lib/`: `scroll`, `motion`,
`halftone`, `customers`,
`partner-application`, `api`, `seo`, `semver`, `community`,
`visual-runtime`.
- Lifted route-scoped data/types out of `app/` into `lib/` +
`sections/`.
- Section shape contract: every section exposes a single compound export
from
`components/index.ts(x)`; non-leaf sections own the outer `<section>`
from
`Root.tsx`; named slots are matched by `displayName` (no
`Children.toArray`
  positional indexing). Enforced by `scripts/check-section-shape.mjs`.
- WebGL boundary: `new THREE.WebGLRenderer(...)` is forbidden outside
`src/lib/visual-runtime/`. Everything goes through
`createSiteWebGlRenderer`,
which enforces the site-wide context cap, the
`NEXT_PUBLIC_DISABLE_HEAVY_VISUALS`
  kill switch, and GPU/power-preference defaults. Enforced by
`scripts/check-boundaries.mjs` with per-line
`boundary-allow-next-line:<rule-id>`
  escape hatches and stale-directive detection.

**Design system grew to cover real cases.**
- Added Modal, Form, and Layout primitives (Stack / Inline / Grid) so
sections stop
reinventing them. Built on `@base-ui/react` for accessibility + focus
management.

**Public API hardening (non-enterprise).**
- New `lib/api/` primitives: `createRateLimiter` (in-memory token
bucket),
`fetchWithTimeout`, `readJsonBody` (Zod-validated). Applied to
newsletter,
community, and partner-application routes. `/api/partner-application`
specifically
  got a per-IP rate limit, body cap, and timeout.

**Performance.**
- `DraggableTerminal` and `DraggableAppWindow`: `pointermove` now
mutates `transform`
(via `translate3d`) and `width`/`height` directly on the DOM ref. React
state
commits only on `pointerup`. Eliminates per-frame re-renders during
interaction.
- `createBoundedFailureCache` (FIFO, 256 entries) replaces unbounded
module-level
failure caches in four visual components. Bounds memory growth from bad
asset URLs.

**Lottie frame-map guard.**
- `dotlottie-react`'s `player.totalFrames` returns a raw float (`op -
ip`), not an
integer. The HomeStepper scroll → frame map is keyed to the authored
timeline,
  so silent drift would desync every step boundary.
- Reads now `Math.floor(player.totalFrames)` consistently.
- `scripts/check-lottie-frames.mjs` extracts `op - ip` from
`public/lottie/stepper/stepper.lottie` at build time and asserts it
against
`HOME_STEPPER_LOTTIE_EXPECTED_TOTAL_FRAMES`. If anyone re-exports the
Lottie,
the build fails until both that constant and every `STEP_*_END` are
updated together.

**Security headers (`next.config.ts`).**
- HSTS, `X-Content-Type-Options: nosniff`, `Referrer-Policy:
strict-origin-when-cross-origin`,
`Permissions-Policy` (camera/mic/geolocation/payment off),
`X-Frame-Options: DENY`,
  `Content-Security-Policy: frame-ancestors 'none'`.
- Full CSP intentionally deferred until we enumerate all third-party
origins
  (Cal.com, Stripe, GitHub avatars, twenty-icons.com, etc.).

**Build / config quirks documented in code.**
- `tsconfig.json` is standalone (does NOT extend the monorepo base) —
Next.js +
  React Compiler require options that conflict with the base config.
- `sharp` moved from `devDependencies` to `dependencies` so production
image
  optimization works.

### What's deliberately NOT in this PR

- **Enterprise / billing routes** — open redirect on
`/api/enterprise/checkout`,
indefinite-bearer JWT, non-idempotent Stripe seat updates, unpinned
Stripe
`apiVersion`, missing webhook reconciliation, inconsistent error
envelope.
  Going out as a separate, security-focused PR.
- **CI workflow for `twenty-website-new`** (`lint` / `typecheck` /
`test` / `build`
  targets) — separate follow-up PR.
- **i18n via Lingui** — decision made (we need internationalization and
we already
use Lingui in `twenty-front` / `twenty-emails`); 4-phase migration plan
exists
  but does not land here.
- **Decomposition of giant visual files** (HomeVisual, ThreeCards
visuals) — blocked
  on the i18n landing first; otherwise we'd rebase the world twice.
- **Customer / legal pages → MDX** — same reason.
- **Selective memoization pass** — needs browser profiling, not blind
`useMemo`.
- **Pre-existing lint errors / typecheck noise** (~44 errors, ~41
warnings, plus
generated Next.js types and `@ts-nocheck` files) are unchanged. The
cleanup
  did not introduce new ones.

### Test plan

- [ ] `yarn install`
- [ ] `yarn nx run twenty-website-new:dev` — homepage, customers,
partner,
enterprise activate, blog, why-twenty, plans/pricing, legal pages
render.
- [ ] HomeStepper: scroll through, confirm the Lottie animation lines up
with
every step boundary. Console must NOT log a `totalFrames` mismatch.
- [ ] HomeVisual: drag and resize the terminal + app window; verify
smoothness
(no per-frame React re-renders) and that final position/size persists on
release.
- [ ] Public API endpoints: hit `/api/newsletter`, `/api/community`,
`/api/partner-application` with bad payloads → expect 4xx with Zod
errors,
not 500s. Hammer `/api/partner-application` past the per-IP limit → 429.
- [ ] Response headers on any page include HSTS, nosniff, referrer
policy,
permissions policy, X-Frame-Options, and `frame-ancestors 'none'` CSP.
- [ ] `yarn nx run twenty-website-new:lint` — error/warning count must
not exceed
      the pre-existing baseline.
- [ ] `yarn nx run twenty-website-new:typecheck` — same baseline rule.
- [ ] `node packages/twenty-website-new/scripts/check-boundaries.mjs` —
passes,
      no stale directives.
- [ ] `node packages/twenty-website-new/scripts/check-section-shape.mjs`
— passes.
- [ ] `node packages/twenty-website-new/scripts/check-lottie-frames.mjs`
— passes.
- [ ] `yarn nx run twenty-website-new:build` — green, including the
three checks
      above if wired into the build target.
2026-04-24 16:40:09 +00:00

203 lines
5.7 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SECTIONS_DIR = path.join(ROOT, 'src', 'sections');
const SECTIONS_USING_NAMED_SLOTS = new Map([
['Marquee', new Set(['Heading'])],
['TrustedBy', new Set(['Separator', 'Logos', 'ClientCount'])],
]);
const LEAF_SECTIONS = new Set([
'CaseStudy',
'CaseStudyCatalog',
'LegalDocument',
]);
async function listSections() {
const entries = await fs.readdir(SECTIONS_DIR, { withFileTypes: true });
return entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
}
async function fileExists(absPath) {
try {
await fs.stat(absPath);
return true;
} catch {
return false;
}
}
async function readFileOrNull(absPath) {
try {
return await fs.readFile(absPath, 'utf8');
} catch {
return null;
}
}
async function findBarrel(sectionDir) {
const candidates = [
path.join(sectionDir, 'components', 'index.ts'),
path.join(sectionDir, 'components', 'index.tsx'),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
}
return null;
}
async function findRoot(sectionDir) {
const candidate = path.join(sectionDir, 'components', 'Root.tsx');
if (await fileExists(candidate)) return candidate;
return null;
}
function parseSlotIdentifiers(barrelContents) {
const exportMatch = barrelContents.match(
/export\s+const\s+\w+\s*=\s*\{([^}]+)\}/m,
);
if (!exportMatch) return null;
const body = exportMatch[1];
return body
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
.map((entry) => {
const colon = entry.indexOf(':');
return colon === -1 ? entry : entry.slice(0, colon).trim();
});
}
const TOARRAY_REGEX = /Children\.toArray\s*\(/;
function stripComments(source) {
let out = source.replace(/\/\*[\s\S]*?\*\//g, '');
out = out.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
return out;
}
async function checkSection(name) {
const sectionDir = path.join(SECTIONS_DIR, name);
const violations = [];
const barrel = await findBarrel(sectionDir);
if (barrel === null) {
violations.push(
`${name}: missing components/index.{ts,tsx} barrel — every section must expose a single compound export.`,
);
return violations;
}
if (LEAF_SECTIONS.has(name)) return violations;
const root = await findRoot(sectionDir);
if (root === null) {
violations.push(
`${name}: missing components/Root.tsx — every section needs a Root that owns the outer <section> element.`,
);
} else {
const rootContents = await readFileOrNull(root);
if (
rootContents !== null &&
TOARRAY_REGEX.test(stripComments(rootContents))
) {
violations.push(
`${name}: components/Root.tsx uses Children.toArray(...) positional indexing — match slots by displayName instead (see TrustedBy.Root for the pattern).`,
);
}
}
const slotsToCheck = SECTIONS_USING_NAMED_SLOTS.get(name);
if (slotsToCheck !== undefined) {
const barrelContents = await readFileOrNull(barrel);
const exportedSlotNames = barrelContents
? parseSlotIdentifiers(barrelContents)
: null;
for (const slot of slotsToCheck) {
if (exportedSlotNames !== null && !exportedSlotNames.includes(slot)) {
violations.push(
`${name}: slot "${slot}" is declared in SECTIONS_USING_NAMED_SLOTS but is not exported from ${path.relative(
ROOT,
barrel,
)}. Either export it or remove the entry from check-section-shape.mjs.`,
);
continue;
}
const expected = `${name}.${slot}`;
const slotFile = await locateSlotFile(sectionDir, slot);
if (slotFile === null) {
violations.push(
`${name}: slot "${slot}" is declared in SECTIONS_USING_NAMED_SLOTS but no source file matches the conventional path (components/${slot}.tsx or components/${slot}/${slot}.tsx).`,
);
continue;
}
const contents = await readFileOrNull(slotFile);
if (contents === null) continue;
if (!contents.includes(`displayName = '${expected}'`)) {
violations.push(
`${name}: slot "${slot}" source (${path.relative(
ROOT,
slotFile,
)}) does not set ${slot}.displayName = '${expected}'. Root looks slots up by displayName; without it the slot silently fails to render.`,
);
}
}
}
return violations;
}
async function locateSlotFile(sectionDir, slot) {
const candidates = [
path.join(sectionDir, 'components', `${slot}.tsx`),
path.join(sectionDir, 'components', slot, `${slot}.tsx`),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate;
}
return null;
}
async function main() {
const sections = await listSections();
const allViolations = [];
for (const name of sections) {
const sectionViolations = await checkSection(name);
for (const v of sectionViolations) allViolations.push(v);
}
if (allViolations.length === 0) {
console.error(
`check-section-shape: OK (${sections.length} sections inspected).`,
);
return 0;
}
console.error('');
console.error(
`\u001b[31mcheck-section-shape: ${allViolations.length} violation(s)\u001b[0m`,
);
console.error('');
for (const v of allViolations) {
console.error(` - ${v}`);
}
console.error('');
return 1;
}
main()
.then((code) => process.exit(code))
.catch((err) => {
console.error('check-section-shape: unexpected error');
console.error(err);
process.exit(2);
});