azoxjs 0.1.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.
@@ -0,0 +1,52 @@
1
+ // Recursive file watching with debounce.
2
+ //
3
+ // fs.watch fires several times for one save (editors write, rename
4
+ // and touch metadata separately), so events are coalesced into a
5
+ // single callback. Node's `recursive: true` works on macOS and
6
+ // Windows; on Linux it needs Node 20+, so a manual fallback walks
7
+ // subdirectories and watches each one.
8
+
9
+ import { watch, readdirSync, statSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ const DEBOUNCE_MS = 40;
13
+
14
+ export function watchDirectory(dir, onChange, { filter = () => true } = {}) {
15
+ const watchers = [];
16
+ let timer = null;
17
+
18
+ const trigger = (filename) => {
19
+ if (filename && !filter(filename)) return;
20
+
21
+ clearTimeout(timer);
22
+ timer = setTimeout(() => onChange(filename), DEBOUNCE_MS);
23
+ };
24
+
25
+ try {
26
+ watchers.push(watch(dir, { recursive: true }, (_event, filename) => trigger(filename)));
27
+ } catch {
28
+ // Recursive mode unavailable: watch this directory and each
29
+ // subdirectory found at startup.
30
+ for (const target of [dir, ...subdirectories(dir)]) {
31
+ watchers.push(watch(target, (_event, filename) => trigger(filename)));
32
+ }
33
+ }
34
+
35
+ return () => {
36
+ clearTimeout(timer);
37
+ for (const watcher of watchers) watcher.close();
38
+ };
39
+ }
40
+
41
+ function subdirectories(dir) {
42
+ const found = [];
43
+
44
+ for (const entry of readdirSync(dir)) {
45
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
46
+
47
+ const full = join(dir, entry);
48
+ if (statSync(full).isDirectory()) found.push(full, ...subdirectories(full));
49
+ }
50
+
51
+ return found;
52
+ }
package/core/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // Public entry point for `import ... from 'azoxjs'`.
2
+
3
+ export { signal, effect, computed } from './reactivity/signal.js';
4
+ export { parseAzox } from './compiler/parser.js';
5
+ export { compileToModule } from './compiler/compileToJs.js';
6
+ export { renderToHtml } from './renderer/renderToHtml.js';
7
+ export { VERSION, TAGLINE } from './meta.js';
package/core/meta.js ADDED
@@ -0,0 +1,15 @@
1
+ // Single source of truth for version and identity strings. Reading
2
+ // from package.json means `azox -v` can never drift out of sync with
3
+ // what npm publishes.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ export const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+
11
+ const pkg = JSON.parse(readFileSync(resolve(ROOT_DIR, 'package.json'), 'utf8'));
12
+
13
+ export const VERSION = pkg.version;
14
+ export const TAGLINE = 'The Sound of Future Web';
15
+ export const BANNER = `Azox Framework v${VERSION} - ${TAGLINE}`;
@@ -0,0 +1,48 @@
1
+ // Azox's reactivity primitive. No Virtual DOM, no diffing tree —
2
+ // a signal tracks its own subscribers and notifies only them when
3
+ // its value changes. Effects that read a signal during their run
4
+ // are auto-subscribed; this is what lets compiled bindings update
5
+ // a single DOM node directly instead of re-rendering a component.
6
+
7
+ let activeEffect = null;
8
+
9
+ export function signal(initialValue) {
10
+ let value = initialValue;
11
+ const subscribers = new Set();
12
+
13
+ function read() {
14
+ if (activeEffect) subscribers.add(activeEffect);
15
+ return value;
16
+ }
17
+
18
+ read.set = (next) => {
19
+ const resolved = typeof next === 'function' ? next(value) : next;
20
+ if (resolved === value) return;
21
+ value = resolved;
22
+ for (const effect of [...subscribers]) effect();
23
+ };
24
+
25
+ read.peek = () => value;
26
+
27
+ return read;
28
+ }
29
+
30
+ export function effect(fn) {
31
+ const wrapped = () => {
32
+ const previous = activeEffect;
33
+ activeEffect = wrapped;
34
+ try {
35
+ fn();
36
+ } finally {
37
+ activeEffect = previous;
38
+ }
39
+ };
40
+ wrapped();
41
+ return wrapped;
42
+ }
43
+
44
+ export function computed(fn) {
45
+ const derived = signal(undefined);
46
+ effect(() => derived.set(fn()));
47
+ return derived;
48
+ }
@@ -0,0 +1,67 @@
1
+ // Server-side renderer. Walks the same AST the compiler uses, but
2
+ // evaluates dynamic expressions once (synchronously) to produce a
3
+ // plain HTML string — no browser DOM APIs involved, so this runs
4
+ // directly in Node. The client script (emitted by compileToJs) then
5
+ // takes over in the browser and wires up live signal bindings on
6
+ // top of this markup.
7
+
8
+ import { BuildError } from '../buildError.js';
9
+
10
+ const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
11
+
12
+ export function renderToHtml(ast, scope) {
13
+ return renderNode(ast.markup, scope);
14
+ }
15
+
16
+ function renderNode(node, scope) {
17
+ if (!node) return '';
18
+
19
+ // A fragment (from <slot />) contributes only its children.
20
+ if (node.type === 'fragment') {
21
+ return node.children.map((child) => renderNode(child, scope)).join('');
22
+ }
23
+
24
+ if (node.type === 'text') {
25
+ return node.parts
26
+ .map((part) => (part.kind === 'static' ? escapeHtml(part.value) : escapeHtml(String(evalExpr(part.expr, scope)))))
27
+ .join('');
28
+ }
29
+
30
+ const attrs = Object.entries(node.attrs)
31
+ .filter(([key]) => !key.startsWith('on:'))
32
+ .map(([key, attr]) => {
33
+ const value = attr.kind === 'static' ? attr.value : String(evalExpr(attr.expr, scope));
34
+ return ` ${key}="${escapeHtml(value)}"`;
35
+ })
36
+ .join('');
37
+
38
+ if (VOID_TAGS.has(node.name)) return `<${node.name}${attrs}>`;
39
+
40
+ const inner = node.children.map((child) => renderNode(child, scope)).join('');
41
+ return `<${node.name}${attrs}>${inner}</${node.name}>`;
42
+ }
43
+
44
+ function evalExpr(expr, scope) {
45
+ const keys = Object.keys(scope);
46
+
47
+ let fn;
48
+ try {
49
+ fn = new Function(...keys, `return (${expr});`);
50
+ } catch (error) {
51
+ throw new BuildError(`{${expr}} is not valid JavaScript: ${error.message}`);
52
+ }
53
+
54
+ try {
55
+ return fn(...keys.map((key) => scope[key]));
56
+ } catch (error) {
57
+ throw new BuildError(`{${expr}} failed while rendering: ${error.message}`);
58
+ }
59
+ }
60
+
61
+ function escapeHtml(str) {
62
+ return str
63
+ .replace(/&/g, '&amp;')
64
+ .replace(/</g, '&lt;')
65
+ .replace(/>/g, '&gt;')
66
+ .replace(/"/g, '&quot;');
67
+ }
package/core/routes.js ADDED
@@ -0,0 +1,78 @@
1
+ // Maps files under pages/ to routes and to their place in the build.
2
+ //
3
+ // pages/index.azox → / → index.html
4
+ // pages/about.azox → /about → about/index.html
5
+ // pages/blog/index.azox → /blog → blog/index.html
6
+ // pages/blog/first.azox → /blog/first → blog/first/index.html
7
+ //
8
+ // Emitting a directory with an index.html means clean URLs work on
9
+ // any static host without rewrite rules, since serving index.html
10
+ // for a directory is universal behaviour.
11
+
12
+ import { readdirSync, existsSync } from 'node:fs';
13
+ import { join, relative, sep } from 'node:path';
14
+
15
+ export const PAGE_EXTENSION = '.azox';
16
+
17
+ // Directories that are never routes.
18
+ const IGNORED = new Set(['node_modules']);
19
+
20
+ export function collectRoutes(pagesDir) {
21
+ if (!existsSync(pagesDir)) return [];
22
+
23
+ return walk(pagesDir, pagesDir)
24
+ .map((sourcePath) => describeRoute(pagesDir, sourcePath))
25
+ .sort((a, b) => a.url.localeCompare(b.url));
26
+ }
27
+
28
+ function walk(dir, pagesDir) {
29
+ const found = [];
30
+
31
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
32
+ if (entry.name.startsWith('.') || IGNORED.has(entry.name)) continue;
33
+
34
+ const full = join(dir, entry.name);
35
+
36
+ if (entry.isDirectory()) {
37
+ found.push(...walk(full, pagesDir));
38
+ } else if (entry.name.endsWith(PAGE_EXTENSION)) {
39
+ found.push(full);
40
+ }
41
+ }
42
+
43
+ return found;
44
+ }
45
+
46
+ function describeRoute(pagesDir, sourcePath) {
47
+ const relativePath = relative(pagesDir, sourcePath);
48
+ const segments = relativePath.slice(0, -PAGE_EXTENSION.length).split(sep);
49
+
50
+ // A trailing "index" names its parent directory rather than adding
51
+ // a segment, so blog/index.azox is /blog and not /blog/index.
52
+ const routeSegments = segments[segments.length - 1] === 'index' ? segments.slice(0, -1) : segments;
53
+
54
+ const url = routeSegments.length ? `/${routeSegments.join('/')}` : '/';
55
+ const outputDir = routeSegments.join('/');
56
+
57
+ return {
58
+ // The name used on the command line: `azox compile --page=blog/first`
59
+ name: segments.join('/'),
60
+ sourcePath,
61
+ url,
62
+ htmlPath: outputDir ? `${outputDir}/index.html` : 'index.html',
63
+ // Assets sit beside the page, so a nested page needs to climb
64
+ // back out to reach the shared runtime at the build root.
65
+ assetPrefix: '../'.repeat(routeSegments.length) || './',
66
+ outputDir,
67
+ };
68
+ }
69
+
70
+ export function findRoute(routes, name) {
71
+ // Accept the route URL as well as the file name, since both read
72
+ // naturally on the command line.
73
+ const wanted = name.replace(/^\/+|\/+$/g, '');
74
+
75
+ return routes.find(
76
+ (route) => route.name === wanted || route.url === `/${wanted}` || (wanted === '' && route.url === '/')
77
+ );
78
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "azoxjs",
3
+ "version": "0.1.0",
4
+ "description": "Azox Framework — The Sound of Future Web",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Daril Pratomo <darilprtmsr@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/darilpratomo/azox.git"
11
+ },
12
+ "homepage": "https://github.com/darilpratomo/azox#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/darilpratomo/azox/issues"
15
+ },
16
+ "bin": {
17
+ "azox": "./bin/azox.js"
18
+ },
19
+ "exports": {
20
+ ".": "./core/index.js",
21
+ "./reactivity": "./core/reactivity/signal.js",
22
+ "./compiler": "./core/compiler/index.js",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "bin",
27
+ "core",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18.0.0"
33
+ },
34
+ "scripts": {
35
+ "test": "node --test test/*.test.js",
36
+ "build": "node ./bin/azox.js compile"
37
+ },
38
+ "keywords": [
39
+ "azox",
40
+ "framework",
41
+ "signals",
42
+ "reactivity",
43
+ "compiler",
44
+ "ssr",
45
+ "no-virtual-dom"
46
+ ]
47
+ }