diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29fba5bab..f00dec533 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: ci on: push: branches: [master, next] - paths-ignore: ['**.md'] + paths-ignore: ["**.md"] pull_request: {} workflow_dispatch: {} concurrency: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 4b049852d..d54ca6dc7 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -5,7 +5,7 @@ on: pull_request: {} workflow_dispatch: {} schedule: - - cron: '28 6 * * 4' + - cron: "28 6 * * 4" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.head.label || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/.github/workflows/semgrep-analysis.yml b/.github/workflows/semgrep-analysis.yml index fe659b69f..1ae40099b 100644 --- a/.github/workflows/semgrep-analysis.yml +++ b/.github/workflows/semgrep-analysis.yml @@ -5,7 +5,7 @@ on: pull_request: {} workflow_dispatch: {} schedule: - - cron: '28 6 * * 4' + - cron: "28 6 * * 4" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.head.label || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/biome.jsonc b/biome.jsonc index 118c702fc..ebafabd54 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -117,6 +117,9 @@ }, "style": { "noNamespaceImport": "off" + }, + "nursery": { + "noDynamicNamespaceImportAccess": "off" } } }, diff --git a/build.ts b/build.ts index 4fb63ed30..8bab4378b 100644 --- a/build.ts +++ b/build.ts @@ -48,11 +48,9 @@ function compileCSS(src: string, from: string) { } /** - * Construct HTML and CSS files and save them to disk. + * Construct HTML file and save it to disk. */ -async function makeHTML(pageName: string, stylePath: string) { - const styleSrc = await Bun.file(stylePath).text(); - const css = compileCSS(styleSrc, stylePath); +async function makeHTML(pageName: string) { const template = ` @@ -64,10 +62,18 @@ async function makeHTML(pageName: string, stylePath: string) { .trim() .replaceAll(/\n\s+/g, '\n'); // remove leading whitespace - await Bun.write(`dist/${pageName}.css`, css); await Bun.write(`dist/${pageName}.html`, template); } +/** + * Construct CSS file and save it to disk. + */ +async function makeCSS(pageName: string, cssEntrypoint: string) { + const cssSource = await Bun.file(cssEntrypoint).text(); + const css = compileCSS(cssSource, cssEntrypoint); + await Bun.write(`dist/${pageName}.css`, css); +} + /** * Compile all themes, combine into a single JSON file, and save it to disk. */ @@ -86,29 +92,37 @@ async function makeThemes() { await Bun.write('dist/themes.json', JSON.stringify(themes)); } -async function minifyJS(artifact: BuildArtifact) { - let source = await artifact.text(); - - // Improve collapsing variables; terser doesn't do this so we do it manually. - source = source.replaceAll('const ', 'let '); - - const result = await terser.minify(source, { - ecma: 2020, - module: true, - compress: { - reduce_funcs: false, // prevent functions being inlined - // XXX: Comment out to keep performance markers for debugging. - pure_funcs: ['performance.mark', 'performance.measure'], - passes: 3, - }, - mangle: { - properties: { - regex: /^\$\$|^__click$/, - }, - }, - }); - - await Bun.write(artifact.path, result.code ?? ''); +async function minifyJS(artifacts: BuildArtifact[]) { + for (const artifact of artifacts) { + if (artifact.kind === 'entry-point' || artifact.kind === 'chunk') { + const source = await artifact.text(); + const result = await terser.minify(source, { + ecma: 2020, + module: true, + compress: { + comparisons: false, + negate_iife: false, + reduce_funcs: false, // prevent function inlining + passes: 3, + // XXX: Comment out to keep performance markers for debugging. + pure_funcs: ['performance.mark', 'performance.measure'], + }, + mangle: { + properties: { + regex: /^\$\$|^__click$/, + }, + }, + format: { + wrap_func_args: true, + wrap_iife: true, + }, + }); + + if (result.code) { + await Bun.write(artifact.path, result.code); + } + } + } } console.time('prebuild'); @@ -121,29 +135,35 @@ console.time('manifest'); await Bun.write('dist/manifest.json', JSON.stringify(createManifest())); console.timeEnd('manifest'); -console.time('html+css'); -await makeHTML('newtab', 'src/css/newtab.xcss'); -await makeHTML('settings', 'src/css/settings.xcss'); -console.timeEnd('html+css'); +console.time('html'); +await makeHTML('newtab'); +await makeHTML('settings'); +console.timeEnd('html'); + +console.time('css'); +await makeCSS('newtab', 'src/css/newtab.xcss'); +await makeCSS('settings', 'src/css/settings.xcss'); +console.timeEnd('css'); console.time('themes'); await makeThemes(); console.timeEnd('themes'); // New Tab & Settings apps -console.time('build'); -const out = await Bun.build({ +console.time('build:1'); +const out1 = await Bun.build({ entrypoints: ['src/newtab.ts', 'src/settings.ts'], outdir: 'dist', target: 'browser', minify: !dev, sourcemap: dev ? 'linked' : 'none', }); -console.timeEnd('build'); -console.log(out); +console.timeEnd('build:1'); +console.log(out1.outputs); +if (!out1.success) throw new AggregateError(out1.logs, 'Build failed'); // Background service worker script -console.time('build2'); +console.time('build:2'); const out2 = await Bun.build({ entrypoints: ['src/sw.ts'], outdir: 'dist', @@ -151,13 +171,13 @@ const out2 = await Bun.build({ minify: !dev, sourcemap: dev ? 'linked' : 'none', }); -console.timeEnd('build2'); -console.log(out2); +console.timeEnd('build:2'); +console.log(out2.outputs); +if (!out2.success) throw new AggregateError(out2.logs, 'Build failed'); if (!dev) { - console.time('minify'); - await minifyJS(out.outputs[0]); - await minifyJS(out.outputs[1]); - await minifyJS(out2.outputs[0]); - console.timeEnd('minify'); + console.time('minify:js'); + await minifyJS(out1.outputs); + await minifyJS(out2.outputs); + console.timeEnd('minify:js'); } diff --git a/bun.lockb b/bun.lockb index e26e5438b..30fb9bf1c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/manifest.config.ts b/manifest.config.ts index 893ee0788..4d42ccc60 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -17,6 +17,7 @@ function gitRef() { } // FIXME: Remove these once @types/chrome is updated. +// https://developer.chrome.com/docs/extensions/mv3/cross-origin-isolation/ interface ManifestExtra { /** https://developer.chrome.com/docs/extensions/mv3/manifest/cross_origin_embedder_policy/ */ cross_origin_embedder_policy?: { @@ -35,7 +36,7 @@ export const createManifest = ( name: 'New Tab', description: pkg.description, homepage_url: pkg.homepage, - version: pkg.version, + version: pkg.version.split('-')[0], // shippable releases should not have a named version version_name: debug ? gitRef() : undefined, minimum_chrome_version: '123', // for light-dark() CSS function @@ -68,10 +69,10 @@ export const createManifest = ( content_security_policy: { extension_pages: [ "default-src 'none'", + "base-uri 'none'", "script-src 'self'", "style-src 'self'", "img-src 'self'", - "base-uri 'none'", '', // include trailing semicolon ].join(';'), }, diff --git a/package.json b/package.json index eacc820fa..c36bd24ac 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "new-tab", "version": "0.23.0", "description": "⚡ A high-performance new tab page that gets you where you need to go faster.", - "repository": "maxmilton/new-tab", + "repository": "github:maxmilton/new-tab", + "bugs": "https://github.com/maxmilton/new-tab/issues", "homepage": "https://github.com/maxmilton/new-tab", "author": "Max Milton ", "license": "MIT", @@ -19,27 +20,27 @@ "test:e2e": "playwright test" }, "dependencies": { - "stage1": "0.8.0-next.13" + "stage1": "0.8.0-next.16" }, "devDependencies": { "@biomejs/biome": "1.9.4", "@ekscss/plugin-import": "0.0.15", - "@eslint/js": "9.14.0", - "@maxmilton/eslint-config": "0.0.7", + "@eslint/js": "9.17.0", + "@maxmilton/eslint-config": "0.0.8", "@maxmilton/stylelint-config": "0.1.2", "@maxmilton/test-utils": "0.0.6", - "@playwright/test": "1.48.2", - "@types/bun": "1.1.13", - "@types/chrome": "0.0.280", + "@playwright/test": "1.49.1", + "@types/bun": "1.1.14", + "@types/chrome": "0.0.287", "ekscss": "0.0.20", - "eslint": "9.14.0", - "eslint-plugin-unicorn": "56.0.0", - "happy-dom": "15.11.0", - "lightningcss": "1.28.1", - "stylelint": "16.10.0", + "eslint": "9.17.0", + "eslint-plugin-unicorn": "56.0.1", + "happy-dom": "15.11.7", + "lightningcss": "1.28.2", + "stylelint": "16.11.0", "stylelint-config-standard": "36.0.1", - "terser": "5.36.0", - "typescript": "5.6.3", - "typescript-eslint": "8.13.0" + "terser": "5.37.0", + "typescript": "5.7.2", + "typescript-eslint": "8.18.0" } } diff --git a/src/components/BookmarkBar.ts b/src/components/BookmarkBar.ts index 0e474be6d..a993f0ebf 100644 --- a/src/components/BookmarkBar.ts +++ b/src/components/BookmarkBar.ts @@ -48,6 +48,7 @@ export const BookmarkBar = (): BookmarkBarComponent => { let index = 0; let node: ReturnType; + // Add one bookmark at a time until we overflow the max width for (; index < len; index++) { node = append(BookmarkNode(bookmarks[index]), root); width += node.clientWidth; @@ -131,3 +132,8 @@ export const BookmarkBar = (): BookmarkBarComponent => { return root; }; + +// // Improve performance of lookups on DOM nodes +// // @ts-expect-error -- add new properties to HTMLElement +// // eslint-disable-next-line no-multi-assign +// Element.prototype.__mouseover = Element.prototype.__mouseout = undefined; diff --git a/src/components/SearchResult.ts b/src/components/SearchResult.ts index 41235ca50..ef599cc11 100644 --- a/src/components/SearchResult.ts +++ b/src/components/SearchResult.ts @@ -94,9 +94,7 @@ export const SearchResult = ( root.$$filter = (text) => renderList( rawData.filter((item) => - (item.title + '[' + item.url) - .toLowerCase() - .includes(text.toLowerCase()), + (item.title + item.url).toLowerCase().includes(text.toLowerCase()), ), ); diff --git a/src/newtab.ts b/src/newtab.ts index 3b889713a..ffdcc9867 100644 --- a/src/newtab.ts +++ b/src/newtab.ts @@ -1,7 +1,7 @@ // Theme loader code must run first import './theme'; -import { append, createFragment } from 'stage1'; +import { append, fragment } from 'stage1'; import { BookmarkBar } from './components/BookmarkBar'; import { Menu } from './components/Menu'; import { Search } from './components/Search'; @@ -9,16 +9,16 @@ import { handleClick, storage } from './utils'; performance.mark('Initialise Components'); -const frag = createFragment(); +const container = fragment(); // Create Search component first because it has asynchronous calls that can // execute while the remaining components are constructed -append(Search(), frag); +append(Search(), container); // Create BookmarkBar component near last because, after an async call, it needs // to synchronously and sequentially calculate DOM layout multiple times and // could cause reflow in extreme situations, so paint the rest of the app first -if (!storage.b) append(BookmarkBar(), frag); -append(Menu(), frag); -append(frag, document.body); +if (!storage.b) append(BookmarkBar(), container); +append(Menu(), container); +append(container, document.body); performance.measure('Initialise Components', 'Initialise Components'); diff --git a/src/settings.ts b/src/settings.ts index 8a9dbd115..63501ce70 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -14,6 +14,7 @@ interface SettingsState { type ItemIndex = [listIndex: 0 | 1, itemIndex: number]; +/** Section drag-and-drop helper functions */ interface SectionScope { indexOf(list: 0 | 1, item: SectionOrderItem): number; moveItem(from: ItemIndex, to: ItemIndex): void; @@ -24,7 +25,7 @@ const DEFAULT_THEME = 'auto'; // eslint-disable-next-line unicorn/prefer-top-level-await const themesData = fetch('themes.json').then( - (res) => res.json() as Promise, + (response) => response.json() as Promise, ); type SectionComponent = HTMLLIElement; @@ -137,7 +138,7 @@ const meta = compile(`
- DISPLAY ORDER + DISPLAY (in order)
    diff --git a/src/types.ts b/src/types.ts index 530224b11..777bc4bf0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,9 @@ declare global { } } +/** JSON object with theme name keys and raw CSS code values. */ +export type ThemesData = Record; + export type SectionOrderItem = (typeof DEFAULT_SECTION_ORDER)[number]; export interface UserStorageData { @@ -20,6 +23,3 @@ export interface UserStorageData { /** Sections order user preference. */ o?: SectionOrderItem[]; } - -/** JSON object with theme name keys and raw CSS code values. */ -export type ThemesData = Record; diff --git a/src/utils.ts b/src/utils.ts index 3869d9f2b..b60aad2fe 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import type { UserStorageData } from './types'; performance.mark('Load Storage'); -export const storage: UserStorageData = await chrome.storage.local.get(); +export const storage = await chrome.storage.local.get(); // NOTE: When updating also update references that lookup items by index export const DEFAULT_SECTION_ORDER = [ @@ -17,7 +17,7 @@ declare const s: HTMLInputElement; // Simplified synthetic click event implementation of setupSyntheticEvent() from // stage1, plus special handling for browser internal links (e.g. chrome://) -// https://github.com/maxmilton/stage1/blob/master/src/events.ts +// @see https://github.com/maxmilton/stage1/blob/master/src/events.ts // eslint-disable-next-line @typescript-eslint/no-invalid-void-type, consistent-return export const handleClick = (event: MouseEvent): false | void => { let node = event.target as diff --git a/test/e2e/fixtures.ts b/test/e2e/fixtures.ts index 4bf35c77d..529961f22 100644 --- a/test/e2e/fixtures.ts +++ b/test/e2e/fixtures.ts @@ -13,7 +13,7 @@ export const test = baseTest.extend<{ async context({}, use) { const extensionPath = path.join(__dirname, '../../dist'); const context = await chromium.launchPersistentContext('', { - // headless: false, + headless: false, args: [ '--headless=new', // chromium 112+ // '--virtual-time-budget=5000', // chromium 112+, fast-forward timers