Skip to content

Commit d6784b0

Browse files
committed
Merge branch 'feature/yeti-layouts-2' into develop
2 parents 699fb91 + 56f1a3e commit d6784b0

63 files changed

Lines changed: 1080 additions & 199 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Yeti targets **Baseline 2025**. Anything that reached Baseline by the end of 202
3333

3434
## Try it today
3535

36-
The tokens, reset, base layer, fifteen layout primitives, three recipes, and the first eight components are in, with two example themes; see the [Layouts guide](docs/guides/layouts.md). Link the unbuilt source and write plain HTML:
36+
The tokens, reset, base layer, seventeen layout primitives, three recipes, and the first eight components are in, with two example themes; see the [Layouts guide](docs/guides/layouts.md). Link the unbuilt source and write plain HTML:
3737

3838
```html
3939
<link rel="stylesheet" href="src/yeti.css">

bin/lib/markdown.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/** Fenced ```html blocks out of a markdown string, each with the line it starts on. */
2+
export function extractHtmlBlocks(markdown) {
3+
const blocks = [];
4+
const re = /```html[^\n]*\n([\s\S]*?)\n```/g;
5+
let m;
6+
while ((m = re.exec(markdown)) !== null) {
7+
blocks.push({ html: m[1], line: markdown.slice(0, m.index).split('\n').length + 1 });
8+
}
9+
return blocks;
10+
}

