diff --git a/README.md b/README.md index 88a0176..a90de28 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,18 @@ This template gives you a simple web page to explore and change. You do not need to know Git or GitHub to get started. GitHub is where your project lives; the Copilot App is where you can open and work on it. -[See the live example](https://OWNER.github.io/REPO/) ยท -[Create your own copy](https://github.com/OWNER/REPO/generate) +[Create your own copy](https://github.com/jbj/typescript-static-template/generate) ## Start in the Copilot App 1. On GitHub, choose **Create your own copy** (or **Use this template**) and create a repository for your project. 2. Open that new repository in the Copilot App. -3. Install [Node.js 22.12 or newer](https://nodejs.org/en/download/) on your - Windows laptop. Node.js is the tool that runs this project. If the - Copilot App was open during installation, reopen its terminal. +3. Install the current Node.js LTS version from + [nodejs.org](https://nodejs.org/en/download/) on your Windows laptop. Choose + the Windows installer and accept its defaults. Node.js is the tool that runs + this project. If the Copilot App was open during installation, reopen its + terminal. 4. In the Copilot App terminal, run these commands: ```powershell @@ -28,30 +29,56 @@ the Copilot App is where you can open and work on it. `http://localhost:5173/`. Leave the command running while you work; press **Ctrl+C** in the terminal when you are done. -5. Open `src/main.ts` in the editor and change the greeting. Save the file to - see the page update in your browser. You can change its colors and layout in - `src/style.css`, and its browser-tab title in `index.html`. +5. To make a game, open the Copilot App chat for your repository and paste this + prompt. Copilot can edit the project files for you: + + ```text + Build a complete, fun browser game called Star Catcher in this project. Make + the changes in the files; don't just tell me how. + + The player moves a basket at the bottom of the screen to catch falling stars. + Use the left/right arrow keys and A/D keys on a keyboard, and add big on-screen + left/right buttons that work on a touchscreen. Catching a star adds a point. + A falling rock ends the game. Show the score, a clear game-over message, and + a button to play again. + + Use the project's existing TypeScript and Vite setup. Do not add packages, + call online services, or depend on images or sounds from the internet. Draw + the game with CSS or simple shapes. Make it work on a phone-sized screen and + a laptop, keep browser zoom working, use readable colors and text, and make + buttons usable by keyboard as well as touch. + + When you're done, run the project's checks. If full verification cannot + finish because Google Chrome isn't installed, explain that; don't let it + stop me from trying the game. Start the local preview if you can, try playing + the game, and fix any problems you find. Tell me what you changed, how to + play, and whether the checks and preview worked. + ``` + +6. Keep `npm run dev` running while you play. Open the address it printed in a + browser; Microsoft Edge is fine. When you want to stop the page, press + **Ctrl+C** in the terminal. -The page works on small screens, supports normal browser zoom, and has no -application services to set up. +You do not need to know how to code or use Git to ask Copilot to make the game. +The project has no application services to set up. ## When you are ready for more Run `npm run verify` to check the project, or `npm run build` to make its -publishable files. Full verification works on Windows and Linux and needs -Chrome or Chromium; setup details are in the -[code guide](docs/code.md#local-and-ci-environments). GitHub also runs the -checks for you when you make a change. The normal checks run on Linux even if -you are working on Windows; maintainers can manually run Windows verification to -check Windows too. +publishable files. Full local verification also needs Google Chrome; you can +still make and play the game without it. GitHub runs checks when you open or +update a pull request, or push changes to `main`. See the +[code guide](docs/code.md#local-and-ci-environments) for Chrome setup. The +normal checks run on Linux even if you are working on Windows; maintainers can +manually run Windows verification to check Windows too. ## For repository maintainers GitHub templates copy files, **not live repository settings**. 1. Rename the package with `npm pkg set name=your-app`, then update the lockfile - with `npm install --package-lock-only`. Replace `OWNER` and `REPO` in this - README and [ruleset instructions](.github/rulesets/README.md), the path + with `npm install --package-lock-only`. Update the repository-specific + examples in the [ruleset instructions](.github/rulesets/README.md), the path example in `vite.config.ts`, and the owner in `.github/CODEOWNERS`. 2. Update the [working agreement](.github/copilot-instructions.md) and selected [documentation topics](docs/) for your app. Keep the original MIT copyright diff --git a/docs/code.md b/docs/code.md index 8a61b9c..77b36f4 100644 --- a/docs/code.md +++ b/docs/code.md @@ -10,8 +10,6 @@ definition of done. - Let the repository's format command handle code style. Markdown is hand-wrapped as described in [`documentation.md`](documentation.md). -- Treat bundle limits as constraints, not targets to move quietly. The budget - checker owns current values and reports the evidence needed to change them. - A check that parallelizes must honor the resource budget supplied by the verify runner rather than discovering and consuming the whole machine. diff --git a/package.json b/package.json index 120c3f6..5ebbaa1 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && npm run budget", - "budget": "node scripts/check-bundle.mjs", + "build": "tsc --noEmit && vite build", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs deleted file mode 100644 index a9f0268..0000000 --- a/scripts/check-bundle.mjs +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Budgets the actual Vite import graph, including initial HTML and CSS. - * The report stays in .art/, never in the published site. - */ -import { readFileSync } from "node:fs"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { createReport } from "./report.mjs"; - -const REPORT = fileURLToPath(new URL("../.art/bundle.json", import.meta.url)); -const REPORT_NAME = ".art/bundle.json"; -const kB = 1000; - -// Preserve the source template's limits. Growth needs an explicit explanation, -// not a quiet budget increase to turn a failing build green. -export const BUDGET = { - initialRaw: 120 * kB, - initialGzip: 40 * kB, - totalRaw: 194 * kB, - totalGzip: 66 * kB, -}; - -export function measureBundle(report) { - const byFile = new Map(report.files.map((file) => [file.file, file])); - const entries = report.files.filter((file) => file.isEntry); - if (entries.length === 0) throw new Error("The bundle report contains no entry chunk."); - const initialNames = new Set(); - const queue = entries.map((entry) => entry.file); - while (queue.length > 0) { - const name = queue.pop(); - if (initialNames.has(name)) continue; - const file = byFile.get(name); - if (!file) throw new Error(`The bundle report references missing asset ${name}.`); - initialNames.add(name); - queue.push(...file.imports, ...file.css); - } - for (const file of report.files) { - if (file.file.endsWith(".html")) initialNames.add(file.file); - } - const initial = report.files.filter((file) => initialNames.has(file.file)); - const sum = (files, key) => files.reduce((total, file) => total + file[key], 0); - return { - initial, - deferred: report.files.filter((file) => !initialNames.has(file.file)), - measurements: { - initialRaw: sum(initial, "bytes"), - initialGzip: sum(initial, "gzip"), - totalRaw: sum(report.files, "bytes"), - totalGzip: sum(report.files, "gzip"), - }, - }; -} - -function main() { - const result = createReport("bundle budget"); - let report; - try { - report = JSON.parse(readFileSync(REPORT, "utf8")); - } catch (error) { - if (error.code !== "ENOENT") throw error; - result.check(REPORT_NAME, "bundle report exists", "missing; run `npm run build` first"); - result.finish(); - return; - } - const { initial, deferred, measurements } = measureBundle(report); - const size = (bytes) => `${(bytes / kB).toFixed(1)} kB`; - for (const [label, files] of [ - ["loaded first", initial], - ["deferred", deferred], - ]) { - result.detail(label); - for (const file of files) { - result.detail(` ${file.file}: ${size(file.bytes)} raw, ${size(file.gzip)} gzipped`); - } - } - const over = []; - for (const [label, used] of Object.entries(measurements)) { - const budget = BUDGET[label]; - if (used > budget) over.push(label); - result.check( - REPORT_NAME, - `${label}: ${size(used)} of ${size(budget)}`, - used <= budget ? [] : `${size(used - budget)} over budget`, - ); - } - if (over.length > 0) { - const files = over.some((label) => label.startsWith("initial")) ? initial : report.files; - result.detail("Largest modules (before minification; comparable, not additive):"); - for (const module of files - .flatMap((file) => file.modules.map((module) => ({ ...module, file: file.file }))) - .sort((a, b) => b.bytes - a.bytes) - .slice(0, 6)) { - result.detail(` ${module.id}: ${size(module.bytes)} in ${module.file}`); - } - result.detail("Reduce the bundle, or explain in the pull request why a budget must change."); - } - result.finish("Within budget."); -} - -if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main(); diff --git a/scripts/verify.mjs b/scripts/verify.mjs index 958f57a..4cc509e 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -5,8 +5,7 @@ * chain made the five-minute browser run wait for every short check before it * could begin, while tools with their own workers could assume they owned the * whole machine. This runner gives every task an explicit cost and keeps the - * only real build edges visible: the budget reads Vite's report, and the shot - * run serves Vite's `dist/`. + * only real build edge visible: the shot run serves Vite's `dist/`. * * Output is held per task. A passing run says one line unless asked for * `--verbose`; a failure prints only the failed tasks in fixed order, so the @@ -34,7 +33,7 @@ const FIX_COMMANDS = new Map([ ["format:check", "npm run format"], ]); const CHEAP_TASKS = new Set(["typecheck", "lint", "format:check", "docs:check"]); -const VERBOSE_TASKS = new Set(["docs:check", "budget", "shot"]); +const VERBOSE_TASKS = new Set(["docs:check", "shot"]); export function verifyTasks(cpuCapacity, browserCapacity) { const shotCpuSlots = Math.min(4, browserCapacity, Math.max(1, cpuCapacity - 2)); @@ -79,23 +78,16 @@ export function verifyTasks(cpuCapacity, browserCapacity) { slots: { cpu: testCpuSlots, browser: 0 }, }, { - name: "bundle", + name: "build", run: packageTool("vite", "bin/vite.js", "build"), needs: [], inputs: [], slots: { cpu: 1, browser: 0 }, }, - { - name: "budget", - run: script("check-bundle.mjs"), - needs: ["bundle"], - inputs: [], - slots: { cpu: 1, browser: 0 }, - }, { name: "shot", run: script("shot.mjs"), - needs: ["bundle"], + needs: ["build"], inputs: [], // Each screenshot worker owns one browser and one CPU share. slots: { cpu: shotCpuSlots, browser: shotCpuSlots }, diff --git a/tests/bundle.test.mjs b/tests/bundle.test.mjs deleted file mode 100644 index b19f23d..0000000 --- a/tests/bundle.test.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { BUDGET, measureBundle } from "../scripts/check-bundle.mjs"; - -const file = (name, fields = {}) => ({ - file: name, - bytes: 100, - gzip: 30, - isEntry: false, - imports: [], - dynamicImports: [], - css: [], - ...fields, -}); - -describe("bundle budgets", () => { - it("preserves the inherited raw and gzip ceilings", () => { - expect(BUDGET).toEqual({ - initialRaw: 120000, - initialGzip: 40000, - totalRaw: 194000, - totalGzip: 66000, - }); - }); - - it("counts static imports, CSS and HTML once, leaving dynamic chunks deferred", () => { - const result = measureBundle({ - files: [ - file("entry.js", { - isEntry: true, - imports: ["shared.js"], - css: ["main.css"], - dynamicImports: ["later.js"], - }), - file("shared.js", { imports: ["entry.js"], css: ["main.css"] }), - file("main.css"), - file("index.html"), - file("later.js"), - ], - }); - expect(result.initial.map(({ file }) => file)).toEqual([ - "entry.js", - "shared.js", - "main.css", - "index.html", - ]); - expect(result.deferred.map(({ file }) => file)).toEqual(["later.js"]); - expect(result.measurements).toEqual({ - initialRaw: 400, - initialGzip: 120, - totalRaw: 500, - totalGzip: 150, - }); - }); - - it("counts every entry in a multi-entry app", () => { - expect( - measureBundle({ - files: [file("one.js", { isEntry: true }), file("two.js", { isEntry: true })], - }).measurements.initialRaw, - ).toBe(200); - }); - - it("rejects empty discovery and broken static references", () => { - expect(() => measureBundle({ files: [] })).toThrow("no entry chunk"); - expect(() => - measureBundle({ files: [file("entry.js", { isEntry: true, imports: ["missing.js"] })] }), - ).toThrow("missing asset missing.js"); - }); -}); diff --git a/tests/verify.test.mjs b/tests/verify.test.mjs index 8650e53..b3a8841 100644 --- a/tests/verify.test.mjs +++ b/tests/verify.test.mjs @@ -100,12 +100,10 @@ describe("the verify runner", () => { "format:check", "docs:check", "test", - "bundle", - "budget", + "build", "shot", ]); - expect(tasks.find(({ name }) => name === "shot").needs).toEqual(["bundle"]); - expect(tasks.find(({ name }) => name === "budget").needs).toEqual(["bundle"]); + expect(tasks.find(({ name }) => name === "shot").needs).toEqual(["build"]); expect(tasks.filter(({ needs }) => needs.length === 0)).toHaveLength(6); }); diff --git a/vite.config.ts b/vite.config.ts index a36646e..62bfb48 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,68 +1,4 @@ -import { gzipSync } from "node:zlib"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { defineConfig, type Plugin } from "vite"; - -/** - * Writes what the build actually produced, so `scripts/check-bundle.mjs` can - * hold it to a budget without having to re-derive any of it. - * - * The initial download is the entry chunk plus everything it imports - * *statically*, and it is only knowable here, where the import graph is - hence - * `imports` and `dynamicImports` per chunk rather than a flat list of files. - * - * `modules` is what each chunk is made of, so a failure can say what grew - * rather than only that something did. Those lengths are measured after - * tree-shaking but before minification, which makes them good for comparing - * modules with each other and wrong for adding up to the file size. - * - * The report goes to `.art/`, not to `dist/`: it is a thing for whoever is - * working on the application, not something that should be deployed. - */ -function bundleReport(): Plugin { - return { - name: "bundle-report", - apply: "build", - writeBundle(options, bundle) { - const outDir = options.dir ?? "dist"; - const root = resolve(outDir, ".."); - const files = []; - - for (const [fileName, output] of Object.entries(bundle)) { - const bytes = readFileSync(join(outDir, fileName)); - const chunk = output.type === "chunk" ? output : null; - files.push({ - file: fileName, - kind: output.type, - isEntry: chunk?.isEntry ?? false, - bytes: bytes.length, - gzip: gzipSync(bytes, { level: 9 }).length, - imports: chunk?.imports ?? [], - dynamicImports: chunk?.dynamicImports ?? [], - // The stylesheet a chunk pulls in with it. Vite links the entry's in - // the head, so that one is downloaded before the first paint too. - css: [...(chunk?.viteMetadata?.importedCss ?? [])], - modules: chunk - ? Object.entries(chunk.modules) - .map(([id, module]) => ({ - id: relative(root, id).replaceAll("\\", "/"), - bytes: module.renderedLength, - })) - .filter((module) => module.bytes > 0) - .sort((a, b) => b.bytes - a.bytes) - : [], - }); - } - - const path = join(root, ".art", "bundle.json"); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync( - path, - `${JSON.stringify({ generatedAt: new Date().toISOString(), outDir, files }, null, 2)}\n`, - ); - }, - }; -} +import { defineConfig } from "vite"; /** * The build is deliberately path-agnostic. @@ -76,5 +12,4 @@ function bundleReport(): Plugin { */ export default defineConfig({ base: "./", - plugins: [bundleReport()], });