azoxjs 1.0.1 → 1.2.0

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 CHANGED
@@ -11,7 +11,7 @@ third-party CLI dependencies, no borrowed syntax from React, Vue, or
11
11
  Next.js. It compiles `.azox` components directly into fine-grained,
12
12
  signal-driven DOM updates.
13
13
 
14
- > Status: stable (v1.0.1). The template syntax, the reactivity exports
14
+ > Status: stable (v1.2.0). The template syntax, the reactivity exports
15
15
  > and the shape of the build output will not change without a 2.0. What
16
16
  > Azox does **not** do is listed in
17
17
  > [Limitations](https://azox.dev/docs/limitations) — those are stopping
package/core/build.js CHANGED
@@ -132,7 +132,18 @@ export function buildRoute(projectDir, route, { transformHtml } = {}) {
132
132
  // A component's <head> block is merged in behind the page's own, so
133
133
  // a layout can carry the stylesheet and fonts every page needs while
134
134
  // the page keeps the last word on its title and description.
135
- const head = mergeHeads(ast.head, ast.componentHeads ?? []);
135
+ // A component's scoped CSS is plain stylesheet text by this point,
136
+ // with every selector already rewritten to require its scope
137
+ // attribute. One <style> carries all of them, so a page makes no
138
+ // extra request for styles that are only a few lines each.
139
+ // A page's own <style> is global: it has no caller to be scoped
140
+ // against, and `body { … }` in a page should mean what it says.
141
+ // Without this it was extracted by the parser and then silently
142
+ // dropped, which is worse than the error it used to raise.
143
+ const scoped = [ast.style, ...(ast.componentStyles ?? [])].filter(Boolean);
144
+ const styleTag = scoped.length ? `<style>\n${scoped.join('\n')}\n</style>` : '';
145
+
146
+ const head = mergeHeads(ast.head, [...(ast.componentHeads ?? []), styleTag].filter(Boolean));
136
147
 
137
148
  let document = wrapDocument(html, projectTitle(projectDir), head, {
138
149
  routerSrc: router ? `${route.assetPrefix}${ROUTER_FILENAME}` : null,
@@ -337,6 +348,17 @@ function removeStaleOutput(projectDir, results, assets = []) {
337
348
  // Everything in public/ is copied to the build root untouched, so a
338
349
  // stylesheet, font or image is referenced by the same path in source
339
350
  // and in the built site: public/style.css -> /style.css.
351
+ // Files an editor or OS leaves behind, which should not be published.
352
+ //
353
+ // Every dotfile used to be skipped, which also dropped the ones a static
354
+ // host needs: .nojekyll tells GitHub Pages not to run Jekyll over the
355
+ // output, and .well-known/ is how a domain or certificate is verified.
356
+ const EDITOR_JUNK = new Set(['.DS_Store', 'Thumbs.db', '.git', 'node_modules']);
357
+
358
+ function isEditorJunk(name) {
359
+ return EDITOR_JUNK.has(name);
360
+ }
361
+
340
362
  export function copyPublicAssets(projectDir) {
341
363
  const publicDir = resolve(projectDir, PUBLIC_DIR);
342
364
  if (!existsSync(publicDir)) return [];
@@ -346,7 +368,7 @@ export function copyPublicAssets(projectDir) {
346
368
 
347
369
  const walk = (dir, relativeDir) => {
348
370
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
349
- if (entry.name.startsWith('.')) continue;
371
+ if (isEditorJunk(entry.name)) continue;
350
372
 
351
373
  const from = join(dir, entry.name);
352
374
  const to = join(buildRoot, relativeDir, entry.name);
@@ -589,6 +611,12 @@ function uniqueTagName(line) {
589
611
  const meta = line.match(/^<meta\s[^>]*name=["']([^"']+)["']/i);
590
612
  if (meta) return `meta:${meta[1].toLowerCase()}`;
591
613
 
614
+ // Open Graph and Twitter cards key on `property`, not `name`. Without
615
+ // this a shared default and a page's own tag both shipped, and a
616
+ // scraper takes whichever it sees first.
617
+ const property = line.match(/^<meta\s[^>]*property=["']([^"']+)["']/i);
618
+ if (property) return `meta:${property[1].toLowerCase()}`;
619
+
592
620
  return null;
593
621
  }
594
622
 
@@ -10,6 +10,14 @@
10
10
  let uid = 0;
11
11
  const nextId = () => `_el${uid++}`;
12
12
 
13
+ // Tags that open an SVG document fragment. Everything inside one is in
14
+ // the SVG namespace too, which is threaded down as `inSvg` — <circle>
15
+ // and <path> carry no hint of their own.
16
+ //
17
+ // <a> and <script> exist in both languages; they are left as HTML,
18
+ // which is what they almost always are on a page.
19
+ const SVG_TAGS = new Set(['svg']);
20
+
13
21
  // Names the build resolved to constants — an inlined JSON import, say.
14
22
  // An expression reading only these can never change, so it is emitted
15
23
  // as text rather than wrapped in an effect. Set per compile.
@@ -375,15 +383,31 @@ function hydrateBlock() {
375
383
  return `
376
384
  // Hydrate: the SSR markup is already on the page, so clear it and
377
385
  // mount the reactive version in its place.
386
+ //
387
+ // Everything inside the root is replaced, so a listener another script
388
+ // attached to server-rendered markup is discarded with it — a menu
389
+ // button that looked right and did nothing. The event says the DOM has
390
+ // been rebuilt, and is the same one the router dispatches after a
391
+ // navigation, so one listener covers both.
378
392
  if (typeof document !== 'undefined') {
379
393
  const mount = document.querySelector('[data-azox-root]') ?? document.body;
380
394
  mount.innerHTML = '';
381
395
  render(mount);
396
+ // Guarded: the emitted module is also run against minimal DOM stubs —
397
+ // in tests, and anywhere rendering happens outside a browser — where
398
+ // CustomEvent and dispatchEvent need not exist.
399
+ if (typeof CustomEvent === 'function' && typeof document.dispatchEvent === 'function') {
400
+ document.dispatchEvent(
401
+ new CustomEvent('azox:navigate', {
402
+ detail: { url: typeof location !== 'undefined' ? location.href : null, from: null, hydrated: true },
403
+ })
404
+ );
405
+ }
382
406
  }
383
407
  `;
384
408
  }
385
409
 
386
- function emitNode(node, statements, fallbackVar) {
410
+ function emitNode(node, statements, fallbackVar, inSvg = false) {
387
411
  if (!node) return 'null';
388
412
 
389
413
  if (node.type === 'text') {
@@ -395,7 +419,7 @@ function emitNode(node, statements, fallbackVar) {
395
419
  if (node.type === 'fragment') {
396
420
  const varName = nextId();
397
421
  statements.push(`const ${varName} = document.createDocumentFragment();`);
398
- appendChildren(varName, node.children, statements, fallbackVar);
422
+ appendChildren(varName, node.children, statements, fallbackVar, inSvg);
399
423
  return varName;
400
424
  }
401
425
 
@@ -404,13 +428,22 @@ function emitNode(node, statements, fallbackVar) {
404
428
  if (node.type === 'scope') return emitScope(node, statements, fallbackVar);
405
429
 
406
430
  const varName = nextId();
407
- statements.push(`const ${varName} = document.createElement(${JSON.stringify(node.name)});`);
431
+
432
+ // An SVG element needs its namespace. createElement always makes an
433
+ // HTML element, so an inline <svg> compiled to something the browser
434
+ // laid out as an unknown HTML tag: present in the DOM, 0×0 on screen.
435
+ const svg = inSvg || SVG_TAGS.has(node.name);
436
+ statements.push(
437
+ svg
438
+ ? `const ${varName} = document.createElementNS("http://www.w3.org/2000/svg", ${JSON.stringify(node.name)});`
439
+ : `const ${varName} = document.createElement(${JSON.stringify(node.name)});`
440
+ );
408
441
 
409
442
  for (const [key, attr] of Object.entries(node.attrs)) {
410
443
  emitAttr(varName, key, attr, statements, node.name);
411
444
  }
412
445
 
413
- appendChildren(varName, node.children, statements, fallbackVar);
446
+ appendChildren(varName, node.children, statements, fallbackVar, svg);
414
447
 
415
448
  return varName;
416
449
  }
@@ -669,9 +702,9 @@ function emitIf(node, statements) {
669
702
  );
670
703
  }
671
704
 
672
- function appendChildren(parentVar, children, statements, fallbackVar) {
705
+ function appendChildren(parentVar, children, statements, fallbackVar, inSvg = false) {
673
706
  for (const child of children) {
674
- const childVar = emitNode(child, statements, fallbackVar);
707
+ const childVar = emitNode(child, statements, fallbackVar, inSvg);
675
708
  if (childVar !== 'null') statements.push(`${parentVar}.appendChild(${childVar});`);
676
709
  }
677
710
  }
@@ -0,0 +1,41 @@
1
+ // Types for `azoxjs/compiler`.
2
+ //
3
+ // The compiler is free of Node built-ins on purpose, so these describe
4
+ // an API that runs in a browser as well as in the build.
5
+
6
+ /** A parsed `.azox` file. The node shapes are internal and may change. */
7
+ export interface AzoxAst {
8
+ /** The `<script>` block, with its imports intact. */
9
+ script: string;
10
+ /** The `<head>` block, verbatim. */
11
+ head: string;
12
+ /** The markup, as a tree the compiler and renderer both walk. */
13
+ markup: unknown;
14
+ /** Component name → the specifier it was imported from. */
15
+ components: Record<string, string>;
16
+ /** Prop names the file declared with `props()`. */
17
+ props: string[];
18
+ /** Route parameter names the file declared with `params()`. */
19
+ params: string[];
20
+ }
21
+
22
+ export interface CompileOptions {
23
+ /** How the emitted module should import the runtime. */
24
+ runtimeSpecifier: string;
25
+ /**
26
+ * Rebases the author's relative imports, since compiled output does
27
+ * not sit beside the source. Called with the file an import was
28
+ * written in, which differs from the page for a hoisted one.
29
+ */
30
+ rewriteImports?: (script: string, sourcePath?: string) => string;
31
+ /** Values the build resolved, inlined instead of imported. */
32
+ inlineModules?: Record<string, unknown>;
33
+ /** Resolved parameters for one page of a dynamic route. */
34
+ routeParams?: Record<string, string> | null;
35
+ }
36
+
37
+ /** Parses a `.azox` file. Throws on malformed markup, naming the tag. */
38
+ export declare function parseAzox(source: string): AzoxAst;
39
+
40
+ /** Compiles a parsed file into a JavaScript module, as a string. */
41
+ export declare function compileToModule(ast: AzoxAst, options: CompileOptions): string;
@@ -16,19 +16,40 @@ export class ParseError extends BuildError {}
16
16
  const isComponentName = (name) => /^[A-Z]/.test(name);
17
17
 
18
18
  export function parseAzox(source) {
19
- const scriptMatch = source.match(/<script>([\s\S]*?)<\/script>/);
19
+ // <script>, <head> and <style> are pulled out of the source before
20
+ // anything else looks at it. A literal <text> block may contain those
21
+ // very tags — documenting them requires it — so the spans are blanked
22
+ // first, and only what is left is searched. Without this, a code
23
+ // sample showing a <style> block was eaten as the page's own CSS.
24
+ const searchable = blankTextBlocks(source);
25
+
26
+ const scriptMatch = searchable.match(/<script>([\s\S]*?)<\/script>/);
20
27
  const script = scriptMatch ? scriptMatch[1].trim() : '';
21
28
 
22
29
  // An optional <head> block is copied into the document head
23
30
  // verbatim: stylesheets, meta tags, fonts. It is markup for the
24
31
  // document, not for the page body, so it skips the AST entirely.
25
- const headMatch = source.match(/<head>([\s\S]*?)<\/head>/);
32
+ const headMatch = searchable.match(/<head>([\s\S]*?)<\/head>/);
26
33
  const head = headMatch ? headMatch[1].trim() : '';
27
34
 
28
- const template = source
29
- .replace(/<script>[\s\S]*?<\/script>/, '')
30
- .replace(/<head>[\s\S]*?<\/head>/, '')
31
- .trim();
35
+ // A <style> block is CSS, not markup. It has to come out before the
36
+ // tokeniser runs: left in, its braces are read as {expressions} and a
37
+ // rule like `.a { color: red }` failed the build with "Unexpected
38
+ // token ':'" — an error about JavaScript, pointing at a stylesheet.
39
+ const styleMatch = searchable.match(/<style>([\s\S]*?)<\/style>/);
40
+ const style = styleMatch ? styleMatch[1].trim() : '';
41
+
42
+ // Cut by position rather than by pattern: replacing on the source
43
+ // would remove the first match anywhere, including one inside a
44
+ // <text> block that was deliberately ignored above.
45
+ const cuts = [scriptMatch, headMatch, styleMatch]
46
+ .filter(Boolean)
47
+ .map((match) => [match.index, match.index + match[0].length])
48
+ .sort((a, b) => b[0] - a[0]);
49
+
50
+ let template = source;
51
+ for (const [from, to] of cuts) template = template.slice(0, from) + template.slice(to);
52
+ template = template.trim();
32
53
 
33
54
  const tokens = tokenize(template);
34
55
 
@@ -55,6 +76,7 @@ export function parseAzox(source) {
55
76
  return {
56
77
  script,
57
78
  head,
79
+ style,
58
80
  markup,
59
81
  components: parseComponentImports(script),
60
82
  props: parsePropNames(script),
@@ -64,6 +86,16 @@ export function parseAzox(source) {
64
86
  };
65
87
  }
66
88
 
89
+ // Replaces the contents of every <text> block with spaces, keeping the
90
+ // length and therefore every offset in the original. What a <text>
91
+ // block holds is literal by definition, so nothing inside one should be
92
+ // mistaken for the page's script, head or style.
93
+ function blankTextBlocks(source) {
94
+ return source.replace(/<text>[\s\S]*?<\/text>/g, (block) =>
95
+ `<text>${' '.repeat(Math.max(0, block.length - '<text></text>'.length))}</text>`
96
+ );
97
+ }
98
+
67
99
  // Component imports are written as ordinary import statements, so an
68
100
  // editor treats them like any other module reference:
69
101
  // import Card from '../components/Card.azox';
@@ -7,6 +7,7 @@
7
7
  // been written by hand.
8
8
 
9
9
  import { parseAzox } from './parser.js';
10
+ import { scopeId, scopeAttribute, scopeCss } from './scopeStyles.js';
10
11
  import { BuildError } from '../buildError.js';
11
12
 
12
13
  // Extends BuildError so the CLI reports it as a user-facing problem
@@ -20,7 +21,14 @@ export class ComponentError extends BuildError {}
20
21
  // `resolver` decides how an import specifier becomes source text, so
21
22
  // this runs unchanged against disk or against an in-memory map. See
22
23
  // sourceResolver.js.
23
- export function resolveComponents(ast, sourcePath, resolver, seen = new Set(), heads = null) {
24
+ export function resolveComponents(
25
+ ast,
26
+ sourcePath,
27
+ resolver,
28
+ seen = new Set(),
29
+ heads = null,
30
+ styles = null
31
+ ) {
24
32
  // Imports from stateful components are hoisted here: they cannot
25
33
  // live inside the scope function the compiler builds for each one.
26
34
  //
@@ -37,17 +45,31 @@ export function resolveComponents(ast, sourcePath, resolver, seen = new Set(), h
37
45
  // now only a page could put one there.
38
46
  const collected = heads ?? new Map();
39
47
 
40
- const markup = expand(ast.markup, ast, sourcePath, resolver, seen, hoisted, collected);
48
+ // A component's <style> block is collected the same way and keyed by
49
+ // the same path, so a component used twice contributes its CSS once.
50
+ const collectedStyles = styles ?? new Map();
51
+
52
+ const markup = expand(
53
+ ast.markup,
54
+ ast,
55
+ sourcePath,
56
+ resolver,
57
+ seen,
58
+ hoisted,
59
+ collected,
60
+ collectedStyles
61
+ );
41
62
 
42
63
  return {
43
64
  ...ast,
44
65
  markup,
45
66
  componentImports: [...hoisted.values()],
46
67
  componentHeads: [...collected.values()],
68
+ componentStyles: [...collectedStyles.values()],
47
69
  };
48
70
  }
49
71
 
50
- function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
72
+ function expand(node, ast, sourcePath, resolver, seen, hoisted, heads, styles) {
51
73
  if (!node || node.type === 'text') return node;
52
74
 
53
75
  // <if> keeps its children in two branches rather than in `children`,
@@ -57,16 +79,16 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
57
79
  return {
58
80
  ...node,
59
81
  then: node.then.map((child) =>
60
- expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
82
+ expand(child, ast, sourcePath, resolver, seen, hoisted, heads, styles)
61
83
  ),
62
84
  otherwise: node.otherwise.map((child) =>
63
- expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
85
+ expand(child, ast, sourcePath, resolver, seen, hoisted, heads, styles)
64
86
  ),
65
87
  };
66
88
  }
67
89
 
68
90
  const children = (node.children ?? []).map((child) =>
69
- expand(child, ast, sourcePath, resolver, seen, hoisted, heads)
91
+ expand(child, ast, sourcePath, resolver, seen, hoisted, heads, styles)
70
92
  );
71
93
 
72
94
  if (node.type !== 'component') {
@@ -83,13 +105,27 @@ function expand(node, ast, sourcePath, resolver, seen, hoisted, heads) {
83
105
  component.path,
84
106
  resolver,
85
107
  new Set([...seen, component.path]),
86
- heads
108
+ heads,
109
+ styles
87
110
  );
88
111
 
89
112
  // Keyed by path: a component used on a page twice must not emit its
90
113
  // stylesheet link twice.
91
114
  if (component.ast.head) heads.set(component.path, component.ast.head);
92
115
 
116
+ // A <style> block scopes to this component: its selectors are
117
+ // rewritten to require an attribute, and that attribute is put on the
118
+ // markup below. Both halves key off the same path, so the CSS and the
119
+ // elements it targets always agree.
120
+ let scope = null;
121
+
122
+ if (component.ast.style) {
123
+ scope = scopeId(component.path);
124
+ styles.set(component.path, scopeCss(component.ast.style, scope));
125
+ }
126
+
127
+ if (scope) markScope(inner.markup, scopeAttribute(scope));
128
+
93
129
  const values = propValues(node, component.ast.props);
94
130
  const { logic, imports } = componentLogic(component.ast.script);
95
131
 
@@ -160,6 +196,30 @@ function componentLogic(script) {
160
196
  return { logic: meaningful.trim() ? body.trim() : null, imports };
161
197
  }
162
198
 
199
+ // Puts the scope attribute on every element the component owns.
200
+ //
201
+ // Slot content is skipped: it was written by whoever used the tag, so
202
+ // it belongs to the caller's scope, and a component must not restyle
203
+ // markup it did not write.
204
+ function markScope(node, attribute) {
205
+ if (!node || node.type === 'text') return;
206
+
207
+ if (node.type === 'fragment' && node.slot) return;
208
+
209
+ if (node.type === 'element' || node.type === 'component') {
210
+ node.attrs = { ...node.attrs, [attribute]: { kind: 'static', value: '' } };
211
+ }
212
+
213
+ if (node.type === 'if') {
214
+ for (const child of [...(node.then ?? []), ...(node.otherwise ?? [])]) {
215
+ markScope(child, attribute);
216
+ }
217
+ return;
218
+ }
219
+
220
+ for (const child of node.children ?? []) markScope(child, attribute);
221
+ }
222
+
163
223
  function loadComponent(name, ast, sourcePath, resolver, seen) {
164
224
  const specifier = ast.components[name];
165
225
 
@@ -0,0 +1,151 @@
1
+ // Scoped styles: a component's CSS applies to that component's markup
2
+ // and nothing else.
3
+ //
4
+ // The mechanism is the one most frameworks settled on, because it needs
5
+ // no runtime: every element the component owns gets an attribute, and
6
+ // every selector in its <style> block is rewritten to require that
7
+ // attribute. The result is plain CSS a browser can parse — no shadow
8
+ // DOM, no class-name mangling, nothing to ship.
9
+ //
10
+ // This file has no Node built-ins on purpose: it runs in the browser
11
+ // playground like the rest of the compiler.
12
+
13
+ // A short, stable id from the component's path. Not cryptographic — it
14
+ // only has to be stable across builds and unlikely to collide between
15
+ // the files of one project.
16
+ export function scopeId(path) {
17
+ let hash = 0;
18
+
19
+ for (let i = 0; i < path.length; i++) {
20
+ hash = (hash * 31 + path.charCodeAt(i)) | 0;
21
+ }
22
+
23
+ return `a${(hash >>> 0).toString(36).slice(0, 6)}`;
24
+ }
25
+
26
+ export const scopeAttribute = (id) => `data-azox-${id}`;
27
+
28
+ // At-rules whose body is more CSS, so the selectors inside them need
29
+ // rewriting too — a rule in a @media block still belongs to the
30
+ // component.
31
+ const NESTED_AT_RULES = /^@(media|supports|container|layer)\b/;
32
+
33
+ // At-rules that contain no selectors. @keyframes names its steps `from`
34
+ // and `50%`, and scoping those would break the animation.
35
+ const OPAQUE_AT_RULES =
36
+ /^@(keyframes|-\w+-keyframes|font-face|import|charset|namespace|property)\b/;
37
+
38
+ /**
39
+ * Rewrites every selector in `css` to require the scope attribute.
40
+ *
41
+ * .title { … } → .title[data-azox-a1b2c3] { … }
42
+ * .a, .b { … } → .a[data-azox-a1b2c3], .b[data-azox-a1b2c3] { … }
43
+ * :global(.x) { … } → .x { … }
44
+ */
45
+ export function scopeCss(css, id) {
46
+ return rewriteBlock(css, `[${scopeAttribute(id)}]`);
47
+ }
48
+
49
+ function rewriteBlock(css, attr) {
50
+ let out = '';
51
+ let i = 0;
52
+
53
+ while (i < css.length) {
54
+ const open = css.indexOf('{', i);
55
+
56
+ // Trailing text with no rule after it: a stray comment, or nothing.
57
+ if (open === -1) {
58
+ out += css.slice(i);
59
+ break;
60
+ }
61
+
62
+ const prelude = css.slice(i, open);
63
+ const close = matchingBrace(css, open);
64
+ const body = css.slice(open + 1, close);
65
+ const trimmed = prelude.trim();
66
+
67
+ if (OPAQUE_AT_RULES.test(trimmed)) {
68
+ // Kept whole: its body is not selectors.
69
+ out += `${prelude}{${body}}`;
70
+ } else if (NESTED_AT_RULES.test(trimmed)) {
71
+ // The condition stays; what is inside it is scoped.
72
+ out += `${prelude}{${rewriteBlock(body, attr)}}`;
73
+ } else {
74
+ out += `${scopeSelectorList(prelude, attr)}{${body}}`;
75
+ }
76
+
77
+ i = close + 1;
78
+ }
79
+
80
+ return out;
81
+ }
82
+
83
+ // Finds the brace closing the one at `open`, so a @media body is taken
84
+ // whole rather than cut at its first inner rule.
85
+ function matchingBrace(css, open) {
86
+ let depth = 0;
87
+
88
+ for (let i = open; i < css.length; i++) {
89
+ if (css[i] === '{') depth++;
90
+ else if (css[i] === '}' && --depth === 0) return i;
91
+ }
92
+
93
+ return css.length;
94
+ }
95
+
96
+ // A placeholder for a comment while the selector list is split on
97
+ // commas. Printable on purpose: a control character would be invisible
98
+ // in any output that quotes this source.
99
+ const COMMENT_MARK = (n) => `/*__azox${n}__*/`;
100
+
101
+ function scopeSelectorList(prelude, attr) {
102
+ // Comments carry no selectors and may contain commas.
103
+ const comments = [];
104
+ const stripped = prelude.replace(/\/\*[\s\S]*?\*\//g, (comment) => {
105
+ comments.push(comment);
106
+ return COMMENT_MARK(comments.length - 1);
107
+ });
108
+
109
+ const scoped = stripped
110
+ .split(',')
111
+ .map((selector) => scopeSelector(selector, attr))
112
+ .join(',');
113
+
114
+ return scoped.replace(/\/\*__azox(\d+)__\*\//g, (_, n) => comments[Number(n)]);
115
+ }
116
+
117
+ function scopeSelector(selector, attr) {
118
+ const trimmed = selector.trim();
119
+ if (!trimmed) return selector;
120
+
121
+ // An escape hatch for the times a component must reach outside
122
+ // itself — a body class, or markup it renders into a portal.
123
+ const global = trimmed.match(/^:global\(([\s\S]*)\)$/);
124
+ if (global) return selector.replace(trimmed, global[1].trim());
125
+
126
+ const leading = selector.slice(0, selector.length - selector.trimStart().length);
127
+ const trailing = selector.slice(selector.trimEnd().length);
128
+
129
+ // The attribute goes on the last compound of the selector, so
130
+ // `.card .title` scopes the element actually being styled rather than
131
+ // its ancestor. Combinators are left where they are.
132
+ const parts = trimmed.split(/(\s+|\s*[>+~]\s*)/);
133
+
134
+ for (let i = parts.length - 1; i >= 0; i--) {
135
+ if (!parts[i].trim() || /^[>+~\s]+$/.test(parts[i])) continue;
136
+ parts[i] = attachAttribute(parts[i], attr);
137
+ break;
138
+ }
139
+
140
+ return leading + parts.join('') + trailing;
141
+ }
142
+
143
+ // Placed before any pseudo-element or pseudo-class: `.a[attr]:hover` is
144
+ // what was meant, and `.a:hover[attr]` would not match a browser's
145
+ // reading of it.
146
+ function attachAttribute(compound, attr) {
147
+ const pseudo = compound.search(/::?[a-z-]/i);
148
+ if (pseudo === -1) return compound + attr;
149
+
150
+ return compound.slice(0, pseudo) + attr + compound.slice(pseudo);
151
+ }
@@ -0,0 +1,41 @@
1
+ // Types for `azoxjs`.
2
+ //
3
+ // The whole reactivity surface, plus the compiler and renderer, so this
4
+ // entry point and `azoxjs/reactivity` offer the same thing.
5
+
6
+ export {
7
+ signal,
8
+ effect,
9
+ computed,
10
+ dispose,
11
+ untracked,
12
+ onMount,
13
+ onCleanup,
14
+ type Signal,
15
+ type ReadonlySignal,
16
+ type EffectHandle,
17
+ } from './reactivity/signal.js';
18
+
19
+ export { parseAzox, compileToModule } from './compiler/index.js';
20
+
21
+ // Imported as well as re-exported: a type-only re-export names the type
22
+ // for consumers but does not put it in scope here, and renderToHtml
23
+ // below refers to it.
24
+ import type { AzoxAst } from './compiler/index.js';
25
+ export type { AzoxAst, CompileOptions } from './compiler/index.js';
26
+
27
+ /**
28
+ * Renders a parsed file to HTML, evaluating its expressions once.
29
+ *
30
+ * `scope` holds the bindings the markup reads. `modules` carries what
31
+ * the file's imports brought in, which the build resolves.
32
+ */
33
+ export declare function renderToHtml(
34
+ ast: AzoxAst,
35
+ scope: Record<string, unknown>,
36
+ modules?: Record<string, unknown>
37
+ ): string;
38
+
39
+ /** The version this copy of Azox reports. */
40
+ export declare const VERSION: string;
41
+ export declare const TAGLINE: string;
@@ -0,0 +1,88 @@
1
+ // Types for `azoxjs/reactivity`.
2
+ //
3
+ // Written by hand rather than generated: the runtime carries no JSDoc,
4
+ // so `tsc --declaration` produces `signal(initialValue: any)`, which
5
+ // type-checks nothing. These describe what the implementation actually
6
+ // guarantees.
7
+
8
+ /**
9
+ * A reactive value. Call it to read — and, inside an `effect`, to
10
+ * subscribe to it.
11
+ */
12
+ export interface Signal<T> {
13
+ (): T;
14
+
15
+ /**
16
+ * Replaces the value, or derives it from the current one.
17
+ *
18
+ * Setting a value equal to the current one (`===`) notifies nobody.
19
+ *
20
+ * A function is treated as an updater, so a signal *holding* a
21
+ * function cannot be replaced by passing it directly — pass
22
+ * `() => theFunction` instead.
23
+ */
24
+ set(next: T | ((previous: T) => T)): void;
25
+
26
+ /** Reads without subscribing, even inside an effect. */
27
+ peek(): T;
28
+ }
29
+
30
+ /** A read-only reactive value. `computed` returns one of these. */
31
+ export interface ReadonlySignal<T> {
32
+ (): T;
33
+ peek(): T;
34
+ }
35
+
36
+ /**
37
+ * The handle `effect` returns. Its only public use is `dispose`; its
38
+ * internals are not part of the API and may change.
39
+ */
40
+ export interface EffectHandle {
41
+ (): void;
42
+ }
43
+
44
+ /** Creates a reactive value. */
45
+ export declare function signal<T>(initialValue: T): Signal<T>;
46
+
47
+ /**
48
+ * Runs `fn` now, and again whenever a signal it read changes.
49
+ *
50
+ * Effects created while another is running belong to it, and are
51
+ * disposed with it. Use `untracked` to opt out of that ownership.
52
+ */
53
+ export declare function effect(fn: () => void): EffectHandle;
54
+
55
+ /**
56
+ * A value derived from other signals, recomputed when they change.
57
+ *
58
+ * Read-only by design. The runtime object does carry `set`, but the
59
+ * deriving effect overwrites whatever you assign on its next run, so
60
+ * it is not offered here.
61
+ */
62
+ export declare function computed<T>(fn: () => T): ReadonlySignal<T>;
63
+
64
+ /**
65
+ * Runs `fn` with no effect considered active, so anything it creates
66
+ * is owned by nobody and survives the caller re-running.
67
+ */
68
+ export declare function untracked<T>(fn: () => T): T;
69
+
70
+ /** Stops an effect and everything it created. Ignores null. */
71
+ export declare function dispose(handle: EffectHandle | null | undefined): void;
72
+
73
+ /**
74
+ * Runs once the DOM this scope built is in the document.
75
+ *
76
+ * A function returned from `fn` becomes the scope's cleanup, so setup
77
+ * and teardown can stay together.
78
+ */
79
+ export declare function onMount(fn: () => void | (() => void)): void;
80
+
81
+ /**
82
+ * Registers work to undo when the surrounding scope goes away: a row
83
+ * leaving a keyed list, or a branch of an `<if>` no longer taken.
84
+ *
85
+ * Called outside any scope it does nothing, which is the case at the
86
+ * top level of a page — nothing ever removes it.
87
+ */
88
+ export declare function onCleanup(fn: () => void): void;
@@ -44,18 +44,25 @@ export function serverOnCleanup() {}
44
44
  export function declaredNames(script) {
45
45
  const names = new Set();
46
46
 
47
- for (const match of script.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) {
47
+ // Only what the script declares at its top level. A name declared
48
+ // inside a callback — `const timer` within onMount, say — is a local
49
+ // of that function, so returning it from the outer scope throws
50
+ // "timer is not defined" and takes the whole page down.
51
+ const topLevel = topLevelSource(script);
52
+
53
+ for (const match of topLevel.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/g)) {
48
54
  names.add(match[1]);
49
55
  }
50
- for (const match of script.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
56
+ for (const match of topLevel.matchAll(/function\s+([A-Za-z_$][\w$]*)\s*\(/g)) {
51
57
  names.add(match[1]);
52
58
  }
53
- for (const match of script.matchAll(/class\s+([A-Za-z_$][\w$]*)/g)) {
59
+ for (const match of topLevel.matchAll(/class\s+([A-Za-z_$][\w$]*)/g)) {
54
60
  names.add(match[1]);
55
61
  }
56
62
 
57
63
  // Destructured declarations: const { a, b: c, d = 1 } = …
58
- for (const match of script.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
64
+ //
65
+ for (const match of topLevel.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
59
66
  for (const part of match[1].split(',')) {
60
67
  const name = part.split(':').pop().split('=')[0].trim();
61
68
  if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
@@ -65,6 +72,97 @@ export function declaredNames(script) {
65
72
  return [...names];
66
73
  }
67
74
 
75
+ // Blanks out everything nested inside braces, brackets or parentheses,
76
+ // leaving the top-level text with its offsets intact — so a declaration
77
+ // inside a function body is no longer visible to the patterns above.
78
+ //
79
+ // Strings, template literals and comments are blanked too: a brace in
80
+ // one of them would otherwise throw the depth count off.
81
+ function topLevelSource(script) {
82
+ let out = '';
83
+ let depth = 0;
84
+ let i = 0;
85
+
86
+ while (i < script.length) {
87
+ const c = script[i];
88
+ const next = script[i + 1];
89
+
90
+ // Comments.
91
+ if (c === '/' && next === '/') {
92
+ const end = script.indexOf('\n', i);
93
+ const stop = end === -1 ? script.length : end;
94
+ out += ' '.repeat(stop - i);
95
+ i = stop;
96
+ continue;
97
+ }
98
+ if (c === '/' && next === '*') {
99
+ const end = script.indexOf('*/', i + 2);
100
+ const stop = end === -1 ? script.length : end + 2;
101
+ out += ' '.repeat(stop - i);
102
+ i = stop;
103
+ continue;
104
+ }
105
+
106
+ // Strings and template literals: skipped whole, so braces inside
107
+ // them do not count. A template's ${...} is skipped with it, which
108
+ // is fine — nothing is declared at the top level in there.
109
+ if (c === '"' || c === "'" || c === '`') {
110
+ const quote = c;
111
+ let j = i + 1;
112
+ while (j < script.length) {
113
+ if (script[j] === '\\') j += 2;
114
+ else if (script[j] === quote) break;
115
+ else j++;
116
+ }
117
+ const stop = Math.min(j + 1, script.length);
118
+ out += ' '.repeat(stop - i);
119
+ i = stop;
120
+ continue;
121
+ }
122
+
123
+ // The delimiter itself is kept when it opens at the top level, so
124
+ // `function f(` and `const { a } =` still match; what is nested
125
+ // inside is blanked.
126
+ //
127
+ // The exception is a destructuring pattern — `const { a, b } = …` —
128
+ // whose names are the thing being declared, so its contents are
129
+ // kept even though they sit one level in.
130
+ if (c === '{' || c === '(' || c === '[') {
131
+ const destructuring = c === '{' && depth === 0 && /(?:const|let|var)\s*$/.test(out);
132
+
133
+ out += depth === 0 ? c : ' ';
134
+ depth++;
135
+
136
+ if (destructuring) {
137
+ const close = script.indexOf('}', i + 1);
138
+ if (close !== -1) {
139
+ out += script.slice(i + 1, close + 1);
140
+ depth--;
141
+ i = close + 1;
142
+ continue;
143
+ }
144
+ }
145
+
146
+ i++;
147
+ continue;
148
+ }
149
+
150
+ if (c === '}' || c === ')' || c === ']') {
151
+ depth = Math.max(0, depth - 1);
152
+ out += depth === 0 ? c : ' ';
153
+ i++;
154
+ continue;
155
+ }
156
+
157
+ // Newlines are kept at any depth so line-anchored patterns still
158
+ // behave; everything else nested is blanked.
159
+ out += depth === 0 || c === '\n' ? c : ' ';
160
+ i++;
161
+ }
162
+
163
+ return out;
164
+ }
165
+
68
166
  // Evaluates a script body and returns its declarations. `params` and
69
167
  // `args` pass a component's props in as arguments.
70
168
  //
@@ -128,6 +128,7 @@ async function go(href, { restore = false } = {}) {
128
128
  // already holds wherever the reader last asked to go.
129
129
  if (location.href !== href) return;
130
130
 
131
+ const previous = currentUrl;
131
132
  const next = new DOMParser().parseFromString(html, 'text/html');
132
133
  const incoming = next.querySelector(ROOT);
133
134
  const target = document.querySelector(ROOT);
@@ -145,6 +146,18 @@ async function go(href, { restore = false } = {}) {
145
146
 
146
147
  currentUrl = href;
147
148
  restoreScroll(restore);
149
+
150
+ // The page's own module is re-run above, but a classic <script> that
151
+ // enhances the markup — syntax highlighting, heading anchors, a
152
+ // table of contents — is not: it ran once on first load and the
153
+ // nodes it worked on have just been replaced. This event is how such
154
+ // a script knows to run again.
155
+ //
156
+ // Dispatched after the swap and after the module, so a listener sees
157
+ // the finished page.
158
+ document.dispatchEvent(
159
+ new CustomEvent('azox:navigate', { detail: { url: href, from: previous } })
160
+ );
148
161
  }
149
162
 
150
163
  // Brings across anything in the new page's head that this one lacks —
@@ -199,13 +212,23 @@ async function runPageModule(next, pageHref) {
199
212
  function restoreScroll(restore) {
200
213
  if (restore) return; // The browser restores the position itself.
201
214
 
202
- const { hash } = location;
203
- const target = hash && document.querySelector(hash);
215
+ const target = elementForHash(location.hash);
204
216
 
205
217
  if (target) target.scrollIntoView();
206
218
  else window.scrollTo(0, 0);
207
219
  }
208
220
 
221
+ // A hash is not always a selector. A page may use it to carry state —
222
+ // "#template=landing" — and handing that to querySelector throws
223
+ // "not a valid selector", which stopped the scroll being restored at
224
+ // all and put an error in every visitor's console.
225
+ function elementForHash(hash) {
226
+ if (!hash || hash.length < 2) return null;
227
+
228
+ // getElementById takes an id, not a selector, so nothing can throw.
229
+ return document.getElementById(decodeURIComponent(hash.slice(1)));
230
+ }
231
+
209
232
  export function currentHref() {
210
233
  return currentUrl;
211
234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "azoxjs",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Azox Framework — The Sound of Future Web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,17 +9,26 @@
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/darilpratomo/azox.git"
11
11
  },
12
- "homepage": "https://github.com/darilpratomo/azox#readme",
12
+ "homepage": "https://azox.dev",
13
13
  "bugs": {
14
14
  "url": "https://github.com/darilpratomo/azox/issues"
15
15
  },
16
16
  "bin": {
17
- "azox": "./bin/azox.js"
17
+ "azox": "bin/azox.js"
18
18
  },
19
19
  "exports": {
20
- ".": "./core/index.js",
21
- "./reactivity": "./core/reactivity/signal.js",
22
- "./compiler": "./core/compiler/index.js",
20
+ ".": {
21
+ "types": "./core/index.d.ts",
22
+ "default": "./core/index.js"
23
+ },
24
+ "./reactivity": {
25
+ "types": "./core/reactivity/signal.d.ts",
26
+ "default": "./core/reactivity/signal.js"
27
+ },
28
+ "./compiler": {
29
+ "types": "./core/compiler/index.d.ts",
30
+ "default": "./core/compiler/index.js"
31
+ },
23
32
  "./package.json": "./package.json"
24
33
  },
25
34
  "files": [
@@ -43,5 +52,6 @@
43
52
  "compiler",
44
53
  "ssr",
45
54
  "no-virtual-dom"
46
- ]
55
+ ],
56
+ "types": "./core/index.d.ts"
47
57
  }