Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,280 changes: 1,278 additions & 2 deletions src/BloomBrowserUI/Readium/reader.css

Large diffs are not rendered by default.

18 changes: 9 additions & 9 deletions src/BloomBrowserUI/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "yarn build:clean && npm-run-all --parallel 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/!": " ",
Expand Down Expand Up @@ -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",
Expand Down
67 changes: 67 additions & 0 deletions src/BloomBrowserUI/scripts/__tests__/compilePug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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 { 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");
});
});
188 changes: 188 additions & 0 deletions src/BloomBrowserUI/scripts/__tests__/watchLess.test.mjs
Original file line number Diff line number Diff line change
@@ -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;
Copy link

Copilot AI Dec 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable baselineMTime is declared with const but reassigned on line 124. This should be declared with let instead of const to allow reassignment.

Suggested change
const baselineMTime = fs.statSync(cssPath).mtimeMs;
let baselineMTime = fs.statSync(cssPath).mtimeMs;

Copilot uses AI. Check for mistakes.

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);
});
});
Loading