@pajh/buldng 0.0.2 → 0.0.4

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,267 @@
1
+ // buldng-web.ts
2
+ // Pure runtime API for buldng. The optional renderer only writes text into a caller-owned element.
3
+
4
+ let errorPage: string | null = null;
5
+ let listenersInstalled = false;
6
+ let redirectStarted = false;
7
+
8
+ declare global {
9
+ interface Window {
10
+ __buldngErrorOverlay?: boolean;
11
+ }
12
+ }
13
+
14
+ const ERROR_STORAGE_PREFIX = "buldng:error:";
15
+
16
+ export type ErrorPayload = {
17
+ id: string;
18
+ type: "error" | "unhandledrejection";
19
+ message: string;
20
+ stack: string;
21
+ time: string;
22
+ url: string;
23
+ userAgent: string;
24
+ platform: string;
25
+ language: string;
26
+ };
27
+
28
+ /**
29
+ * Configure the global error handler.
30
+ * @param url A full URL path like "/error/" or "/fatal/".
31
+ */
32
+ export function setErrorPage(url: string) {
33
+ if (typeof url !== "string" || !url.length) {
34
+ throw new Error("setErrorPage: URL must be a non-empty string");
35
+ }
36
+
37
+ // Normalize: ensure no accidental double slashes
38
+ errorPage = url.endsWith("/") ? url.slice(0, -1) : url;
39
+
40
+ // Install global listeners once. Calling setErrorPage again only changes the destination.
41
+ installGlobalErrorHandler();
42
+ }
43
+
44
+ /**
45
+ * Internal: installs global error + unhandled rejection handlers.
46
+ */
47
+ function installGlobalErrorHandler() {
48
+ if (!errorPage || listenersInstalled || isErrorPage() || window.__buldngErrorOverlay) return;
49
+
50
+ listenersInstalled = true;
51
+
52
+ window.addEventListener("error", (event) => {
53
+ handleError({
54
+ type: "error",
55
+ reason: event.error ?? event.message,
56
+ fallbackMessage: event.message,
57
+ });
58
+ });
59
+
60
+ window.addEventListener("unhandledrejection", (event) => {
61
+ handleError({
62
+ type: "unhandledrejection",
63
+ reason: event.reason,
64
+ fallbackMessage: "Unhandled promise rejection",
65
+ });
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Read an error payload previously saved by the global handler.
71
+ * Defaults to the errorId query parameter on the current page.
72
+ */
73
+ export function readErrorPayload(id = getErrorId()): ErrorPayload | null {
74
+ if (!id) return null;
75
+
76
+ try {
77
+ const raw = localStorage.getItem(storageKey(id));
78
+ if (!raw) return null;
79
+
80
+ const payload: unknown = JSON.parse(raw);
81
+ return isErrorPayload(payload) ? payload : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read and remove an error payload. Useful for one-shot error pages.
89
+ */
90
+ export function consumeErrorPayload(id = getErrorId()): ErrorPayload | null {
91
+ const payload = readErrorPayload(id);
92
+ if (id) {
93
+ try {
94
+ localStorage.removeItem(storageKey(id));
95
+ } catch {
96
+ // Storage may be unavailable or blocked; the payload was still safely read if possible.
97
+ }
98
+ }
99
+ return payload;
100
+ }
101
+
102
+ /**
103
+ * Render a payload as safe text inside a caller-owned element.
104
+ * Returns the payload that was rendered, or null when it was unavailable.
105
+ */
106
+ export function renderErrorPayload(
107
+ elementId: string,
108
+ payload = readErrorPayload()
109
+ ): ErrorPayload | null {
110
+ const container = document.getElementById(elementId);
111
+ if (!container) return null;
112
+
113
+ container.replaceChildren();
114
+ if (!payload) {
115
+ const empty = document.createElement("p");
116
+ empty.textContent = "Error details are no longer available.";
117
+ container.appendChild(empty);
118
+ return null;
119
+ }
120
+
121
+ const details: Array<[string, string]> = [
122
+ ["Type", payload.type],
123
+ ["Message", payload.message],
124
+ ["Time", payload.time],
125
+ ["Page", payload.url],
126
+ ["User agent", payload.userAgent],
127
+ ["Platform", payload.platform],
128
+ ["Language", payload.language],
129
+ ];
130
+
131
+ const list = document.createElement("dl");
132
+ for (const [label, value] of details) {
133
+ const term = document.createElement("dt");
134
+ term.textContent = label;
135
+ const description = document.createElement("dd");
136
+ description.textContent = value;
137
+ list.append(term, description);
138
+ }
139
+
140
+ if (payload.stack) {
141
+ const stackLabel = document.createElement("h3");
142
+ stackLabel.textContent = "Stack";
143
+ const stack = document.createElement("pre");
144
+ stack.textContent = payload.stack;
145
+ list.append(stackLabel, stack);
146
+ }
147
+
148
+ container.appendChild(list);
149
+ return payload;
150
+ }
151
+
152
+ /**
153
+ * Return the error record ID from the current URL.
154
+ */
155
+ export function getErrorId(): string | null {
156
+ return new URLSearchParams(location.search).get("errorId");
157
+ }
158
+
159
+ function handleError(info: {
160
+ type: ErrorPayload["type"];
161
+ reason: unknown;
162
+ fallbackMessage: string;
163
+ }) {
164
+ if (redirectStarted || !errorPage) return;
165
+ redirectStarted = true;
166
+
167
+ const payload = buildPayload(info);
168
+ const stored = saveErrorPayload(payload);
169
+ redirect(payload, stored);
170
+ }
171
+
172
+ function buildPayload(info: {
173
+ type: ErrorPayload["type"];
174
+ reason: unknown;
175
+ fallbackMessage: string;
176
+ }): ErrorPayload {
177
+ const id = createId();
178
+ const error = info.reason instanceof Error ? info.reason : null;
179
+
180
+ return {
181
+ id,
182
+ type: info.type,
183
+ message: getErrorMessage(info.reason, info.fallbackMessage),
184
+ stack: error?.stack ?? getErrorStack(info.reason),
185
+ time: new Date().toISOString(),
186
+ url: location.href,
187
+ userAgent: navigator.userAgent,
188
+ platform: navigator.platform,
189
+ language: navigator.language,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Redirect to the configured error page with a storage key, plus a small fallback.
195
+ */
196
+ function redirect(payload: ErrorPayload, stored: boolean) {
197
+ if (!errorPage) return;
198
+
199
+ const destination = new URL(errorPage, location.href);
200
+ if (stored) {
201
+ destination.searchParams.set("errorId", payload.id);
202
+ } else {
203
+ destination.searchParams.set("type", payload.type);
204
+ destination.searchParams.set("message", payload.message.slice(0, 500));
205
+ }
206
+
207
+ window.location.href = destination.href;
208
+ }
209
+
210
+ function saveErrorPayload(payload: ErrorPayload): boolean {
211
+ try {
212
+ localStorage.setItem(storageKey(payload.id), JSON.stringify(payload));
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ function storageKey(id: string) {
220
+ return `${ERROR_STORAGE_PREFIX}${id}`;
221
+ }
222
+
223
+ function createId() {
224
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
225
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
226
+ }
227
+
228
+ function isErrorPage() {
229
+ const destination = new URL(errorPage!, location.href);
230
+ const destinationPath = destination.pathname.replace(/\/$/, "") || "/";
231
+ const currentPath = location.pathname.replace(/\/$/, "") || "/";
232
+ return destination.origin === location.origin && destinationPath === currentPath;
233
+ }
234
+
235
+ function getErrorMessage(reason: unknown, fallback: string) {
236
+ if (reason instanceof Error && reason.message) return reason.message;
237
+ if (typeof reason === "string" && reason) return reason;
238
+ if (reason !== null && reason !== undefined) {
239
+ try {
240
+ const text = JSON.stringify(reason);
241
+ if (text) return text;
242
+ } catch {
243
+ // Fall through to the stable message below.
244
+ }
245
+ }
246
+ return fallback;
247
+ }
248
+
249
+ function getErrorStack(reason: unknown) {
250
+ if (!reason || typeof reason !== "object") return "";
251
+ const stack = (reason as { stack?: unknown }).stack;
252
+ return typeof stack === "string" ? stack : "";
253
+ }
254
+
255
+ function isErrorPayload(value: unknown): value is ErrorPayload {
256
+ if (!value || typeof value !== "object") return false;
257
+ const payload = value as Partial<ErrorPayload>;
258
+ return typeof payload.id === "string"
259
+ && (payload.type === "error" || payload.type === "unhandledrejection")
260
+ && typeof payload.message === "string"
261
+ && typeof payload.stack === "string"
262
+ && typeof payload.time === "string"
263
+ && typeof payload.url === "string"
264
+ && typeof payload.userAgent === "string"
265
+ && typeof payload.platform === "string"
266
+ && typeof payload.language === "string";
267
+ }
package/src/buldng.css ADDED
@@ -0,0 +1,148 @@
1
+ /* --------------------------------------------------
2
+ LOCAL FONTS
3
+ -------------------------------------------------- */
4
+ @font-face {
5
+ font-family: Inter;
6
+ src: url("/assets/InterVariable.woff2") format("woff2");
7
+ font-weight: 100 900;
8
+ font-display: swap;
9
+ }
10
+
11
+ @font-face {
12
+ font-family: "JetBrains Mono";
13
+ src: url("/assets/JetBrainsMono-Regular.woff2") format("woff2");
14
+ font-weight: 400;
15
+ font-display: swap;
16
+ }
17
+
18
+ /* --------------------------------------------------
19
+ CAMERA GEOMETRY — JS-SIZED, CSS-LAID-OUT
20
+ Matches layout.ts:
21
+ CAM-PORTRAIT
22
+ CAM-LANDSCAPE-A
23
+ CAM-LANDSCAPE-B
24
+ -------------------------------------------------- */
25
+
26
+ #b-camera {
27
+ position: absolute;
28
+ inset: 0;
29
+ margin: auto;
30
+ container-type: size;
31
+ overflow: hidden;
32
+ background: var(--back);
33
+ }
34
+
35
+ /* --------------------------------------------------
36
+ CAMERA INTERNAL AREAS
37
+ -------------------------------------------------- */
38
+
39
+ #b-camera-style {
40
+ display: grid;
41
+ height: 100%;
42
+ width: 100%;
43
+ grid-template-areas:
44
+ "header"
45
+ "client"
46
+ "footer";
47
+ }
48
+
49
+ #b-top-bar {
50
+ grid-area: header;
51
+ overflow: hidden;
52
+ container-type: size;
53
+ width: 100%;
54
+ }
55
+
56
+ #b-client-area {
57
+ grid-area: client;
58
+ }
59
+
60
+ #b-bottom-bar {
61
+ grid-area: footer;
62
+ overflow: hidden;
63
+ }
64
+
65
+ /* --------------------------------------------------
66
+ PORTRAIT MODE
67
+ JS sets width/height; CSS sets layout only
68
+ -------------------------------------------------- */
69
+
70
+ .CAM-PORTRAIT#b-camera-style {
71
+ grid-template-rows: 20% auto 5%
72
+ }
73
+
74
+ /* --------------------------------------------------
75
+ LANDSCAPE-A MODE
76
+ JS sets width/height; CSS sets layout only
77
+ -------------------------------------------------- */
78
+
79
+ .CAM-LANDSCAPE-A#b-camera-style {
80
+ grid-template-rows: 15% 80% 5%;
81
+ }
82
+
83
+ /* --------------------------------------------------
84
+ LANDSCAPE-B MODE
85
+ JS sets width/height; CSS sets layout only
86
+ -------------------------------------------------- */
87
+
88
+ .CAM-LANDSCAPE-B#b-camera-style {
89
+ grid-template-rows: 15% 80% 5%;
90
+ }
91
+
92
+ /* --------------------------------------------------
93
+ GLOBAL THEME VARIABLES
94
+ -------------------------------------------------- */
95
+ :root[data-theme="light"] {
96
+ --back: #f7f7f7;
97
+ --panel: #ffffff;
98
+ --button: #f0f0f0;
99
+ --text: #222222;
100
+ --line: #9c9c9c;
101
+ --highlight1: #4aa3ff;
102
+ --highlight2: #ffd84a;
103
+ --logo-bg: #e0e0e0;
104
+ --logo-fg: #222;
105
+ --logo-shadow: 0 2px 4px rgb(0 0 0 0.15);
106
+ }
107
+
108
+ :root[data-theme="dark"] {
109
+ --back: #111111;
110
+ --panel: #1b1b1b;
111
+ --button: #4a4a4a;
112
+ --text: #eeeeee;
113
+ --line: #848484;
114
+ --highlight1: #4aa3ff;
115
+ --highlight2: #ffd84a;
116
+ --logo-bg: #333;
117
+ --logo-fg: #eee;
118
+ --logo-shadow: 0 2px 4px rgb(0 0 0 0.4);
119
+ }
120
+
121
+ /* --------------------------------------------------
122
+ GLOBAL BODY
123
+ -------------------------------------------------- */
124
+ html {
125
+ margin: 0;
126
+ padding: 0;
127
+ height:100%;
128
+ width:100%;
129
+ }
130
+
131
+ body {
132
+ background: var(--back);
133
+ margin: 0;
134
+ padding: 0;
135
+ height: 100%;
136
+ width: 100%;
137
+ }
138
+
139
+ html, body, button, input, select, textarea {
140
+ font-family: Inter, sans-serif;
141
+ font-size: var(--std-font-size);
142
+ color: var(--text);
143
+ }
144
+
145
+ button {
146
+ background: var(--button);
147
+ }
148
+
package/src/clean.sh ADDED
@@ -0,0 +1,14 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Sentinel must exist in the current directory
5
+ if [ ! -f BULDNG_DEST ]; then
6
+ echo "clean.sh: refusing — sentinel missing"
7
+ exit 1
8
+ fi
9
+
10
+ # Delete everything in the current directory, including subdirectories
11
+ rm -rf ./*
12
+
13
+ # Delete the script itself
14
+ rm -f "$0"
package/src/layout.ts ADDED
@@ -0,0 +1,184 @@
1
+ /* --------------------------------------------------
2
+ GLOBAL VIEWPORT STATE
3
+ -------------------------------------------------- */
4
+ function applyCameraSize(mode: string) {
5
+ const cam = document.getElementById("b-camera");
6
+ if (!cam) return;
7
+
8
+ const vw = window.innerWidth;
9
+ const vh = window.innerHeight;
10
+
11
+ let width, height;
12
+
13
+ if (mode === "PORTRAIT") {
14
+ height = vh;
15
+ width = vw;
16
+ } else if (mode === "LANDSCAPE_A") {
17
+ height = vh;
18
+ width = vh * 4/3;
19
+ } else {
20
+ // LANDSCAPE_B
21
+ width = vw;
22
+ height = vw * 3/4;
23
+ }
24
+
25
+ cam.style.width = `${width}px`;
26
+ cam.style.height = `${height}px`;
27
+
28
+ // Center WITHOUT transform (no containing-block issues)
29
+ cam.style.position = "absolute";
30
+ cam.style.top = "0";
31
+ cam.style.bottom = "0";
32
+ cam.style.left = "0";
33
+ cam.style.right = "0";
34
+ cam.style.margin = "auto";
35
+ }
36
+
37
+ export const buldrViewport = {
38
+ width: window.innerWidth,
39
+ height: window.innerHeight,
40
+ aspect: window.innerWidth / window.innerHeight, // w/h
41
+ mode: "PORTRAIT", // PORTRAIT | LANDSCAPE_A | LANDSCAPE_B
42
+ };
43
+
44
+ /* --------------------------------------------------
45
+ MODE COMPUTATION
46
+ -------------------------------------------------- */
47
+
48
+ function computeMode() {
49
+ const a = buldrViewport.aspect;
50
+
51
+ if (a >= 4/3) return "LANDSCAPE_A";
52
+ if (a <= 3/4) return "PORTRAIT";
53
+ return "LANDSCAPE_B";
54
+ }
55
+
56
+ /* --------------------------------------------------
57
+ APPLY CAMERA MODE CLASS
58
+ -------------------------------------------------- */
59
+
60
+ function applyCameraMode(mode: string) {
61
+ const cam = document.getElementById("b-camera");
62
+ if (!cam) return;
63
+ const style = document.getElementById("b-camera-style");
64
+ if (!style) return;
65
+
66
+ // Remove all camera classes
67
+ cam.classList.remove("CAM-PORTRAIT", "CAM-LANDSCAPE-A", "CAM-LANDSCAPE-B");
68
+ style.classList.remove("CAM-PORTRAIT", "CAM-LANDSCAPE-A", "CAM-LANDSCAPE-B");
69
+ // Add the new one
70
+ if (mode === "PORTRAIT") {
71
+ cam.classList.add("CAM-PORTRAIT");
72
+ style.classList.add("CAM-PORTRAIT");
73
+ } else if (mode === "LANDSCAPE_A") {
74
+ cam.classList.add("CAM-LANDSCAPE-A");
75
+ style.classList.add("CAM-LANDSCAPE-A");
76
+ } else {
77
+ cam.classList.add("CAM-LANDSCAPE-B");
78
+ style.classList.add("CAM-LANDSCAPE-B");
79
+ }
80
+ }
81
+
82
+ /* --------------------------------------------------
83
+ APPLY GLOBAL CSS MODE (PORTRAIT / LANDSCAPE)
84
+ -------------------------------------------------- */
85
+ function applyCssMode(mode: string) {
86
+ const html = document.documentElement;
87
+
88
+ if (mode === "PORTRAIT") {
89
+ html.classList.remove("LANDSCAPE");
90
+ html.classList.add("PORTRAIT");
91
+ } else {
92
+ html.classList.remove("PORTRAIT");
93
+ html.classList.add("LANDSCAPE");
94
+ }
95
+ }
96
+ /* --------------------------------------------------
97
+ FONT SIZE COMPUTATION (NEW)
98
+ -------------------------------------------------- */
99
+
100
+ const FACTOR_PORTRAIT = 0.035;
101
+ const FACTOR_LANDSCAPE_B = 0.035;
102
+ const FACTOR_LANDSCAPE_A = 0.020;
103
+
104
+ function applyFontSize(mode: string) {
105
+ const cam = document.getElementById("b-camera");
106
+ if (!cam) return;
107
+
108
+ const camWidth = cam.getBoundingClientRect().width;
109
+
110
+ let factor = FACTOR_PORTRAIT;
111
+ if (mode === "LANDSCAPE_A") factor = FACTOR_LANDSCAPE_A;
112
+ else if (mode === "LANDSCAPE_B") factor = FACTOR_LANDSCAPE_B;
113
+
114
+ const size = camWidth * factor;
115
+
116
+ document.documentElement.style.setProperty("--std-font-size", `${size}px`);
117
+ }
118
+
119
+ /* --------------------------------------------------
120
+ UPDATE + DEBUG
121
+ -------------------------------------------------- */
122
+
123
+ function updateViewportInfo() {
124
+ buldrViewport.width = window.innerWidth;
125
+ buldrViewport.height = window.innerHeight;
126
+ buldrViewport.aspect = window.innerWidth / window.innerHeight;
127
+
128
+ const mode = computeMode();
129
+ buldrViewport.mode = mode;
130
+
131
+ console.log(
132
+ `[BULDR] viewport ${buldrViewport.width}x${buldrViewport.height}, `
133
+ + `aspect=${buldrViewport.aspect.toFixed(3)}, `
134
+ + `mode=${mode}`
135
+ );
136
+
137
+ applyCameraMode(mode);
138
+ applyCssMode(mode);
139
+ applyCameraSize(mode);
140
+ applyFontSize(mode);
141
+ }
142
+
143
+ /* --------------------------------------------------
144
+ THEME
145
+ -------------------------------------------------- */
146
+
147
+ function initTheme() {
148
+ const btn = document.getElementById("theme-toggle");
149
+ if (!btn) return;
150
+
151
+ const current = localStorage.getItem("theme") || "light";
152
+ document.documentElement.dataset.theme = current;
153
+
154
+ btn.addEventListener("click", () => {
155
+ const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
156
+ document.documentElement.dataset.theme = next;
157
+ localStorage.setItem("theme", next);
158
+ });
159
+ }
160
+
161
+ /* --------------------------------------------------
162
+ VIEWPORT LISTENER
163
+ -------------------------------------------------- */
164
+
165
+ function initViewportListener() {
166
+ updateViewportInfo(); // initial
167
+
168
+ window.addEventListener("resize", () => {
169
+ updateViewportInfo();
170
+ });
171
+ }
172
+
173
+ /* --------------------------------------------------
174
+ LAYOUT INIT
175
+ -------------------------------------------------- */
176
+
177
+ function initLayout() {
178
+ initTheme();
179
+ initViewportListener();
180
+ }
181
+
182
+ // Run layout immediately
183
+ initLayout();
184
+ window.__ready._resolveA();
@@ -0,0 +1,10 @@
1
+ export {};
2
+
3
+ declare global {
4
+ interface Window {
5
+ __ready: {
6
+ a: Promise<void>;
7
+ _resolveA: () => void;
8
+ };
9
+ }
10
+ }
@@ -0,0 +1,69 @@
1
+ // --------------------------------------------------
2
+ // WorkFile — immutable, lazy, tree-structured build node
3
+ // --------------------------------------------------
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import esbuild from "esbuild";
8
+ import { assertDestWrite } from "./buldng-lib.js";
9
+ import { OPS } from "./buldng-ops.js";
10
+
11
+ export class WorkFile {
12
+ constructor(op, config = {}, children = [], tag = null) {
13
+ if (!OPS[op]) {
14
+ throw new Error(`Invalid WorkFile op "${op}" — not one of: ${Object.keys(OPS).join(", ")}`);
15
+ }
16
+ this.op = op; // "file", "literal", "append", "replace", "compile"
17
+ this.config = Object.freeze(config);
18
+ this.children = Object.freeze(children);
19
+ this.tag = tag; // optional tag
20
+ Object.freeze(this); // full immutability
21
+ }
22
+
23
+ // --------------------------------------------------
24
+ // execute(): recursively produce output string
25
+ // --------------------------------------------------
26
+ execute() {
27
+ return OPS[this.op](this.config, this.children);
28
+
29
+ }
30
+
31
+ // --------------------------------------------------
32
+ // save(): write output to disk
33
+ // --------------------------------------------------
34
+ save(target) {
35
+ if (!target) {
36
+ throw new Error("WorkFile.save(): target filename required");
37
+ }
38
+
39
+ const abs = assertDestWrite(target);
40
+ const out = this.execute();
41
+
42
+ // If file exists, enforce integrity rule
43
+ if (fs.existsSync(abs)) {
44
+ const existing = fs.readFileSync(abs, "utf8");
45
+
46
+ if (existing === out) {
47
+ // Byte-identical → warn but allow
48
+ console.warn(
49
+ `WorkFile.save(): attempted to overwrite '${target}' with identical content — continuing`
50
+ );
51
+ return "/" + target;
52
+ }
53
+
54
+ // Different → fatal error
55
+ throw new Error(
56
+ `WorkFile.save(): cannot overwrite '${target}' — existing file differs from generated output`
57
+ );
58
+ }
59
+
60
+ // File does not exist → write it
61
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
62
+ fs.writeFileSync(abs, out, "utf8");
63
+ // NEW: mark file read-only
64
+ fs.chmodSync(abs, 0o444);
65
+
66
+ console.log(`save: ${abs}`);
67
+ return "/" + target;
68
+ }
69
+ }