diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 3feee6a4fc73..861d7ad82ae2 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -10,29 +10,27 @@ "node": ">=22.12.0" }, "scripts": { - "dev": "vite", + "dev": "node ./scripts/dev.mjs", + "// Watching: yarn dev starts vite + file watchers (LESS, pug, static assets, and key content folders)": " ", "// COMMENTS: make the action a space rather than empty string so `yarn run` can list the scripts": " ", "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run", "check-that-node-modules-exists-in-content-dir": "cd ../content && node checkForNodeModules.js && cd ../BloomBrowserUI", "// 'build:ui': 'builds all the stuff handled directly by vite'": " ", - "build:ui": "vite build", - "// 'build': 'builds all the core stuff devs need in both this folder and content. Does not clean.'": " ", - "build": "npm-run-all --parallel build:ui build:content", + "build:ui": "vite build --logLevel error", + "// 'build': 'builds all the core stuff devs need in both this folder and content. Cleans first.'": " ", + "build": "node scripts/build.js", "build:clean": "node scripts/clean.js", - "// 'build-prod': 'production build: clean, then build+content in parallel, then l10n'": " ", - "build-prod": "npm run build:clean && npm-run-all --parallel build:ui build:content && npm-run-all --parallel build:l10n:translate build:l10n:create", - "build:pug": "node ./scripts/compilePug.mjs", + "// 'build-prod': 'production build: clean, pageSizes, then build:ui and build:content, then l10n'": " ", + "build-prod": "yarn build:clean && yarn --cwd ../content build:pageSizes && npm-run-all build:ui build:content && npm-run-all --parallel build:l10n:translate build:l10n:create", "// 'build:l10n': creates/updates xliff files and translates html files.": " ", "// 'build:l10n': is needed when markdown/html content changes or when testing l10n.": " ", "// 'build:l10n': should be run after build. (build-prod includes this functionality.)": " ", "build:l10n": "node scripts/l10n-build.js", "build:l10n:translate": "node scripts/l10n-build.js translate", "build:l10n:create": "node scripts/l10n-build.js create", - "build:content": "npm run check-that-node-modules-exists-in-content-dir && cd ../content && npm run build", - "// We shouldn't need'watchBookEditLess' anymore once we finish getting vite dev working": " ", - "watchBookEditLess": "less-watch-compiler bookEdit ../../output/browser/bookEdit", + "build:content": "yarn run check-that-node-modules-exists-in-content-dir && yarn --cwd ../content build", "// 'watch': rebuilds bundles when source files change (for entrypoints not yet working with vite dev)": " ", "watch": "vite build --watch", "// You can use yarn link to symlink bloom-player. But also, bloom needs a copy in output/!": " ", @@ -233,11 +231,13 @@ "less": "^3.13.1", "less-watch-compiler": "^1.13.0", "lessc-glob": "^1.0.9", + "chokidar": "^3.6.0", "lint-staged": "^15.4.3", "lorem-ipsum": "^2.0.2", "markdown-it-attrs": "^4.3.1", "markdown-it-container": "^4.0.0", "npm-run-all": "^4.1.5", + "onchange": "^7.1.0", "patch-package": "^6.4.7", "path": "^0.12.7", "playwright": "^1.56.1", diff --git a/src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts b/src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts new file mode 100644 index 000000000000..fa783e8c371f --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts @@ -0,0 +1,88 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import pug from "pug"; +import { compilePugFiles } from "../compilePug.mjs"; + +let tempDir: string; +let browserUIRoot: string; +let contentRoot: string; +let outputBase: string; + +function makeDir(dirPath: string) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath: string, contents: string) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "compile-pug-")); + browserUIRoot = path.join(tempDir, "browserUI"); + contentRoot = path.join(tempDir, "content"); + outputBase = path.join(tempDir, "out"); + makeDir(browserUIRoot); + makeDir(contentRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("compilePugFiles", () => { + it("recompiles dependents when an included pug file changes", async () => { + const partialPath = path.join(browserUIRoot, "partials", "partial.pug"); + const mainPath = path.join(browserUIRoot, "pages", "main.pug"); + + writeFile(partialPath, "p Partial A\n"); + + writeFile( + mainPath, + [ + "doctype html", + "html", + " body", + " include ../partials/partial.pug", + "", + ].join("\n"), + ); + + await compilePugFiles({ browserUIRoot, contentRoot, outputBase }); + + const outPath = path.join(outputBase, "pages", "main.html"); + expect(fs.existsSync(outPath)).toBe(true); + const firstHtml = fs.readFileSync(outPath, "utf8"); + expect(firstHtml).toContain("Partial A"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(partialPath, "p Partial B\n"); + + await compilePugFiles({ browserUIRoot, contentRoot, outputBase }); + const secondHtml = fs.readFileSync(outPath, "utf8"); + expect(secondHtml).toContain("Partial B"); + expect(secondHtml).not.toContain("Partial A"); + }); + + it("skips expensive compilation when files are up to date", async () => { + const mainPath = path.join(browserUIRoot, "pages", "main.pug"); + writeFile(mainPath, "p Hello\n"); + + await compilePugFiles({ browserUIRoot, contentRoot, outputBase }); + + const compileSpy = vi.spyOn(pug, "compileFile"); + const result = await compilePugFiles({ + browserUIRoot, + contentRoot, + outputBase, + }); + + expect(compileSpy).not.toHaveBeenCalled(); + expect(result.compiled).toBe(0); + expect(result.skipped).toBeGreaterThan(0); + + compileSpy.mockRestore(); + }); +}); diff --git a/src/BloomBrowserUI/scripts/__tests__/copyStaticFile.test.ts b/src/BloomBrowserUI/scripts/__tests__/copyStaticFile.test.ts new file mode 100644 index 000000000000..7b269dc123eb --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/copyStaticFile.test.ts @@ -0,0 +1,54 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { copyStaticFile } from "../copyStaticFile.mjs"; + +let tempDir: string; +let browserUIRoot: string; +let outputBase: string; + +function makeDir(dirPath: string) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath: string, contents: string) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copy-static-")); + browserUIRoot = path.join(tempDir, "browserUI"); + outputBase = path.join(tempDir, "out"); + makeDir(browserUIRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("copyStaticFile", () => { + it("ignores tsconfig.json files in nested folders", () => { + const nestedTsConfig = path.join( + browserUIRoot, + "sub", + "folder", + "tsconfig.json", + ); + writeFile(nestedTsConfig, '{"compilerOptions":{}}\n'); + + const copied = copyStaticFile(nestedTsConfig, { + browserUIRoot, + outputBase, + quiet: true, + }); + + expect(copied).toBe(false); + expect( + fs.existsSync( + path.join(outputBase, "sub", "folder", "tsconfig.json"), + ), + ).toBe(false); + }); +}); diff --git a/src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs new file mode 100644 index 000000000000..e55a6aaa5575 --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs @@ -0,0 +1,188 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { LessWatchManager } from "../watchLess.mjs"; + +const silentLogger = { + log: () => {}, + warn: () => {}, + error: () => {}, +}; + +let tempDir; +let sourceRoot; +let outputRoot; +let metadataPath; + +function makeDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath, contents) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +function makeManager(overrides = {}) { + return new LessWatchManager({ + repoRoot: tempDir, + metadataPath, + logger: silentLogger, + targets: [ + { + name: "test", + root: sourceRoot, + outputBase: outputRoot, + }, + ], + ...overrides, + }); +} + +function getEntryId(manager, filePath) { + const key = path + .relative(manager.repoRoot, path.resolve(filePath)) + .replace(/\\/g, "/"); + return key; +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "watch-less-")); + sourceRoot = path.join(tempDir, "src"); + outputRoot = path.join(tempDir, "out"); + metadataPath = path.join(outputRoot, ".state.json"); + makeDir(sourceRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("LessWatchManager", () => { + it("compiles missing outputs and records metadata", async () => { + const entryPath = path.join(sourceRoot, "pages", "main.less"); + const partialPath = path.join(sourceRoot, "partials", "colors.less"); + writeFile(partialPath, "@primary: #ff0000;\n"); + writeFile( + entryPath, + '@import "../partials/colors.less";\nbody { color: @primary; }\n', + ); + + const manager = makeManager(); + await manager.initialize(); + + const cssPath = path.join(outputRoot, "pages", "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + const css = fs.readFileSync(cssPath, "utf8"); + expect(css).toContain("body"); + expect(fs.existsSync(`${cssPath}.map`)).toBe(true); + + const state = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + const entryId = getEntryId(manager, entryPath); + expect(state.entries[entryId]).toContain( + path.relative(tempDir, partialPath).replace(/\\/g, "/"), + ); + }); + + it("rebuilds when dependency is newer on startup", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const partialPath = path.join(sourceRoot, "dep.less"); + writeFile(partialPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const firstManager = makeManager(); + await firstManager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + const initialMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(partialPath, "@val: green;\n"); + + const secondManager = makeManager(); + await secondManager.initialize(); + const rebuiltMTime = fs.statSync(cssPath).mtimeMs; + expect(rebuiltMTime).toBeGreaterThan(initialMTime); + }); + + it("updates dependency graph when imports change", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depPath = path.join(sourceRoot, "dep.less"); + writeFile(depPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const entryId = getEntryId(manager, entryPath); + const cssPath = path.join(outputRoot, "main.css"); + const baselineMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(entryPath, "body { color: black; }\n"); + await manager.handleFileChanged(entryPath, "entry updated"); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.length).toBe(1); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depPath, "@val: red;\n"); + await manager.handleFileChanged(depPath, "dep changed"); + const afterMTime = fs.statSync(cssPath).mtimeMs; + expect(afterMTime).toBe(baselineMTime); + }); + + it("adds new dependencies and rebuilds when partial changes", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depA = path.join(sourceRoot, "depA.less"); + const depB = path.join(sourceRoot, "depB.less"); + writeFile(depA, "@val: blue;\n"); + writeFile(depB, "@alt: red;\n"); + writeFile(entryPath, '@import "depA.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile( + entryPath, + '@import "depA.less";\n@import "depB.less";\nbody { color: @alt; }\n', + ); + await manager.handleFileChanged(entryPath, "entry changed"); + const entryId = getEntryId(manager, entryPath); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.some((dep) => dep.endsWith("depB.less"))).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depB, "@alt: purple;\n"); + const before = fs.statSync(cssPath).mtimeMs; + await manager.handleFileChanged(depB, "depB updated"); + const after = fs.statSync(cssPath).mtimeMs; + expect(after).toBeGreaterThan(before); + }); + + it("removes outputs when an entry is deleted", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + writeFile(entryPath, "body { color: blue; }\n"); + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + + fs.unlinkSync(entryPath); + await manager.handleFileRemoved(entryPath); + expect(fs.existsSync(cssPath)).toBe(false); + expect(fs.existsSync(`${cssPath}.map`)).toBe(false); + }); + + it("builds new entries on the fly", async () => { + const manager = makeManager(); + await manager.initialize(); + + const entryPath = path.join(sourceRoot, "new.less"); + writeFile(entryPath, "body { color: orange; }\n"); + await manager.handleFileAdded(manager.targets[0], entryPath); + + const cssPath = path.join(outputRoot, "new.css"); + expect(fs.existsSync(cssPath)).toBe(true); + }); +}); diff --git a/src/BloomBrowserUI/scripts/__tests__/watchLess.test.ts b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.ts new file mode 100644 index 000000000000..14ca792214f2 --- /dev/null +++ b/src/BloomBrowserUI/scripts/__tests__/watchLess.test.ts @@ -0,0 +1,288 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { LessWatchManager } from "../watchLess.mjs"; +import type { LessWatchTarget } from "../watchLess.mjs"; + +const silentLogger = { + log: () => {}, + warn: () => {}, + error: () => {}, +}; + +let tempDir: string; +let sourceRoot: string; +let outputRoot: string; +let metadataPath: string; + +function makeDir(dirPath: string) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function writeFile(filePath: string, contents: string) { + makeDir(path.dirname(filePath)); + fs.writeFileSync(filePath, contents); +} + +function makeManager(overrides: Partial<{ targets: LessWatchTarget[] }> = {}) { + const defaultTarget: LessWatchTarget = { + name: "test", + root: sourceRoot, + outputBase: outputRoot, + }; + + return new LessWatchManager({ + repoRoot: tempDir, + metadataPath, + logger: silentLogger, + targets: overrides.targets ?? [defaultTarget], + }); +} + +function getEntryId(manager: LessWatchManager, filePath: string) { + return path + .relative(manager.repoRoot, path.resolve(filePath)) + .replace(/\\/g, "/"); +} + +beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "watch-less-")); + sourceRoot = path.join(tempDir, "src"); + outputRoot = path.join(tempDir, "out"); + metadataPath = path.join(outputRoot, ".state.json"); + makeDir(sourceRoot); +}); + +afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("LessWatchManager", () => { + it("compiles missing outputs and records metadata", async () => { + const entryPath = path.join(sourceRoot, "pages", "main.less"); + const partialPath = path.join(sourceRoot, "partials", "colors.less"); + writeFile(partialPath, "@primary: #ff0000;\n"); + writeFile( + entryPath, + '@import "../partials/colors.less";\nbody { color: @primary; }\n', + ); + + const manager = makeManager(); + await manager.initialize(); + + const cssPath = path.join(outputRoot, "pages", "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + const css = fs.readFileSync(cssPath, "utf8"); + expect(css).toContain("body"); + expect(fs.existsSync(`${cssPath}.map`)).toBe(true); + + const state = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + const entryId = getEntryId(manager, entryPath); + expect(state.entries[entryId]).toContain( + path.relative(tempDir, partialPath).replace(/\\/g, "/"), + ); + }); + + it("rebuilds when dependency is newer on startup", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const partialPath = path.join(sourceRoot, "dep.less"); + writeFile(partialPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const firstManager = makeManager(); + await firstManager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + const initialMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(partialPath, "@val: green;\n"); + + const secondManager = makeManager(); + await secondManager.initialize(); + const rebuiltMTime = fs.statSync(cssPath).mtimeMs; + expect(rebuiltMTime).toBeGreaterThan(initialMTime); + }); + + it("updates dependency graph when imports change", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depPath = path.join(sourceRoot, "dep.less"); + writeFile(depPath, "@val: blue;\n"); + writeFile(entryPath, '@import "dep.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const entryId = getEntryId(manager, entryPath); + const cssPath = path.join(outputRoot, "main.css"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(entryPath, "body { color: black; }\n"); + await manager.handleFileChanged(entryPath, "entry updated"); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.length).toBe(1); + const baselineMTime = fs.statSync(cssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depPath, "@val: red;\n"); + await manager.handleFileChanged(depPath, "dep changed"); + const afterMTime = fs.statSync(cssPath).mtimeMs; + expect(afterMTime).toBe(baselineMTime); + }); + + it("adds new dependencies and rebuilds when partial changes", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + const depA = path.join(sourceRoot, "depA.less"); + const depB = path.join(sourceRoot, "depB.less"); + writeFile(depA, "@val: blue;\n"); + writeFile(depB, "@alt: red;\n"); + writeFile(entryPath, '@import "depA.less";\nbody { color: @val; }\n'); + + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile( + entryPath, + '@import "depA.less";\n@import "depB.less";\nbody { color: @alt; }\n', + ); + await manager.handleFileChanged(entryPath, "entry changed"); + const entryId = getEntryId(manager, entryPath); + const deps = manager.entryDependencies.get(entryId) ?? []; + expect(deps.some((dep) => dep.endsWith("depB.less"))).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(depB, "@alt: purple;\n"); + const before = fs.statSync(cssPath).mtimeMs; + await manager.handleFileChanged(depB, "depB updated"); + const after = fs.statSync(cssPath).mtimeMs; + expect(after).toBeGreaterThan(before); + }); + + it("removes outputs when an entry is deleted", async () => { + const entryPath = path.join(sourceRoot, "main.less"); + writeFile(entryPath, "body { color: blue; }\n"); + const manager = makeManager(); + await manager.initialize(); + const cssPath = path.join(outputRoot, "main.css"); + expect(fs.existsSync(cssPath)).toBe(true); + + fs.unlinkSync(entryPath); + await manager.handleFileRemoved(entryPath); + expect(fs.existsSync(cssPath)).toBe(false); + expect(fs.existsSync(`${cssPath}.map`)).toBe(false); + }); + + it("builds new entries on the fly", async () => { + const manager = makeManager(); + await manager.initialize(); + + const entryPath = path.join(sourceRoot, "new.less"); + writeFile(entryPath, "body { color: orange; }\n"); + await manager.handleFileAdded(manager.targets[0], entryPath); + + const cssPath = path.join(outputRoot, "new.css"); + expect(fs.existsSync(cssPath)).toBe(true); + }); + + it("rebuilds direct + transitive dependents when a dependency changes, even without prior metadata", async () => { + const fontsPath = path.join(sourceRoot, "bloomWebFonts.less"); + const uiPath = path.join(sourceRoot, "bloomUI.less"); + const editModePath = path.join( + sourceRoot, + "bookEdit", + "css", + "editMode.less", + ); + + writeFile(fontsPath, "@UIFontStack: Arial;\n"); + writeFile( + uiPath, + '@import "./bloomWebFonts.less";\nbody { font-family: @UIFontStack; }\n', + ); + writeFile( + editModePath, + '@import "../../bloomUI.less";\n.editMode { color: black; }\n', + ); + + // Simulate pre-existing CSS outputs (e.g. built by some other pipeline) so the manager + // won't compile anything on startup unless it can still determine dependencies. + await new Promise((resolve) => setTimeout(resolve, 30)); + const fontsCssPath = path.join(outputRoot, "bloomWebFonts.css"); + const uiCssPath = path.join(outputRoot, "bloomUI.css"); + const editModeCssPath = path.join( + outputRoot, + "bookEdit", + "css", + "editMode.css", + ); + writeFile(fontsCssPath, "/* prebuilt */\n"); + writeFile(uiCssPath, "/* prebuilt */\n"); + writeFile(editModeCssPath, "/* prebuilt */\n"); + + const manager = makeManager(); + await manager.initialize(); + + const fontsBaseline = fs.statSync(fontsCssPath).mtimeMs; + const uiBaseline = fs.statSync(uiCssPath).mtimeMs; + const editModeBaseline = fs.statSync(editModeCssPath).mtimeMs; + + await new Promise((resolve) => setTimeout(resolve, 30)); + writeFile(fontsPath, "@UIFontStack: Verdana;\n"); + await manager.handleFileChanged(fontsPath, "fonts changed"); + + const fontsAfter = fs.statSync(fontsCssPath).mtimeMs; + const uiAfter = fs.statSync(uiCssPath).mtimeMs; + const editModeAfter = fs.statSync(editModeCssPath).mtimeMs; + + expect(fontsAfter).toBeGreaterThan(fontsBaseline); + expect(uiAfter).toBeGreaterThan(uiBaseline); + expect(editModeAfter).toBeGreaterThan(editModeBaseline); + }); + + it("preserves metadata entries from other scopes", async () => { + const uiRoot = path.join(tempDir, "ui"); + const contentRoot = path.join(tempDir, "content"); + makeDir(uiRoot); + makeDir(contentRoot); + + const uiEntryPath = path.join(uiRoot, "ui.less"); + const contentEntryPath = path.join(contentRoot, "content.less"); + writeFile(uiEntryPath, "body { color: blue; }\n"); + writeFile(contentEntryPath, "body { color: green; }\n"); + + const uiTarget: LessWatchTarget = { + name: "ui", + root: uiRoot, + outputBase: path.join(outputRoot, "ui"), + }; + const contentTarget: LessWatchTarget = { + name: "content", + root: contentRoot, + outputBase: path.join(outputRoot, "content"), + }; + + const allTargetsManager = makeManager({ + targets: [uiTarget, contentTarget], + }); + await allTargetsManager.initialize(); + + const uiEntryId = getEntryId(allTargetsManager, uiEntryPath); + const contentEntryId = getEntryId(allTargetsManager, contentEntryPath); + + const stateAfterAllTargets = JSON.parse( + fs.readFileSync(metadataPath, "utf8"), + ); + expect(stateAfterAllTargets.entries[uiEntryId]).toBeDefined(); + expect(stateAfterAllTargets.entries[contentEntryId]).toBeDefined(); + + const contentOnlyManager = makeManager({ targets: [contentTarget] }); + await contentOnlyManager.initialize(); + + const stateAfterContentOnly = JSON.parse( + fs.readFileSync(metadataPath, "utf8"), + ); + expect(stateAfterContentOnly.entries[uiEntryId]).toBeDefined(); + expect(stateAfterContentOnly.entries[contentEntryId]).toBeDefined(); + }); +}); diff --git a/src/BloomBrowserUI/scripts/build.js b/src/BloomBrowserUI/scripts/build.js new file mode 100644 index 000000000000..4b87308ba51c --- /dev/null +++ b/src/BloomBrowserUI/scripts/build.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +const { spawn } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const args = new Set(process.argv.slice(2)); +const verbose = args.has("--verbose"); + +const browserUIRoot = path.resolve(__dirname, ".."); +const contentRoot = path.resolve(browserUIRoot, "..", "content"); + +const env = { ...process.env }; + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8")); + +const resolvePackageBin = (packageRoot, packageName, binName) => { + const packageJsonPath = path.resolve( + packageRoot, + "node_modules", + packageName, + "package.json", + ); + const packageJson = readJson(packageJsonPath); + const binField = packageJson.bin; + const binRelativePath = + typeof binField === "string" ? binField : binField?.[binName]; + + if (!binRelativePath) { + throw new Error( + `Unable to resolve bin \"${binName}\" from ${packageJsonPath}`, + ); + } + + return path.resolve( + packageRoot, + "node_modules", + packageName, + binRelativePath, + ); +}; + +const viteBin = resolvePackageBin(browserUIRoot, "vite", "vite"); + +const runCommand = (command, commandArgs, options = {}) => + new Promise((resolve, reject) => { + const showOutput = options.showOutput ?? verbose; + const child = spawn(command, commandArgs, { + cwd: options.cwd ?? browserUIRoot, + env, + shell: false, + stdio: showOutput ? "inherit" : ["ignore", "pipe", "pipe"], + }); + + if (showOutput) { + child.on("close", (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `Command failed (${code}): ${command} ${commandArgs.join( + " ", + )}`, + ), + ); + } + }); + child.on("error", reject); + return; + } + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (data) => { + stdout += data; + }); + child.stderr.on("data", (data) => { + stderr += data; + }); + child.on("close", (code) => { + if (code === 0) { + resolve(); + return; + } + if (stdout) { + process.stdout.write(stdout); + } + if (stderr) { + process.stderr.write(stderr); + } + reject( + new Error( + `Command failed (${code}): ${command} ${commandArgs.join( + " ", + )}`, + ), + ); + }); + child.on("error", reject); + }); + +const run = async () => { + console.log("Cleaning output/browser..."); + await runCommand("node", ["scripts/clean.js", "--quiet"], { + cwd: browserUIRoot, + }); + + console.log("Building content assets..."); + await runCommand("node", ["checkForNodeModules.js"], { cwd: contentRoot }); + + const tsNodeBin = resolvePackageBin(contentRoot, "ts-node", "ts-node"); + const cpxBin = resolvePackageBin(contentRoot, "cpx", "cpx"); + const rimrafBin = resolvePackageBin(contentRoot, "rimraf", "rimraf"); + + await runCommand("node", [tsNodeBin, "pageSizes.ts"], { + cwd: contentRoot, + }); + + console.log("Vite build..."); + await runCommand("node", [viteBin, "build", "--logLevel", "warn"], { + cwd: browserUIRoot, + showOutput: true, + }); + + console.log("Copying branding assets..."); + await runCommand( + "node", + [ + cpxBin, + "branding/**/!(source)/*.{png,jpg,svg,css,json,htm}", + "../../output/browser/branding", + ], + { cwd: contentRoot }, + ); + console.log("Copying template assets..."); + await runCommand( + "node", + [ + cpxBin, + "templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}", + "../../output/browser/templates", + ], + { cwd: contentRoot }, + ); + console.log("Copying appearance themes..."); + await runCommand( + "node", + [rimrafBin, "../../output/browser/appearanceThemes"], + { + cwd: contentRoot, + }, + ); + await runCommand( + "node", + [ + cpxBin, + "appearanceThemes/**/*.css", + "../../output/browser/appearanceThemes", + ], + { cwd: contentRoot }, + ); + await runCommand( + "node", + [ + cpxBin, + "appearanceMigrations/**", + "../../output/browser/appearanceMigrations", + ], + { cwd: contentRoot }, + ); + + console.log("Build complete."); +}; + +run().catch((error) => { + console.error(error.message ?? error); + process.exit(1); +}); diff --git a/src/BloomBrowserUI/scripts/clean.js b/src/BloomBrowserUI/scripts/clean.js index 98a859671796..e7244efa061a 100644 --- a/src/BloomBrowserUI/scripts/clean.js +++ b/src/BloomBrowserUI/scripts/clean.js @@ -7,17 +7,27 @@ const fs = require("fs"); const path = require("path"); -const outputBase = path.resolve(__dirname, "../../../output/browser"); +const outputDirs = [path.resolve(__dirname, "../../../output/browser")]; +const quiet = process.argv.includes("--quiet"); -if (fs.existsSync(outputBase)) { - console.log(`\nCleaning output directory: ${outputBase}`); +for (const outputDir of outputDirs) { + if (!fs.existsSync(outputDir)) { + if (!quiet) { + console.log(`\nOutput directory does not exist: ${outputDir}`); + console.log("Nothing to clean.\n"); + } + continue; + } - // Delete all files and subdirectories - const entries = fs.readdirSync(outputBase); + if (!quiet) { + console.log(`\nCleaning output directory: ${outputDir}`); + } + + const entries = fs.readdirSync(outputDir); let deletedCount = 0; for (const entry of entries) { - const fullPath = path.join(outputBase, entry); + const fullPath = path.join(outputDir, entry); try { fs.rmSync(fullPath, { recursive: true, force: true }); deletedCount++; @@ -27,8 +37,7 @@ if (fs.existsSync(outputBase)) { } } - console.log(`✓ Deleted ${deletedCount} items from output directory\n`); -} else { - console.log(`\nOutput directory does not exist: ${outputBase}`); - console.log("Nothing to clean.\n"); + if (!quiet) { + console.log(`✓ Deleted ${deletedCount} items from output directory\n`); + } } diff --git a/src/BloomBrowserUI/scripts/compileLess.mjs b/src/BloomBrowserUI/scripts/compileLess.mjs new file mode 100644 index 000000000000..858097e40e58 --- /dev/null +++ b/src/BloomBrowserUI/scripts/compileLess.mjs @@ -0,0 +1,87 @@ +/* eslint-env node */ +/* global console, process */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const require = createRequire(import.meta.url); +const { LessWatchManager } = require("./watchLessManager.js"); + +function resolvePaths(options = {}) { + const browserUIRoot = + options.browserUIRoot ?? path.resolve(__dirname, ".."); + const outputBase = + options.outputBase ?? + path.resolve(browserUIRoot, "../../output/browser"); + + const repoRoot = + options.repoRoot ?? path.resolve(browserUIRoot, "..", ".."); + const metadataPath = + options.metadataPath ?? path.join(outputBase, ".less-watch-state.json"); + + return { browserUIRoot, outputBase, repoRoot, metadataPath }; +} + +export async function compileLessFiles(options = {}) { + const { browserUIRoot, outputBase, repoRoot, metadataPath } = + resolvePaths(options); + + const contentRoot = + options.contentRoot ?? path.resolve(browserUIRoot, "..", "content"); + + const logger = console; + + const targets = options.targets ?? [ + { + name: "browser-ui", + root: browserUIRoot, + outputBase, + }, + { + name: "branding", + root: path.join(contentRoot, "branding"), + outputBase: path.join(outputBase, "branding"), + }, + { + name: "templates", + root: path.join(contentRoot, "templates"), + outputBase: path.join(outputBase, "templates"), + }, + { + name: "bookLayout", + root: path.join(contentRoot, "bookLayout"), + outputBase: path.join(outputBase, "bookLayout"), + entries: ["basePage.less", "canvasElement.less"], + }, + ]; + + const manager = new LessWatchManager({ + repoRoot, + metadataPath, + targets, + logger, + }); + + await manager.initialize(); + const compiled = manager.compiledCount ?? 0; + const total = manager.entries?.size ?? 0; + const skipped = Math.max(0, total - compiled); + console.log( + `Less: ${compiled} compiled, ${skipped} up-to-date (${total} total)\n`, + ); +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "compileLess.mjs"; + +if (invokedDirectly) { + compileLessFiles().catch((err) => { + console.error("Failed to compile LESS files:", err); + process.exitCode = 1; + }); +} diff --git a/src/BloomBrowserUI/scripts/compilePug.mjs b/src/BloomBrowserUI/scripts/compilePug.mjs index 9874d4db5c42..90028fc24430 100644 --- a/src/BloomBrowserUI/scripts/compilePug.mjs +++ b/src/BloomBrowserUI/scripts/compilePug.mjs @@ -1,8 +1,8 @@ /* eslint-env node */ /* global console, process */ -import path from "path"; -import { pathToFileURL, fileURLToPath } from "url"; -import fs from "fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; import { glob } from "glob"; import pug from "pug"; @@ -17,12 +17,65 @@ function resolvePaths(options = {}) { const outputBase = options.outputBase ?? path.resolve(browserUIRoot, "../../output/browser"); + const repoRoot = + options.repoRoot ?? path.resolve(browserUIRoot, "..", ".."); + const metadataPath = + options.metadataPath ?? path.join(outputBase, ".pug-watch-state.json"); - return { browserUIRoot, contentRoot, outputBase }; + return { browserUIRoot, contentRoot, outputBase, repoRoot, metadataPath }; +} + +function getMTime(filePath) { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } +} + +function needsRebuild(sourceFile, dependencyFiles, outputFile) { + if (!fs.existsSync(outputFile)) { + return true; + } + + const outputTime = getMTime(outputFile); + const timesToCheck = [sourceFile, ...(dependencyFiles ?? [])]; + for (const dep of timesToCheck) { + const depTime = getMTime(dep); + if (!depTime || depTime > outputTime) { + return true; + } + } + + return false; +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function writeJsonAtomic(filePath, data) { + const dirPath = path.dirname(filePath); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + fs.renameSync(tmpPath, filePath); } export async function compilePugFiles(options = {}) { - const { browserUIRoot, contentRoot, outputBase } = resolvePaths(options); + const { browserUIRoot, contentRoot, outputBase, repoRoot, metadataPath } = + resolvePaths(options); + const { + logSummary = false, + logWhenNoChanges = false, + logFiles = false, + } = options; const browserUIPugFiles = glob.sync("**/*.pug", { cwd: browserUIRoot, @@ -40,9 +93,16 @@ export async function compilePugFiles(options = {}) { const allPugFiles = [...browserUIPugFiles, ...contentPugFiles]; - console.log( - `\nCompiling ${allPugFiles.length} Pug files (${browserUIPugFiles.length} from BloomBrowserUI, ${contentPugFiles.length} from content)...`, - ); + const metadataVersion = 1; + const existingMetadata = readJson(metadataPath); + const cachedEntries = + existingMetadata?.version === metadataVersion + ? (existingMetadata.entries ?? {}) + : {}; + const nextEntries = {}; + + let compiled = 0; + let skipped = 0; for (const file of allPugFiles) { const isContentFile = file.startsWith(contentRoot + path.sep); @@ -53,27 +113,83 @@ export async function compilePugFiles(options = {}) { .replace(/\.pug$/i, ".html"); const outputFile = path.join(outputBase, relativePath); - const outputDir = path.dirname(outputFile); + const entryId = path.relative(repoRoot, file).replace(/\\/g, "/"); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); + const cachedDependencies = (cachedEntries[entryId] ?? []).map((dep) => + path.resolve(repoRoot, dep), + ); + + if (!needsRebuild(file, cachedDependencies, outputFile)) { + nextEntries[entryId] = cachedEntries[entryId] ?? []; + skipped++; + continue; } - const html = pug.renderFile(file, { + const compiledTemplate = pug.compileFile(file, { basedir: baseRoot, pretty: true, }); + const dependencies = Array.from( + new Set( + (compiledTemplate.dependencies ?? []).map((dep) => + path.resolve(dep), + ), + ), + ); + + nextEntries[entryId] = dependencies.map((dep) => + path.relative(repoRoot, dep).replace(/\\/g, "/"), + ); + + if (!needsRebuild(file, dependencies, outputFile)) { + skipped++; + continue; + } + + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + const html = compiledTemplate({}); + fs.writeFileSync(outputFile, html); - const displayPath = path.relative(browserUIRoot, file); - console.log(` ✓ ${displayPath} → ${relativePath}`); + if (logFiles) { + const displayPath = path.relative(browserUIRoot, file); + console.log(` ✓ ${displayPath} → ${relativePath}`); + } + compiled++; } - console.log("Pug compilation complete!\n"); + writeJsonAtomic(metadataPath, { + version: metadataVersion, + entries: nextEntries, + }); + + const total = allPugFiles.length; + if (logSummary && (logWhenNoChanges || compiled > 0)) { + console.log( + `Pug: ${compiled} compiled, ${skipped} up-to-date (${total} total)\n`, + ); + } + + return { compiled, skipped, total }; } -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - compilePugFiles().catch((err) => { +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "compilePug.mjs"; + +if (invokedDirectly) { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + compilePugFiles({ + logSummary: verbose, + logWhenNoChanges: verbose, + logFiles: verbose, + }).catch((err) => { console.error("Failed to compile Pug files:", err); process.exitCode = 1; }); diff --git a/src/BloomBrowserUI/scripts/copyStaticFile.mjs b/src/BloomBrowserUI/scripts/copyStaticFile.mjs new file mode 100644 index 000000000000..6db33d036535 --- /dev/null +++ b/src/BloomBrowserUI/scripts/copyStaticFile.mjs @@ -0,0 +1,103 @@ +/* eslint-env node */ +/* global console, process */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const excludedExtensions = new Set([ + ".ts", + ".tsx", + ".less", + ".pug", + ".md", + ".bat", +]); + +function resolvePaths(options = {}) { + const browserUIRoot = + options.browserUIRoot ?? path.resolve(__dirname, ".."); + const outputBase = + options.outputBase ?? + path.resolve(browserUIRoot, "../../output/browser"); + + return { browserUIRoot, outputBase }; +} + +function needsCopy(sourceFile, outputFile) { + if (!fs.existsSync(outputFile)) { + return true; + } + const sourceStat = fs.statSync(sourceFile); + const outputStat = fs.statSync(outputFile); + return sourceStat.mtimeMs > outputStat.mtimeMs; +} + +function copyStaticFile(filePath, options = {}) { + const quiet = options.quiet ?? true; + if (!filePath) { + return false; + } + + const { browserUIRoot, outputBase } = resolvePaths(options); + const absolutePath = path.resolve(filePath); + + if (!fs.existsSync(absolutePath)) { + return false; + } + + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + return false; + } + + if (absolutePath.includes(`${path.sep}node_modules${path.sep}`)) { + return false; + } + + const relativePath = path + .relative(browserUIRoot, absolutePath) + .replace(/\\/g, "/"); + const fileName = path.basename(relativePath).toLowerCase(); + + if ( + fileName === "tsconfig.json" || + relativePath.startsWith(".") || + excludedExtensions.has(path.extname(relativePath)) + ) { + return false; + } + + const outputFile = path.join(outputBase, relativePath); + + if (!needsCopy(absolutePath, outputFile)) { + return false; + } + + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.copyFileSync(absolutePath, outputFile); + if (!quiet) { + console.log(` ✓ Copied ${relativePath}`); + } + return true; +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "copyStaticFile.mjs"; + +if (invokedDirectly) { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + const filtered = args.filter((arg) => arg !== "--verbose"); + copyStaticFile(filtered[0], { quiet: !verbose }); +} + +export { copyStaticFile }; diff --git a/src/BloomBrowserUI/scripts/dev.mjs b/src/BloomBrowserUI/scripts/dev.mjs new file mode 100644 index 000000000000..c5ea374ac046 --- /dev/null +++ b/src/BloomBrowserUI/scripts/dev.mjs @@ -0,0 +1,479 @@ +/* eslint-env node */ +/* global console, process */ +import { spawn } from "child_process"; +import path from "node:path"; +import * as fs from "node:fs"; +import * as net from "node:net"; +import { fileURLToPath } from "node:url"; +import { glob } from "glob"; +import { compilePugFiles } from "./compilePug.mjs"; +import { copyStaticFile } from "./copyStaticFile.mjs"; +import { copyContentFile } from "../../content/scripts/copyContentFile.mjs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const browserUIRoot = path.resolve(__dirname, ".."); +const contentRoot = path.resolve(browserUIRoot, "../content"); + +const isWindows = process.platform === "win32"; + +const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8")); + +const resolvePackageBin = (packageRoot, packageName, binName) => { + const packageJsonPath = path.resolve( + packageRoot, + "node_modules", + packageName, + "package.json", + ); + + const packageJson = readJson(packageJsonPath); + const binField = packageJson.bin; + let binRelativePath; + if (typeof binField === "string") { + binRelativePath = binField; + } else { + binRelativePath = binField?.[binName]; + } + + if (!binRelativePath) { + throw new Error( + `Unable to resolve bin \"${binName}\" from ${packageJsonPath}`, + ); + } + + return path.resolve( + packageRoot, + "node_modules", + packageName, + binRelativePath, + ); +}; + +const processes = []; +let isShuttingDown = false; +const isVerbose = process.argv.includes("--verbose"); + +const defaultVitePort = 5173; + +const parsePortValue = (value) => { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + return parsed; + } + return undefined; +}; + +const parsePort = () => { + const equalsArg = process.argv.find((value) => + value?.startsWith("--port="), + ); + if (equalsArg) { + const parsed = parsePortValue(equalsArg.split("=")[1]); + if (parsed !== undefined) { + return parsed; + } + } + + const flagIndex = process.argv.findIndex( + (value) => value === "--port" || value === "-p", + ); + if (flagIndex >= 0 && flagIndex + 1 < process.argv.length) { + const parsed = parsePortValue(process.argv[flagIndex + 1]); + if (parsed !== undefined) { + return parsed; + } + } + + if (process.env.PORT) { + const parsed = parsePortValue(process.env.PORT); + if (parsed !== undefined) { + return parsed; + } + } + + return defaultVitePort; +}; + +const isPortAvailable = (port) => + new Promise((resolve) => { + const server = net + .createServer() + .once("error", () => { + resolve(false); + }) + .once("listening", () => { + server.close(() => resolve(true)); + }) + .listen(port, "127.0.0.1"); + }); + +function spawnProcess(command, args, options = {}) { + const proc = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + shell: false, + ...options, + }); + + proc.stdout.on("data", (data) => process.stdout.write(data)); + proc.stderr.on("data", (data) => process.stderr.write(data)); + + proc.on("error", (err) => { + if (isShuttingDown) { + return; + } + console.error(`Process failed to start: ${command}`); + console.error(err); + cleanup(1); + }); + + proc.on("close", (code, signal) => { + if (isShuttingDown) { + return; + } + + if (signal) { + console.error(`Process exited due to signal ${signal}: ${command}`); + cleanup(1); + return; + } + + if (code !== 0) { + console.error(`Process exited with code ${code}: ${command}`); + cleanup(code ?? 1); + } + }); + + processes.push(proc); + return proc; +} + +function spawnNodeScript(scriptPath, args, options = {}) { + return spawnProcess(process.execPath, [scriptPath, ...args], { + shell: false, + ...options, + }); +} + +function startVite(port) { + return new Promise((resolve) => { + console.log("Starting Vite dev server...\n"); + + const viteBin = resolvePackageBin(browserUIRoot, "vite", "vite"); + let ready = false; + const vite = spawn( + process.execPath, + [viteBin, "--port", String(port), "--strictPort"], + { + cwd: browserUIRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + env: { + ...process.env, + PORT: String(port), + }, + }, + ); + + processes.push(vite); + + vite.stdout.on("data", (data) => { + process.stdout.write(data); + if (data.toString().includes("ready in")) { + ready = true; + resolve(); + } + }); + + vite.stderr.on("data", (data) => process.stderr.write(data)); + + vite.on("error", (err) => { + if (isShuttingDown) { + return; + } + console.error("Vite failed to start:", err); + cleanup(1); + }); + + vite.on("close", (code) => { + if (isShuttingDown) { + return; + } + if (!ready) { + console.error( + `Vite exited before becoming ready (code ${code}).`, + ); + cleanup(1); + return; + } + + console.error(`Vite exited unexpectedly (code ${code}).`); + cleanup(code ?? 1); + }); + }); +} + +async function runInitialBuilds() { + let copiedCount = 0; + const pugResult = await compilePugFiles({ + logSummary: isVerbose, + logWhenNoChanges: isVerbose, + logFiles: isVerbose, + }); + + const staticFiles = glob.sync("**/*.*", { + cwd: browserUIRoot, + nodir: true, + absolute: true, + ignore: ["**/node_modules/**"], + }); + for (const file of staticFiles) { + if (copyStaticFile(file, { quiet: !isVerbose })) { + copiedCount++; + } + } + + const contentCopyJobs = [ + { + label: "template files", + pattern: + "templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}", + sourceBase: "templates", + destinationBase: "templates", + }, + { + label: "branding files", + pattern: + "branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}", + sourceBase: "branding", + destinationBase: "branding", + }, + { + label: "appearance theme files", + pattern: "appearanceThemes/**/*.css", + sourceBase: "appearanceThemes", + destinationBase: "appearanceThemes", + }, + { + label: "appearance migration files", + pattern: "appearanceMigrations/**", + sourceBase: "appearanceMigrations", + destinationBase: "appearanceMigrations", + }, + ]; + + for (const job of contentCopyJobs) { + const files = glob.sync(job.pattern, { + cwd: contentRoot, + nodir: true, + absolute: true, + }); + for (const file of files) { + if ( + copyContentFile(file, job.sourceBase, job.destinationBase, { + quiet: !isVerbose, + }) + ) { + copiedCount++; + } + } + } + + const compiledCount = pugResult?.compiled ?? 0; + const totalChanges = compiledCount + copiedCount; + if (totalChanges === 0) { + console.log("\nInitial build done (no changes).\n"); + return; + } + + if (isVerbose) { + const summaryParts = []; + if (compiledCount > 0) { + summaryParts.push(`Pug: ${compiledCount} compiled`); + } + if (copiedCount > 0) { + summaryParts.push(`${copiedCount} files copied`); + } + + const summaryText = summaryParts.length + ? ` (${summaryParts.join(", ")})` + : ""; + console.log(`\nInitial build done${summaryText}.\n`); + } else { + console.log("\nInitial build done.\n"); + } +} + +async function startWatchers() { + await runInitialBuilds(); + console.log("\nStarting file watchers...\n"); + + const onchangeBin = resolvePackageBin( + browserUIRoot, + "onchange", + "onchange", + ); + const nodeForOnchange = isWindows ? "node" : process.execPath; + + // Pug watcher - compile all pug files initially, then watch for changes + console.log("Watching pug files..."); + const verboseFlag = isVerbose ? ["--verbose"] : []; + + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "**/*.pug", + "../content/**/*.pug", + "--", + nodeForOnchange, + "./scripts/compilePug.mjs", + ...verboseFlag, + ], + { cwd: browserUIRoot }, + ); + + // Less watcher - consolidate BloomBrowserUI and content LESS processing + console.log("Watching LESS files..."); + spawnProcess(process.execPath, ["./scripts/watchLess.mjs", "--scope=all"], { + cwd: browserUIRoot, + shell: false, + }); + + // Static file watcher - only triggers on actual changes (no -i since copyStaticFile needs a specific file) + console.log("Watching browser UI static files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "**/*.*", + "--", + nodeForOnchange, + "./scripts/copyStaticFile.mjs", + "{{file}}", + ...verboseFlag, + ], + { cwd: browserUIRoot }, + ); + + // Content watchers (spawn directly to avoid printing full commands) + console.log("Watching template files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "templates", + "templates", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); + + console.log("Watching branding files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "branding", + "branding", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); + + console.log("Watching appearance theme files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "appearanceThemes/**/*.css", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "appearanceThemes", + "appearanceThemes", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); + + console.log("Watching appearance migration files..."); + spawnNodeScript( + onchangeBin, + [ + "-k", + "-i", + "-a", + "appearanceMigrations/**", + "--", + nodeForOnchange, + "./scripts/copyContentFile.mjs", + "{{file}}", + "appearanceMigrations", + "appearanceMigrations", + ...verboseFlag, + ], + { cwd: contentRoot }, + ); +} + +function cleanup(exitCode = 0) { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + console.log("\nShutting down..."); + for (const proc of processes) { + proc.kill(); + } + const normalizedExitCode = + typeof exitCode === "number" && Number.isFinite(exitCode) + ? exitCode + : 0; + process.exit(normalizedExitCode); +} + +process.on("SIGINT", () => cleanup(0)); +process.on("SIGTERM", () => cleanup(0)); + +async function main() { + if (!fs.existsSync(process.execPath)) { + throw new Error(`Node executable not found at ${process.execPath}`); + } + + const port = parsePort(); + const available = await isPortAvailable(port); + if (!available) { + console.error(`Port ${port} is already in use.`); + console.error( + `Stop the other dev server, or run: yarn dev --port=${port + 1}`, + ); + process.exit(1); + } + + await startVite(port); + await startWatchers(); +} + +main().catch((err) => { + console.error("Dev script failed:", err); + cleanup(1); +}); diff --git a/src/BloomBrowserUI/scripts/watchLess.d.ts b/src/BloomBrowserUI/scripts/watchLess.d.ts new file mode 100644 index 000000000000..ffb645f0d729 --- /dev/null +++ b/src/BloomBrowserUI/scripts/watchLess.d.ts @@ -0,0 +1,34 @@ +import type { RenderOutput } from "less"; + +export interface LessWatchTarget { + name: string; + root: string; + outputBase: string; + include?: string | string[]; + ignore?: string | string[]; + entries?: string[]; +} + +export interface LessWatchOptions { + repoRoot: string; + metadataPath: string; + targets: LessWatchTarget[]; + logger?: Console; + lessRenderer?: ( + input: string, + options: Record, + ) => Promise; +} + +export class LessWatchManager { + constructor(options: LessWatchOptions); + initialize(): Promise; + startWatching(): Promise; + dispose(): Promise; + handleFileAdded(target: LessWatchTarget, absPath: string): Promise; + handleFileChanged(absPath: string, reason: string): Promise; + handleFileRemoved(absPath: string): Promise; + entryDependencies: Map; + targets: LessWatchTarget[]; + repoRoot: string; +} diff --git a/src/BloomBrowserUI/scripts/watchLess.mjs b/src/BloomBrowserUI/scripts/watchLess.mjs new file mode 100644 index 000000000000..13df048c5aae --- /dev/null +++ b/src/BloomBrowserUI/scripts/watchLess.mjs @@ -0,0 +1,107 @@ +/* eslint-env node */ +/* global console, process */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { LessWatchManager } = require("./watchLessManager.js"); + +export { LessWatchManager }; + +async function runWatcherFromCli() { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const browserUIRoot = path.resolve(__dirname, ".."); + const repoRoot = path.resolve(browserUIRoot, "..", ".."); + const contentRoot = path.resolve(browserUIRoot, "..", "content"); + const outputRoot = path.resolve( + browserUIRoot, + "..", + "..", + "output", + "browser", + ); + + const scopeArg = process.argv.find((arg) => arg.startsWith("--scope=")); + const scope = scopeArg ? scopeArg.split("=")[1] : "all"; + const once = process.argv.includes("--once"); + + const targets = []; + if (scope === "all" || scope === "browser-ui") { + targets.push({ + name: "browser-ui", + root: browserUIRoot, + outputBase: outputRoot, + }); + } + if (scope === "all" || scope === "content") { + targets.push( + { + name: "branding", + root: path.join(contentRoot, "branding"), + outputBase: path.join(outputRoot, "branding"), + }, + { + name: "templates", + root: path.join(contentRoot, "templates"), + outputBase: path.join(outputRoot, "templates"), + }, + { + name: "bookLayout", + root: path.join(contentRoot, "bookLayout"), + outputBase: path.join(outputRoot, "bookLayout"), + entries: ["basePage.less", "canvasElement.less"], + }, + ); + } + + if (targets.length === 0) { + console.error(`Unknown scope "${scope}" supplied to watchLess`); + process.exit(1); + } + + const metadataPath = path.join(outputRoot, ".less-watch-state.json"); + + const quietLogger = { + log: () => {}, + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), + }; + + const manager = new LessWatchManager({ + repoRoot, + metadataPath, + targets, + logger: once ? quietLogger : undefined, + }); + + await manager.initialize(); + if (!once) { + await manager.startWatching(); + } + + function shutdown() { + manager + .dispose() + .catch((err) => console.error("Failed to stop watchers", err)) + .finally(() => process.exit(0)); + } + + if (!once) { + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "watchLess.mjs"; + +if (invokedDirectly) { + runWatcherFromCli().catch((err) => { + console.error("watchLess failed:", err); + process.exit(1); + }); +} diff --git a/src/BloomBrowserUI/scripts/watchLessManager.js b/src/BloomBrowserUI/scripts/watchLessManager.js new file mode 100644 index 000000000000..26cb17095be0 --- /dev/null +++ b/src/BloomBrowserUI/scripts/watchLessManager.js @@ -0,0 +1,634 @@ +const chokidar = require("chokidar"); +const fs = require("fs"); +const path = require("path"); +const { glob } = require("glob"); +const less = require("less"); + +const isWindows = process.platform === "win32"; +const defaultIgnore = ["**/node_modules/**"]; + +function scanLessImports(sourceText) { + // Capture common LESS import forms: + // @import "a.less"; + // @import (reference) "a.less"; + // @import url("a.less"); + // @import (reference) url("a.less"); + // We intentionally ignore variable-based/dynamic import paths. + const imports = []; + const importRegex = + /@import\s*(?:\([^)]+\)\s*)?(?:url\(\s*)?["']([^"']+)["']\s*\)?\s*;?/gi; + let match; + while ((match = importRegex.exec(sourceText))) { + const raw = (match[1] ?? "").trim(); + if (!raw) { + continue; + } + if ( + raw.includes("@{") || + raw.includes("://") || + raw.startsWith("data:") + ) { + continue; + } + imports.push(raw); + } + return imports; +} + +function resolveLessImport(fromFilePath, importPath) { + if (!importPath || typeof importPath !== "string") { + return null; + } + + // Ignore module-style imports (e.g. ~package/path.less). If these become relevant, + // prefer relying on actual less compilation to discover them. + if (importPath.startsWith("~")) { + return null; + } + + const baseDir = path.dirname(fromFilePath); + const candidate = path.isAbsolute(importPath) + ? importPath + : path.resolve(baseDir, importPath); + + const ext = path.extname(candidate); + const candidates = []; + if (ext) { + candidates.push(candidate); + } else { + candidates.push(`${candidate}.less`); + candidates.push(candidate); + } + + for (const filePath of candidates) { + try { + if (fs.statSync(filePath).isFile()) { + return path.resolve(filePath); + } + } catch { + // try next candidate + } + } + + return null; +} + +function normalizePath(filePath) { + const resolved = path.resolve(filePath); + return isWindows ? resolved.toLowerCase() : resolved; +} + +function ensureDir(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return null; + } +} + +function writeJsonAtomic(filePath, data) { + ensureDir(path.dirname(filePath)); + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + fs.renameSync(tmpPath, filePath); +} + +function pathToRepoRelative(repoRoot, absPath) { + return path.relative(repoRoot, absPath).replace(/\\/g, "/"); +} + +function repoRelativeToAbsolute(repoRoot, relativePath) { + return path.resolve(repoRoot, relativePath); +} + +function toArray(value, fallback) { + if (!value) { + return fallback.slice(); + } + return Array.isArray(value) ? value.slice() : [value]; +} + +class LessWatchManager { + constructor(options) { + this.repoRoot = options.repoRoot; + this.metadataPath = options.metadataPath; + this.targets = options.targets.map((target) => ({ + ...target, + root: path.resolve(target.root), + outputBase: path.resolve(target.outputBase), + include: toArray(target.include, ["**/*.less"]), + ignore: toArray(target.ignore, defaultIgnore), + explicitEntries: target.entries?.map((entry) => + path.resolve(target.root, entry), + ), + })); + this.logger = options.logger ?? console; + this.lessRenderer = options.lessRenderer ?? less.render; + this.metadataVersion = 1; + + this.entries = new Map(); + this.entryByPath = new Map(); + this.entryDependencies = new Map(); + this.dependencyToEntries = new Map(); + this.pendingBuilds = new Map(); + this.watchers = []; + this.dependencyWatcher = null; + + this.compiledCount = 0; + } + + async initialize() { + await this.loadMetadata(); + await this.registerInitialEntries(); + await this.ensureEntryDependenciesKnown(); + await this.ensureOutputsUpToDate(); + } + + async ensureEntryDependenciesKnown() { + // The watcher relies on the dependency graph to know what to rebuild. + // If we have outputs already (built by some other pipeline) and no metadata yet, + // we still need a best-effort dependency graph so changes rebuild dependents. + for (const entry of this.entries.values()) { + const deps = this.entryDependencies.get(entry.id); + if (deps && deps.length > 0) { + continue; + } + + const lessInput = fs.readFileSync(entry.entryPath, "utf8"); + const imports = scanLessImports(lessInput) + .map((importPath) => + resolveLessImport(entry.entryPath, importPath), + ) + .filter((dep) => !!dep); + + const dependencies = [path.resolve(entry.entryPath), ...imports]; + this.updateEntryDependencies(entry.id, dependencies); + } + } + + async startWatching() { + if (this.watchers.length > 0) { + return; + } + + const failFast = (promise) => + promise.catch((err) => { + this.logger.error(`[LESS] watcher failure:`, err); + process.exit(1); + }); + + for (const target of this.targets) { + const watcher = chokidar.watch(target.include, { + cwd: target.root, + ignored: target.ignore, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 50, + }, + }); + + watcher + .on("add", (file) => + failFast( + this.handleFileAdded( + target, + path.join(target.root, file), + ), + ), + ) + .on("change", (file) => + failFast( + this.handleFileChanged( + path.join(target.root, file), + `modified ${path.basename(file)}`, + ), + ), + ) + .on("unlink", (file) => + failFast( + this.handleFileRemoved(path.join(target.root, file)), + ), + ); + + this.watchers.push(watcher); + } + + this.dependencyWatcher = chokidar.watch([], { + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 200, + pollInterval: 50, + }, + }); + + this.dependencyWatcher + .on("change", (file) => + failFast(this.handleFileChanged(file, "dependency changed")), + ) + .on("unlink", (file) => failFast(this.handleFileRemoved(file))); + + this.primeDependencyWatcher(); + } + + async dispose() { + for (const watcher of this.watchers) { + await watcher.close(); + } + this.watchers = []; + if (this.dependencyWatcher) { + await this.dependencyWatcher.close(); + this.dependencyWatcher = null; + } + } + + async loadMetadata() { + const data = readJson(this.metadataPath); + if (!data || data.version !== this.metadataVersion) { + return; + } + + for (const [entryId, deps] of Object.entries(data.entries ?? {})) { + const absDeps = deps.map((dep) => + repoRelativeToAbsolute(this.repoRoot, dep), + ); + this.entryDependencies.set(entryId, absDeps); + for (const dep of absDeps) { + this.linkDependency(entryId, dep, { watch: false }); + } + } + } + + async registerInitialEntries() { + for (const target of this.targets) { + const files = target.explicitEntries + ? target.explicitEntries + : await this.globEntries(target); + for (const file of files) { + this.registerEntry(file, target); + } + } + + for (const entryId of Array.from(this.entryDependencies.keys())) { + if (this.entries.has(entryId)) { + continue; + } + + const entryPath = repoRelativeToAbsolute(this.repoRoot, entryId); + if (!this.isInsideKnownTarget(entryPath)) { + continue; + } + + this.clearDependencyMappings(entryId); + this.entryDependencies.delete(entryId); + } + } + + async globEntries(target) { + const results = new Set(); + for (const pattern of target.include) { + const files = await glob(pattern, { + cwd: target.root, + ignore: target.ignore, + nodir: true, + absolute: true, + }); + for (const file of files) { + results.add(path.resolve(file)); + } + } + return Array.from(results); + } + + registerEntry(entryPath, target) { + const absPath = path.resolve(entryPath); + const key = pathToRepoRelative(this.repoRoot, absPath); + if (this.entries.has(key)) { + return this.entries.get(key); + } + + const relativeToRoot = path.relative(target.root, absPath); + const outputPath = path.join( + target.outputBase, + relativeToRoot.replace(/\.less$/i, ".css"), + ); + + const entry = { + id: key, + entryPath: absPath, + outputPath, + target, + }; + + this.entries.set(key, entry); + this.entryByPath.set(normalizePath(absPath), key); + return entry; + } + + async ensureOutputsUpToDate() { + for (const entry of this.entries.values()) { + if (await this.needsBuild(entry)) { + await this.compileEntry(entry, "initial sync"); + } + } + await this.persistMetadata(); + } + + async needsBuild(entry) { + const outputMTime = this.getMTime(entry.outputPath); + if (!outputMTime) { + return true; + } + + const deps = this.entryDependencies.get(entry.id); + if (!deps || deps.length === 0) { + const sourceTime = this.getMTime(entry.entryPath); + return !sourceTime || sourceTime > outputMTime; + } + + for (const dep of deps) { + const depTime = this.getMTime(dep); + if (!depTime || depTime > outputMTime) { + return true; + } + } + return false; + } + + getMTime(filePath) { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return 0; + } + } + + async handleFileAdded(target, absPath) { + this.registerEntry(absPath, target); + await this.queueBuildForPath(absPath, "file added"); + } + + async handleFileChanged(absPath, reason) { + const affected = this.collectAffectedEntries(absPath); + await Promise.all( + Array.from(affected).map((entryId) => + this.queueBuild(entryId, reason), + ), + ); + } + + async handleFileRemoved(absPath) { + const affected = this.collectAffectedEntries(absPath); + const key = normalizePath(absPath); + const entryId = this.entryByPath.get(key); + if (entryId) { + await this.removeEntry(entryId); + } + + await Promise.all( + Array.from(affected).map((affectedEntryId) => + this.queueBuild(affectedEntryId, "dependency removed"), + ), + ); + } + + collectAffectedEntries(absPath) { + // Rebuild entries that directly include the changed file, and also rebuild any entries + // that depend on those entries (transitively). + // Example: bloomWebFonts.less -> bloomUI.less -> editMode.less + const startKey = normalizePath(absPath); + const affected = new Set(); + const visitedFileKeys = new Set([startKey]); + const queue = [startKey]; + + while (queue.length > 0) { + const fileKey = queue.shift(); + + const directEntryId = this.entryByPath.get(fileKey); + if (directEntryId) { + affected.add(directEntryId); + } + + const dependents = this.dependencyToEntries.get(fileKey); + if (!dependents) { + continue; + } + + for (const dependentEntryId of dependents) { + if (!this.entries.has(dependentEntryId)) { + continue; + } + affected.add(dependentEntryId); + + const dependentEntry = this.entries.get(dependentEntryId); + if (!dependentEntry) { + continue; + } + const dependentEntryPathKey = normalizePath( + dependentEntry.entryPath, + ); + if (!visitedFileKeys.has(dependentEntryPathKey)) { + visitedFileKeys.add(dependentEntryPathKey); + queue.push(dependentEntryPathKey); + } + } + } + + return affected; + } + + async queueBuildForPath(absPath, reason) { + const key = normalizePath(absPath); + const entryId = this.entryByPath.get(key); + if (!entryId) { + return; + } + await this.queueBuild(entryId, reason); + } + + async queueBuild(entryId, reason) { + if (!this.entries.has(entryId)) { + return; + } + const pending = this.pendingBuilds.get(entryId) ?? Promise.resolve(); + const next = pending + .catch(() => {}) + .then(() => { + const entry = this.entries.get(entryId); + if (!entry) { + return; + } + return this.compileEntry(entry, reason); + }); + + this.pendingBuilds.set( + entryId, + next.finally(() => { + if (this.pendingBuilds.get(entryId) === next) { + this.pendingBuilds.delete(entryId); + } + }), + ); + + await next; + } + + async compileEntry(entry, reason) { + ensureDir(path.dirname(entry.outputPath)); + const lessInput = fs.readFileSync(entry.entryPath, "utf8"); + const result = await this.lessRenderer(lessInput, { + filename: entry.entryPath, + sourceMap: { + sourceMapFileInline: false, + outputSourceFiles: true, + sourceMapURL: `${path.basename(entry.outputPath)}.map`, + }, + }); + + let css = result.css; + if (result.map) { + css += `\n/*# sourceMappingURL=${path.basename(entry.outputPath)}.map */`; + fs.writeFileSync(`${entry.outputPath}.map`, result.map); + } + fs.writeFileSync(entry.outputPath, css); + + const dependencies = (result.imports ?? []).map((dep) => + path.resolve(dep), + ); + if (!dependencies.includes(path.resolve(entry.entryPath))) { + dependencies.unshift(path.resolve(entry.entryPath)); + } + + this.updateEntryDependencies(entry.id, dependencies); + await this.persistMetadata(); + + this.compiledCount += 1; + + this.logger.log( + `[LESS] ✓ ${entry.id} (${reason ?? "recompiled"}) → ${pathToRepoRelative( + this.repoRoot, + entry.outputPath, + )}`, + ); + } + + clearDependencyMappings(entryId) { + const prevDeps = this.entryDependencies.get(entryId) ?? []; + for (const dep of prevDeps) { + const key = normalizePath(dep); + const set = this.dependencyToEntries.get(key); + if (set) { + set.delete(entryId); + if (set.size === 0) { + this.dependencyToEntries.delete(key); + if (this.dependencyWatcher) { + this.dependencyWatcher.unwatch(dep); + } + } + } + } + } + + linkDependency(entryId, dep, options = {}) { + const abs = path.resolve(dep); + const key = normalizePath(abs); + let set = this.dependencyToEntries.get(key); + if (!set) { + set = new Set(); + this.dependencyToEntries.set(key, set); + } + set.add(entryId); + + const shouldWatch = options.watch ?? false; + if ( + shouldWatch && + this.dependencyWatcher && + !this.isInsideKnownTarget(abs) + ) { + this.dependencyWatcher.add(abs); + } + return abs; + } + + updateEntryDependencies(entryId, dependencies) { + this.clearDependencyMappings(entryId); + + const uniqueDeps = []; + const seen = new Set(); + for (const dep of dependencies) { + const abs = path.resolve(dep); + const key = normalizePath(abs); + if (seen.has(key)) { + continue; + } + seen.add(key); + uniqueDeps.push(abs); + this.linkDependency(entryId, abs, { watch: true }); + } + + this.entryDependencies.set(entryId, uniqueDeps); + } + + isInsideKnownTarget(filePath) { + const absPath = path.resolve(filePath); + return this.targets.some( + (target) => + absPath === target.root || + absPath.startsWith(`${target.root}${path.sep}`), + ); + } + + async removeEntry(entryId) { + const entry = this.entries.get(entryId); + if (!entry) { + return; + } + this.entries.delete(entryId); + this.entryByPath.delete(normalizePath(entry.entryPath)); + this.clearDependencyMappings(entryId); + this.entryDependencies.delete(entryId); + if (fs.existsSync(entry.outputPath)) { + fs.unlinkSync(entry.outputPath); + } + if (fs.existsSync(`${entry.outputPath}.map`)) { + fs.unlinkSync(`${entry.outputPath}.map`); + } + await this.persistMetadata(); + this.logger.warn(`[LESS] removed entry ${entryId}`); + } + + async persistMetadata() { + const entries = {}; + for (const [entryId, deps] of this.entryDependencies.entries()) { + entries[entryId] = deps.map((dep) => + path.relative(this.repoRoot, dep).replace(/\\/g, "/"), + ); + } + writeJsonAtomic(this.metadataPath, { + version: this.metadataVersion, + entries, + }); + } + + primeDependencyWatcher() { + if (!this.dependencyWatcher) { + return; + } + + for (const deps of this.entryDependencies.values()) { + for (const dep of deps ?? []) { + if (!this.isInsideKnownTarget(dep)) { + this.dependencyWatcher.add(dep); + } + } + } + } +} + +module.exports = { LessWatchManager }; diff --git a/src/BloomBrowserUI/vite.config.mts b/src/BloomBrowserUI/vite.config.mts index dac69aa98036..1708c32fe236 100644 --- a/src/BloomBrowserUI/vite.config.mts +++ b/src/BloomBrowserUI/vite.config.mts @@ -13,12 +13,12 @@ import { glob } from "glob"; import react from "@vitejs/plugin-react"; import { viteStaticCopy } from "vite-plugin-static-copy"; import * as fs from "fs"; -import less from "less"; import MarkdownIt from "markdown-it"; import markdownItContainer from "markdown-it-container"; import markdownItAttrs from "markdown-it-attrs"; import { playwright } from "@vitest/browser-playwright"; import { compilePugFiles } from "./scripts/compilePug.mjs"; +import { compileLessFiles } from "./scripts/compileLess.mjs"; // Custom plugin to compile Pug files to HTML // There are a couple of npm packages for pug, but as of October 2025, they are experimental @@ -38,73 +38,14 @@ function compilePugPlugin(): Plugin { // Custom plugin to compile LESS files to CSS // Similar to pug plugin - compiles standalone LESS files to CSS with sourcemaps +// Handles both BloomBrowserUI and content LESS files // Claude sonnet 4.5 came up with this. function compileLessPlugin(): Plugin { return { name: "compile-less", apply: "build", async closeBundle() { - // Find LESS files in BloomBrowserUI - const lessFiles = glob.sync("./**/*.less", { - ignore: ["**/node_modules/**"], - }); - - console.log(`\nCompiling ${lessFiles.length} LESS files...`); - - const outputBase = path.resolve(__dirname, "../../output/browser"); - - for (const file of lessFiles) { - // Normalize path separators - const normalizedFile = file.replace(/\\/g, "/"); - - // Convert to output path: "./bookEdit/css/editMode.less" -> "bookEdit/css/editMode.css" - const relativePath = normalizedFile - .replace("./", "") - .replace(".less", ".css"); - - const outputFile = path.join(outputBase, relativePath); - const outputDir = path.dirname(outputFile); - - // Ensure output directory exists - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - try { - // Read LESS file - const lessContent = fs.readFileSync(file, "utf8"); - - // Compile LESS to CSS with sourcemap - const result = await less.render(lessContent, { - filename: file, - sourceMap: { - sourceMapFileInline: false, - outputSourceFiles: true, - sourceMapURL: path.basename(outputFile) + ".map", - }, - }); - - // Write CSS file with sourcemap reference - let cssOutput = result.css; - if (result.map) { - cssOutput += `\n/*# sourceMappingURL=${path.basename(outputFile)}.map */`; - } - fs.writeFileSync(outputFile, cssOutput); - - // Write sourcemap if generated - if (result.map) { - const mapFile = outputFile + ".map"; - fs.writeFileSync(mapFile, result.map); - } - - console.log(` ✓ ${file} → ${relativePath}`); - } catch (error) { - console.error(` ✗ Error compiling ${file}:`, error); - throw error; // Exit build on LESS compilation error - } - } - - console.log(`LESS compilation complete!\n`); + await compileLessFiles(); }, }; } @@ -501,6 +442,12 @@ ${injectedCss.map((call) => `(function() { ${call} })();`).join("\n")} // config, Node can still load ESM-only plugins (like @vitejs/plugin-react) via // native dynamic import instead of require(). export default defineConfig(async ({ command }) => { + const parsedPort = Number.parseInt(process.env.PORT ?? "", 10); + const devServerPort = + Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535 + ? parsedPort + : 5173; + // ENTRY POINTS CONFIGURATION // Define all JavaScript/TypeScript entry points - these are the "root" files that // Vite will build into separate bundles. Each entry becomes a standalone .js file @@ -564,7 +511,7 @@ export default defineConfig(async ({ command }) => { plugins: [ // React plugin: Enables JSX, Fast Refresh, and React-specific optimizations react({ - reactRefreshHost: `http://localhost:${process.env.PORT || 5173}`, + reactRefreshHost: `http://localhost:${devServerPort}`, babel: { parserOpts: { // This enables decorators like @mobxReact.observer. @@ -633,12 +580,13 @@ export default defineConfig(async ({ command }) => { // DEV SERVER CONFIGURATION // Controls the local development server behavior server: { - port: 5173, // Default Vite port + port: devServerPort, strictPort: true, // Fail if port is already in use (don't try other ports) hmr: { protocol: "ws", host: "localhost", // The host where your Vite server is running - port: 5173, // The port where your Vite server is running + port: devServerPort, + clientPort: devServerPort, overlay: true, }, }, diff --git a/src/BloomBrowserUI/yarn.lock b/src/BloomBrowserUI/yarn.lock index ee407ac4f839..e142e06819b6 100644 --- a/src/BloomBrowserUI/yarn.lock +++ b/src/BloomBrowserUI/yarn.lock @@ -1611,6 +1611,16 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@blakeembrey/deque@^1.0.5": + version "1.0.5" + resolved "https://registry.npmjs.org/@blakeembrey/deque/-/deque-1.0.5.tgz#f4fa17fc5ee18317ec01a763d355782c7b395eaf" + integrity sha512-6xnwtvp9DY1EINIKdTfvfeAtCYw4OqBZJhtiqkT3ivjnEfa25VQ3TsKvaFfKm8MyGIEfE95qLe+bNEt3nB0Ylg== + +"@blakeembrey/template@^1.0.0": + version "1.2.0" + resolved "https://registry.npmjs.org/@blakeembrey/template/-/template-1.2.0.tgz#acd948eb29334e882019e8876b42ff6da1dcf528" + integrity sha512-w/63nURdkRPpg3AXbNr7lPv6HgOuVDyefTumiXsbXxtIwcuk5EXayWR5OpSwDjsQPgaYsfUSedMduaNOjAYY8A== + "@chromatic-com/storybook@4.1.2": version "4.1.2" resolved "https://registry.yarnpkg.com/@chromatic-com/storybook/-/storybook-4.1.2.tgz#d9e6d5a552e125f40c78ac494706f0bd5c2147a2" @@ -3826,6 +3836,11 @@ anymatch@~3.1.1, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +arg@^4.1.3: + version "4.1.3" + resolved "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -4490,6 +4505,21 @@ chokidar@^1.6.0: optionalDependencies: fsevents "^1.0.0" +chokidar@^3.3.1, chokidar@^3.6.0: + version "3.6.0" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + chokidar@^3.4.0: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" @@ -4505,21 +4535,6 @@ chokidar@^3.4.0: optionalDependencies: fsevents "~2.3.1" -chokidar@^3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" - integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - chromatic@^12.0.0: version "12.2.0" resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-12.2.0.tgz#2f22865d66fa82d7c5565170f70eabb613223671" @@ -4876,7 +4891,7 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.1, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -8319,6 +8334,19 @@ once@^1.3.0: dependencies: wrappy "1" +onchange@^7.1.0: + version "7.1.0" + resolved "https://registry.npmjs.org/onchange/-/onchange-7.1.0.tgz#a6f0f7733e4d47014b4cd70aa1ad36c2b4cf3804" + integrity sha512-ZJcqsPiWUAUpvmnJri5TPBooqJOPmC0ttN65juhN15Q8xA+Nbg3BaxBHXQ45EistKKlKElb0edmbPWnKSBkvMg== + dependencies: + "@blakeembrey/deque" "^1.0.5" + "@blakeembrey/template" "^1.0.0" + arg "^4.1.3" + chokidar "^3.3.1" + cross-spawn "^7.0.1" + ignore "^5.1.4" + tree-kill "^1.2.2" + onecolor@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/onecolor/-/onecolor-2.5.0.tgz#2256b651dc807c101f00aedbd49925c57a4431c1" @@ -10727,6 +10755,11 @@ tr46@^5.1.0: dependencies: punycode "^2.3.1" +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + ts-api-utils@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" diff --git a/src/content/package.json b/src/content/package.json index a8c1cb3fd778..a8fc32f444e7 100644 --- a/src/content/package.json +++ b/src/content/package.json @@ -2,38 +2,39 @@ "license": "MIT", "scripts": { "build": "npm-run-all build:pug build:less build:branding:files build:templates:files build:appearance build:appearanceMigrations", - "watch": "npm-run-all watch:pug watch:less watch:branding:files", + "watch": "npm-run-all watch:pug watch:branding:files", + "watch:dev": "npm-run-all --parallel watch:branding:files watch:templates:files watch:appearance watch:appearanceMigrations", "//1": "TODO: currently we haven't figured this out with the simple build tools we're using here, so we're just building *all* pugs in this directory along with BloomBrowserUI/", "//2": "See https://github.com/Qard/onchange/issues/118", "//3": "The key to navigating these issues it to realize that the problem doesn't exist outside windows. Elsewhere, the terminal expands globs.", "//4": "I'm not making this actually do the BloomBrowserUI pug build, because it's currently part of the whole vite build, which turns around and builds this, so could be recursive.", - "build:pug": "echo \"Run a build in BloomBrowserUI to get pug files built.\"", + "build:pug": "node -e \"\"", "//NB we don't have a version of pug that handles globs, so we wrap with onchange": "", "//Notice that onchange can steer files to the right folder, which isn't doable with the command with built-in glob handling": "", "The -j option is how many jobs to do in parallel": "", "The -a option says to run them all before watching": "", "watch:pug": "onchange -j 9 -a \"**/*.pug\" -- pug {{file}} --pretty --out ../../output/browser/{{fileDir}}", "//": "______________________________________________________________________________________________________", - "build:pageSizes": "yarn ts-node pageSizes.ts", + "build:pageSizes": "ts-node pageSizes.ts", "build:less": "npm-run-all build:pageSizes build:less-inner", - "build:less-inner": "npm-run-all --parallel build:branding:less build:templates:less build:layout:less", + "build:less-inner": "node ../BloomBrowserUI/scripts/watchLess.mjs --scope=content --once", "// there is no current way to exclude node_modules, so we can't just do the whole directory.": "https://github.com/jonycheung/deadsimple-less-watch-compiler/issues/72", - "watch:less": "npm-run-all --parallel watch:branding:less watch:templates:less watch:layout:less", - "build:templates:files": "cpx \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" \"../../output/browser/templates\" -v", - "watch:templates:files": "cpx \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" \"../../output/browser/templates\" --watch -v", - "build:branding:files": "cpx \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm}\" ../../output/browser/branding -v", + "watch:less": "node ../BloomBrowserUI/scripts/watchLess.mjs --scope=content", + "build:templates:files": "cpx \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" \"../../output/browser/templates\"", + "watch:templates:files": "onchange -k -i -a \"templates/**/!(tsconfig).{png,jpg,svg,css,json,htm,html,txt,js,gif}\" -- node ./scripts/copyContentFile.mjs {{file}} templates templates", + "build:branding:files": "cpx \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm}\" ../../output/browser/branding", "build:branding:less": "less-watch-compiler --source-map --run-once branding ../../output/browser/branding", "build:templates:less": "less-watch-compiler --source-map --run-once templates ../../output/browser/templates", "build:layout:less": "less-watch-compiler --source-map --run-once bookLayout ../../output/browser/bookLayout", "build:layout:less:main": "lessc --source-map bookLayout/basePage.less ../../output/browser/bookLayout/basePage.css && lessc --source-map bookLayout/canvasElement.less ../../output/browser/bookLayout/canvasElement.css", - "build:appearance": "yarn rimraf ../../output/browser/appearanceThemes && cpx \"appearanceThemes/**/*.css\" ../../output/browser/appearanceThemes -v", - "watch:appearance": "cpx \"appearanceThemes/**/*.css\" ../../output/browser/appearanceThemes --watch -v", - "watch:branding:files": "cpx \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}\" ../../output/browser/branding --watch -v", - "build:appearanceMigrations": "cpx \"appearanceMigrations/**\" ../../output/browser/appearanceMigrations -v", - "watch:appearanceMigrations": "cpx \"appearanceMigrations/**\" ../../output/browser/appearanceMigrations --watch -v", - "watch:branding:less": "onchange -k -i -a \"branding/**/*.less\" -- npm run build:branding:less", - "watch:templates:less": "onchange -k -i -a \"templates/**/*.less\" -- npm run build:templates:less", - "watch:layout:less": "onchange -k -i -a \"bookLayout/**/*.less\" -- npm run build:layout:less:main", + "build:appearance": "rimraf ../../output/browser/appearanceThemes && cpx \"appearanceThemes/**/*.css\" ../../output/browser/appearanceThemes", + "watch:appearance": "onchange -k -i -a \"appearanceThemes/**/*.css\" -- node ./scripts/copyContentFile.mjs {{file}} appearanceThemes appearanceThemes", + "watch:branding:files": "onchange -k -i -a \"branding/**/!(source)/*.{png,jpg,svg,css,json,htm,html,txt,js}\" -- node ./scripts/copyContentFile.mjs {{file}} branding branding", + "build:appearanceMigrations": "cpx \"appearanceMigrations/**\" ../../output/browser/appearanceMigrations", + "watch:appearanceMigrations": "onchange -k -i -a \"appearanceMigrations/**\" -- node ./scripts/copyContentFile.mjs {{file}} appearanceMigrations appearanceMigrations", + "watch:branding:less": "onchange -k -i -a \"branding/**/*.less\" -- yarn build:branding:less", + "watch:templates:less": "onchange -k -i -a \"templates/**/*.less\" -- yarn build:templates:less", + "watch:layout:less": "onchange -k -i -a \"bookLayout/**/*.less\" -- yarn build:layout:less:main", "///": "______________________________________________________________________________________________________", "//This is slow compared to less-watch-compiler: watch:less": "onchange -a \"**/*.less\" -- lessc {{file}} ../../output/browser/{{fileDir}}", "//Note that this is really slow compared to cpx with the glob (buildBrandingFiles/watchBrandingFiles)": "//", diff --git a/src/content/scripts/copyContentFile.mjs b/src/content/scripts/copyContentFile.mjs new file mode 100644 index 000000000000..267e86b193b0 --- /dev/null +++ b/src/content/scripts/copyContentFile.mjs @@ -0,0 +1,94 @@ +/* eslint-env node */ +import path from "node:path"; +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const contentRoot = path.resolve(__dirname, ".."); +const outputRoot = path.resolve(contentRoot, "../../output/browser"); + +function needsCopy(sourceFile, destinationFile) { + if (!fs.existsSync(destinationFile)) { + return true; + } + + const sourceStat = fs.statSync(sourceFile); + const destinationStat = fs.statSync(destinationFile); + + if (sourceStat.size !== destinationStat.size) { + return true; + } + + return sourceStat.mtimeMs > destinationStat.mtimeMs; +} + +function copyContentFile(filePath, sourceBase, destinationBase, options = {}) { + const quiet = options.quiet ?? true; + if (!filePath || !sourceBase) { + return false; + } + + const absolutePath = path.resolve(filePath); + + if (!fs.existsSync(absolutePath)) { + return false; + } + + const stat = fs.statSync(absolutePath); + if (stat.isDirectory()) { + return false; + } + + const sourceBasePath = path.resolve(contentRoot, sourceBase); + if ( + !absolutePath.startsWith(sourceBasePath + path.sep) && + absolutePath !== sourceBasePath + ) { + return false; + } + + const relativePath = path.relative(sourceBasePath, absolutePath); + const destinationRoot = path.resolve(outputRoot, destinationBase || "."); + const destinationFile = path.join(destinationRoot, relativePath); + + if (!needsCopy(absolutePath, destinationFile)) { + return false; + } + + const destinationDir = path.dirname(destinationFile); + if (!fs.existsSync(destinationDir)) { + fs.mkdirSync(destinationDir, { recursive: true }); + } + + fs.copyFileSync(absolutePath, destinationFile); + + const fromDisplay = path + .relative(contentRoot, absolutePath) + .replace(/\\/g, "/"); + const toDisplay = path + .relative(outputRoot, destinationFile) + .replace(/\\/g, "/"); + + if (!quiet) { + console.log(` ✓ Copied ${fromDisplay} -> ${toDisplay}`); + } + return true; +} + +const invokedDirectly = + typeof process !== "undefined" && + typeof process.argv?.[1] === "string" && + path.basename(process.argv[1]) === "copyContentFile.mjs"; + +if (invokedDirectly) { + const args = process.argv.slice(2); + const verbose = args.includes("--verbose"); + const filtered = args.filter((arg) => arg !== "--verbose"); + const [filePath, sourceBase = ".", destinationBase = "."] = filtered; + copyContentFile(filePath, sourceBase, destinationBase, { + quiet: !verbose, + }); +} + +export { copyContentFile };