volaro 0.0.2 → 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -22
- package/bin/vl.js +466 -28
- package/compiler/SOURCE_INFO.json +6 -0
- package/compiler/SOURCE_REV +1 -0
- package/compiler/validator/vlcheck/__init__.py +10 -0
- package/compiler/validator/vlcheck/__main__.py +128 -0
- package/compiler/validator/vlcheck/ast_nodes.py +397 -0
- package/compiler/validator/vlcheck/checks.py +1227 -0
- package/compiler/validator/vlcheck/diagnostics.py +88 -0
- package/compiler/validator/vlcheck/lexer.py +343 -0
- package/compiler/validator/vlcheck/parser.py +1638 -0
- package/compiler/validator/vlcheck/project_config.py +97 -0
- package/compiler/validator/vlcheck/resolve.py +849 -0
- package/compiler/validator/vlcheck/test_ids.py +98 -0
- package/compiler/vlbuild/styling/README.md +39 -0
- package/compiler/vlbuild/styling/build-css.mjs +103 -0
- package/compiler/vlbuild/styling/package-lock.json +1254 -0
- package/compiler/vlbuild/styling/package.json +15 -0
- package/compiler/vlbuild/styling/test-build-css.mjs +69 -0
- package/compiler/vlbuild/vlbuild/__init__.py +16 -0
- package/compiler/vlbuild/vlbuild/__main__.py +246 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.css +165 -0
- package/compiler/vlbuild/vlbuild/assets/vlrt.js +1291 -0
- package/compiler/vlbuild/vlbuild/emit.py +2023 -0
- package/compiler/vlbuild/vlbuild/server_emit.py +1551 -0
- package/compiler/vlbuild/vlbuild/static_assets.py +83 -0
- package/compiler/vlbuild/vlbuild/style_config.py +347 -0
- package/compiler/vlbuild/vlbuild/styling.py +39 -0
- package/examples/station.vl +2 -2
- package/language/crib.md +131 -10
- package/language/spec.md +209 -7
- package/language/supported.md +185 -0
- package/lib/env.js +107 -0
- package/package.json +19 -2
- package/scripts/record-provenance.mjs +51 -0
- package/scripts/selftest.mjs +54 -0
- package/scripts/sync-compiler.sh +52 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Automation-hook source checks and bounded composed literal-ID analysis."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import fields, is_dataclass
|
|
5
|
+
from . import ast_nodes as A
|
|
6
|
+
from .diagnostics import Diagnostic, Severity
|
|
7
|
+
|
|
8
|
+
TARGETS = set('col row stack grid card text img button input link spacer h1 h2 h3 h4 h5 h6 main nav status'.split())
|
|
9
|
+
SEGMENT = re.compile(r'[a-z][a-z0-9-]{0,79}\Z')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def literal(expr):
|
|
13
|
+
if isinstance(expr, A.Literal) and expr.kind == 'str' and not getattr(expr, 'interp_names', None):
|
|
14
|
+
try:
|
|
15
|
+
return json.loads(expr.raw)
|
|
16
|
+
except ValueError:
|
|
17
|
+
return None
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def walk(node):
|
|
22
|
+
if isinstance(node, (list, tuple)):
|
|
23
|
+
for child in node:
|
|
24
|
+
yield from walk(child)
|
|
25
|
+
elif is_dataclass(node):
|
|
26
|
+
yield node
|
|
27
|
+
for f in fields(node):
|
|
28
|
+
yield from walk(getattr(node, f.name))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def check_test_ids(module, filename):
|
|
32
|
+
errors = []
|
|
33
|
+
def error(code, node, message, fix='Use a literal test_id:"save" and an explicit public test_scope: for repetition.'):
|
|
34
|
+
errors.append(Diagnostic(code, Severity.ERROR, message, node.start, node.end, fix, filename))
|
|
35
|
+
views = {x.name: x for x in module.items if isinstance(x, A.ViewDecl)}
|
|
36
|
+
for node in walk(module):
|
|
37
|
+
if not isinstance(node, A.Element):
|
|
38
|
+
continue
|
|
39
|
+
for key in ('test_id', 'test_scope'):
|
|
40
|
+
vals = [v for k, v in node.attrs if k == key]
|
|
41
|
+
if len(vals) > 1:
|
|
42
|
+
error('E-TEST-ID', node, 'automation attribute repeated')
|
|
43
|
+
for value in vals:
|
|
44
|
+
if key == 'test_id':
|
|
45
|
+
text = literal(value)
|
|
46
|
+
if node.name not in TARGETS or not isinstance(text, str) or not SEGMENT.fullmatch(text):
|
|
47
|
+
error('E-TEST-ID', node,
|
|
48
|
+
'test_id must be a literal slug matching [a-z][a-z0-9-]{0,79} '
|
|
49
|
+
'(lowercase letters, digits, hyphens; no underscore) on a '
|
|
50
|
+
'supported element',
|
|
51
|
+
fix='test_id:"edit-field" (hyphens, not underscores)')
|
|
52
|
+
else:
|
|
53
|
+
if node.name not in TARGETS | {'dialog', 'tabs'} | views.keys():
|
|
54
|
+
error('E-TEST-ID-SCOPE', node, 'test_scope is unsupported on this target')
|
|
55
|
+
if isinstance(value, A.Literal) and not getattr(value, 'interp_names', None) and (not isinstance(literal(value), str) or not SEGMENT.fullmatch(literal(value) or '')):
|
|
56
|
+
error('E-TEST-ID-SCOPE', node, 'literal test_scope must be a lowercase slug (1–80 characters)')
|
|
57
|
+
|
|
58
|
+
def combine(parts, alternative=False):
|
|
59
|
+
out = {}
|
|
60
|
+
for part in parts:
|
|
61
|
+
for key, (count, node) in part.items():
|
|
62
|
+
previous = out.get(key, (0, node))[0]
|
|
63
|
+
out[key] = ((max(previous, count) if alternative else previous + count), node)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
def visit(nodes, scope=(), repeated=False, stack=()):
|
|
67
|
+
parts = []
|
|
68
|
+
for n in nodes:
|
|
69
|
+
if isinstance(n, A.Element):
|
|
70
|
+
attrs = dict(n.attrs)
|
|
71
|
+
current = scope
|
|
72
|
+
if 'test_scope' in attrs:
|
|
73
|
+
s = literal(attrs['test_scope'])
|
|
74
|
+
current = None if scope is None or s is None else (*scope, s)
|
|
75
|
+
local = literal(attrs.get('test_id'))
|
|
76
|
+
own = {}
|
|
77
|
+
if isinstance(local, str) and current is not None:
|
|
78
|
+
own[(*current, local)] = (2 if repeated else 1, n)
|
|
79
|
+
children = visit(n.body, current, repeated, stack)
|
|
80
|
+
if n.name in views and n.name not in stack:
|
|
81
|
+
children = combine([children, visit(views[n.name].body, current, repeated, (*stack, n.name))])
|
|
82
|
+
parts.append(combine([own, children]))
|
|
83
|
+
elif isinstance(n, A.If):
|
|
84
|
+
parts.append(combine([visit(n.then, scope, repeated, stack),
|
|
85
|
+
*(visit(body, scope, repeated, stack) for _, body in n.elifs),
|
|
86
|
+
visit(n.orelse, scope, repeated, stack)], True))
|
|
87
|
+
elif isinstance(n, A.ListBlock):
|
|
88
|
+
parts.append(visit(n.body, scope, True, stack))
|
|
89
|
+
return combine(parts)
|
|
90
|
+
# Check every view as an entry as well as its expansion from other views.
|
|
91
|
+
# Dynamic scope relationships remain a runtime check, never inferred safe.
|
|
92
|
+
seen = set()
|
|
93
|
+
for name, view in views.items():
|
|
94
|
+
for key, (count, node) in visit(view.body, stack=(name,)).items():
|
|
95
|
+
if count > 1 and (node.start, key) not in seen:
|
|
96
|
+
seen.add((node.start, key))
|
|
97
|
+
error('E-TEST-ID-DUP', node, 'automation ID can occur more than once in a composed view; add distinct scopes')
|
|
98
|
+
return errors
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Tailwind build-tool foundation
|
|
2
|
+
|
|
3
|
+
The internal adapter is integrated with `vlbuild` theme/recipe/class lowering.
|
|
4
|
+
The present capability boundaries live in the repository's `ROADMAP.md`.
|
|
5
|
+
This private build package is not an npm-distribution or publication change.
|
|
6
|
+
|
|
7
|
+
Install reproducibly from the repository root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm ci --ignore-scripts --no-audit --no-fund --prefix prototype/vlbuild/styling
|
|
11
|
+
./verify.sh
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The private package pins upstream Tailwind and its CLI, plus PostCSS and its
|
|
15
|
+
selector parser for output verification. The lockfile records transitive
|
|
16
|
+
dependencies. No global install, CDN, browser compiler or server-runtime
|
|
17
|
+
dependency is involved. Unstyled `vlbuild` builds remain dependency-free;
|
|
18
|
+
the required compiler test gate now also tests this local toolchain.
|
|
19
|
+
|
|
20
|
+
`build-css.mjs` accepts JSON on stdin with `candidates` (each has `class` and
|
|
21
|
+
`origin`) and optional compiler-generated `themeCss`. It returns JSON containing
|
|
22
|
+
verified CSS and sorted unique candidates. `vlbuild.styling.compile_css` is the
|
|
23
|
+
Python bridge. Neither writes application output files.
|
|
24
|
+
|
|
25
|
+
Only the explicit temporary candidate file is scanned. Automatic discovery is
|
|
26
|
+
disabled and Preflight is omitted. Unknown candidates are errors even if
|
|
27
|
+
Tailwind succeeds. CSS selectors are parsed, not searched as raw strings;
|
|
28
|
+
responsive, state and arbitrary-value escapes are covered. Only `group`, `peer`
|
|
29
|
+
and their simple named markers are exempt from standalone-rule verification.
|
|
30
|
+
Presence of a rule does not prove its visual effectiveness or accessibility.
|
|
31
|
+
|
|
32
|
+
`build_file` invokes this bridge before writing a styled bundle. Runtime
|
|
33
|
+
compatibility CSS occupies `vl-legacy`, before generated utility styles;
|
|
34
|
+
recipe/inline-style conflicts are checked during lowering. See `style_config.py`
|
|
35
|
+
for bounded sibling discovery and recipe resolution. Unstyled builds retain
|
|
36
|
+
their legacy colour variables and do not require an `app.css` file.
|
|
37
|
+
|
|
38
|
+
Upstream integration: [Tailwind CLI](https://tailwindcss.com/docs/installation/tailwind-cli)
|
|
39
|
+
and [explicit source detection](https://tailwindcss.com/docs/detecting-classes-in-source-files).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Build-only adapter. Explicit candidates in; verified CSS out.
|
|
2
|
+
* No repository scanning, shell execution, network fallback, or runtime CSS.
|
|
3
|
+
* This is the toolchain foundation, not yet a public vlbuild styling flag.
|
|
4
|
+
*/
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import postcss from 'postcss';
|
|
11
|
+
import selectorParser from 'postcss-selector-parser';
|
|
12
|
+
|
|
13
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const cli = join(here, 'node_modules/@tailwindcss/cli/dist/index.mjs');
|
|
15
|
+
const theme = join(here, 'node_modules/tailwindcss/theme.css');
|
|
16
|
+
const utilities = join(here, 'node_modules/tailwindcss/utilities.css');
|
|
17
|
+
|
|
18
|
+
export class StylingError extends Error {
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(`${code}: ${message}`);
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Markers do not emit their own rules. Keep the exemption narrow: a typo
|
|
26
|
+
// beginning with "group" is not a marker. Variant utilities are NOT exempt.
|
|
27
|
+
export function isStructuralMarker(candidate) {
|
|
28
|
+
return /^(group|peer)(\/[A-Za-z0-9_-]+)?$/.test(candidate);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function verifyCandidates(css, candidates) {
|
|
32
|
+
const emitted = new Set();
|
|
33
|
+
let ast;
|
|
34
|
+
try {
|
|
35
|
+
ast = postcss.parse(css);
|
|
36
|
+
ast.walkRules(rule => {
|
|
37
|
+
// Nested variant rules are traversed too. The selector parser decodes
|
|
38
|
+
// CSS escapes, including arbitrary values, rather than guessing escapes.
|
|
39
|
+
selectorParser(selectors => {
|
|
40
|
+
selectors.walkClasses(node => emitted.add(node.value));
|
|
41
|
+
}).processSync(rule.selector);
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new StylingError('E-CSS-OUTPUT', `cannot parse generated CSS: ${error.message}`);
|
|
45
|
+
}
|
|
46
|
+
for (const { class: candidate, origin } of candidates) {
|
|
47
|
+
if (!emitted.has(candidate) && !isStructuralMarker(candidate)) {
|
|
48
|
+
throw new StylingError('E-CLASS-NO-CSS',
|
|
49
|
+
`${JSON.stringify(candidate)} from ${origin} produced no CSS rule; ` +
|
|
50
|
+
'check the utility spelling and the available theme scales');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildCss({ candidates, themeCss = '' }) {
|
|
56
|
+
if (!Array.isArray(candidates) || typeof themeCss !== 'string') {
|
|
57
|
+
throw new StylingError('E-CSS-MANIFEST', 'expected candidates and generated theme CSS');
|
|
58
|
+
}
|
|
59
|
+
for (const entry of candidates) {
|
|
60
|
+
if (!entry || typeof entry.class !== 'string' || !entry.class ||
|
|
61
|
+
/\s/.test(entry.class) || typeof entry.origin !== 'string' || !entry.origin) {
|
|
62
|
+
throw new StylingError('E-CSS-MANIFEST', 'each candidate needs one class and a source origin');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const unique = [...new Set(candidates.map(entry => entry.class))].sort();
|
|
66
|
+
// Only the generated compiler theme is trusted CSS. User class strings go
|
|
67
|
+
// into a plain source file, never into CSS directives or command arguments.
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), 'volaro-css-'));
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(join(dir, 'candidates.txt'), unique.join('\n') + '\n');
|
|
71
|
+
const input = join(dir, 'input.css');
|
|
72
|
+
const output = join(dir, 'output.css');
|
|
73
|
+
// No preflight: keep existing generated HTML defaults intact. Cascade
|
|
74
|
+
// integration with vlrt.css belongs to the emitter integration slice.
|
|
75
|
+
writeFileSync(input,
|
|
76
|
+
`@import ${JSON.stringify(theme)} layer(theme);\n` +
|
|
77
|
+
`@import ${JSON.stringify(utilities)} layer(utilities) source(none);\n` +
|
|
78
|
+
'@source "./candidates.txt";\n' + themeCss + '\n');
|
|
79
|
+
const result = spawnSync(process.execPath, [cli, '-i', input, '-o', output, '--minify'], {
|
|
80
|
+
cwd: dir, encoding: 'utf8', timeout: 30000, maxBuffer: 4 * 1024 * 1024,
|
|
81
|
+
});
|
|
82
|
+
if (result.error || result.status !== 0) {
|
|
83
|
+
throw new StylingError('E-CSS-BUILD', result.error?.message || result.stderr ||
|
|
84
|
+
`Tailwind exited ${result.status}`);
|
|
85
|
+
}
|
|
86
|
+
const css = readFileSync(output, 'utf8');
|
|
87
|
+
verifyCandidates(css, candidates);
|
|
88
|
+
return { css, candidates: unique };
|
|
89
|
+
} finally {
|
|
90
|
+
// Only the concrete directory returned by mkdtemp is ever deleted.
|
|
91
|
+
rmSync(dir, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
96
|
+
try {
|
|
97
|
+
const request = JSON.parse(readFileSync(0, 'utf8'));
|
|
98
|
+
process.stdout.write(JSON.stringify(buildCss(request)));
|
|
99
|
+
} catch (error) {
|
|
100
|
+
process.stderr.write(`${error.message}\n`);
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
}
|
|
103
|
+
}
|