Skip to content
Merged
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 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run verify:build
- run: npm test
- run: npm run lint
- run: npm run typecheck
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ If using the docker compose services, the `OPENPROJECT_CORE` environment variabl
## Building

To build for the OpenProject core, run `npm run build` or `docker compose run --rm build`. This will override the
`frontend/src/vendor/ckeditor/*` contents in the core repository with the newest webpack build. You need to run this
`frontend/src/vendor/ckeditor/*` contents in the core repository with the newest build. You need to run this
before opening a pull request.

> [!IMPORTANT]
Expand All @@ -53,12 +53,12 @@ See https://community.openproject.org/work_packages/47084 for context.
- Run `npm run watch`
- Alternatively, run `docker compose up -d watch`

Now the webpack development mode is building the files and outputting them to `frontend/src/vendor/ckeditor/*` in the
Now esbuild is rebuilding the bundle on every change and writing it to `frontend/src/vendor/ckeditor/*` in the
core repository, overriding anything in there.

### Type checking

The source and tests are TypeScript, checked in strict mode. Webpack and jest strip types without checking them, so run
The source and tests are TypeScript, checked in strict mode. esbuild and jest strip types without checking them, so run
the checker separately:

```
Expand Down
62 changes: 62 additions & 0 deletions bin/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/* eslint-env node */

// Bundles the editor. Pass --watch to rebuild on change.

import fs from 'node:fs/promises';
import path from 'node:path';
import * as esbuild from 'esbuild';

const core = process.env.OPENPROJECT_CORE;

if (!core) {
throw new Error("Expected OPENPROJECT_CORE to be present, but wasn't.");
}

const root = path.resolve(import.meta.dirname, '..');
const outdir = path.resolve(core, 'frontend', 'src', 'vendor', 'ckeditor');
const production = process.env.NODE_ENV === 'production';
const watch = process.argv.includes('--watch');

const banner = `/*!
* @license Copyright (c) 2003-${new Date().getFullYear()}, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md.
*/`;

// Dependencies ship source maps that point at their own sources. Following
// them doubles the size of our source map, so map to the files we bundle.
const ignoreDependencySourceMaps = {
name: 'ignore-dependency-source-maps',
setup(build) {
build.onLoad({ filter: /[\\/]node_modules[\\/].*\.js$/ }, async (args) => {
const contents = await fs.readFile(args.path, 'utf8');

return { contents: contents.replace(/^\/\/# sourceMappingURL=.*$/gm, ''), loader: 'js' };
});
},
};

const options = {
entryPoints: [path.join(root, 'src', 'op-ckeditor.ts')],
outfile: path.join(outdir, 'ckeditor.js'),
bundle: true,
format: 'esm',
platform: 'browser',
target: 'es2022',
minify: production,
sourcemap: true,
// Keep the licence comments of bundled dependencies where they are.
legalComments: 'inline',
// Icons are imported as strings and inlined into templates.
loader: { '.svg': 'text' },
banner: { js: banner },
logLevel: 'info',
plugins: [ignoreDependencySourceMaps],
};

if (watch) {
const context = await esbuild.context(options);
await context.watch();
} else {
await esbuild.build(options);
}
8 changes: 0 additions & 8 deletions bin/clean.sh

This file was deleted.

102 changes: 102 additions & 0 deletions bin/verify-build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/* eslint-env node */

// Asserts that a directory holds a usable editor build. Without an argument,
// checks the build in the OpenProject core checkout.
// Usage: node bin/verify-build.mjs [build directory]

import fs from 'node:fs/promises';
import path from 'node:path';

// The webpack build was 1.3 MB. Fail well before a regression doubles it.
const MAX_BUNDLE_BYTES = 1_500_000;
// The webpack source map was 7.7 MB. It doubles when the bundler follows the
// source maps of dependencies back to their original sources.
const MAX_SOURCE_MAP_BYTES = 9_000_000;
const MIN_LOCALES = 50;

const core = process.env.OPENPROJECT_CORE;
const dir = process.argv[2]
?? (core && path.resolve(core, 'frontend', 'src', 'vendor', 'ckeditor'));

if (!dir) {
console.error('Usage: node bin/verify-build.mjs [build directory]');
console.error('Set OPENPROJECT_CORE to leave out the build directory.');
process.exit(1);
}

const failures = [];

async function read(file) {
try {
return await fs.readFile(path.join(dir, file), 'utf8');
} catch {
failures.push(`${file} is missing`);
return null;
}
}

const bundle = await read('ckeditor.js');
const sourceMap = await read('ckeditor.js.map');
const css = await read('ckeditor.css');

if (bundle !== null) {
const bytes = Buffer.byteLength(bundle);

if (bytes > MAX_BUNDLE_BYTES) {
failures.push(`ckeditor.js is ${bytes} bytes, over the ${MAX_BUNDLE_BYTES} budget`);
}
if (!bundle.startsWith('/*!')) {
failures.push('ckeditor.js does not start with the licence banner');
}
if (!bundle.includes('@license Copyright (c) 2003-')) {
failures.push('ckeditor.js lacks the CKSource licence notice');
}
for (const global of ['OPClassicEditor', 'OPConstrainedEditor', 'OPEditorWatchdog']) {
if (!bundle.includes(global)) {
failures.push(`ckeditor.js never assigns window.${global}`);
}
}
}

if (sourceMap !== null) {
const { sources } = JSON.parse(sourceMap);
const bytes = Buffer.byteLength(sourceMap);

if (bytes > MAX_SOURCE_MAP_BYTES) {
failures.push(`ckeditor.js.map is ${bytes} bytes, over the ${MAX_SOURCE_MAP_BYTES} budget`);
}

if (!sources.some((source) => source.endsWith('src/op-ckeditor.ts'))) {
failures.push('ckeditor.js.map does not map back to src/op-ckeditor.ts');
}
}

if (css !== null && css.trim().length === 0) {
failures.push('ckeditor.css is empty');
}

let locales = null;
try {
locales = (await fs.readdir(path.join(dir, 'translations')))
.filter((name) => /^[a-z-]+\.js$/.test(name) && name !== 'index.js');
} catch {
failures.push('translations/ is missing');
}

if (locales !== null && locales.length < MIN_LOCALES) {
failures.push(`translations/ holds ${locales.length} locales, expected at least ${MIN_LOCALES}`);
}
if (locales !== null && !locales.includes('de.js')) {
failures.push('translations/de.js is missing');
}

if (failures.length > 0) {
console.error(`Build in ${dir} is not usable:`);
for (const failure of failures) {
console.error(` - ${failure}`);
}
process.exit(1);
}

console.log(`Build OK (${Buffer.byteLength(bundle)} bytes, ${locales.length} locales)`);
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default [
ignores: ["tmp/", "coverage/", "node_modules/"],
},
{
files: ["jest.config.js", 'babel.config.js', 'webpack.config.js'],
files: ["jest.config.js", 'babel.config.js'],
languageOptions: {
globals: {
...globals.node
Expand Down
Loading
Loading