@pajh/buldng 0.0.4 → 0.0.6

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,348 @@
1
+ export type ModalType =
2
+ | "info"
3
+ | "confirmation"
4
+ | "destructive"
5
+ | "error";
6
+
7
+ export type ModalIcon = "info" | "warning" | "error" | null;
8
+
9
+ export type ModalResult = "ok" | "cancel" | "dismiss";
10
+
11
+ export interface ModalSettings {
12
+ title?: string;
13
+ icon?: ModalIcon;
14
+ autoDismiss?: number;
15
+ dismissOnEsc?: boolean;
16
+ dismissOnBackdrop?: boolean;
17
+ }
18
+
19
+ const MODAL_ROOT_ID = "b-modal-root";
20
+ let modalId = 0;
21
+
22
+ interface ModalRoot {
23
+ element: HTMLElement;
24
+ created: boolean;
25
+ }
26
+
27
+ function getModalRoot(): ModalRoot {
28
+ const existingRoot = document.getElementById(MODAL_ROOT_ID);
29
+ if (existingRoot instanceof HTMLElement) {
30
+ return { element: existingRoot, created: false };
31
+ }
32
+
33
+ const createdRoot = document.createElement("div");
34
+ createdRoot.id = MODAL_ROOT_ID;
35
+ document.body.appendChild(createdRoot);
36
+ return { element: createdRoot, created: true };
37
+ }
38
+
39
+ function getIconText(icon: ModalIcon): string {
40
+ if (icon === "info") {
41
+ return "i";
42
+ }
43
+ if (icon === "warning") {
44
+ return "!";
45
+ }
46
+ if (icon === "error") {
47
+ return "!";
48
+ }
49
+ return "";
50
+ }
51
+
52
+ function getDefaultIcon(type: ModalType): ModalIcon {
53
+ if (type === "destructive") {
54
+ return "warning";
55
+ }
56
+ if (type === "error") {
57
+ return "error";
58
+ }
59
+ return null;
60
+ }
61
+
62
+ function getButtonLabels(type: ModalType): { primary: string; secondary: string | null } {
63
+ if (type === "confirmation") {
64
+ return { primary: "OK", secondary: "Cancel" };
65
+ }
66
+ if (type === "destructive") {
67
+ return { primary: "Delete", secondary: "Cancel" };
68
+ }
69
+ return { primary: "OK", secondary: null };
70
+ }
71
+
72
+ function getDefaultDismissal(type: ModalType): boolean {
73
+ return type !== "destructive";
74
+ }
75
+
76
+ function getInitialFocus(dialog: HTMLElement, primaryButton: HTMLButtonElement): HTMLElement {
77
+ const focusable = dialog.querySelector<HTMLElement>(
78
+ "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"
79
+ );
80
+ if (focusable !== null) {
81
+ return focusable;
82
+ }
83
+ return primaryButton;
84
+ }
85
+
86
+ function isInsideDialog(target: EventTarget | null, dialog: HTMLElement): boolean {
87
+ return target instanceof Node && dialog.contains(target);
88
+ }
89
+
90
+ class ModalController {
91
+ private readonly type: ModalType;
92
+ private readonly content: string;
93
+ private readonly settings: ModalSettings;
94
+ private readonly root: HTMLElement;
95
+ private readonly ownsRoot: boolean;
96
+ private readonly previousFocus: HTMLElement | null;
97
+ private readonly dialog: HTMLElement;
98
+ private readonly backdrop: HTMLElement;
99
+ private readonly primaryButton: HTMLButtonElement;
100
+ private readonly secondaryButton: HTMLButtonElement | null;
101
+ private readonly keydownHandler: (event: KeyboardEvent) => void;
102
+ private timeoutId: number | null;
103
+ private settled: boolean;
104
+ private resolveResult: ((result: ModalResult) => void) | null;
105
+
106
+ public constructor(
107
+ type: ModalType,
108
+ content: string,
109
+ settings: ModalSettings
110
+ ) {
111
+ this.type = type;
112
+ this.content = content;
113
+ this.settings = settings;
114
+ const modalRoot = getModalRoot();
115
+ this.root = modalRoot.element;
116
+ this.ownsRoot = modalRoot.created;
117
+ this.previousFocus = document.activeElement instanceof HTMLElement
118
+ ? document.activeElement
119
+ : null;
120
+ this.dialog = this.createDialog();
121
+ this.backdrop = this.createBackdrop();
122
+ this.primaryButton = this.createPrimaryButton();
123
+ this.secondaryButton = this.createSecondaryButton();
124
+ this.addHeader();
125
+ this.addContent();
126
+ this.addFooter();
127
+ this.timeoutId = null;
128
+ this.settled = false;
129
+ this.resolveResult = null;
130
+ this.keydownHandler = (event: KeyboardEvent) => {
131
+ this.handleKeydown(event);
132
+ };
133
+ }
134
+
135
+ public open(): Promise<ModalResult> {
136
+ this.root.appendChild(this.backdrop);
137
+ this.backdrop.appendChild(this.dialog);
138
+ this.registerListeners();
139
+
140
+ const initialFocus = getInitialFocus(this.dialog, this.primaryButton);
141
+ initialFocus.focus();
142
+
143
+ if (this.settings.autoDismiss !== undefined && this.settings.autoDismiss > 0) {
144
+ this.timeoutId = window.setTimeout(() => {
145
+ this.finish("dismiss");
146
+ }, this.settings.autoDismiss);
147
+ }
148
+
149
+ return new Promise<ModalResult>((resolve) => {
150
+ this.resolveResult = resolve;
151
+ });
152
+ }
153
+
154
+ private createDialog(): HTMLElement {
155
+ const dialog = document.createElement("div");
156
+ dialog.className = `buldng-modal ${this.type}`;
157
+ dialog.setAttribute("role", "dialog");
158
+ dialog.setAttribute("aria-modal", "true");
159
+ dialog.tabIndex = -1;
160
+ return dialog;
161
+ }
162
+
163
+ private createBackdrop(): HTMLElement {
164
+ const backdrop = document.createElement("div");
165
+ backdrop.className = "buldng-modal-backdrop";
166
+ backdrop.setAttribute("aria-hidden", "true");
167
+ return backdrop;
168
+ }
169
+
170
+ private createPrimaryButton(): HTMLButtonElement {
171
+ const button = document.createElement("button");
172
+ button.type = "button";
173
+ button.className = "buldng-modal-button buldng-modal-button-primary";
174
+ button.textContent = getButtonLabels(this.type).primary;
175
+ return button;
176
+ }
177
+
178
+ private createSecondaryButton(): HTMLButtonElement | null {
179
+ const labels = getButtonLabels(this.type);
180
+ if (labels.secondary === null) {
181
+ return null;
182
+ }
183
+
184
+ const button = document.createElement("button");
185
+ button.type = "button";
186
+ button.className = "buldng-modal-button buldng-modal-button-secondary";
187
+ button.textContent = labels.secondary;
188
+ return button;
189
+ }
190
+
191
+ private addHeader(): void {
192
+ const hasTitle = this.settings.title !== undefined && this.settings.title.length > 0;
193
+ const icon = this.settings.icon === undefined
194
+ ? getDefaultIcon(this.type)
195
+ : this.settings.icon;
196
+ const hasIcon = icon !== null;
197
+
198
+ if (!hasTitle && !hasIcon) {
199
+ return;
200
+ }
201
+
202
+ const header = document.createElement("div");
203
+ header.className = "buldng-modal-header";
204
+
205
+ if (hasIcon) {
206
+ const iconElement = document.createElement("span");
207
+ iconElement.className = "buldng-modal-icon";
208
+ iconElement.setAttribute("aria-hidden", "true");
209
+ iconElement.textContent = getIconText(icon);
210
+ header.appendChild(iconElement);
211
+ }
212
+
213
+ if (hasTitle) {
214
+ const title = document.createElement("h2");
215
+ title.className = "buldng-modal-title";
216
+ title.textContent = this.settings.title as string;
217
+ modalId += 1;
218
+ const titleId = `buldng-modal-title-${modalId}`;
219
+ title.id = titleId;
220
+ this.dialog.setAttribute("aria-labelledby", titleId);
221
+ header.appendChild(title);
222
+ this.dialog.appendChild(header);
223
+ return;
224
+ }
225
+
226
+ this.dialog.appendChild(header);
227
+ }
228
+
229
+ private addContent(): void {
230
+ const content = document.createElement("div");
231
+ content.className = "buldng-modal-content";
232
+ content.innerHTML = this.content;
233
+ this.dialog.appendChild(content);
234
+ }
235
+
236
+ private addFooter(): void {
237
+ const footer = document.createElement("div");
238
+ footer.className = "buldng-modal-footer";
239
+
240
+ if (this.secondaryButton !== null) {
241
+ footer.appendChild(this.secondaryButton);
242
+ }
243
+ footer.appendChild(this.primaryButton);
244
+ this.dialog.appendChild(footer);
245
+ }
246
+
247
+ private registerListeners(): void {
248
+ this.primaryButton.addEventListener("click", () => {
249
+ this.finish("ok");
250
+ });
251
+
252
+ if (this.secondaryButton !== null) {
253
+ this.secondaryButton.addEventListener("click", () => {
254
+ this.finish("cancel");
255
+ });
256
+ }
257
+
258
+ this.backdrop.addEventListener("click", (event: MouseEvent) => {
259
+ if (isInsideDialog(event.target, this.dialog)) {
260
+ return;
261
+ }
262
+ const dismissOnBackdrop = this.settings.dismissOnBackdrop === undefined
263
+ ? getDefaultDismissal(this.type)
264
+ : this.settings.dismissOnBackdrop;
265
+ if (dismissOnBackdrop === true) {
266
+ this.finish("dismiss");
267
+ }
268
+ });
269
+
270
+ document.addEventListener("keydown", this.keydownHandler);
271
+ }
272
+
273
+ private handleKeydown(event: KeyboardEvent): void {
274
+ if (event.key === "Escape") {
275
+ const dismissOnEsc = this.settings.dismissOnEsc === undefined
276
+ ? getDefaultDismissal(this.type)
277
+ : this.settings.dismissOnEsc;
278
+ if (dismissOnEsc === true) {
279
+ event.preventDefault();
280
+ this.finish("dismiss");
281
+ }
282
+ return;
283
+ }
284
+
285
+ if (event.key === "Tab") {
286
+ this.keepFocusInside(event);
287
+ }
288
+ }
289
+
290
+ private keepFocusInside(event: KeyboardEvent): void {
291
+ const focusable = Array.from(this.dialog.querySelectorAll<HTMLElement>(
292
+ "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])"
293
+ ));
294
+ if (focusable.length === 0) {
295
+ event.preventDefault();
296
+ this.dialog.focus();
297
+ return;
298
+ }
299
+
300
+ const first = focusable[0];
301
+ const last = focusable[focusable.length - 1];
302
+ const active = document.activeElement;
303
+
304
+ if (event.shiftKey === true && active === first) {
305
+ event.preventDefault();
306
+ last.focus();
307
+ } else if (event.shiftKey === false && active === last) {
308
+ event.preventDefault();
309
+ first.focus();
310
+ }
311
+ }
312
+
313
+ private finish(result: ModalResult): void {
314
+ if (this.settled === true) {
315
+ return;
316
+ }
317
+ this.settled = true;
318
+
319
+ if (this.timeoutId !== null) {
320
+ window.clearTimeout(this.timeoutId);
321
+ this.timeoutId = null;
322
+ }
323
+
324
+ document.removeEventListener("keydown", this.keydownHandler);
325
+ this.backdrop.remove();
326
+ if (this.ownsRoot === true && this.root.childElementCount === 0) {
327
+ this.root.remove();
328
+ }
329
+
330
+ if (this.previousFocus !== null && document.contains(this.previousFocus)) {
331
+ this.previousFocus.focus();
332
+ }
333
+
334
+ if (this.resolveResult !== null) {
335
+ this.resolveResult(result);
336
+ this.resolveResult = null;
337
+ }
338
+ }
339
+ }
340
+
341
+ export function showModal(
342
+ type: ModalType,
343
+ content: string,
344
+ settings: ModalSettings = {}
345
+ ): Promise<ModalResult> {
346
+ const controller = new ModalController(type, content, settings);
347
+ return controller.open();
348
+ }
package/src/buldng-ops.js CHANGED
@@ -5,7 +5,7 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import esbuild from "esbuild";
7
7
 