bin/lib/validate-docs.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { parseHtml, walkElements, classList } from './html.js';
4+
import { extractHtmlBlocks } from './markdown.js';
5+
6+
export const BUILT_FROM_MESSAGE = 'recipes must show the same result built from primitives under a "## Built from primitives" heading with a fenced html block';
7+
8+
/** The text of one `## Heading` section, up to the next `## `. Empty string when absent. */
9+
export function markdownSection(markdown, heading) {
10+
const re = new RegExp(`^## ${heading}\\s*$`, 'm');
11+
const m = re.exec(markdown);
12+
if (!m) return { text: '', offset: 0 };
13+
const start = m.index + m[0].length;
14+
const next = /^## /m.exec(markdown.slice(start));
15+
return { text: markdown.slice(start, next ? start + next.index : undefined), offset: start };
16+
}
17+
18+
const ACCESSIBILITY_MESSAGE = 'components must document accessibility under a "## Accessibility" heading';
19+
20+
/** Every layout, recipe, and component ships a docs.md; layouts and recipes explain their
21+
* name, recipes also show the composed form, and components document accessibility. */
22+
export function validateDocsFragments(entries) {
23+
const errors = [];
24+
for (const entry of entries.filter((e) => e.kind !== 'utility')) {
25+
const file = path.join(entry.dir, 'docs.md');
26+
if (!fs.existsSync(file)) {
27+
errors.push({ file: entry.dir, message: entry.kind === 'component' ? `${entry.kind}s must have a docs.md with a "## Accessibility" heading` : `${entry.kind}s must have a docs.md with a "## Why this name" heading` });
28+
continue;
29+
}
30+
const markdown = fs.readFileSync(file, 'utf8');
31+
if (entry.kind === 'recipe') {
32+
const section = markdownSection(markdown, 'Built from primitives');
33+
const blocks = extractHtmlBlocks(section.text);
34+
if (!blocks.length) {
35+
errors.push({ file, message: BUILT_FROM_MESSAGE });
36+
} else {
37+
const lineOffset = markdown.slice(0, section.offset).split('\n').length - 1;
38+
for (const block of blocks) {
39+
walkElements(parseHtml(block.html), (el) => {
40+
if (classList(el).includes(entry.manifest.class)) {
41+
errors.push({ file, line: block.line + lineOffset + (el.sourceCodeLocation ? el.sourceCodeLocation.startLine - 1 : 0), message: `the composed form must not use the recipe's own class .${entry.manifest.class}` });
42+
}
43+
});
44+
}
45+
}
46+
}
47+
if (entry.kind === 'component') {
48+
if (!/^## Accessibility\s*$/m.test(markdown)) errors.push({ file, message: ACCESSIBILITY_MESSAGE });
49+
} else if (!/^## Why this name\s*$/m.test(markdown)) {
50+
errors.push({ file, message: 'layouts must explain their name under a "## Why this name" heading' });
51+
}
52+
}
53+
return errors;
54+
}

bin/lib/validate-fields.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { walkFiles } from './files.js';
4+
import { parseHtml, walkElements, classList, attributes, elementChildren } from './html.js';
5+
import { extractHtmlBlocks } from './markdown.js';
6+
7+
export const FIELD_MESSAGE = '.field: the label must reference the control with for, and the control must carry that id';
8+
9+
/** Every .field pairs its label with its control by for/id (or is a fieldset with a legend). */
10+
export function validateFields(entries, docsDir, fixturesDir) {
11+
const errors = [];
12+
const sources = [];
13+
for (const entry of entries) {
14+
sources.push({ file: path.join(entry.dir, 'example.html'), html: fs.readFileSync(path.join(entry.dir, 'example.html'), 'utf8'), line: 0 });
15+
const docsFile = path.join(entry.dir, 'docs.md');
16+
if (fs.existsSync(docsFile)) for (const b of extractHtmlBlocks(fs.readFileSync(docsFile, 'utf8'))) sources.push({ file: docsFile, html: b.html, line: b.line - 1 });
17+
}
18+
if (fs.existsSync(docsDir)) {
19+
for (const file of walkFiles(docsDir).filter((f) => f.endsWith('.md'))) {
20+
for (const b of extractHtmlBlocks(fs.readFileSync(file, 'utf8'))) sources.push({ file, html: b.html, line: b.line - 1 });
21+
}
22+
}
23+
if (fixturesDir && fs.existsSync(fixturesDir)) {
24+
for (const file of walkFiles(fixturesDir).filter((f) => f.endsWith('.html'))) {
25+
sources.push({ file, html: fs.readFileSync(file, 'utf8'), line: 0 });
26+
}
27+
}
28+
for (const { file, html, line } of sources) {
29+
walkElements(parseHtml(html), (el) => {
30+
if (!classList(el).includes('field')) return;
31+
const kids = elementChildren(el);
32+
if (el.tagName === 'fieldset') {
33+
if (kids.filter((k) => k.tagName === 'legend').length !== 1) errors.push({ file, line: line + el.sourceCodeLocation.startLine, message: '.field on a fieldset needs exactly one legend' });
34+
return;
35+
}
36+
const label = kids.find((k) => k.tagName === 'label');
37+
const controls = kids.flatMap((k) => classList(k).includes('affix') ? elementChildren(k).filter((g) => ['input', 'select'].includes(g.tagName)) : (['input', 'select', 'textarea'].includes(k.tagName) ? [k] : []));
38+
const forId = label && attributes(label).get('for');
39+
const named = controls.filter((c) => attributes(c).get('id') === forId);
40+
if (!label || !forId || controls.length === 0 || named.length !== 1) {
41+
errors.push({ file, line: line + el.sourceCodeLocation.startLine, message: FIELD_MESSAGE });
42+
}
43+
});
44+
}
45+
return errors;
46+
}

bin/lib/validate-themes.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { walkFiles } from './files.js';
4+
import { stripComments } from './imports.js';
5+
import { loadSchema } from './manifest.js';
6+
import { loadCatalogue } from './tokens.js';
7+
8+
/** Blanks url(...) contents and quoted strings, preserving length, so a semicolon or colon
9+
* inside a token's value (a data: URL, say) is never mistaken for a declaration or
10+
* property-name boundary. Only property names are checked, so values may be replaced. */
11+
export function sanitizeDeclarations(text) {
12+
return text
13+
.replace(/url\(([^)]*)\)/g, (m, inner) => `url(${' '.repeat(inner.length)})`)
14+
.replace(/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g, (m) => ' '.repeat(m.length));
15+
}
16+
17+
/** A theme is :root blocks of --yeti-* public tokens, nothing else. */
18+
export function validateThemes(root) {
19+
const themesDir = path.join(root, 'src', 'themes');
20+
const catalogueFile = path.join(root, 'src', 'tokens', 'tokens.json');
21+
if (!fs.existsSync(themesDir) || !fs.existsSync(catalogueFile)) return [];
22+
const schema = loadSchema(path.join(root, 'schema', 'tokens.schema.json'));
23+
const { entries } = loadCatalogue(catalogueFile, schema);
24+
const publicNames = new Set(entries.filter((e) => e.public).map((e) => e.name));
25+
const errors = [];
26+
for (const file of walkFiles(themesDir).filter((f) => f.endsWith('.css'))) {
27+
const text = stripComments(fs.readFileSync(file, 'utf8'));
28+
const stack = [];
29+
let selector = '';
30+
let line = 1;
31+
let selectorLine = 1;
32+
let decls = '';
33+
for (const ch of text) {
34+
if (ch === '{') {
35+
const sel = selector.trim();
36+
if (/^@media\s*\(\s*prefers-color-scheme:\s*(light|dark)\s*\)$/.test(sel) && !(stack.length && stack.at(-1).kind === 'media')) {
37+
stack.push({ kind: 'media' });
38+
} else if (sel === ':root' && (stack.length === 0 || stack.at(-1).kind === 'media')) {
39+
stack.push({ kind: 'root', line: selectorLine });
40+
} else {
41+
errors.push({ file, line: selectorLine, message: `themes may only set --yeti-* tokens on :root (found "${sel}")` });
42+
stack.push({ kind: 'other' });
43+
}
44+
selector = ''; decls = '';
45+
} else if (ch === '}') {
46+
const block = stack.pop();
47+
if (block?.kind === 'root') {
48+
for (const d of sanitizeDeclarations(decls).split(';')) {
49+
const [prop] = d.split(':').map((s) => s.trim());
50+
if (!prop) continue;
51+
if (!prop.startsWith('--yeti-')) errors.push({ file, line: block.line, message: `themes may only set --yeti-* tokens (found "${prop}")` });
52+
else if (!publicNames.has(prop)) errors.push({ file, line: block.line, message: `theme sets "${prop}", which is not a public token` });
53+
}
54+
}
55+
selector = ''; decls = '';
56+
} else {
57+
if (stack.length && stack.at(-1).kind === 'root') decls += ch; else selector += ch;
58+
if (ch === '\n') { line++; if (!selector.trim()) selectorLine = line; }
59+
}
60+
}
61+
// A statement at-rule (@import …; or @layer x;) never opens a block, so the
62+
// char loop above never sees it: it just keeps accumulating in `selector`
63+
// until the file ends. Catch that leftover text here.
64+
if (selector.trim()) {
65+
errors.push({ file, line: selectorLine, message: `themes may only set --yeti-* tokens on :root (found "${selector.trim()}")` });
66+
}
67+
}
68+
return errors;
69+
}

0 commit comments

Comments
 (0)