@zvndev/darkmown 0.4.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/docs/cli.md +51 -0
- package/docs/spec-alignment.md +60 -0
- package/package.json +50 -0
- package/site/_/feature-card.wd +7 -0
- package/site/_/features.json +14 -0
- package/site/_/logo.svg +1 -0
- package/site/_/nav.skin +84 -0
- package/site/_/nav.wd +8 -0
- package/site/_/quotes.json +6 -0
- package/site/_/sprint-loop.wd +11 -0
- package/site/_/team.json +7 -0
- package/site/pages/-draft.wd +3 -0
- package/site/pages/data.js +8 -0
- package/site/pages/data.skin +98 -0
- package/site/pages/data.wd +110 -0
- package/site/pages/docs/-relative-note.wd +5 -0
- package/site/pages/docs/.secret.wd +3 -0
- package/site/pages/docs/index.skin +39 -0
- package/site/pages/docs/index.wd +125 -0
- package/site/pages/index.js +10 -0
- package/site/pages/index.skin +134 -0
- package/site/pages/index.wd +64 -0
- package/site/pages/markdown.md +36 -0
- package/site/pages/markdown.skin +63 -0
- package/site/pages/reactive.skin +62 -0
- package/site/pages/reactive.wd +68 -0
- package/src/builder.js +87 -0
- package/src/cli.js +156 -0
- package/src/compiler.js +907 -0
- package/src/config.js +19 -0
- package/src/dev.js +30 -0
- package/src/router.js +51 -0
- package/src/runtime.js +222 -0
- package/src/scaffold.js +95 -0
- package/src/skin.js +68 -0
- package/src/statics.js +31 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export const routesDir = "site/pages";
|
|
4
|
+
export const shelfDir = "site/_";
|
|
5
|
+
export const distDir = "dist";
|
|
6
|
+
export const pageExtensions = new Set([".md", ".wd"]);
|
|
7
|
+
|
|
8
|
+
export function createPaths(cwd = process.cwd()) {
|
|
9
|
+
return {
|
|
10
|
+
cwd,
|
|
11
|
+
routesRoot: path.join(cwd, routesDir),
|
|
12
|
+
shelfRoot: path.join(cwd, shelfDir),
|
|
13
|
+
distRoot: path.join(cwd, distDir)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isHiddenName(name) {
|
|
18
|
+
return name.startsWith(".") || name.startsWith("-") || name.startsWith("_");
|
|
19
|
+
}
|
package/src/dev.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const devClientPath = "/__wd/dev-client.js";
|
|
2
|
+
export const devEventsPath = "/__wd/dev-events";
|
|
3
|
+
|
|
4
|
+
export function injectDevClient(html) {
|
|
5
|
+
const script = `<script type="module" src="${devClientPath}"></script>`;
|
|
6
|
+
return html.includes("</body>") ? html.replace("</body>", `${script}\n</body>`) : `${html}\n${script}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function devClientScript() {
|
|
10
|
+
return `
|
|
11
|
+
const source = new EventSource("${devEventsPath}");
|
|
12
|
+
source.addEventListener("reload", () => location.reload());
|
|
13
|
+
source.addEventListener("builderror", (event) => {
|
|
14
|
+
const { message } = JSON.parse(event.data);
|
|
15
|
+
let overlay = document.getElementById("__wd-error-overlay");
|
|
16
|
+
if (!overlay) {
|
|
17
|
+
overlay = document.createElement("div");
|
|
18
|
+
overlay.id = "__wd-error-overlay";
|
|
19
|
+
overlay.style.cssText = [
|
|
20
|
+
"position:fixed", "inset:0", "z-index:99999", "background:rgba(24,18,16,.96)",
|
|
21
|
+
"color:#ffd9cf", "font:14px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace",
|
|
22
|
+
"padding:3rem 2rem", "white-space:pre-wrap", "overflow:auto"
|
|
23
|
+
].join(";");
|
|
24
|
+
document.body.appendChild(overlay);
|
|
25
|
+
}
|
|
26
|
+
overlay.textContent = "Darkmown build failed\\n\\n" + message + "\\n\\nFix the file and this overlay clears on the next successful build.";
|
|
27
|
+
});
|
|
28
|
+
source.addEventListener("error", () => {});
|
|
29
|
+
`;
|
|
30
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isHiddenName, pageExtensions } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export function discoverRoutes(routesRoot) {
|
|
6
|
+
if (!fs.existsSync(routesRoot)) return [];
|
|
7
|
+
const routes = [];
|
|
8
|
+
|
|
9
|
+
function walk(dir) {
|
|
10
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
11
|
+
if (isHiddenName(entry.name)) continue;
|
|
12
|
+
const abs = path.join(dir, entry.name);
|
|
13
|
+
if (entry.isDirectory()) {
|
|
14
|
+
walk(abs);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const ext = path.extname(entry.name);
|
|
18
|
+
if (!pageExtensions.has(ext)) continue;
|
|
19
|
+
const base = path.basename(entry.name, ext);
|
|
20
|
+
if (isHiddenName(base)) continue;
|
|
21
|
+
routes.push({
|
|
22
|
+
file: abs,
|
|
23
|
+
route: routeFromFile(routesRoot, abs)
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
walk(routesRoot);
|
|
29
|
+
const seen = new Map();
|
|
30
|
+
for (const route of routes) {
|
|
31
|
+
if (seen.has(route.route)) {
|
|
32
|
+
throw new Error(`Duplicate route "${route.route}" from ${seen.get(route.route)} and ${route.file}`);
|
|
33
|
+
}
|
|
34
|
+
seen.set(route.route, route.file);
|
|
35
|
+
}
|
|
36
|
+
return routes.sort((a, b) => a.route.localeCompare(b.route));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function routeFromFile(routesRoot, file) {
|
|
40
|
+
const ext = path.extname(file);
|
|
41
|
+
const rel = path.relative(routesRoot, file).slice(0, -ext.length);
|
|
42
|
+
const parts = rel.split(path.sep);
|
|
43
|
+
if (parts.at(-1) === "index") parts.pop();
|
|
44
|
+
const route = `/${parts.join("/")}`;
|
|
45
|
+
return route === "/" ? "/" : `${route}/`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function outputPathForRoute(distRoot, route) {
|
|
49
|
+
const clean = route === "/" ? "" : route.replace(/^\/|\/$/g, "");
|
|
50
|
+
return path.join(distRoot, clean, "index.html");
|
|
51
|
+
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const state = {};
|
|
2
|
+
const persistKeys = new Set();
|
|
3
|
+
|
|
4
|
+
for (const script of document.querySelectorAll("script[data-wd-state]")) {
|
|
5
|
+
Object.assign(state, JSON.parse(script.textContent || "{}"));
|
|
6
|
+
const persist = script.getAttribute("data-wd-persist");
|
|
7
|
+
if (persist) persistKeys.add(persist);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
for (const key of persistKeys) {
|
|
11
|
+
const stored = localStorage.getItem(`wd:${key}`);
|
|
12
|
+
if (stored === null) continue;
|
|
13
|
+
try {
|
|
14
|
+
state[key] = JSON.parse(stored);
|
|
15
|
+
} catch {
|
|
16
|
+
localStorage.removeItem(`wd:${key}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function savePersisted() {
|
|
21
|
+
for (const key of persistKeys) {
|
|
22
|
+
localStorage.setItem(`wd:${key}`, JSON.stringify(state[key] ?? null));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getPath(value, path) {
|
|
27
|
+
if (!path) return value;
|
|
28
|
+
let current = value;
|
|
29
|
+
for (const segment of path.split(".")) {
|
|
30
|
+
if (current == null) return undefined;
|
|
31
|
+
if (segment === "constructor" || segment === "prototype" || segment === "__proto__") return undefined;
|
|
32
|
+
current = current[segment];
|
|
33
|
+
}
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const computedDefs = [...document.querySelectorAll("[data-wd-computed]")].map((node) => ({
|
|
38
|
+
key: node.getAttribute("data-wd-computed-key"),
|
|
39
|
+
evaluate: new Function("S", `return (${node.getAttribute("data-wd-computed-expr")});`)
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
function recompute() {
|
|
43
|
+
const read = (key, path) => getPath(state[key], path);
|
|
44
|
+
for (const def of computedDefs) {
|
|
45
|
+
try {
|
|
46
|
+
state[def.key] = def.evaluate(read);
|
|
47
|
+
} catch {
|
|
48
|
+
state[def.key] = undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function loopKeyOf(item, counts) {
|
|
54
|
+
const base =
|
|
55
|
+
item && typeof item === "object"
|
|
56
|
+
? String(item.id ?? item.key ?? JSON.stringify(item))
|
|
57
|
+
: String(item);
|
|
58
|
+
const seen = counts.get(base) || 0;
|
|
59
|
+
counts.set(base, seen + 1);
|
|
60
|
+
return seen ? `${base}#${seen}` : base;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fillItem(node, item) {
|
|
64
|
+
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
65
|
+
const value = getPath(item, region.getAttribute("data-wd-path"));
|
|
66
|
+
const output = region.querySelector("[data-wd-each-if-out]");
|
|
67
|
+
const template = region.querySelector(value ? "template[data-wd-if-true]" : "template[data-wd-if-false]");
|
|
68
|
+
output.innerHTML = template?.innerHTML || "";
|
|
69
|
+
}
|
|
70
|
+
const targets = node.matches("[data-wd-each]")
|
|
71
|
+
? [node, ...node.querySelectorAll("[data-wd-each]")]
|
|
72
|
+
: [...node.querySelectorAll("[data-wd-each]")];
|
|
73
|
+
for (const target of targets) {
|
|
74
|
+
target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function render() {
|
|
79
|
+
recompute();
|
|
80
|
+
for (const node of document.querySelectorAll("[data-wd-if]")) {
|
|
81
|
+
const value = getPath(state[node.getAttribute("data-wd-if")], node.getAttribute("data-wd-path"));
|
|
82
|
+
const active = String(Boolean(value));
|
|
83
|
+
if (node.getAttribute("data-wd-if-active") === active) continue;
|
|
84
|
+
node.setAttribute("data-wd-if-active", active);
|
|
85
|
+
const output = node.querySelector("[data-wd-if-out]");
|
|
86
|
+
const template = node.querySelector(value ? "template[data-wd-true]" : "template[data-wd-false]");
|
|
87
|
+
output.innerHTML = template?.innerHTML || "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const region of document.querySelectorAll("[data-wd-loop]")) {
|
|
91
|
+
const rows = state[region.getAttribute("data-wd-loop")];
|
|
92
|
+
const template = region.querySelector("template[data-wd-loop-template]");
|
|
93
|
+
const out = region.querySelector("[data-wd-loop-out]");
|
|
94
|
+
if (!template || !out) continue;
|
|
95
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
96
|
+
|
|
97
|
+
const existing = new Map();
|
|
98
|
+
for (const child of [...out.children]) {
|
|
99
|
+
existing.set(child.getAttribute("data-wd-loop-key"), child);
|
|
100
|
+
}
|
|
101
|
+
const counts = new Map();
|
|
102
|
+
const used = new Set();
|
|
103
|
+
for (const item of list) {
|
|
104
|
+
const key = loopKeyOf(item, counts);
|
|
105
|
+
let node = existing.get(key);
|
|
106
|
+
if (!node || used.has(key)) {
|
|
107
|
+
node = template.content.firstElementChild.cloneNode(true);
|
|
108
|
+
node.setAttribute("data-wd-loop-key", key);
|
|
109
|
+
}
|
|
110
|
+
used.add(key);
|
|
111
|
+
fillItem(node, item);
|
|
112
|
+
out.appendChild(node);
|
|
113
|
+
}
|
|
114
|
+
for (const [key, node] of existing) {
|
|
115
|
+
if (!used.has(key)) node.remove();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const node of document.querySelectorAll("[data-wd-bind]")) {
|
|
120
|
+
node.textContent = getPath(state[node.getAttribute("data-wd-bind")], node.getAttribute("data-wd-path")) ?? "";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
document.addEventListener("click", (event) => {
|
|
125
|
+
const action = event.target.closest("[data-wd-action]");
|
|
126
|
+
if (!action) return;
|
|
127
|
+
const op = action.getAttribute("data-wd-action");
|
|
128
|
+
const target = action.getAttribute("data-wd-target");
|
|
129
|
+
const rawValue = action.getAttribute("data-wd-value");
|
|
130
|
+
const value = rawValue === null ? undefined : JSON.parse(rawValue);
|
|
131
|
+
|
|
132
|
+
if (op === "inc") state[target] = Number(state[target] ?? 0) + 1;
|
|
133
|
+
if (op === "dec") state[target] = Number(state[target] ?? 0) - 1;
|
|
134
|
+
if (op === "add") state[target] = Number(state[target] ?? 0) + Number(value);
|
|
135
|
+
if (op === "append") state[target] = [...(Array.isArray(state[target]) ? state[target] : []), value];
|
|
136
|
+
if (op === "set") state[target] = value;
|
|
137
|
+
savePersisted();
|
|
138
|
+
render();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
document.addEventListener("submit", (event) => {
|
|
142
|
+
const form = event.target.closest("[data-wd-form]");
|
|
143
|
+
if (!form) return;
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
const key = form.getAttribute("data-wd-form");
|
|
146
|
+
const action = form.getAttribute("action");
|
|
147
|
+
|
|
148
|
+
if (!action) {
|
|
149
|
+
state[key] = Object.fromEntries(new FormData(form));
|
|
150
|
+
savePersisted();
|
|
151
|
+
render();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fetch(action, {
|
|
156
|
+
method: form.getAttribute("method") || "post",
|
|
157
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
158
|
+
body: new URLSearchParams(new FormData(form)).toString()
|
|
159
|
+
})
|
|
160
|
+
.then(async (response) => {
|
|
161
|
+
const text = await response.text();
|
|
162
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
163
|
+
let value;
|
|
164
|
+
try {
|
|
165
|
+
value = JSON.parse(text);
|
|
166
|
+
} catch {
|
|
167
|
+
value = { status: response.status, body: text };
|
|
168
|
+
}
|
|
169
|
+
state[key] = value;
|
|
170
|
+
state[`${key}_error`] = null;
|
|
171
|
+
savePersisted();
|
|
172
|
+
render();
|
|
173
|
+
})
|
|
174
|
+
.catch((error) => {
|
|
175
|
+
state[`${key}_error`] = String(error);
|
|
176
|
+
render();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
function startFetch(node) {
|
|
181
|
+
const key = node.getAttribute("data-wd-fetch-key");
|
|
182
|
+
fetch(node.getAttribute("data-wd-fetch-url"))
|
|
183
|
+
.then((response) => {
|
|
184
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
185
|
+
return response.json();
|
|
186
|
+
})
|
|
187
|
+
.then((value) => {
|
|
188
|
+
state[key] = value;
|
|
189
|
+
state[`${key}_error`] = null;
|
|
190
|
+
render();
|
|
191
|
+
})
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
state[`${key}_error`] = String(error);
|
|
194
|
+
render();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const node of document.querySelectorAll("[data-wd-fetch]")) {
|
|
199
|
+
if (node.getAttribute("data-wd-fetch-when") === "visible" && "IntersectionObserver" in window) {
|
|
200
|
+
const observer = new IntersectionObserver((entries) => {
|
|
201
|
+
if (!entries.some((entry) => entry.isIntersecting)) return;
|
|
202
|
+
observer.disconnect();
|
|
203
|
+
startFetch(node);
|
|
204
|
+
});
|
|
205
|
+
observer.observe(node);
|
|
206
|
+
} else {
|
|
207
|
+
startFetch(node);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
window.wd = {
|
|
212
|
+
state,
|
|
213
|
+
get: (key) => state[key],
|
|
214
|
+
set: (key, value) => {
|
|
215
|
+
state[key] = value;
|
|
216
|
+
savePersisted();
|
|
217
|
+
render();
|
|
218
|
+
},
|
|
219
|
+
render
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
render();
|
package/src/scaffold.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const darkmownVersion = JSON.parse(
|
|
5
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
6
|
+
).version;
|
|
7
|
+
|
|
8
|
+
export function initProject(root) {
|
|
9
|
+
fs.mkdirSync(root, { recursive: true });
|
|
10
|
+
writeNew(root, "package.json", JSON.stringify({
|
|
11
|
+
scripts: {
|
|
12
|
+
dev: "darkmown dev",
|
|
13
|
+
build: "darkmown build"
|
|
14
|
+
},
|
|
15
|
+
devDependencies: {
|
|
16
|
+
"@zvndev/darkmown": `^${darkmownVersion}`
|
|
17
|
+
}
|
|
18
|
+
}, null, 2));
|
|
19
|
+
writeNew(root, "site/pages/index.wd", [
|
|
20
|
+
"---",
|
|
21
|
+
"title: My Darkmown site",
|
|
22
|
+
"---",
|
|
23
|
+
"",
|
|
24
|
+
"@include /nav.wd",
|
|
25
|
+
"",
|
|
26
|
+
"<main>",
|
|
27
|
+
"",
|
|
28
|
+
"# My Darkmown site",
|
|
29
|
+
"",
|
|
30
|
+
"Plain Markdown works. Rename to `.wd` when you want directives.",
|
|
31
|
+
"",
|
|
32
|
+
":state count = 0",
|
|
33
|
+
"",
|
|
34
|
+
"Count: { count }",
|
|
35
|
+
"",
|
|
36
|
+
":button \"Increment\" -> count++",
|
|
37
|
+
"",
|
|
38
|
+
":state todos = [\"Write a page\"]",
|
|
39
|
+
"",
|
|
40
|
+
"@loop todos into todo",
|
|
41
|
+
"- { todo }",
|
|
42
|
+
"@endloop",
|
|
43
|
+
"",
|
|
44
|
+
":button \"Add\" -> todos += \"Add a loop\"",
|
|
45
|
+
"",
|
|
46
|
+
"</main>"
|
|
47
|
+
].join("\n"));
|
|
48
|
+
writeNew(root, "site/pages/index.skin", [
|
|
49
|
+
"tokens",
|
|
50
|
+
" ink #172026",
|
|
51
|
+
" paper #f7f2e8",
|
|
52
|
+
" accent #0f8b8d",
|
|
53
|
+
"",
|
|
54
|
+
"page",
|
|
55
|
+
" margin 0",
|
|
56
|
+
" font ui-sans-serif, system-ui, sans-serif",
|
|
57
|
+
" color $ink",
|
|
58
|
+
" bg $paper",
|
|
59
|
+
"",
|
|
60
|
+
"main",
|
|
61
|
+
" max-width 760px",
|
|
62
|
+
" margin 0 auto",
|
|
63
|
+
" padding 3rem 1.5rem",
|
|
64
|
+
"",
|
|
65
|
+
"button",
|
|
66
|
+
" bg $accent",
|
|
67
|
+
" color white",
|
|
68
|
+
" border 0",
|
|
69
|
+
" padding .75rem 1rem",
|
|
70
|
+
" radius 8px"
|
|
71
|
+
].join("\n"));
|
|
72
|
+
writeNew(root, "site/_/nav.wd", [
|
|
73
|
+
"<nav>",
|
|
74
|
+
" <a href=\"/\">Home</a>",
|
|
75
|
+
"</nav>"
|
|
76
|
+
].join("\n"));
|
|
77
|
+
writeNew(root, "README.md", [
|
|
78
|
+
"# My Darkmown site",
|
|
79
|
+
"",
|
|
80
|
+
"Run `npm install` and `npm run dev` to start the live compiler.",
|
|
81
|
+
"",
|
|
82
|
+
"- Pages live in `site/pages`.",
|
|
83
|
+
"- Shared includes live in `site/_`.",
|
|
84
|
+
"- Hidden route files start with `.` or `-`.",
|
|
85
|
+
"- Colocated `index.skin` and `index.js` attach automatically."
|
|
86
|
+
].join("\n"));
|
|
87
|
+
return { root };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeNew(root, file, content) {
|
|
91
|
+
const target = path.join(root, file);
|
|
92
|
+
if (fs.existsSync(target)) return;
|
|
93
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
94
|
+
fs.writeFileSync(target, `${content}\n`);
|
|
95
|
+
}
|
package/src/skin.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const aliases = new Map([
|
|
2
|
+
["radius", "border-radius"],
|
|
3
|
+
["shadow", "box-shadow"],
|
|
4
|
+
["font", "font-family"],
|
|
5
|
+
["bg", "background"]
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function compileSkin(source) {
|
|
9
|
+
const lines = source
|
|
10
|
+
.replace(/\r\n?/g, "\n")
|
|
11
|
+
.split("\n")
|
|
12
|
+
.filter((raw) => raw.trim() && !raw.trim().startsWith("//"))
|
|
13
|
+
.map((raw) => ({ indent: raw.match(/^\s*/)[0].length, text: raw.trim() }));
|
|
14
|
+
|
|
15
|
+
const stack = [];
|
|
16
|
+
const css = [];
|
|
17
|
+
let tokenIndent = null;
|
|
18
|
+
const rootVars = [];
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < lines.length; i++) {
|
|
21
|
+
const { indent, text } = lines[i];
|
|
22
|
+
// Structure decides meaning: a line opens a block (selector) exactly when
|
|
23
|
+
// the next line is indented deeper. Everything else is a declaration.
|
|
24
|
+
const opensBlock = i + 1 < lines.length && lines[i + 1].indent > indent;
|
|
25
|
+
|
|
26
|
+
if (text === "tokens" && opensBlock) {
|
|
27
|
+
tokenIndent = indent;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (tokenIndent !== null && indent > tokenIndent) {
|
|
32
|
+
const [name, ...rest] = text.split(/\s+/);
|
|
33
|
+
rootVars.push(` --${name}: ${rest.join(" ")};`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (tokenIndent !== null && indent <= tokenIndent) tokenIndent = null;
|
|
38
|
+
while (stack.length && stack.at(-1).indent >= indent) stack.pop();
|
|
39
|
+
|
|
40
|
+
if (opensBlock) {
|
|
41
|
+
const parent = stack.at(-1)?.selector;
|
|
42
|
+
const selector = normalizeSelector(text, parent);
|
|
43
|
+
stack.push({ indent, selector });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const current = stack.at(-1)?.selector || ":root";
|
|
48
|
+
const [prop, ...rest] = text.split(/\s+/);
|
|
49
|
+
const cssProp = aliases.get(prop) || prop;
|
|
50
|
+
const value = rest.join(" ").replace(/\$([a-zA-Z0-9_-]+)/g, "var(--$1)");
|
|
51
|
+
css.push(`${current} { ${cssProp}: ${value}; }`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (rootVars.length) css.unshift(`:root {\n${rootVars.join("\n")}\n}`);
|
|
55
|
+
return css.join("\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeSelector(text, parent) {
|
|
59
|
+
const selector = text === "page" ? "body" : text;
|
|
60
|
+
if (!parent || parent === ":root") return selector;
|
|
61
|
+
return selector
|
|
62
|
+
.split(",")
|
|
63
|
+
.map((part) => {
|
|
64
|
+
const clean = part.trim();
|
|
65
|
+
return clean.startsWith("&") ? clean.replace("&", parent) : `${parent} ${clean}`;
|
|
66
|
+
})
|
|
67
|
+
.join(", ");
|
|
68
|
+
}
|
package/src/statics.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function serve(distRoot, url, res) {
|
|
5
|
+
const file = resolvePublicFile(distRoot, url || "/");
|
|
6
|
+
if (!file || !fs.existsSync(file)) {
|
|
7
|
+
res.writeHead(404, { "content-type": "text/html" });
|
|
8
|
+
res.end("<h1>Not found</h1><p>This route is hidden or has not been created.</p>");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
res.writeHead(200, { "content-type": contentType(file) });
|
|
12
|
+
fs.createReadStream(file).pipe(res);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolvePublicFile(distRoot, url) {
|
|
16
|
+
const cleanUrl = decodeURIComponent(url.split("?")[0]);
|
|
17
|
+
const base = cleanUrl.startsWith("/__wd/")
|
|
18
|
+
? cleanUrl
|
|
19
|
+
: path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
|
|
20
|
+
const requested = path.resolve(distRoot, `.${base}`);
|
|
21
|
+
const root = path.resolve(distRoot);
|
|
22
|
+
if (requested !== root && !requested.startsWith(`${root}${path.sep}`)) return null;
|
|
23
|
+
return requested;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function contentType(file) {
|
|
27
|
+
if (file.endsWith(".css")) return "text/css";
|
|
28
|
+
if (file.endsWith(".js")) return "text/javascript";
|
|
29
|
+
if (file.endsWith(".json")) return "application/json";
|
|
30
|
+
return "text/html";
|
|
31
|
+
}
|