@half-built/tooling 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Curt Henrichs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @half-built/tooling
2
+
3
+ Lint presets and the test kit for half-built sites.
4
+
5
+ ## Test kit
6
+
7
+ `test-kit/browser-server.ts` type-imports and dynamically imports
8
+ `puppeteer-core` for `launchChrome`; it is an optional peer dependency,
9
+ so consumers of the test kit install `puppeteer-core` themselves.
10
+
11
+ ## Provenance
12
+
13
+ Extracted from the private `half-built-robots-blog` repository, where
14
+ this configuration was built and used in production. The extraction
15
+ review that checked it for blog-specific assumptions before the move is
16
+ the design record for this package.
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@half-built/tooling",
3
+ "version": "0.1.0",
4
+ "description": "Lint presets and the test kit for half-built sites.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": ["src"],
8
+ "exports": {
9
+ "./eslint": "./src/eslint.config.mjs",
10
+ "./stylelint": "./src/stylelintrc.json",
11
+ "./htmlvalidate": "./src/htmlvalidate.json",
12
+ "./test-kit/*": "./src/test-kit/*"
13
+ },
14
+ "dependencies": {
15
+ "@eslint/js": "^10.0.1",
16
+ "typescript-eslint": "^8.65.0",
17
+ "eslint-plugin-astro": "^1.7.0",
18
+ "globals": "^17.8.0"
19
+ },
20
+ "peerDependencies": {
21
+ "eslint": "^10.8.0",
22
+ "stylelint": "^17.14.1",
23
+ "html-validate": "^9.7.1",
24
+ "vitest": "^2.1.9",
25
+ "puppeteer-core": "^25.8.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "puppeteer-core": { "optional": true }
29
+ },
30
+ "publishConfig": { "access": "public" }
31
+ }
@@ -0,0 +1,77 @@
1
+ // Flat ESLint config: JS/TS plus Astro component linting, shared across
2
+ // half-built packages and any site that consumes them. Adapted from the
3
+ // half-built-robots-blog repo's eslint.config.mjs (step 11.3 task 5):
4
+ // the blog-only ignores (.superpowers/, .claude/, .visual-check/, the
5
+ // vendored fluid-sim.js exemption) are gone, since none of that exists
6
+ // outside the blog, and the file-scoped overrides below are widened from
7
+ // root-anchored globs to "**/"-prefixed ones so they still find
8
+ // scripts/, test/, and src/pages/ wherever a package or site nests them
9
+ // in this monorepo, not only at repo root.
10
+ import js from "@eslint/js";
11
+ import tseslint from "typescript-eslint";
12
+ import eslintPluginAstro from "eslint-plugin-astro";
13
+ import globals from "globals";
14
+
15
+ export default tseslint.config(
16
+ { ignores: ["**/dist/", "**/node_modules/", "**/.astro/", "**/public/"] },
17
+ js.configs.recommended,
18
+ ...tseslint.configs.recommended,
19
+ {
20
+ // Type-checked strict tier for real TypeScript (libs, tests, config).
21
+ // Astro virtual scripts and .mjs tooling stay on recommended: typed
22
+ // linting needs the project service, which does not cover them.
23
+ files: ["**/*.ts"],
24
+ extends: [
25
+ ...tseslint.configs.strictTypeChecked,
26
+ ...tseslint.configs.stylisticTypeChecked,
27
+ ],
28
+ languageOptions: {
29
+ parserOptions: {
30
+ projectService: true,
31
+ // Not import.meta.dirname: the blog's original pinned this to
32
+ // wherever eslint.config.mjs itself lived, which was correct for
33
+ // a single-repo config at the repo root but breaks the moment
34
+ // this config ships in a package. import.meta.dirname would then
35
+ // resolve inside node_modules/@half-built/tooling/src, and
36
+ // TypeScript's project service treats that as an upper bound: it
37
+ // will not walk up past it looking for a consumer's tsconfig.json,
38
+ // so every consumer's typed linting would silently fail (and, in
39
+ // this repo, so does packages/tooling's own test-kit, since
40
+ // its tsconfig.json sits one directory above this file). cwd is
41
+ // wherever eslint was invoked from, which for a shared preset is
42
+ // the actual project root every time.
43
+ tsconfigRootDir: process.cwd(),
44
+ },
45
+ },
46
+ rules: {
47
+ // Numbers interpolate into template literals losslessly; forbidding
48
+ // them buys String() noise, not safety.
49
+ "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
50
+ },
51
+ },
52
+ ...eslintPluginAstro.configs.recommended,
53
+ {
54
+ files: ["**/scripts/**/*.mjs"],
55
+ languageOptions: { globals: { ...globals.node } },
56
+ },
57
+ {
58
+ // This file itself: it reads process.cwd() above, and any consumer's
59
+ // eslint.config.mjs that imports this preset runs in Node too.
60
+ files: ["**/eslint.config.mjs"],
61
+ languageOptions: { globals: { ...globals.node } },
62
+ },
63
+ {
64
+ files: ["**/test/**/*.ts"],
65
+ languageOptions: { globals: { ...globals.node } },
66
+ },
67
+ {
68
+ // Astro endpoints run at build time in Node with the web Response global.
69
+ files: ["**/src/pages/**/*.js"],
70
+ languageOptions: { globals: { ...globals.node, Response: "readonly" } },
71
+ },
72
+ {
73
+ // Client-side <script> blocks inside Astro components run in the browser.
74
+ files: ["**/*.astro/*.js", "**/*.astro/*.ts", "**/src/pages/**/*.astro"],
75
+ languageOptions: { globals: { ...globals.browser } },
76
+ }
77
+ );
@@ -0,0 +1,32 @@
1
+ {
2
+ "extends": [
3
+ "html-validate:recommended"
4
+ ],
5
+ "rules": {
6
+ "no-inline-style": [
7
+ "error",
8
+ {
9
+ "allowedProperties": [
10
+ "height",
11
+ "background-image",
12
+ "--gallery-cols",
13
+ "object-position",
14
+ "color",
15
+ "background-color",
16
+ "overflow-x",
17
+ "font-style"
18
+ ]
19
+ }
20
+ ],
21
+ "void-style": "off",
22
+ "no-trailing-whitespace": "off",
23
+ "prefer-button": "off",
24
+ "long-title": "off",
25
+ "valid-id": [
26
+ "error",
27
+ {
28
+ "relaxed": true
29
+ }
30
+ ]
31
+ }
32
+ }
@@ -0,0 +1,70 @@
1
+ {
2
+ "extends": [
3
+ "stylelint-config-standard"
4
+ ],
5
+ "ignoreFiles": [
6
+ "**/dist/**",
7
+ "**/node_modules/**",
8
+ "**/public/**"
9
+ ],
10
+ "rules": {
11
+ "color-no-hex": [
12
+ true,
13
+ {
14
+ "message": "Hex colors live in tokens.css only; use var(--...) (docs/css-architecture.md)"
15
+ }
16
+ ],
17
+ "media-feature-range-notation": null,
18
+ "alpha-value-notation": null,
19
+ "color-function-notation": null,
20
+ "no-descending-specificity": null,
21
+ "custom-property-empty-line-before": null,
22
+ "declaration-empty-line-before": null,
23
+ "comment-empty-line-before": null,
24
+ "rule-empty-line-before": null,
25
+ "value-keyword-case": null,
26
+ "selector-class-pattern": null,
27
+ "font-family-name-quotes": null,
28
+ "shorthand-property-no-redundant-values": null,
29
+ "declaration-block-no-redundant-longhand-properties": null,
30
+ "number-max-precision": null,
31
+ "declaration-block-single-line-max-declarations": null,
32
+ "selector-pseudo-class-no-unknown": [
33
+ true,
34
+ {
35
+ "ignorePseudoClasses": [
36
+ "global"
37
+ ]
38
+ }
39
+ ],
40
+ "at-rule-empty-line-before": null,
41
+ "color-hex-length": null,
42
+ "color-function-alias-notation": null,
43
+ "import-notation": null,
44
+ "declaration-property-value-disallowed-list": [
45
+ {
46
+ "/^(width|min-width|max-width)$/": ["/ch$/"]
47
+ },
48
+ {
49
+ "message": "never-cap-prose (step 11.1): a ch measure exists to cap prose width and has no legitimate non-prose use, so this half of the guard is safe as a blanket, selector-blind rule. The other half, that a prose stylesheet's only max-width is the 100% image-fit exception, needs to know which selectors are prose and which are not; stylelint's property/value rules cannot see selector context, so that half stays a consumer's own test (see half-built-robots-blog/test/tokens.test.ts, \"prose never caps width\", which reads the prose stylesheet directly and asserts its max-width declarations are exactly [\"max-width: 100%;\"])."
50
+ }
51
+ ]
52
+ },
53
+ "overrides": [
54
+ {
55
+ "files": [
56
+ "**/*.astro"
57
+ ],
58
+ "customSyntax": "postcss-html"
59
+ },
60
+ {
61
+ "files": [
62
+ "**/tokens.css",
63
+ "**/tokens/*.css"
64
+ ],
65
+ "rules": {
66
+ "color-no-hex": null
67
+ }
68
+ }
69
+ ]
70
+ }
@@ -0,0 +1,153 @@
1
+ /* One preview server and one headless Chrome for a consumer's browser
2
+ suites. Adapted from half-built-robots-blog's test/browser-server.ts
3
+ (step 11.3 task 5): that file imported findChrome from a sibling
4
+ ../scripts/find-chrome.mjs, which existed because the blog keeps a
5
+ repo-wide scripts/ directory (visual-check.mjs used the same finder).
6
+ The tooling package has no such directory of its own to point at, so
7
+ findChrome is inlined below instead of adding a sixth file outside the
8
+ brief's five; its CHROME_CANDIDATES search order is preserved verbatim.
9
+ Everything else here is the blog's original: one definition of spawn,
10
+ wait, launch, and kill, so a consumer's suites do not each carry their
11
+ own drifted copy. */
12
+ import { existsSync } from "node:fs";
13
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
14
+ import type { Browser, Page } from "puppeteer-core";
15
+
16
+ const CHROME_CANDIDATES = [
17
+ /* CHROME_PATH wins when set, for environments this list cannot know. */
18
+ process.env.CHROME_PATH ?? "",
19
+ "C:/Program Files/Google/Chrome/Application/chrome.exe",
20
+ "C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
21
+ `${process.env.LOCALAPPDATA ?? ""}/Google/Chrome/Application/chrome.exe`,
22
+ /* ubuntu-latest CI runners ship Chrome here. */
23
+ "/usr/bin/google-chrome",
24
+ "/usr/bin/chromium-browser",
25
+ "/usr/bin/chromium",
26
+ ];
27
+
28
+ /** Absolute path to a local Chrome; throws naming every path checked. */
29
+ export function findChrome(): string {
30
+ const chrome = CHROME_CANDIDATES.find((p) => p && existsSync(p));
31
+ if (chrome == null || chrome === "") {
32
+ throw new Error(`Chrome not found; checked: ${CHROME_CANDIDATES.filter(Boolean).join(", ")}`);
33
+ }
34
+ return chrome;
35
+ }
36
+
37
+ async function answers(url: string): Promise<boolean> {
38
+ try {
39
+ await fetch(url);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ async function waitForServer(url: string, proc: ChildProcess, tries = 60): Promise<void> {
47
+ for (let i = 0; i < tries; i++) {
48
+ if (proc.exitCode != null) {
49
+ throw new Error(`preview server exited with code ${proc.exitCode} (see stderr above)`);
50
+ }
51
+ let res: Response | undefined;
52
+ try {
53
+ res = await fetch(url);
54
+ } catch {
55
+ /* not up yet */
56
+ }
57
+ if (res?.status === 404) {
58
+ /* The server is up but the page is not where the suite thinks:
59
+ fail now and name the likely cause instead of timing out. */
60
+ throw new Error(`server is up but ${url} is 404; did the route or slug change?`);
61
+ }
62
+ if (res?.ok) return;
63
+ await new Promise((r) => setTimeout(r, 500));
64
+ }
65
+ throw new Error(`preview server never answered at ${url}`);
66
+ }
67
+
68
+ /* Serves dist on `port` and resolves once `readyPath` answers 200.
69
+ The port must be OURS: a stale preview (a suite's own leak, or some
70
+ other tooling session) would answer the wait and the tests would
71
+ validate a server this run does not control. Chrome is located
72
+ before the spawn so a machine without one fails before it has a
73
+ server to clean up. */
74
+ export async function startPreview(port: number, readyPath: string): Promise<ChildProcess> {
75
+ findChrome();
76
+ const origin = `http://localhost:${port}`;
77
+ if (await answers(`${origin}/`)) {
78
+ throw new Error(`something already serves ${origin}; kill it before running the browser suites`);
79
+ }
80
+ /* stderr inherited so a failed spawn (missing dist, bad flag) is
81
+ loud instead of a silent 30 s timeout. detached on POSIX so the
82
+ shell wrapper gets its own process group we can kill whole. */
83
+ const server = spawn("npx", ["astro", "preview", "--port", String(port)], {
84
+ shell: true,
85
+ stdio: ["ignore", "ignore", "inherit"],
86
+ detached: process.platform !== "win32",
87
+ });
88
+ await waitForServer(`${origin}${readyPath}`, server);
89
+ return server;
90
+ }
91
+
92
+ /* shell: true wraps the server in a shell; kill the whole tree. Safe
93
+ to call when the spawn itself failed, so no orphan keeps the port. */
94
+ export function stopPreview(server: ChildProcess | undefined): void {
95
+ if (server?.pid == null) return;
96
+ if (process.platform === "win32") {
97
+ spawnSync("taskkill", ["/pid", String(server.pid), "/T", "/F"], { stdio: "ignore" });
98
+ } else {
99
+ try {
100
+ process.kill(-server.pid, "SIGTERM"); // negative pid: the detached group
101
+ } catch {
102
+ /* already gone */
103
+ }
104
+ }
105
+ }
106
+
107
+ /* CI runners restrict the user namespaces Chrome's sandbox needs; the
108
+ runner is already a throwaway VM. It also has no GPU, so WebGL there
109
+ is SwiftShader on a couple of vCPUs; a canvas- or WebGL-driven
110
+ consumer should account for that fallback path the way the blog's own
111
+ fluid player does, or a timeout there usually means the lighter path
112
+ stopped engaging, not that the assertion is slow. */
113
+ export async function launchChrome(): Promise<Browser> {
114
+ const puppeteer = await import("puppeteer-core");
115
+ return puppeteer.launch({
116
+ executablePath: findChrome(),
117
+ headless: true,
118
+ args: [
119
+ /* A desktop pointer, stated at launch. Headless Chrome on a CI
120
+ runner with no input devices reports (hover: none) and
121
+ (pointer: none); anything gated on a hover/pointer media query
122
+ (the blog's link-tip.ts is one example) then correctly declines
123
+ to mount, which can pass locally and fail on the runner. CDP's
124
+ Emulation.setEmulatedMedia hover/pointer features are ignored
125
+ by Chrome (probed both directions), so the only lever is
126
+ Blink's own settings: hover type 2 = hover, pointer type 4 =
127
+ fine. Launching with the "none" values (1) reproduces the
128
+ runner failure exactly on a laptop. Touch emulation from
129
+ setViewport({ hasTouch }) still flips these per page, so
130
+ phonePage below keeps proving the no-hover path. */
131
+ "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4",
132
+ ...(process.env.CI ? ["--no-sandbox", "--disable-setuid-sandbox"] : []),
133
+ ],
134
+ });
135
+ }
136
+
137
+ /* A desktop-class page. The pointer itself is a launch setting (see
138
+ launchChrome); this is the viewport, and the one place desktop pages
139
+ are opened so a consumer's suites cannot drift. */
140
+ export async function desktopPage(browser: Browser, width = 1280, height = 900): Promise<Page> {
141
+ const p = await browser.newPage();
142
+ await p.setViewport({ width, height });
143
+ return p;
144
+ }
145
+
146
+ /* A phone-class page: iPhone width and touch. hasTouch is what flips
147
+ (hover: none) and (pointer: coarse) on, per page, over the launch
148
+ setting; it is the reason a touch test proves the no-hover path. */
149
+ export async function phonePage(browser: Browser, width = 390, height = 664): Promise<Page> {
150
+ const p = await browser.newPage();
151
+ await p.setViewport({ width, height, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
152
+ return p;
153
+ }
@@ -0,0 +1,87 @@
1
+ /* Shared test utilities for consumers of @half-built/tooling. Adapted from
2
+ half-built-robots-blog's test/helpers.ts (step 11.3 task 5): the two
3
+ blog-content exports are dropped since neither means anything outside
4
+ that repo. FLUID_ART_POST was a post slug shared by three of the blog's
5
+ own suites; allHtml() walked a built dist/ tree looking for that blog's
6
+ post pages. What is left is generic: DOM and canvas test scaffolding
7
+ any consumer's suite can use. (packages/astro/test/helpers.ts made the
8
+ same two drops independently for the package's own tests; this file
9
+ serves outside consumers instead, so the overlap is expected, not
10
+ duplication to dedupe.) */
11
+ import { vi } from "vitest";
12
+
13
+ /* rAF driven by the fake-timer clock, for suites under vi.useFakeTimers.
14
+ Self-advancing frame stamp: do not lean on performance.now or Date.now
15
+ here, fake timers do not reliably advance them across environments.
16
+ Call after vi.useFakeTimers(); undone by vi.unstubAllGlobals(). */
17
+ export function stubRafOnFakeTimers(): void {
18
+ let frameNow = 0;
19
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) =>
20
+ setTimeout(() => { frameNow += 16; cb(frameNow); }, 16) as unknown as number);
21
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => { clearTimeout(id); });
22
+ }
23
+
24
+ /* jsdom's <dialog> support has lagged the platform; polyfill the members
25
+ modal decorators use so tests exercise your logic, not jsdom's. Touches
26
+ HTMLDialogElement only when called, so node-env tests importing this
27
+ module are unaffected. */
28
+ export function polyfillDialog(): void {
29
+ const p = HTMLDialogElement.prototype;
30
+ if (typeof p.showModal !== "function") {
31
+ p.showModal = function (this: HTMLDialogElement) { this.setAttribute("open", ""); };
32
+ }
33
+ if (typeof p.close !== "function") {
34
+ p.close = function (this: HTMLDialogElement) {
35
+ this.removeAttribute("open");
36
+ this.dispatchEvent(new Event("close"));
37
+ };
38
+ }
39
+ }
40
+
41
+ /* Canvas call recorder for canvas-painting code under test (it should be
42
+ pure over the 2D context it is handed). Every recorded method pushes
43
+ {op, args} plus the style fields as they stood at call time, so a test
44
+ can ask what color a given rect was painted in. measureText answers 5px
45
+ per character. */
46
+ export interface CanvasCall {
47
+ op: string;
48
+ args: unknown[];
49
+ fillStyle: string;
50
+ strokeStyle: string;
51
+ lineWidth: number;
52
+ font: string;
53
+ textAlign: string;
54
+ }
55
+ export interface RecordingContext {
56
+ calls: CanvasCall[];
57
+ ops: (op: string) => CanvasCall[];
58
+ ctx: CanvasRenderingContext2D;
59
+ }
60
+ export function recordingContext(): RecordingContext {
61
+ const calls: CanvasCall[] = [];
62
+ const ctx: Record<string, unknown> = {
63
+ fillStyle: "", strokeStyle: "", lineWidth: 1, font: "", textAlign: "start",
64
+ };
65
+ const snap = (op: string, args: unknown[]): void => {
66
+ calls.push({
67
+ op, args,
68
+ fillStyle: String(ctx.fillStyle), strokeStyle: String(ctx.strokeStyle),
69
+ lineWidth: Number(ctx.lineWidth), font: String(ctx.font), textAlign: String(ctx.textAlign),
70
+ });
71
+ };
72
+ for (const op of [
73
+ "fillRect", "strokeRect", "beginPath", "moveTo", "lineTo", "stroke",
74
+ "fillText", "scale", "clearRect", "drawImage", "save", "restore",
75
+ ]) {
76
+ ctx[op] = (...args: unknown[]): void => { snap(op, args); };
77
+ }
78
+ ctx.measureText = (text: string): { width: number } => {
79
+ snap("measureText", [text]);
80
+ return { width: text.length * 5 };
81
+ };
82
+ return {
83
+ calls,
84
+ ops: (op) => calls.filter((c) => c.op === op),
85
+ ctx: ctx as unknown as CanvasRenderingContext2D,
86
+ };
87
+ }