@pajh/buldng 0.0.2 → 0.0.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.
@@ -0,0 +1,258 @@
1
+ import { WorkFile } from "./workfile.js";
2
+
3
+ // buldng-ops.js
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import esbuild from "esbuild";
7
+
8
+ import { assertSourceRead, assertDestWrite, looksLikeFilename, ensureWorkFile } from "./buldng-validate.js";
9
+ export const OPS = {
10
+ file: op_file,
11
+ literal: op_literal,
12
+ append: op_append,
13
+ replace: op_replace,
14
+ compile: op_compile,
15
+ materialise: op_materialise,
16
+ wrap: op_wrap,
17
+ };
18
+
19
+ // --------------------------------------------------
20
+ // FILE — lazy read from disk. op:"file"
21
+ // --------------------------------------------------
22
+ export function file(strings, ...values) {
23
+ if (values.length > 0) {
24
+ throw new Error("file`` does not support interpolation");
25
+ }
26
+
27
+ const relPath = strings.join("");
28
+ const absPath = assertSourceRead(relPath);
29
+
30
+ return new WorkFile("file", { relPath, absPath }, []);
31
+ }
32
+
33
+ // op for "file"
34
+ export function op_file(config, children) {
35
+ return fs.readFileSync(config.absPath, "utf8");
36
+ }
37
+
38
+ // --------------------------------------------------
39
+ // LITERAL — inline text
40
+ // --------------------------------------------------
41
+ export function op_literal(config, children) {
42
+ return config.value;
43
+ }
44
+
45
+ // --------------------------------------------------
46
+ // APPEND — concat children in order
47
+ // --------------------------------------------------
48
+ export function op_append(config, children) {
49
+ let out = "";
50
+
51
+ for (const child of children) {
52
+ const chunk = child.execute();
53
+
54
+ if (out.length > 0 &&
55
+ !out.endsWith("\n") &&
56
+ !chunk.startsWith("\n")) {
57
+ out += "\n";
58
+ }
59
+
60
+ out += chunk;
61
+ }
62
+
63
+ return out;
64
+ }
65
+
66
+ // --------------------------------------------------
67
+ // REPLACE — replace tag with replacement
68
+ // --------------------------------------------------
69
+ export function op_replace(config, children) {
70
+ if (children.length !== 3) {
71
+ throw new Error(`replace(): expected 3 children, got ${children.length}`);
72
+ }
73
+
74
+ const [srcWF, tagWF, repWF] = children;
75
+
76
+ const src = srcWF.execute();
77
+ const tag = tagWF.execute();
78
+ const rep = repWF.execute();
79
+
80
+ if (!src.includes(tag)) {
81
+ throw new Error(`replace(): tag "${tag}" not found in source`);
82
+ }
83
+
84
+ return src.replace(tag, rep);
85
+ }
86
+
87
+ // --------------------------------------------------
88
+ // COMPILE — esbuild wrapper
89
+ // --------------------------------------------------
90
+ export function op_compile(config, children) {
91
+ if (children.length !== 1) {
92
+ throw new Error(`compile(): expected 1 child, got ${children.length}`);
93
+ }
94
+
95
+ const child = children[0];
96
+
97
+ // If child is a file WorkFile, use its absolute path directly
98
+ if (child.op === "file" && child.config.absPath) {
99
+ const entry = child.config.absPath;
100
+
101
+ const result = esbuild.buildSync({
102
+ entryPoints: [entry],
103
+ bundle: true,
104
+ minify: true,
105
+ sourcemap: true,
106
+ format: "esm",
107
+ target: "esnext",
108
+ write: false
109
+ });
110
+
111
+ if (result.errors?.length) {
112
+ throw new Error("Build failed:\n" + result.errors.map(e => e.text).join("\n"));
113
+ }
114
+
115
+ return result.outputFiles[0].text;
116
+ }
117
+
118
+ // Otherwise compile from stdin
119
+ const srcText = child.execute();
120
+
121
+ const result = esbuild.buildSync({
122
+ stdin: {
123
+ contents: srcText,
124
+ resolveDir: process.cwd(),
125
+ sourcefile: "input.ts"
126
+ },
127
+ bundle: true,
128
+ minify: true,
129
+ sourcemap: true,
130
+ format: "esm",
131
+ target: "esnext",
132
+ write: false
133
+ });
134
+
135
+ if (result.errors?.length) {
136
+ throw new Error("Build failed:\n" + result.errors.map(e => e.text).join("\n"));
137
+ }
138
+
139
+ return result.outputFiles[0].text;
140
+ }
141
+
142
+ // --------------------------------------------------
143
+ // MATERIALISE — write file if identical or new
144
+ // --------------------------------------------------
145
+ export function materialise(child, target) {
146
+ const childWF = ensureWorkFile(child, "materialise");
147
+ return new WorkFile("materialise", { target }, [childWF]);
148
+ }
149
+
150
+ export function op_materialise(config, children) {
151
+ if (children.length !== 1) {
152
+ throw new Error(`materialise(): expected 1 child, got ${children.length}`);
153
+ }
154
+
155
+ const child = children[0];
156
+
157
+ const text = child.execute();
158
+
159
+ if (looksLikeFilename(text)) {
160
+ console.warn(
161
+ `materialise(): child returned a filename '${text}' but expected text.\n` +
162
+ `Did you accidentally pass a materialised WorkFile instead of raw text?`
163
+ );
164
+ }
165
+
166
+ const abs = assertDestWrite(config.target);
167
+
168
+ if (!fs.existsSync(abs)) {
169
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
170
+ fs.writeFileSync(abs, text, "utf8");
171
+ // NEW: mark file read-only
172
+ fs.chmodSync(abs, 0o444);
173
+ console.log(`materialise: ${abs}`);
174
+ return "/" + config.target;
175
+ }
176
+
177
+ const existing = fs.readFileSync(abs, "utf8");
178
+
179
+ if (existing === text) {
180
+ return "/" + config.target;
181
+ }
182
+
183
+ throw new Error(
184
+ `materialise(): cannot overwrite '${config.target}' — existing file differs from generated output`
185
+ );
186
+ }
187
+
188
+ // --------------------------------------------------
189
+ // WRAP — pre + child + post
190
+ // --------------------------------------------------
191
+ export function op_wrap(config, children) {
192
+ if (children.length !== 1) {
193
+ throw new Error(`wrap(): expected 1 child, got ${children.length}`);
194
+ }
195
+
196
+ const inner = children[0].execute();
197
+ const pre = config.pre ?? "";
198
+ const post = config.post ?? "";
199
+
200
+ return pre + inner + post;
201
+ }
202
+
203
+
204
+
205
+ // --------------------------------------------------
206
+ // literal(text)
207
+ // --------------------------------------------------
208
+ export function literal(value) {
209
+ return new WorkFile("literal", { value }, []);
210
+ }
211
+
212
+ // --------------------------------------------------
213
+ // wrap(pre, child, post)
214
+ // --------------------------------------------------
215
+ export function wrap(pre, child, post, tag=null) {
216
+ const childWF = ensureWorkFile(child, "wrap child");
217
+ return new WorkFile("wrap", { pre, post }, [childWF], tag);
218
+ }
219
+
220
+ export function append() {
221
+ const children = [];
222
+
223
+ for (let i = 0; i < arguments.length; i++) {
224
+ const item = arguments[i];
225
+ const wf = ensureWorkFile(item, "append item");
226
+ children.push(wf);
227
+ }
228
+
229
+ if (children.length < 2) {
230
+ throw new Error("append(): requires at least 2 items");
231
+ }
232
+
233
+ return new WorkFile("append", {}, children);
234
+ }
235
+
236
+
237
+ // --------------------------------------------------
238
+ // replace(source, tag, replacement)
239
+ // --------------------------------------------------
240
+ export function replace(source, tag, replacement) {
241
+ const srcWF = ensureWorkFile(source, "source");
242
+ const tagWF = ensureWorkFile(tag, "tag");
243
+ const repWF = ensureWorkFile(replacement, "replacement");
244
+
245
+ if (tagWF.op === "literal" && tagWF.config.value.trim() === "") {
246
+ throw new Error(`replace(): tag cannot be blank`);
247
+ }
248
+
249
+ return new WorkFile("replace", {}, [srcWF, tagWF, repWF]);
250
+ }
251
+
252
+ // --------------------------------------------------
253
+ // compile(source)
254
+ // --------------------------------------------------
255
+ export function compile(source, flags = {}) {
256
+ const srcWF = ensureWorkFile(source, "source");
257
+ return new WorkFile("compile", { flags }, [srcWF]);
258
+ }
@@ -1,6 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
-
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawnSync } from "node:child_process";
5
+ import { WorkFile } from "./workfile.js";
6
+ import { literal } from "./buldng-ops.js";
4
7
  // --------------------------------------------------
