Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions motion-kit/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { htmlLeaks } from './html.ts';
import { type Motion, readMotion } from './motion.ts';
import { previewHtml } from './preview.ts';
import { CELL, DEFAULT_VIEWPORT, ROOT_FONT_SIZE, readSpec, type Viewport } from './spec.ts';
import { type Source, stateClasses, type StyleModules } from './stateClasses.ts';
import { missingClasses, type Source, stateClasses, type StyleModules } from './stateClasses.ts';

/**
* The generator: bundles render.tsx with esbuild (SCSS modules, SVGR icons
Expand Down Expand Up @@ -156,16 +156,21 @@ const stubPlugin: Plugin = {

// --- the renderer -------------------------------------------------------------

type Renderer = Pick<typeof import('./render.tsx'), 'renderStudio' | 'layoutDefaultChart'>;
type Renderer = Pick<
typeof import('./render.tsx'),
'renderStudio' | 'layoutDefaultChart' | 'stepLines'
>;

/** render.tsx bundled for Node and loaded, with the CSS its imports pulled
* in (every module of every component in the bundle), the class map of
* each SCSS module and the app's TSX sources in the bundle. */
* each SCSS module, the app's TSX sources in the bundle and the kit's own
* sources in it. */
export const loadRenderer = async (): Promise<{
renderer: Renderer;
moduleCss: string;
styleModules: StyleModules;
sources: Source[];
kitSources: Source[];
}> => {
const out = await mkdtemp(join(tmpdir(), 'motion-kit-'));

Expand Down Expand Up @@ -204,17 +209,26 @@ export const loadRenderer = async (): Promise<{

const renderer = requireHere(bundlePath) as Renderer;
const scope = globalThis as { [STYLE_MODULES]?: StyleModules };
const sources = await Promise.all(
Object.keys(result.metafile.inputs)
.filter((path) => path.startsWith('src/') && path.endsWith('.tsx'))
.map(async (path) => ({ path, text: await readFile(join(REPO, path), 'utf8') })),
const read = (keep: (path: string) => boolean) =>
Promise.all(
Object.keys(result.metafile.inputs)
.filter(keep)
.map(async (path) => ({
path,
text: await readFile(join(REPO, path), 'utf8'),
})),
);
const sources = await read((path) => path.startsWith('src/') && path.endsWith('.tsx'));
const kitSources = await read(
(path) => path.startsWith('motion-kit/') && /\.tsx?$/.test(path),
);

return {
renderer,
moduleCss: css?.text ?? '',
styleModules: scope[STYLE_MODULES] ?? {},
sources,
kitSources,
};
} finally {
await rm(out, { recursive: true, force: true });
Expand Down Expand Up @@ -456,7 +470,13 @@ export const generate = async ({
// the markup the same on every machine.
process.env.TZ = 'UTC';

const { renderer, moduleCss, styleModules, sources } = await loadRenderer();
const { renderer, moduleCss, styleModules, sources, kitSources } = await loadRenderer();
const missing = missingClasses([...kitSources, ...sources], styleModules, REPO);

if (missing.length) {
throw new Error(`motion-kit: classes no SCSS module defines: ${missing.join(', ')}`);
}

const rendered = renderer.renderStudio(spec, { fontsDir: FONTS });
const html = inertLinks(rendered.html);
const leaks = htmlLeaks(html);
Expand Down
32 changes: 28 additions & 4 deletions motion-kit/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,6 @@ const Slot = ({ placed }: { placed: Placed }) => {
}
>
<div className={boardStyles.cardBox} data-draggable="">
<span className={boardStyles.handle} data-nowave="" />
{!isText && (
<span
className={classNames(
Expand Down Expand Up @@ -454,6 +453,31 @@ const planStates = (spec: DemoSpec, placed: Placed[], markup: string): PlanState
};
};

/** The step lines of an activity turn: every list item it shows outside
* another (a step's tries). Each is a step of the spec's activity, so each
* must carry `stepClass`, the class its hook is found by. */
export const stepLines = (turn: Element, stepClass: string): Element[] => {
const outermost = (el: Element) => {
for (let up = el.parent; up && up !== turn; up = up.parent) {
if (up.name === 'li') {
return false;
}
}

return true;
};
const lines = walk(turn.children).filter((el) => el.name === 'li' && outermost(el));
const steps = lines.filter((el) => hasClass(el, stepClass));

if (steps.length !== lines.length) {
throw new Error(
`render: an activity turn shows ${lines.length} lines, ${steps.length} with the step class`,
);
}

return steps;
};

// The hooks inside the app's own components, found by their classes.
const chatHooks = (html: string, spec: DemoSpec): string => {
const all = walk(parseMarkup(html));
Expand Down Expand Up @@ -555,9 +579,9 @@ const chatHooks = (html: string, spec: DemoSpec): string => {
.filter((el) => el.name === 'section' && hasClass(el, activityStyles.turn!))
.forEach((turn, index) => {
hook(turn, `activity-${index}`);
within(turn)
.filter((el) => el.name === 'li' && hasClass(el, activityStyles.step!))
.forEach((line, step) => hook(line, `activity-${index}-${step}`));
stepLines(turn, activityStyles.step!).forEach((line, step) =>
hook(line, `activity-${index}-${step}`),
);
});

// The app's entrance reveals start hidden until an observer marks them;
Expand Down
118 changes: 87 additions & 31 deletions motion-kit/stateClasses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,54 @@ const modulePath = (specifier: string, from: string, repo: string): string =>
repo,
specifier.startsWith('@/')
? join(repo, 'src', specifier.slice(2))
: resolve(dirname(from), specifier),
: resolve(repo, dirname(from), specifier),
);

const parse = ({ path, text }: Source): ts.SourceFile =>
ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);

// The local names of a source's `classnames` import and of each SCSS module
// it imports, with the module's class map, and the modules without a map.
const readImports = (
source: Source,
styles: StyleModules,
repo: string,
): {
file: ts.SourceFile;
joiners: Set<string>;
modules: Map<string, Record<string, string>>;
unmapped: string[];
} => {
const file = parse(source);
const joiners = new Set<string>();
const modules = new Map<string, Record<string, string>>();
const unmapped: string[] = [];

for (const statement of file.statements) {
const name = ts.isImportDeclaration(statement) && statement.importClause?.name?.text;

if (!name || !ts.isStringLiteral(statement.moduleSpecifier)) {
continue;
}

const specifier = statement.moduleSpecifier.text;

if (specifier === 'classnames') {
joiners.add(name);
} else if (specifier.endsWith('.module.scss')) {
const map = styles[modulePath(specifier, source.path, repo)];

if (map) {
modules.set(name, map);
} else {
unmapped.push(specifier);
}
}
}

return { file, joiners, modules, unmapped };
};

export const stateClasses = (
sources: Source[],
styles: StyleModules,
Expand All @@ -36,36 +81,7 @@ export const stateClasses = (
const found = new Map<string, Set<string>>();

for (const { path, text } of sources) {
const file = ts.createSourceFile(
path,
text,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TSX,
);
// The local names of the `classnames` import and of each SCSS module.
const joiners = new Set<string>();
const modules = new Map<string, Record<string, string>>();

for (const statement of file.statements) {
const name = ts.isImportDeclaration(statement) && statement.importClause?.name?.text;

if (!name || !ts.isStringLiteral(statement.moduleSpecifier)) {
continue;
}

const specifier = statement.moduleSpecifier.text;

if (specifier === 'classnames') {
joiners.add(name);
} else if (specifier.endsWith('.module.scss')) {
const map = styles[modulePath(specifier, path, repo)];

if (map) {
modules.set(name, map);
}
}
}
const { file, joiners, modules } = readImports({ path, text }, styles, repo);

if (!joiners.size || !modules.size) {
continue;
Expand Down Expand Up @@ -156,3 +172,43 @@ export const stateClasses = (
[...found].filter(([, set]) => set.size).map(([base, set]) => [base, [...set].toSorted()]),
);
};

/**
* Every class a source reads from an SCSS module it imports, as
* `styles.name` or `styles['name']`, that the module's class map lacks, listed
* as `path: styles.name` and sorted. Such a read is `undefined` at run time,
* so the element it names carries no class. An imported module without a
* class map is listed as `path: specifier`, since its reads go unchecked.
*/
export const missingClasses = (sources: Source[], styles: StyleModules, repo: string): string[] => {
const missing = new Set<string>();

for (const source of sources) {
const { file, modules, unmapped } = readImports(source, styles, repo);

unmapped.forEach((specifier) => missing.add(`${source.path}: ${specifier}`));

const visit = (node: ts.Node) => {
const read = ts.isPropertyAccessExpression(node)
? { object: node.expression, key: node.name.text }
: ts.isElementAccessExpression(node) &&
ts.isStringLiteralLike(node.argumentExpression)
? { object: node.expression, key: node.argumentExpression.text }
: null;

if (read && ts.isIdentifier(read.object)) {
const map = modules.get(read.object.text);

if (map && !Object.hasOwn(map, read.key)) {
missing.add(`${source.path}: ${read.object.text}.${read.key}`);
}
}

ts.forEachChild(node, visit);
};

visit(file);
}

return [...missing].toSorted();
};
44 changes: 44 additions & 0 deletions motion-kit/test/motion-kit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ import { externalUrls, filmCss, mediaMatches, scopeSelector, toPx } from '../css
import { readCues } from '../cues.ts';
import { outfitMeasure, parseFont } from '../fontMetrics.ts';
import type { Motion } from '../motion.ts';
import { parseMarkup } from '../html.ts';
import { readSpec } from '../spec.ts';
import { missingClasses } from '../stateClasses.ts';
import { hasBrowser, startBrowser } from './browser.ts';
import type { ChartWidget } from '../../src/lib/chat/widgets.ts';
import { plotHeight, plotShape } from '../../src/lib/studio/chartFit.ts';

const KENYA = join(import.meta.dirname, '../demos/kenya-debt-health.json');
const REPO = join(import.meta.dirname, '../..');

const chart = (unit: string, series: (number | null)[][]): ChartWidget => ({
kind: 'chart',
Expand Down Expand Up @@ -617,3 +620,44 @@ test('the stylesheet keeps its motion, scoped, only when asked', () => {
assert.match(kept, /\.dfo-ui \.a\s*\{\s*animation: spin 1s;\s*margin: 16px/);
assert.doesNotMatch(frozen, /keyframes|animation/);
});

test('a class read from an SCSS module must be in its class map', async () => {
const { styleModules, sources, kitSources } = await loadRenderer();

// Every class the kit and the app's components in the bundle read exists.
assert.deepEqual(missingClasses([...kitSources, ...sources], styleModules, REPO), []);

const styles = { 'src/a.module.scss': { known: 'a_known' } };
const text = [
"import styles from '@/a.module.scss';",
"import other from './b.module.scss';",
"const ok = [styles.known, styles['known'], styles[name]];",
"const bad = [styles.gone, styles['lost'], other.any];",
].join('\n');

assert.deepEqual(missingClasses([{ path: 'src/x.tsx', text }], styles, '/repo'), [
'src/x.tsx: ./b.module.scss',
'src/x.tsx: styles.gone',
'src/x.tsx: styles.lost',
]);
});

test('a line an activity turn shows without the step class is refused', async () => {
const { renderer } = await loadRenderer();
const spec = readSpec(JSON.parse(await readFile(KENYA, 'utf8')));
const turn = (html: string) => parseMarkup(`<section>${html}</section>`)[0]!;

assert.ok(renderer.renderStudio(spec, { fontsDir: FONTS }).hooks.includes('activity-0-0'));
// A step's tries are list items inside the step's own.
assert.equal(
renderer.stepLines(
turn('<ol><li class="s"><ol><li></li></ol></li><li class="s"></li></ol>'),
's',
).length,
2,
);
assert.throws(
() => renderer.stepLines(turn('<ol><li class="s"></li><li class="t"></li></ol>'), 's'),
/shows 2 lines, 1 with the step class/,
);
});
Loading