8
- import { assertSourceRead, assertDestWrite, looksLikeFilename, ensureWorkFile } from "./buldng-validate.js";
8
+ import { assertSourceRead, assertDestWrite, trackInput, looksLikeFilename, ensureWorkFile } from "./buldng-validate.js";
9
9
  export const OPS = {
10
10
  file: op_file,
11
11
  literal: op_literal,
@@ -107,10 +107,13 @@ export function op_compile(config, children) {
107
107
  const result = esbuild.buildSync({
108
108
  entryPoints: [entry],
109
109
  bundle: true,
110
+ metafile: true,
110
111
  ...compileOptions,
111
112
  write: false
112
113
  });
113
114
 
115
+ trackEsbuildInputs(result);
116
+
114
117
  if (result.errors?.length) {
115
118
  throw new Error("Build failed:\n" + result.errors.map(e => e.text).join("\n"));
116
119
  }
@@ -121,16 +124,19 @@ export function op_compile(config, children) {
121
124
  // Otherwise compile from stdin
122
125
  const srcText = child.execute();
123
126
 
124
- const result = esbuild.buildSync({
127
+ const result = esbuild.buildSync({
125
128
  stdin: {
126
129
  contents: srcText,
127
130
  resolveDir: process.cwd(),
128
131
  sourcefile: "input.ts"
129
132
  },
130
- bundle: true,
131
- ...compileOptions,
132
- write: false
133
- });
133
+ bundle: true,
134
+ metafile: true,
135
+ ...compileOptions,
136
+ write: false
137
+ });
138
+
139
+ trackEsbuildInputs(result);
134
140
 
135
141
  if (result.errors?.length) {
136
142
  throw new Error("Build failed:\n" + result.errors.map(e => e.text).join("\n"));
@@ -139,6 +145,15 @@ export function op_compile(config, children) {
139
145
  return result.outputFiles[0].text;
140
146
  }
141
147
 
148
+ function trackEsbuildInputs(result) {
149
+ for (const input of Object.keys(result.metafile?.inputs ?? {})) {
150
+ const abs = path.resolve(input);
151
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
152
+ trackInput(abs);
153
+ }
154
+ }
155
+ }
156
+
142
157
  // --------------------------------------------------
143
158
  // MATERIALISE — write file if identical or new
144
159
  // --------------------------------------------------
@@ -0,0 +1,77 @@
1
+ // buldng-typescript.js
2
+ // typescript build support for buldng
3
+ //
4
+
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import { createRequire } from "module";
8
+ import { execSync } from "child_process";
9
+
10
+ function findTSCFromResolved(tsPath) {
11
+ // tsPath → /.../typescript/lib/typescript.js
12
+ let dir = path.dirname(tsPath); // /.../typescript/lib
13
+ dir = path.dirname(dir); // /.../typescript
14
+
15
+ while (true) {
16
+ const candidate = path.join(dir, ".bin", "tsc");
17
+ if (fs.existsSync(candidate)) {
18
+ return candidate;
19
+ }
20
+
21
+ const parent = path.dirname(dir);
22
+ if (parent === dir) break; // reached filesystem root
23
+ dir = parent;
24
+ }
25
+
26
+ return null;
27
+ }
28
+
29
+ function findGlobalTSC() {
30
+ try {
31
+ const globalTSC = execSync("which tsc").toString().trim();
32
+ return globalTSC || null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function typescriptCheck() {
39
+ const require = createRequire(import.meta.url);
40
+
41
+ let tsPath = null;
42
+
43
+ try {
44
+ tsPath = require.resolve("typescript");
45
+ } catch {
46
+ tsPath = null;
47
+ }
48
+
49
+ let tscPath = null;
50
+
51
+ if (tsPath) {
52
+ // Local / nested / linked TypeScript
53
+ tscPath = findTSCFromResolved(tsPath);
54
+ } else {
55
+ // Try global TypeScript
56
+ tscPath = findGlobalTSC();
57
+ }
58
+
59
+ if (!tscPath) {
60
+ console.log("TypeScript not found — skipping TS checks");
61
+ return;
62
+ }
63
+
64
+ console.log("Using TypeScript compiler:", tscPath);
65
+
66
+ try {
67
+ execSync(`${tscPath} --noEmit`, { stdio: "inherit" });
68
+ } catch (err) {
69
+ // If tsc exists but cannot run → internal error
70
+ if (err.code === "ENOENT") {
71
+ throw new Error("Internal TypeScript error — 'tsc' binary exists but cannot be executed");
72
+ }
73
+
74
+ // If tsc runs but reports TS errors → build failure
75
+ throw new Error("TypeScript check failed");
76
+ }
77
+ }
@@ -158,10 +158,17 @@ export function getInputs() {
158
158
  }
159
159
 
160
160
  export function trackInput(absPath) {
161
- if (!SRC || !DEST) return;
162
- if (absPath.startsWith(SRC) && !absPath.startsWith(DEST)) {
163
- INPUTS.add(absPath);
161
+ const abs = path.resolve(absPath);
162
+
163
+ if (DEST) {
164
+ const relativeToDest = path.relative(DEST, abs);
165
+ const isInsideDest = relativeToDest === "" ||
166
+ (!relativeToDest.startsWith("..") && !path.isAbsolute(relativeToDest));
167
+
168
+ if (isInsideDest) return;
164
169
  }
170
+
171
+ INPUTS.add(abs);
165
172
  }
166
173
 
167
174
  export function looksLikeFilename(s) {
@@ -204,4 +211,4 @@ export function ensureWorkFile(x, fieldName = "value") {
204
211
  }
205
212
 
206
213
  throw new Error(`${fieldName} must be WorkFile or string`);
207
- }
214
+ }