5
8
  // GLOBAL SOURCE & DEST ROOTS
6
9
  // --------------------------------------------------
@@ -25,24 +28,84 @@ export function setSource(dir) {
25
28
  export function setDest(dir) {
26
29
  const abs = path.resolve(dir);
27
30
 
31
+ // ------------------------------------------------------------
32
+ // If dest exists, perform full safety checks
33
+ // ------------------------------------------------------------
28
34
  if (fs.existsSync(abs)) {
29
- const sentinel = path.join(abs, "BULDNG_SRC");
30
- if (fs.existsSync(sentinel)) {
31
- throw new Error(`SECURITY: Dest directory "${abs}" contains BLDNG_SRC sentinel`);
35
+
36
+ // SECURITY: full recursive scan for BULDNG_SRC
37
+ const stack = [abs];
38
+ while (stack.length) {
39
+ const current = stack.pop();
40
+ const entries = fs.readdirSync(current, { withFileTypes: true });
41
+
42
+ for (const entry of entries) {
43
+ const full = path.join(current, entry.name);
44
+
45
+ if (entry.isDirectory()) {
46
+ stack.push(full);
47
+ } else if (entry.name === "BULDNG_SRC") {
48
+ throw new Error(`SECURITY: Dest directory "${abs}" contains BULDNG_SRC sentinel`);
49
+ }
50
+ }
51
+ }
52
+
53
+ // SECURITY: require DEST sentinel in root
54
+ const destSentinel = path.join(abs, "BULDNG_DEST");
55
+ if (!fs.existsSync(destSentinel)) {
56
+ throw new Error(`SECURITY: Refusing to delete "${abs}" — missing BULDNG_DEST sentinel`);
32
57
  }
33
58
 
34
- const entries = fs.readdirSync(abs, { withFileTypes: true });
35
- for (const entry of entries) {
36
- fs.rmSync(path.join(abs, entry.name), { recursive: true, force: true });
59
+ // -----------------------------------------------------------
60
+ // Copy clean.sh from package into dest
61
+ // ------------------------------------------------------------
62
+ const __filename = fileURLToPath(import.meta.url);
63
+ const __dirname = path.dirname(__filename);
64
+ const cleanSrc = path.join(__dirname, "clean.sh"); // your package copy
65
+ const cleanDest = path.join(abs, "clean.sh");
66
+
67
+ fs.copyFileSync(cleanSrc, cleanDest);
68
+ fs.chmodSync(cleanDest, 0o755);
69
+
70
+ // ------------------------------------------------------------
71
+ // Call safe_clean.sh with full path to clean.sh
72
+ // ------------------------------------------------------------
73
+ const safeClean = path.resolve("safe_clean.sh");
74
+ console.log(`SECURITY: Running safe_clean.sh on "${abs}"`);
75
+
76
+ const result = spawnSync(safeClean, [cleanDest], {
77
+ stdio: "inherit"
78
+ });
79
+
80
+ if (result.status !== 0) {
81
+ throw new Error(`SECURITY: safe_clean.sh failed with exit code ${result.status}`);
82
+ }
83
+
84
+ // ------------------------------------------------------------
85
+ // Verify directory is empty after cleaning
86
+ // ------------------------------------------------------------
87
+ const remaining = fs.readdirSync(abs);
88
+ if (remaining.length !== 0) {
89
+ throw new Error(`SECURITY: Dest directory "${abs}" not empty after safe_clean`);
37
90
  }
38
- } else {
39
- fs.mkdirSync(abs, { recursive: true });
40
91
  }
41
92
 
93
+ // ------------------------------------------------------------
94
+ // Always recreate DEST fresh
95
+ // ------------------------------------------------------------
96
+ fs.mkdirSync(abs, { recursive: true });
97
+
98
+ // Create DEST sentinel and mark read-only
99
+ const sentinelPath = path.join(abs, "BULDNG_DEST");
100
+ fs.writeFileSync(sentinelPath, "");
101
+ fs.chmodSync(sentinelPath, 0o444);
102
+
42
103
  DEST = abs;
43
104
  validateRoots();
44
105
  }
45
106
 
107
+
108
+
46
109
  export function getDest() {
47
110
  if (!DEST) throw new Error("SECURITY: Dest root not set");
48
111
  return DEST;
@@ -99,4 +162,46 @@ export function trackInput(absPath) {
99
162
  if (absPath.startsWith(SRC) && !absPath.startsWith(DEST)) {
100
163
  INPUTS.add(absPath);
101
164
  }
165
+ }
166
+
167
+ export function looksLikeFilename(s) {
168
+ return typeof s === "string" &&
169
+ s.length < 200 &&
170
+ /[A-Za-z0-9_\-\/]+\.(html|css|js)$/.test(s);
171
+ }
172
+
173
+ export function looksLikeHTML(s) {
174
+ return typeof s === "string" &&
175
+ s.trim().startsWith("<") &&
176
+ s.includes(">");
177
+ }
178
+
179
+ export function looksLikeCSS(s) {
180
+ return typeof s === "string" &&
181
+ s.includes("{") &&
182
+ s.includes("}");
183
+ }
184
+
185
+ export function looksLikeJS(s) {
186
+ return typeof s === "string" &&
187
+ (s.includes("function") ||
188
+ s.includes("const ") ||
189
+ s.includes("let "));
190
+ }
191
+
192
+ export function ensureLeadingSlash(p) {
193
+ return p.startsWith("/") ? p : "/" + p;
194
+ }
195
+
196
+ // --------------------------------------------------
197
+ // ensureWorkFile(x)
198
+ // --------------------------------------------------
199
+ export function ensureWorkFile(x, fieldName = "value") {
200
+ if (x instanceof WorkFile) return x;
201
+
202
+ if (typeof x === "string") {
203
+ return literal(x);
204
+ }
205
+
206
+ throw new Error(`${fieldName} must be WorkFile or string`);
102
207
  }
@@ -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/dist/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"