From 2fc1c8b9d3a0aec44e3eeb6bcacb588d90ab0ecf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 12:04:11 -0700 Subject: [PATCH 01/13] restore make-pdf renderer source + html-to-docx dep Restores the Markdown to styled/publication PDF pipeline removed in 9053324a. Source recovered from bb57306d: 13 src files + tests + SKILL template. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 155 +++- make-pdf/SKILL.md.tmpl | 247 +++++ make-pdf/src/browseClient.ts | 410 +++++++++ make-pdf/src/cli.ts | 275 ++++++ make-pdf/src/commands.ts | 62 ++ make-pdf/src/diagram-prepass.ts | 846 ++++++++++++++++++ make-pdf/src/image-policy.ts | 236 +++++ make-pdf/src/image-size.ts | 117 +++ make-pdf/src/orchestrator.ts | 399 +++++++++ make-pdf/src/pdftotext.ts | 312 +++++++ make-pdf/src/print-css.ts | 452 ++++++++++ make-pdf/src/render.ts | 438 +++++++++ make-pdf/src/setup.ts | 110 +++ make-pdf/src/smartypants.ts | 100 +++ make-pdf/src/types.ts | 136 +++ make-pdf/test/browseClient.test.ts | 135 +++ make-pdf/test/coverage-gaps.test.ts | 220 +++++ make-pdf/test/diagram-prepass.test.ts | 403 +++++++++ make-pdf/test/e2e/combined-gate.test.ts | 76 ++ make-pdf/test/e2e/diagram-gate.test.ts | 173 ++++ make-pdf/test/e2e/emoji-gate.test.ts | 197 ++++ make-pdf/test/e2e/format-gate.test.ts | 131 +++ make-pdf/test/e2e/landscape-gate.test.ts | 136 +++ .../test/fixtures/combined-gate.expected.txt | 20 + make-pdf/test/fixtures/combined-gate.md | 30 + .../fixtures/diagram-assets/huge-noise.png | Bin 0 -> 302582 bytes .../test/fixtures/diagram-assets/red-box.png | Bin 0 -> 131 bytes .../fixtures/diagram-assets/wide-arch.png | Bin 0 -> 9302 bytes .../diagram-assets/wide-screenshot.png | Bin 0 -> 10062 bytes make-pdf/test/fixtures/diagram-gate.md | 48 + make-pdf/test/fixtures/emoji-gate.md | 12 + make-pdf/test/fixtures/landscape-gate.md | 52 ++ make-pdf/test/image-policy.test.ts | 215 +++++ make-pdf/test/pdftotext.test.ts | 207 +++++ make-pdf/test/render.test.ts | 523 +++++++++++ package.json | 1 + 36 files changed, 6872 insertions(+), 2 deletions(-) create mode 100644 make-pdf/SKILL.md.tmpl create mode 100644 make-pdf/src/browseClient.ts create mode 100644 make-pdf/src/cli.ts create mode 100644 make-pdf/src/commands.ts create mode 100644 make-pdf/src/diagram-prepass.ts create mode 100644 make-pdf/src/image-policy.ts create mode 100644 make-pdf/src/image-size.ts create mode 100644 make-pdf/src/orchestrator.ts create mode 100644 make-pdf/src/pdftotext.ts create mode 100644 make-pdf/src/print-css.ts create mode 100644 make-pdf/src/render.ts create mode 100644 make-pdf/src/setup.ts create mode 100644 make-pdf/src/smartypants.ts create mode 100644 make-pdf/src/types.ts create mode 100644 make-pdf/test/browseClient.test.ts create mode 100644 make-pdf/test/coverage-gaps.test.ts create mode 100644 make-pdf/test/diagram-prepass.test.ts create mode 100644 make-pdf/test/e2e/combined-gate.test.ts create mode 100644 make-pdf/test/e2e/diagram-gate.test.ts create mode 100644 make-pdf/test/e2e/emoji-gate.test.ts create mode 100644 make-pdf/test/e2e/format-gate.test.ts create mode 100644 make-pdf/test/e2e/landscape-gate.test.ts create mode 100644 make-pdf/test/fixtures/combined-gate.expected.txt create mode 100644 make-pdf/test/fixtures/combined-gate.md create mode 100644 make-pdf/test/fixtures/diagram-assets/huge-noise.png create mode 100644 make-pdf/test/fixtures/diagram-assets/red-box.png create mode 100644 make-pdf/test/fixtures/diagram-assets/wide-arch.png create mode 100644 make-pdf/test/fixtures/diagram-assets/wide-screenshot.png create mode 100644 make-pdf/test/fixtures/diagram-gate.md create mode 100644 make-pdf/test/fixtures/emoji-gate.md create mode 100644 make-pdf/test/fixtures/landscape-gate.md create mode 100644 make-pdf/test/image-policy.test.ts create mode 100644 make-pdf/test/pdftotext.test.ts create mode 100644 make-pdf/test/render.test.ts diff --git a/bun.lock b/bun.lock index 31f309ef14..8d67517343 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@anthropic-ai/sdk": "^0.112.4", "@ngrok/ngrok": "^1.7.0", "diff": "^9.0.0", + "html-to-docx": "1.8.0", "marked": "^18.0.6", "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", @@ -220,6 +221,14 @@ "@ngrok/ngrok-win32-x64-msvc": ["@ngrok/ngrok-win32-x64-msvc@1.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA=="], + "@oozcitak/dom": ["@oozcitak/dom@1.15.6", "", { "dependencies": { "@oozcitak/infra": "1.0.5", "@oozcitak/url": "1.0.0", "@oozcitak/util": "8.3.4" } }, "sha512-k4uEIa6DI3FCrFJMGq/05U/59WnS9DjME0kaPqBRCJAqBTkmopbYV1Xs4qFKbDJ/9wOg8W97p+1E0heng/LH7g=="], + + "@oozcitak/infra": ["@oozcitak/infra@1.0.5", "", { "dependencies": { "@oozcitak/util": "8.0.0" } }, "sha512-o+zZH7M6l5e3FaAWy3ojaPIVN5eusaYPrKm6MZQt0DKNdgXa2wDYExjpP0t/zx+GoQgQKzLu7cfD8rHCLt8JrQ=="], + + "@oozcitak/url": ["@oozcitak/url@1.0.0", "", { "dependencies": { "@oozcitak/infra": "1.0.3", "@oozcitak/util": "1.0.2" } }, "sha512-LGrMeSxeLzsdaitxq3ZmBRVOrlRRQIgNNci6L0VRnOKlJFuRIkNm4B+BObXPCJA6JT5bEJtrrwjn30jueHJYZQ=="], + + "@oozcitak/util": ["@oozcitak/util@8.3.4", "", {}, "sha512-6gH/bLQJSJEg7OEpkH4wGQdA8KXHRbzL1YkGyUO12YNAgV3jxKy4K9kvfXj4+9T0OLug5k58cnPCKSSIKzp7pg=="], + "@simple-git/args-pathspec": ["@simple-git/args-pathspec@1.0.3", "", {}, "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA=="], "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], @@ -256,12 +265,16 @@ "braintrust": ["braintrust@3.24.0", "", { "dependencies": { "@next/env": "^14.2.3", "@vercel/functions": "^1.0.2", "acorn": "^8.16.0", "acorn-import-attributes": "^1.9.5", "ajv": "^8.20.0", "argparse": "^2.0.1", "astring": "^1.9.0", "cjs-module-lexer": "^2.2.0", "cli-progress": "^3.12.0", "cli-table3": "^0.6.5", "cors": "^2.8.5", "dc-browser": "^1.0.4", "dotenv": "^16.4.5", "esbuild": "0.28.1", "esquery": "^1.7.0", "eventsource-parser": "^1.1.2", "express": "^5.2.1", "http-errors": "^2.0.0", "meriyah": "^6.1.4", "minimatch": "^10.2.5", "module-details-from-path": "^1.0.4", "mustache": "^4.2.0", "pluralize": "^8.0.0", "semifies": "^1.0.0", "simple-git": "^3.36.0", "source-map": "^0.7.4", "termi-link": "^1.0.1", "unplugin": "^2.3.5", "uuid": "^11.1.1", "zod-to-json-schema": "^3.25.0" }, "optionalDependencies": { "@braintrust/bt-darwin-arm64": "0.12.0", "@braintrust/bt-darwin-x64": "0.12.0", "@braintrust/bt-linux-arm64": "0.12.0", "@braintrust/bt-linux-x64": "0.12.0", "@braintrust/bt-linux-x64-musl": "0.12.0", "@braintrust/bt-win32-arm64": "0.12.0", "@braintrust/bt-win32-x64": "0.12.0" }, "peerDependencies": { "zod": "^3.25.34 || ^4.0" }, "bin": { "braintrust": "dist/cli.js", "bt": "bin/bt" } }, "sha512-jF2XK1AImY2jI6uPxxU/KNGyYIIsr4PhaUD1IsbdggSNLIBkwzmluKvCzs1OUVy6LbpL4T2ekv8t+83UQ64hEg=="], + "browser-split": ["browser-split@0.0.1", "", {}, "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "cheminfo-types": ["cheminfo-types@1.15.0", "", {}, "sha512-shv45WN2u0yN9EHH1bisNrv+fy4Cw+eLM5lOoriP67mePrwbHZ1kJqg90C8GEU7K1A8gJsicEoVZHcuBbuul/w=="], "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], @@ -270,6 +283,8 @@ "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "compute-cosine-similarity": ["compute-cosine-similarity@1.1.0", "", { "dependencies": { "compute-dot": "^1.1.0", "compute-l2norm": "^1.1.0", "validate.io-array": "^1.0.5", "validate.io-function": "^1.0.2" } }, "sha512-FXhNx0ILLjGi9Z9+lglLzM12+0uoTnYkHm7GiadXDAr0HGVLm25OivUS1B/LPkbzzvlcXz/1EvWg9ZYyJSdhTw=="], "compute-dot": ["compute-dot@1.1.0", "", { "dependencies": { "validate.io-array": "^1.0.3", "validate.io-function": "^1.0.2" } }, "sha512-L5Ocet4DdMrXboss13K59OK23GXjiSia7+7Ukc7q4Bl+RVpIXK2W9IHMbWDZkh+JUEvJAwOKRaJDiFUa1LTnJg=="], @@ -284,6 +299,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -298,6 +315,16 @@ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], + "dom-serializer": ["dom-serializer@0.2.2", "", { "dependencies": { "domelementtype": "^2.0.1", "entities": "^2.0.0" } }, "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g=="], + + "dom-walk": ["dom-walk@0.1.2", "", {}, "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="], + + "domelementtype": ["domelementtype@1.3.1", "", {}, "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="], + + "domhandler": ["domhandler@2.4.2", "", { "dependencies": { "domelementtype": "1" } }, "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA=="], + + "domutils": ["domutils@1.7.0", "", { "dependencies": { "dom-serializer": "0", "domelementtype": "1" } }, "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -308,6 +335,12 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "ent": ["ent@2.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "punycode": "^1.4.1", "safe-regex-test": "^1.1.0" } }, "sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw=="], + + "entities": ["entities@1.1.2", "", {}, "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="], + + "error": ["error@4.4.0", "", { "dependencies": { "camelize": "^1.0.0", "string-template": "~0.2.0", "xtend": "~4.0.0" } }, "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -324,6 +357,8 @@ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "ev-store": ["ev-store@7.0.0", "", { "dependencies": { "individual": "^3.0.0" } }, "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], "eventsource-parser": ["eventsource-parser@1.1.2", "", {}, "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA=="], @@ -352,18 +387,38 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "global": ["global@4.4.0", "", { "dependencies": { "min-document": "^2.19.0", "process": "^0.11.10" } }, "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], + "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], + + "html-to-docx": ["html-to-docx@1.8.0", "", { "dependencies": { "@oozcitak/dom": "1.15.6", "@oozcitak/util": "8.3.4", "color-name": "^1.1.4", "html-entities": "^2.3.3", "html-to-vdom": "^0.7.0", "image-size": "^1.0.0", "image-to-base64": "^2.2.0", "jszip": "^3.7.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "nanoid": "^3.1.25", "virtual-dom": "^2.1.1", "xmlbuilder2": "2.1.2" } }, "sha512-IiMBWIqXM4+cEsW//RKoonWV7DlXAJBmmKI73XJSVWTIXjGUaxSr2ck1jqzVRZknpvO8xsFnVicldKVAWrBYBA=="], + + "html-to-vdom": ["html-to-vdom@0.7.0", "", { "dependencies": { "ent": "^2.0.0", "htmlparser2": "^3.8.2" } }, "sha512-k+d2qNkbx0JO00KezQsNcn6k2I/xSBP4yXYFLvXbcasTTDh+RDLUJS3puxqyNnpdyXWRHFGoKU7cRmby8/APcQ=="], + + "htmlparser2": ["htmlparser2@3.10.1", "", { "dependencies": { "domelementtype": "^1.3.1", "domhandler": "^2.3.0", "domutils": "^1.5.1", "entities": "^1.1.1", "inherits": "^2.0.1", "readable-stream": "^3.1.1" } }, "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="], + + "image-to-base64": ["image-to-base64@2.2.0", "", { "dependencies": { "node-fetch": "^2.6.0" } }, "sha512-Z+aMwm/91UOQqHhrz7Upre2ytKhWejZlWV/JxUTD1sT7GWWKFDJUEV5scVQKnkzSgPHFuQBUEWcanO+ma0PSVw=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "individual": ["individual@3.0.0", "", {}, "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -374,8 +429,14 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-object": ["is-object@1.0.2", "", {}, "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], @@ -390,8 +451,14 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "linear-sum-assignment": ["linear-sum-assignment@1.0.9", "", { "dependencies": { "cheminfo-types": "^1.8.1", "ml-matrix": "^6.12.1", "ml-spectra-processing": "^14.18.0" } }, "sha512-1T2Ek3sxpt2mBHeBFMRJEikiIK/yIOwf+mrxv/DkAU/5ddnCMndZL//hFH7QuHa1tbaQADzsf9t7rkGZKqoFfQ=="], + "lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="], + "marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -402,9 +469,11 @@ "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], - "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "min-document": ["min-document@2.19.2", "", { "dependencies": { "dom-walk": "^0.1.0" } }, "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A=="], "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -426,8 +495,14 @@ "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "next-tick": ["next-tick@0.2.2", "", {}, "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -438,6 +513,8 @@ "openai": ["openai@6.48.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -452,18 +529,32 @@ "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "punycode": ["punycode@1.4.1", "", {}, "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="], + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + "queue": ["queue@6.0.2", "", { "dependencies": { "inherits": "~2.0.3" } }, "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semifies": ["semifies@1.0.0", "", {}, "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw=="], @@ -474,6 +565,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], @@ -502,14 +595,20 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "string-template": ["string-template@0.2.1", "", {}, "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "termi-link": ["termi-link@1.1.0", "", {}, "sha512-2qSN6TnomHgVLtk+htSWbaYs4Rd2MH/RU7VpHTy6MBstyNyWbM4yKd1DCYpE3fDg8dmGWojXCngNi/MHCzGuAA=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -520,6 +619,8 @@ "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@11.1.1", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ=="], "validate.io-array": ["validate.io-array@1.0.6", "", {}, "sha512-DeOy7CnPEziggrOO5CZhVKJw6S3Yi7e9e65R1Nl/RTN1vTQKnzjfvks0/8kQ40FP/dsjRAOd4hxmJ7uLa6vxkg=="], @@ -528,12 +629,26 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "virtual-dom": ["virtual-dom@2.1.1", "", { "dependencies": { "browser-split": "0.0.1", "error": "^4.3.0", "ev-store": "^7.0.0", "global": "^4.3.0", "is-object": "^1.0.1", "next-tick": "^0.2.2", "x-is-array": "0.1.0", "x-is-string": "0.1.0" } }, "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "x-is-array": ["x-is-array@0.1.0", "", {}, "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="], + + "x-is-string": ["x-is-string@0.1.0", "", {}, "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="], + + "xmlbuilder2": ["xmlbuilder2@2.1.2", "", { "dependencies": { "@oozcitak/dom": "1.15.5", "@oozcitak/infra": "1.0.5", "@oozcitak/util": "8.3.3" } }, "sha512-PI710tmtVlQ5VmwzbRTuhmVhKnj9pM8Si+iOZCV2g2SNo3gCrpzR2Ka9wNzZtqfD+mnP+xkrqoNy0sjKZqP4Dg=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.0", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ=="], @@ -542,10 +657,46 @@ "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@oozcitak/infra/@oozcitak/util": ["@oozcitak/util@8.0.0", "", {}, "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw=="], + + "@oozcitak/url/@oozcitak/infra": ["@oozcitak/infra@1.0.3", "", { "dependencies": { "@oozcitak/util": "1.0.1" } }, "sha512-9O2wxXGnRzy76O1XUxESxDGsXT5kzETJPvYbreO4mv6bqe1+YSuux2cZTagjJ/T4UfEwFJz5ixanOqB0QgYAag=="], + + "@oozcitak/url/@oozcitak/util": ["@oozcitak/util@1.0.2", "", {}, "sha512-4n8B1cWlJleSOSba5gxsMcN4tO8KkkcvXhNWW+ADqvq9Xj+Lrl9uCa90GRpjekqQJyt84aUX015DG81LFpZYXA=="], + + "accepts/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "braintrust/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "braintrust/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "eventsource/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="], + + "express/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "htmlparser2/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "type-is/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "xmlbuilder2/@oozcitak/dom": ["@oozcitak/dom@1.15.5", "", { "dependencies": { "@oozcitak/infra": "1.0.5", "@oozcitak/url": "1.0.0", "@oozcitak/util": "8.0.0" } }, "sha512-L6v3Mwb0TaYBYgeYlIeBaHnc+2ZEaDSbFiRm5KmqZQSoBlbPlf+l6aIH/sD5GUf2MYwULw00LT7+dOnEuAEC0A=="], + + "xmlbuilder2/@oozcitak/util": ["@oozcitak/util@8.3.3", "", {}, "sha512-Ufpab7G5PfnEhQyy5kDg9C8ltWJjsVT1P/IYqacjstaqydG4Q21HAT2HUZQYBrC/a1ZLKCz87pfydlDvv8y97w=="], + + "@oozcitak/url/@oozcitak/infra/@oozcitak/util": ["@oozcitak/util@1.0.1", "", {}, "sha512-dFwFqcKrQnJ2SapOmRD1nQWEZUtbtIy9Y6TyJquzsalWNJsKIPxmTI0KG6Ypyl8j7v89L2wixH9fQDNrF78hKg=="], + + "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "send/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "xmlbuilder2/@oozcitak/dom/@oozcitak/util": ["@oozcitak/util@8.0.0", "", {}, "sha512-+9Hq6yuoq/3TRV/n/xcpydGBq2qN2/DEDMqNTG7rm95K6ZE2/YY/sPyx62+1n8QsE9O26e5M1URlXsk+AnN9Jw=="], } } diff --git a/make-pdf/SKILL.md.tmpl b/make-pdf/SKILL.md.tmpl new file mode 100644 index 0000000000..9133a711d4 --- /dev/null +++ b/make-pdf/SKILL.md.tmpl @@ -0,0 +1,247 @@ +--- +name: make-pdf +preamble-tier: 1 +version: 1.0.0 +description: | + Turn any markdown file into a publication-quality PDF. Proper 1in margins, + intelligent page breaks, page numbers, cover pages, running headers, curly + quotes and em dashes, clickable TOC, diagonal DRAFT watermark. Not a draft + artifact — a finished artifact. Use when asked to "make a PDF", "export to + PDF", "turn this markdown into a PDF", or "generate a document". (gstack) +voice-triggers: + - "make this a pdf" + - "make it a pdf" + - "export to pdf" + - "turn this into a pdf" + - "turn this markdown into a pdf" + - "generate a pdf" + - "make a pdf from" + - "pdf this markdown" +triggers: + - markdown to pdf + - generate pdf + - make pdf + - export pdf +allowed-tools: + - Bash + - Read + - AskUserQuestion +--- + +{{PREAMBLE}} + +# make-pdf: publication-quality PDFs from markdown + +Turn `.md` files into PDFs that look like Faber & Faber essays: 1in margins, +left-aligned body, Helvetica throughout, curly quotes and em dashes, optional +cover page and clickable TOC, diagonal DRAFT watermark when you need it. +Copy-paste from the PDF produces clean words, never "S a i l i n g". + +On Linux, install `fonts-liberation` for correct rendering — Helvetica and Arial +aren't present by default, and Liberation Sans is the standard metric-compatible +fallback. CI and Docker builds install it automatically via Dockerfile.ci. + +Emoji need a color-emoji font. macOS (Apple Color Emoji) and Windows (Segoe UI +Emoji) ship one; most Linux distros and containers ship none, so emoji render as +empty boxes (▯). `./setup` auto-installs `fonts-noto-color-emoji` on Linux +(apt/dnf/pacman/apk, best-effort) and the print CSS falls back through Apple / +Segoe / Noto emoji families. Set `GSTACK_SKIP_FONTS=1` to skip the install (CI +without sudo, managed or offline machines). + +## Core patterns + +### 80% case — memo/letter + +One command, no flags. Gets a clean PDF with running header + page numbers ++ CONFIDENTIAL footer by default. + +```bash +$P generate letter.md # writes /tmp/letter.pdf +$P generate letter.md letter.pdf # explicit output path +``` + +### Publication mode — cover + TOC + chapter breaks + +```bash +$P generate --cover --toc --author "Garry Tan" --title "On Horizons" \ + essay.md essay.pdf +``` + +Each top-level H1 in the markdown starts a new page. Disable with +`--no-chapter-breaks` for memos that happen to have multiple H1s. + +### Draft-stage watermark + +```bash +$P generate --watermark DRAFT memo.md draft.pdf +``` + +Diagonal 10% opacity DRAFT across every page. When the draft is final, drop +the flag and regenerate. + +### Fast iteration via preview + +```bash +$P preview essay.md +``` + +Renders HTML with the same print CSS and opens it in your browser. Refresh +as you edit the markdown. Skip the PDF round trip until you're ready. + +### Brand-free (no CONFIDENTIAL footer) + +```bash +$P generate --no-confidential memo.md memo.pdf +``` + +### Diagrams — mermaid and excalidraw fences render as pictures + +A column-0 ` ```mermaid ` or ` ```excalidraw ` fence in the markdown renders +as a crisp vector diagram, fully offline (vendored bundle, no CDN). Indented +fences (inside lists) stay plain code blocks by design. A broken fence +produces a visible red diagnostic block with the parse error — never silent +raw code. + +Fence info-string options: + +``` +```mermaid title="Auth flow" ← caption + aria-label +```mermaid render=false ← keep it as a code block (today's behavior) +```mermaid page=landscape ← force this diagram onto a landscape page +```mermaid page=portrait ← veto auto-landscape for this diagram +``` + +A ` ```excalidraw ` fence contains a full .excalidraw scene file (what +excalidraw.com saves). Authoring NEW diagrams from English is `/diagram`'s +job — it emits an editable triplet (source, .excalidraw, SVG/PNG) and pairs +with this skill: embed the `.mmd` source in your markdown, not the PNG. + +### Images — scaled right, never truncated + +Local images inline automatically (relative paths resolve against the +markdown file). Every image caps at the content box — zero truncation, ever. +Oversized photos downscale to print resolution (300dpi) so payloads stay +small with no visible quality loss. + +Remote (http/https) images are **blocked with a visible placeholder** by +default — offline posture; pass `--allow-network` to fetch them. An image +that resolves outside the markdown's directory (even via symlink) still +inlines, but warns loudly; `--strict` makes it fatal. Files over 64MB or +non-regular files (fifos, devices) degrade to a placeholder instead of +hanging the run. + +Per-image directives, written immediately after the image: + +``` +![chart](data.png){width=full} ← stretch to content-box width +![chart](data.png){width=50%} ← percentage or 3in/8cm/200px +![wide](arch.png){page=landscape} ← give it its own landscape page +![wide](shot.png){page=portrait} ← veto auto-landscape +``` + +Wide, small-text diagram images auto-promote to their own landscape page +(conservative: aspect ≥ 1.8, width over ~2.5x the content box, AND a +diagram-ish alt word — diagram/architecture/flowchart/chart/graph). The +promoted page is vertically centered. When the heuristic guesses wrong, +`{page=portrait}` vetoes it; false negatives just need `{page=landscape}`. + +### Other formats — single-file HTML and Word + +```bash +$P generate readme.md out.html --to html # ONE self-contained file: inline + # SVG diagrams, data-URI images, + # zero network refs, screen-readable +$P generate readme.md out.docx --to docx # Word: content fidelity (headings, + # tables, code, diagrams as PNG) — + # layout is Word's, not ours +``` + +`--to` is the output format. `--format` is something else entirely (a +`--page-size` alias) — don't confuse them. + +### CI mode — fail loud on missing assets + +```bash +$P generate docs.md --strict # missing, remote, out-of-tree, oversized, + # and non-regular-file images exit non-zero + # instead of warn + placeholder +``` + +## Common flags + +``` +Page layout: + --margins 1in (default) | 72pt | 2.54cm | 25mm + --page-size letter|a4|legal + +Structure: + --cover Cover page (title, author, date, hairline rule) + --toc Clickable TOC with page numbers + --no-chapter-breaks Don't start a new page at every H1 + +Branding: + --watermark Diagonal watermark ("DRAFT", "CONFIDENTIAL") + --header-template Custom running header + --footer-template Custom footer (mutex with --page-numbers) + --no-confidential Suppress the CONFIDENTIAL right-footer + +Output: + --to pdf|html|docx Output format (default: pdf). html = single + self-contained file; docx = content fidelity. + --strict Missing, remote, out-of-tree, oversized, or + non-regular-file images fail the run (CI mode). + --page-numbers "N of M" footer (default on) + --tagged Accessible PDF (default on) + --outline PDF bookmarks from headings (default on) + --quiet Suppress progress on stderr + --verbose Per-stage timings + +Network: + --allow-network Fetch external images. Off by default: remote + images render as a visible blocked placeholder + (no tracking pixels fetch at print time). + +Metadata: + --title "..." Document title (defaults to first H1) + --author "..." Author for cover + PDF metadata + --date "..." Date for cover (defaults to today) +``` + +## When Claude should run it + +Watch for markdown-to-PDF intent. Any of these patterns → run `$P generate`: + +- "Can you make this markdown a PDF" +- "Export it as a PDF" +- "Turn this letter into a PDF" +- "I need a PDF of the essay" +- "Print this as a PDF for me" + +If the user has a `.md` file open and says "make it look nice", propose +`$P generate --cover --toc` and ask before running. + +## Debugging + +- Output looks empty / blank → check browse daemon is running: `$B status`. +- Fragmented text on copy-paste → highlight.js output (Phase 4). Retry with + `--no-syntax` once that flag exists. For now, remove fenced code blocks + and regenerate. +- Paged.js timeout → probably no headings in the markdown. Drop `--toc`. +- "[remote image blocked]" placeholder in the output → add `--allow-network` + (understand you're giving the markdown file permission to fetch from its + image URLs). +- Generated PDF too tall/wide → `--page-size a4` or `--margins 0.75in`. + +## Output contract + +``` +stdout: /tmp/letter.pdf ← just the path, one line +stderr: Rendering HTML... ← progress spinner (unless --quiet) + Generating PDF... + Done in 1.5s. 43 words · 22KB · /tmp/letter.pdf + +exit code: 0 success / 1 bad args / 2 render error / 3 Paged.js timeout + / 4 browse unavailable +``` + +Capture the path: `PDF=$($P generate letter.md)` — then use `$PDF`. diff --git a/make-pdf/src/browseClient.ts b/make-pdf/src/browseClient.ts new file mode 100644 index 0000000000..da25677e59 --- /dev/null +++ b/make-pdf/src/browseClient.ts @@ -0,0 +1,410 @@ +/** + * Typed shell-out wrapper for the browse CLI. + * + * Every browse call goes through this file. Reasons: + * - One place to do binary resolution. + * - One place to enforce the --from-file convention for large payloads + * (Windows argv cap is 8191 chars; 200KB HTML dies without this). + * - One place that maps non-zero exit codes to typed errors. + * + * Binary resolution order (Codex round 2 #4, v1.24-aligned): + * 1. $GSTACK_BROWSE_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern) + * 2. $BROWSE_BIN env override (back-compat alias) + * 3. sibling dir: dirname(argv[0])/../browse/dist/browse[.exe] + * 4. ~/.claude/skills/gstack/browse/dist/browse[.exe] + * 5. PATH lookup via Bun.which('browse') — handles Windows PATHEXT natively + * 6. error with setup hint + * + * Windows quirks: + * - bun build --compile --outfile X emits X.exe on win32, so candidate paths + * need a .exe probe pass (fs.accessSync(X_OK) degrades to existence-checking + * on Windows per Node docs, so the bare path silently misses the .exe file). + * - `which` only exists in Git Bash; Bun.which() handles cmd.exe / PowerShell + * natively via PATHEXT semantics. + */ + +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; + +import { BrowseClientError } from "./types"; + +export interface LoadHtmlOptions { + html: string; // raw HTML string + waitUntil?: "load" | "domcontentloaded" | "networkidle"; + tabId: number; +} + +export interface PdfOptions { + output: string; + tabId: number; + format?: string; + width?: string; + height?: string; + marginTop?: string; + marginRight?: string; + marginBottom?: string; + marginLeft?: string; + headerTemplate?: string; + footerTemplate?: string; + pageNumbers?: boolean; + tagged?: boolean; + outline?: boolean; + printBackground?: boolean; + preferCSSPageSize?: boolean; + toc?: boolean; +} + +export interface JsOptions { + tabId: number; + expression: string; // JS expression to evaluate +} + +/** + * Resolve an absolute or PATH-resolvable command via Bun.which-style semantics, + * with a Windows .exe/.cmd/.bat extension probe for absolute paths. Mirrors + * the v1.24 claude-bin.ts override-resolution shape. + * + * Returns null if nothing resolves; callers degrade with a typed error rather + * than throwing here. + */ +function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null { + if (!value?.trim()) return null; + const trimmed = value.trim().replace(/^"(.*)"$/, '$1'); + if (path.isAbsolute(trimmed)) return findExecutable(trimmed); + const PATH = env.PATH ?? env.Path ?? ''; + return Bun.which(trimmed, { PATH }) ?? null; +} + +/** + * Probe a base path for executability, honoring Windows extension suffixes. + * + * On POSIX, isExecutable(base) is the only check that matters. On Windows, + * fs.accessSync(p, X_OK) degrades to an existence check — so a bare-path probe + * misses bun-compiled binaries (which land at base.exe). After the bare probe + * fails on win32, try .exe / .cmd / .bat. Linux/macOS behavior is unchanged. + */ +export function findExecutable(base: string): string | null { + if (isExecutable(base)) return base; + if (process.platform === "win32") { + for (const ext of [".exe", ".cmd", ".bat"]) { + const withExt = base + ext; + if (isExecutable(withExt)) return withExt; + } + } + return null; +} + +/** + * Locate the browse binary. Throws a BrowseClientError with a + * canonical setup message if not found. See header for resolution order. + */ +export function resolveBrowseBin(env: NodeJS.ProcessEnv = process.env): string { + // 1 + 2: env overrides (GSTACK_BROWSE_BIN preferred, BROWSE_BIN back-compat). + const overrideRaw = env.GSTACK_BROWSE_BIN ?? env.BROWSE_BIN; + const override = resolveOverride(overrideRaw, env); + if (override) return override; + + // 3: sibling — make-pdf and browse co-located in dist/. + const selfDir = path.dirname(process.argv[0]); + const siblingCandidates = [ + path.resolve(selfDir, "../browse/dist/browse"), + path.resolve(selfDir, "../../browse/dist/browse"), + path.resolve(selfDir, "../browse"), + ]; + for (const candidate of siblingCandidates) { + const found = findExecutable(candidate); + if (found) return found; + } + + // 4: global install. + const home = os.homedir(); + const globalPath = path.join(home, ".claude/skills/gstack/browse/dist/browse"); + const globalFound = findExecutable(globalPath); + if (globalFound) return globalFound; + + // 5: PATH lookup via Bun.which — handles Windows PATHEXT natively (no `which` + // dependency on cmd.exe / PowerShell, no `where`-vs-`which` branch). + const PATH = env.PATH ?? env.Path ?? ''; + const onPath = Bun.which('browse', { PATH }); + if (onPath) return onPath; + + throw new BrowseClientError( + /* exitCode */ 127, + "resolve", + [ + "browse binary not found.", + "", + "make-pdf needs browse (the gstack Chromium daemon) to render PDFs.", + "Tried:", + ` - $GSTACK_BROWSE_BIN (${env.GSTACK_BROWSE_BIN || "unset"})`, + ` - $BROWSE_BIN (${env.BROWSE_BIN || "unset"})`, + ` - sibling: ${siblingCandidates.join(", ")}`, + ` - global: ${globalPath}`, + " - PATH: `browse`", + "", + "To fix: run gstack setup from the gstack repo:", + " cd ~/.claude/skills/gstack && ./setup", + "", + "Or set GSTACK_BROWSE_BIN explicitly:", + process.platform === "win32" + ? ' setx GSTACK_BROWSE_BIN "C:\\path\\to\\browse.exe"' + : " export GSTACK_BROWSE_BIN=/path/to/browse", + ].join("\n"), + ); +} + +function isExecutable(p: string): boolean { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +/** + * Run a browse command. Returns stdout on success. + * Throws BrowseClientError on non-zero exit. + */ +function runBrowse(args: string[]): string { + const bin = resolveBrowseBin(); + try { + return execFileSync(bin, args, { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, // 16MB; tab content can be large + stdio: ["ignore", "pipe", "pipe"], + // A wedged daemon (or a hostile mermaid source spinning the renderer) + // must fail the run, not hang it forever. + timeout: 120_000, + }); + } catch (err: any) { + const exitCode = typeof err.status === "number" ? err.status : 1; + const stderr = typeof err.stderr === "string" + ? err.stderr + : (err.stderr?.toString() ?? ""); + throw new BrowseClientError(exitCode, args[0] || "unknown", stderr); + } +} + +/** + * Write a payload to a tmp file and return the path. Used for any payload + * >4KB to avoid Windows argv limits (Codex round 2 #3). + * + * Path must be under the browse safe-dirs allowlist (/tmp or cwd on + * non-Windows; os.tmpdir on Windows). v1.6.0.0 tightened --from-file + * validation to close a CLI/API parity gap (PR #1103), so os.tmpdir() + * on macOS (/var/folders/...) now fails validateReadPath. Use the same + * TEMP_DIR convention as browse/src/platform.ts. + */ +const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp"; + +function writePayloadFile(payload: Record): string { + const hash = crypto.createHash("sha256") + .update(JSON.stringify(payload)) + .digest("hex") + .slice(0, 12); + const tmpPath = path.join(PAYLOAD_TMP_DIR, `make-pdf-browse-${process.pid}-${hash}.json`); + fs.writeFileSync(tmpPath, JSON.stringify(payload), "utf8"); + return tmpPath; +} + +function cleanupPayloadFile(p: string): void { + try { fs.unlinkSync(p); } catch { /* best-effort */ } +} + +// ─── Public API ───────────────────────────────────────────────── + +/** + * Open a new tab. Returns the tabId. + * Requires `$B newtab --json` to be available (added in the browse flag + * extension for this feature). If --json isn't supported yet, the fallback + * parses "Opened tab N" from stdout. + */ +export function newtab(url?: string): number { + const args = ["newtab"]; + if (url) args.push(url); + // Try --json first (preferred path for programmatic use) + try { + const out = runBrowse([...args, "--json"]); + const parsed = JSON.parse(out); + if (typeof parsed.tabId === "number") return parsed.tabId; + } catch { + // Fall back to stdout-string parsing. Brittle, but works on older browse builds. + } + const out = runBrowse(args); + const m = out.match(/tab\s+(\d+)/i); + if (!m) throw new BrowseClientError(1, "newtab", `could not parse tab id from: ${out}`); + return parseInt(m[1], 10); +} + +/** + * Close a tab (by id or the active tab). + */ +export function closetab(tabId?: number): void { + const args = ["closetab"]; + if (tabId !== undefined) args.push(String(tabId)); + runBrowse(args); +} + +/** + * Load raw HTML into a specific tab. + * Uses --from-file for any payload >4KB (Codex round 2 #3). + */ +export function loadHtml(opts: LoadHtmlOptions): void { + // Always use --from-file to dodge argv limits. The HTML is almost always >4KB. + const payload = { + html: opts.html, + waitUntil: opts.waitUntil ?? "domcontentloaded", + }; + const payloadFile = writePayloadFile(payload); + try { + runBrowse([ + "load-html", + "--from-file", payloadFile, + "--tab-id", String(opts.tabId), + ]); + } finally { + cleanupPayloadFile(payloadFile); + } +} + +/** + * Load an HTML file (already under browse's safe dirs, e.g. /tmp) into a tab + * by path. Cheaper than loadHtml for large pages — no JSON payload round-trip; + * browse reads the file directly (diagram-render bundle is ~9MB). + */ +export function loadHtmlFile(opts: { file: string; tabId: number; waitUntil?: "load" | "domcontentloaded" | "networkidle" }): void { + const args = ["load-html", opts.file, "--tab-id", String(opts.tabId)]; + if (opts.waitUntil) args.push("--wait-until", opts.waitUntil); + runBrowse(args); +} + +/** + * Evaluate a JS expression in a tab. Returns the serialized result as string. + */ +export function js(opts: JsOptions): string { + return runBrowse([ + "js", + opts.expression, + "--tab-id", String(opts.tabId), + ]).trim(); +} + +/** + * Evaluate a JS file in a tab (`browse eval `): the argv-safe transport + * for expressions too large for a command-line element. The file must live + * under browse's safe dirs (/tmp or cwd). + */ +export function evalFile(opts: { file: string; tabId: number }): string { + return runBrowse([ + "eval", + opts.file, + "--tab-id", String(opts.tabId), + ]).trim(); +} + +/** + * Poll a boolean JS expression until it evaluates to true, or timeout. + * Returns true if it succeeded, false if timed out. + */ +export function waitForExpression(opts: { + expression: string; + tabId: number; + timeoutMs: number; + pollIntervalMs?: number; +}): boolean { + const poll = opts.pollIntervalMs ?? 200; + const deadline = Date.now() + opts.timeoutMs; + while (Date.now() < deadline) { + try { + const result = js({ expression: opts.expression, tabId: opts.tabId }); + if (result === "true") return true; + } catch { + // Tab may still be loading; keep polling + } + const wait = Math.min(poll, Math.max(0, deadline - Date.now())); + if (wait <= 0) break; + // Real sleep, not a busy-wait: this poll now runs on every diagram-render + // bundle load (and after every fence render error), exactly while Chromium + // is parsing a 9MB page on the same machine — spinning a core competes + // with the work being awaited. + Bun.sleepSync(wait); + } + return false; +} + +/** + * Generate a PDF from the given tab. Uses --from-file when header/footer + * templates are present (they can be HTML strings of arbitrary size). + */ +export function pdf(opts: PdfOptions): void { + // If any large payload is present, send via --from-file + const hasLargePayload = + (opts.headerTemplate && opts.headerTemplate.length > 1024) || + (opts.footerTemplate && opts.footerTemplate.length > 1024); + + if (hasLargePayload) { + const payloadFile = writePayloadFile({ + output: opts.output, + tabId: opts.tabId, + ...optionsToPdfFlags(opts), + }); + try { + runBrowse(["pdf", "--from-file", payloadFile]); + } finally { + cleanupPayloadFile(payloadFile); + } + return; + } + + // Small payload: pass flags via argv + const args = ["pdf", opts.output, "--tab-id", String(opts.tabId)]; + pushFlagsFromOptions(args, opts); + runBrowse(args); +} + +function optionsToPdfFlags(opts: PdfOptions): Record { + // Shape mirrors what the browse `pdf` case expects when reading --from-file + const out: Record = {}; + if (opts.format) out.format = opts.format; + if (opts.width) out.width = opts.width; + if (opts.height) out.height = opts.height; + if (opts.marginTop) out.marginTop = opts.marginTop; + if (opts.marginRight) out.marginRight = opts.marginRight; + if (opts.marginBottom) out.marginBottom = opts.marginBottom; + if (opts.marginLeft) out.marginLeft = opts.marginLeft; + if (opts.headerTemplate !== undefined) out.headerTemplate = opts.headerTemplate; + if (opts.footerTemplate !== undefined) out.footerTemplate = opts.footerTemplate; + if (opts.pageNumbers !== undefined) out.pageNumbers = opts.pageNumbers; + if (opts.tagged !== undefined) out.tagged = opts.tagged; + if (opts.outline !== undefined) out.outline = opts.outline; + if (opts.printBackground !== undefined) out.printBackground = opts.printBackground; + if (opts.preferCSSPageSize !== undefined) out.preferCSSPageSize = opts.preferCSSPageSize; + if (opts.toc !== undefined) out.toc = opts.toc; + return out; +} + +function pushFlagsFromOptions(args: string[], opts: PdfOptions): void { + if (opts.format) { args.push("--format", opts.format); } + if (opts.width) { args.push("--width", opts.width); } + if (opts.height) { args.push("--height", opts.height); } + if (opts.marginTop) { args.push("--margin-top", opts.marginTop); } + if (opts.marginRight) { args.push("--margin-right", opts.marginRight); } + if (opts.marginBottom) { args.push("--margin-bottom", opts.marginBottom); } + if (opts.marginLeft) { args.push("--margin-left", opts.marginLeft); } + if (opts.headerTemplate !== undefined) { + args.push("--header-template", opts.headerTemplate); + } + if (opts.footerTemplate !== undefined) { + args.push("--footer-template", opts.footerTemplate); + } + if (opts.pageNumbers === true) args.push("--page-numbers"); + if (opts.tagged === true) args.push("--tagged"); + if (opts.outline === true) args.push("--outline"); + if (opts.printBackground === true) args.push("--print-background"); + if (opts.preferCSSPageSize === true) args.push("--prefer-css-page-size"); + if (opts.toc === true) args.push("--toc"); +} diff --git a/make-pdf/src/cli.ts b/make-pdf/src/cli.ts new file mode 100644 index 0000000000..988e204449 --- /dev/null +++ b/make-pdf/src/cli.ts @@ -0,0 +1,275 @@ +#!/usr/bin/env bun +/** + * make-pdf CLI — argv parse, dispatch, exit. + * + * Output contract (per CEO plan DX spec): + * stdout: ONLY the output path on success. One line. Nothing else. + * stderr: progress spinner per stage, final "Done in Xs. N pages." + * --quiet: suppress progress. Errors still print. + * --verbose: per-stage timings. + * exit 0 success / 1 bad args / 2 render error / 3 Paged.js timeout / 4 browse unavailable. + */ + +import { COMMANDS } from "./commands"; +import { ExitCode, BrowseClientError } from "./types"; +import type { GenerateOptions, PreviewOptions } from "./types"; + +interface ParsedArgs { + command: string; + positional: string[]; + flags: Record; +} + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2); + if (args.length === 0) { + printUsage(); + process.exit(ExitCode.Success); + } + + // First non-flag arg is the command. + let command = ""; + const positional: string[] = []; + const flags: Record = {}; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith("--")) { + const key = a.slice(2); + const next = args[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else if (!command) { + command = a; + } else { + positional.push(a); + } + } + + return { command, positional, flags }; +} + +function printUsage(): void { + const lines = [ + "make-pdf — turn markdown into publication-quality PDFs", + "", + "Usage:", + ]; + for (const [name, info] of COMMANDS) { + lines.push(` $P ${info.usage}`); + lines.push(` ${info.description}`); + } + lines.push(""); + lines.push("Output format:"); + lines.push(" --to pdf|html|docx What to produce (default: pdf)."); + lines.push(" html = single self-contained file, no network refs."); + lines.push(" docx = content fidelity, diagrams as PNG."); + lines.push(""); + lines.push("Page layout:"); + lines.push(" --margins All four margins (default: 1in). in, pt, cm, mm."); + lines.push(" --page-size letter|a4|legal (aliases: --format — page SIZE, not output format)"); + lines.push(""); + lines.push("Document structure:"); + lines.push(" --cover Add a cover page."); + lines.push(" --toc Generate clickable table of contents."); + lines.push(" --no-chapter-breaks Don't start a new page at every H1."); + lines.push(""); + lines.push("Branding:"); + lines.push(" --watermark Diagonal watermark on every page."); + lines.push(" --header-template "); + lines.push(" --footer-template Mutex with --page-numbers."); + lines.push(" --no-confidential Suppress the CONFIDENTIAL footer."); + lines.push(""); + lines.push("Output control:"); + lines.push(" --page-numbers / --no-page-numbers (default: on)"); + lines.push(" --tagged / --no-tagged (default: on, accessible PDF)"); + lines.push(" --outline / --no-outline (default: on, PDF bookmarks)"); + lines.push(" --quiet Suppress progress on stderr."); + lines.push(" --verbose Per-stage timings on stderr."); + lines.push(""); + lines.push("Diagrams & images:"); + lines.push(" ```mermaid / ```excalidraw fences render as vector diagrams."); + lines.push(" Add render=false to a fence info string to keep it as a code block."); + lines.push(" Local images are inlined; oversized rasters downscale to print resolution."); + lines.push(" --strict Missing/remote images fail the run (CI mode)."); + lines.push(""); + lines.push("Network:"); + lines.push(" --allow-network Load external images (off by default)."); + lines.push(""); + lines.push("Examples:"); + lines.push(" $P generate letter.md"); + lines.push(" $P generate --cover --toc essay.md essay.pdf"); + lines.push(" $P generate --watermark DRAFT memo.md draft.pdf"); + lines.push(" $P preview letter.md"); + lines.push(""); + lines.push("Run `$P setup` to verify browse + Chromium + pdftotext install."); + console.error(lines.join("\n")); +} + +function generateOptionsFromFlags(parsed: ParsedArgs): GenerateOptions { + const p = parsed.positional; + if (p.length === 0) { + console.error("$P generate: missing "); + console.error("Usage: $P generate [output.pdf] [options]"); + process.exit(ExitCode.BadArgs); + } + const f = parsed.flags; + const booleanFlag = (key: string, def: boolean): boolean => { + if (f[key] === true) return true; + if (f[`no-${key}`] === true) return false; + return def; + }; + const to = typeof f.to === "string" ? f.to.toLowerCase() : "pdf"; + if (to !== "pdf" && to !== "html" && to !== "docx") { + console.error(`$P generate: invalid --to '${f.to}'. Expected pdf, html, or docx.`); + console.error("(--format is a --page-size alias, not the output format.)"); + process.exit(ExitCode.BadArgs); + } + return { + input: p[0], + output: p[1], + to: to as GenerateOptions["to"], + margins: f.margins as string | undefined, + marginTop: f["margin-top"] as string | undefined, + marginRight: f["margin-right"] as string | undefined, + marginBottom: f["margin-bottom"] as string | undefined, + marginLeft: f["margin-left"] as string | undefined, + pageSize: ((f["page-size"] ?? f.format) as any), + cover: f.cover === true, + toc: f.toc === true, + noChapterBreaks: f["no-chapter-breaks"] === true, + watermark: typeof f.watermark === "string" ? f.watermark : undefined, + headerTemplate: typeof f["header-template"] === "string" + ? f["header-template"] : undefined, + footerTemplate: typeof f["footer-template"] === "string" + ? f["footer-template"] : undefined, + confidential: booleanFlag("confidential", true), + pageNumbers: booleanFlag("page-numbers", true), + tagged: booleanFlag("tagged", true), + outline: booleanFlag("outline", true), + quiet: f.quiet === true, + verbose: f.verbose === true, + allowNetwork: f["allow-network"] === true, + strict: f.strict === true, + title: typeof f.title === "string" ? f.title : undefined, + author: typeof f.author === "string" ? f.author : undefined, + date: typeof f.date === "string" ? f.date : undefined, + }; +} + +function previewOptionsFromFlags(parsed: ParsedArgs): PreviewOptions { + const p = parsed.positional; + if (p.length === 0) { + console.error("$P preview: missing "); + console.error("Usage: $P preview [options]"); + process.exit(ExitCode.BadArgs); + } + const f = parsed.flags; + const booleanFlag = (key: string, def: boolean): boolean => { + if (f[key] === true) return true; + if (f[`no-${key}`] === true) return false; + return def; + }; + return { + input: p[0], + cover: f.cover === true, + toc: f.toc === true, + watermark: typeof f.watermark === "string" ? f.watermark : undefined, + noChapterBreaks: f["no-chapter-breaks"] === true, + confidential: booleanFlag("confidential", true), + allowNetwork: f["allow-network"] === true, + title: typeof f.title === "string" ? f.title : undefined, + author: typeof f.author === "string" ? f.author : undefined, + date: typeof f.date === "string" ? f.date : undefined, + quiet: f.quiet === true, + verbose: f.verbose === true, + }; +} + +async function main(): Promise { + const parsed = parseArgs(process.argv); + + if (!parsed.command) { + printUsage(); + process.exit(ExitCode.BadArgs); + } + + if (!COMMANDS.has(parsed.command)) { + console.error(`$P: unknown command: ${parsed.command}`); + console.error(""); + printUsage(); + process.exit(ExitCode.BadArgs); + } + + try { + switch (parsed.command) { + case "version": { + // Read from VERSION file or fall back to a hard-coded default. + try { + const fs = await import("node:fs"); + const path = await import("node:path"); + const versionFile = path.resolve( + path.dirname(process.argv[1] || ""), + "../../VERSION", + ); + const version = fs.readFileSync(versionFile, "utf8").trim(); + console.log(version); + } catch { + console.log("make-pdf (version unknown)"); + } + process.exit(ExitCode.Success); + } + + case "setup": { + const { runSetup } = await import("./setup"); + await runSetup(); + process.exit(ExitCode.Success); + } + + case "generate": { + const opts = generateOptionsFromFlags(parsed); + const { generate } = await import("./orchestrator"); + const outputPath = await generate(opts); + // Contract: stdout = output path only + console.log(outputPath); + process.exit(ExitCode.Success); + } + + case "preview": { + const opts = previewOptionsFromFlags(parsed); + const { preview } = await import("./orchestrator"); + const htmlPath = await preview(opts); + console.log(htmlPath); + process.exit(ExitCode.Success); + } + + default: + // Unreachable: COMMANDS.has guarded above + process.exit(ExitCode.BadArgs); + } + } catch (err: any) { + if (err instanceof BrowseClientError) { + console.error(`$P: ${err.message}`); + process.exit(ExitCode.BrowseUnavailable); + } + if (err?.code === "ENOENT") { + console.error(`$P: file not found: ${err.path ?? err.message}`); + process.exit(ExitCode.BadArgs); + } + if (err?.name === "PagedJsTimeout") { + console.error(`$P: ${err.message}`); + process.exit(ExitCode.PagedJsTimeout); + } + console.error(`$P: ${err?.message ?? String(err)}`); + if (parsed.flags.verbose && err?.stack) { + console.error(err.stack); + } + process.exit(ExitCode.RenderError); + } +} + +main(); diff --git a/make-pdf/src/commands.ts b/make-pdf/src/commands.ts new file mode 100644 index 0000000000..a5e781d1e2 --- /dev/null +++ b/make-pdf/src/commands.ts @@ -0,0 +1,62 @@ +/** + * Command registry for make-pdf — single source of truth. + * + * Dependency graph: + * commands.ts ──▶ cli.ts (runtime dispatch) + * ──▶ gen-skill-docs.ts (generates usage table in SKILL.md) + * ──▶ tests (validation) + * + * Zero side effects. Safe to import from build scripts. + */ + +export const COMMANDS = new Map([ + ["generate", { + description: "Render a markdown file to a publication-quality PDF", + usage: "generate [output.pdf] [options]", + category: "Primary", + flags: [ + // Page layout + "--margins", "--margin-top", "--margin-right", "--margin-bottom", "--margin-left", + "--page-size", "--format", + // Structure + "--cover", "--toc", "--no-chapter-breaks", + // Branding + "--watermark", "--header-template", "--footer-template", "--no-confidential", + // Output + "--page-numbers", "--no-page-numbers", "--tagged", "--no-tagged", + "--outline", "--no-outline", "--quiet", "--verbose", + // Network + "--allow-network", + // Metadata + "--title", "--author", "--date", + ], + }], + ["preview", { + description: "Render markdown to HTML and open it in the browser (fast iteration)", + usage: "preview [options]", + category: "Primary", + flags: [ + "--cover", "--toc", "--no-chapter-breaks", "--watermark", + "--no-confidential", "--allow-network", + "--title", "--author", "--date", + "--quiet", "--verbose", + ], + }], + ["setup", { + description: "Verify browse + Chromium + pdftotext, then run a smoke test", + usage: "setup", + category: "Setup", + flags: [], + }], + ["version", { + description: "Print make-pdf version", + usage: "version", + category: "Setup", + flags: [], + }], +]); diff --git a/make-pdf/src/diagram-prepass.ts b/make-pdf/src/diagram-prepass.ts new file mode 100644 index 0000000000..bf12249bbc --- /dev/null +++ b/make-pdf/src/diagram-prepass.ts @@ -0,0 +1,846 @@ +/** + * Diagram + image pre-pass. Runs between "read markdown" and render() in the + * orchestrator, and owns everything that needs the diagram-render bundle. + * + * markdown ─▶ extractDiagramFences() ──▶ render() (marked+sanitize+smarty) + * │ fences → placeholder tokens │ + * │ ▼ + * └─▶ renderFenceSlots() ───────────▶ substituteSlots(html, slots) + * one browse render tab/run │ + * error ⇒ diagnostic block + page reload ▼ + * inlineLocalImages(html) + * data URIs, probe dims from bytes, + * downscale >2x content box @300dpi, + * remote warn / missing placeholder / + * --strict hard-fail + * + * Placeholders survive marked, the sanitizer, and smartypants because they are + * plain hyphenated lowercase tokens with no quotes or HTML. Slot HTML is run + * through the same sanitizer as user content before substitution (the bundle + * renders with securityLevel strict — the sanitizer is the second layer). + * + * Reset contract (eng-review D6.2): each fence renders with a fresh + * mermaid.render id; after ANY render error the bundle page is reloaded before + * the next fence so a poisoned global can't corrupt diagram N+1. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; + +import * as browseClient from "./browseClient"; +import { escapeHtml, sanitizeUntrustedHtml } from "./render"; +import { imageDims } from "./image-size"; + +// ─── Types ──────────────────────────────────────────────────────────── + +export interface DiagramFence { + /** "mermaid" | "excalidraw" */ + lang: string; + /** Fence body (the diagram source). */ + source: string; + /** Optional title="..." from the fence info string (a11y label, D6.4). */ + title?: string; + /** Optional page=landscape|portrait fence directive (image-policy override). */ + page?: "landscape" | "portrait"; + /** render=false → leave as a plain code block (escape hatch, D6.3). */ + render: boolean; + /** Placeholder token substituted into the markdown. */ + token: string; + /** 1-based ordinal among rendered fences (unique ids, aria fallback). */ + ordinal: number; +} + +export interface FenceExtraction { + markdown: string; + fences: DiagramFence[]; +} + +export interface PrepassWarnings { + warn: (msg: string) => void; +} + +export interface PrepassImageOptions { + /** Directory of the source markdown — relative image paths resolve here. */ + inputDir: string; + /** Hard-fail on missing/remote images instead of warn (D6.1). */ + strict: boolean; + /** Remote images are left untouched when network is explicitly allowed. */ + allowNetwork: boolean; + /** Physical content-box width in inches (page width minus margins). */ + contentWidthIn: number; + warn: (msg: string) => void; + /** Lazily provides a ready bundle tab (only opened when needed). */ + getTab: () => RenderTab | null; +} + +/** Print-resolution policy (eng-review D4): downscale rasters wider than + * 2 × contentWidth × 300dpi down to contentWidth × 300dpi. */ +const PRINT_DPI = 300; +const DOWNSCALE_FACTOR = 2; +/** Per-image read ceiling — bounds memory before any policy runs. */ +const MAX_IMAGE_BYTES = 64 * 1024 * 1024; + +export class StrictModeError extends Error { + constructor(msg: string) { + super(msg); + this.name = "StrictModeError"; + } +} + +// ─── Fence extraction (pure) ────────────────────────────────────────── + +const DIAGRAM_LANGS = new Set(["mermaid", "excalidraw"]); + +/** + * Extract column-0 ```mermaid / ```excalidraw fences, replacing each with a + * unique placeholder token paragraph. Backtick and tilde fences, any length + * >= 3; closers must be at least as long as the opener (CommonMark). Fences + * with `render=false` are left untouched. + * + * Two deliberate conservatisms (red-team finding — the original version + * reconstructed fences at column 0 and restructured lists): + * - Non-diagram fences replay as their ORIGINAL raw lines, byte-for-byte + * (only a render=false flag is removed, in place, preserving indent). + * - INDENTED diagram fences (inside lists/quotes) are NOT extracted — a + * column-0 placeholder would split the list. They replay verbatim as code. + */ +export function extractDiagramFences(markdown: string): FenceExtraction { + const lines = markdown.split("\n"); + const out: string[] = []; + const fences: DiagramFence[] = []; + const runId = crypto.randomBytes(4).toString("hex"); + + let i = 0; + let openFence: { + char: string; len: number; indent: number; info: string; + rawOpener: string; body: string[]; + } | null = null; + let ordinal = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (openFence) { + const close = matchFenceLine(line); + if (close && close.char === openFence.char && close.len >= openFence.len && close.info === "") { + const info = parseInfoString(openFence.info); + if (DIAGRAM_LANGS.has(info.lang) && info.render && openFence.indent === 0) { + ordinal++; + const token = `gstack-diagram-slot-${runId}-${ordinal}`; + fences.push({ + lang: info.lang, + source: openFence.body.join("\n"), + title: info.title, + page: info.page, + render: true, + token, + ordinal, + }); + out.push("", token, ""); + } else { + // Not extracted (other language, render=false, or indented): replay + // the ORIGINAL lines verbatim; only strip a render=false flag. + out.push(stripRenderFalse(openFence.rawOpener)); + out.push(...openFence.body); + out.push(line); + } + openFence = null; + i++; + continue; + } + openFence.body.push(line); + i++; + continue; + } + + const open = matchFenceLine(line); + if (open && open.info !== "") { + openFence = { ...open, rawOpener: line, body: [] }; + i++; + continue; + } + if (open) { + // Anonymous fence (plain code block) — copy through to its closer so a + // ```mermaid example INSIDE a plain fence is never extracted. + out.push(line); + i++; + while (i < lines.length) { + const l = lines[i]; + const close = matchFenceLine(l); + out.push(l); + i++; + if (close && close.char === open.char && close.len >= open.len && close.info === "") break; + } + continue; + } + + out.push(line); + i++; + } + + // Unclosed fence at EOF: replay verbatim (CommonMark treats it as code to EOF). + if (openFence) { + out.push(openFence.rawOpener); + out.push(...openFence.body); + } + + return { markdown: out.join("\n"), fences }; +} + +function matchFenceLine(line: string): { char: string; len: number; indent: number; info: string } | null { + const m = line.match(/^( {0,3})(`{3,}|~{3,})\s*(.*)$/); + if (!m) return null; + return { indent: m[1].length, char: m[2][0], len: m[2].length, info: m[3].trim() }; +} + +/** Remove a render=false flag from a raw opener line, preserving everything else. */ +function stripRenderFalse(rawOpener: string): string { + return rawOpener.replace(/\s*\brender\s*=\s*false\b/i, ""); +} + +/** Parse a fence info string: `mermaid`, `mermaid render=false`, + * `mermaid title="Auth flow"`, `mermaid page=landscape`. */ +export function parseInfoString(info: string): { + lang: string; render: boolean; title?: string; page?: "landscape" | "portrait"; +} { + const lang = (info.match(/^\S+/)?.[0] ?? "").toLowerCase(); + const render = !/\brender\s*=\s*false\b/i.test(info); + const title = info.match(/\btitle\s*=\s*"([^"]*)"/i)?.[1] + ?? info.match(/\btitle\s*=\s*'([^']*)'/i)?.[1]; + const pageRaw = info.match(/\bpage\s*=\s*(landscape|portrait)\b/i)?.[1]?.toLowerCase(); + const page = pageRaw === "landscape" || pageRaw === "portrait" ? pageRaw : undefined; + return { lang, render, title, page }; +} + +// ─── Slot substitution (pure) ───────────────────────────────────────── + +/** + * Replace placeholder tokens in rendered HTML with their final slot HTML. + * marked wraps the bare token line in

; replace the wrapper too so + * the figure isn't nested inside a paragraph. + */ +export function substituteSlots(html: string, slots: Map): string { + let s = html; + for (const [token, slotHtml] of slots) { + // Function replacement is load-bearing: slot HTML carries user/LLM-authored + // diagram label text, and string-form replace() expands $&, $', $` patterns + // inside it — a label containing "$'" would duplicate the document tail. + const wrapped = new RegExp(`

\\s*${token}\\s*

`, "g"); + const replaced = s.replace(wrapped, () => slotHtml); + s = replaced !== s ? replaced : s.split(token).join(slotHtml); + } + return s; +} + +/** + * Visible diagnostic block for a failed fence render — never silent raw code + * (eng-review: explicit error blocks). Sanitizer-safe: all dynamic content is + * HTML-escaped. + */ +export function buildDiagnosticBlock(fence: DiagramFence, errorMessage: string): string { + const excerpt = fence.source.split("\n").slice(0, 8).join("\n"); + const truncated = fence.source.split("\n").length > 8 ? "\n…" : ""; + return [ + ``, + ].join("\n"); +} + +/** + * Wrap a rendered SVG in an accessible figure (D6.4). The raw fence source is + * preserved base64-encoded in a data attribute — an HTML comment would need + * `--` escaping, which corrupts every mermaid arrow (`-->`) and breaks + * round-trip recovery. + */ +export function buildDiagramFigure(fence: DiagramFence, svg: string): string { + const label = diagramLabel(fence); + const cleanSvg = sanitizeUntrustedHtml(svg); + const captioned = fence.title + ? `\n
${escapeHtml(fence.title)}
` + : ""; + const pageAttr = fence.page ? ` data-gstack-page="${fence.page}"` : ""; + const sourceB64 = Buffer.from(fence.source, "utf8").toString("base64"); + return [ + ``, + ].join("\n"); +} + +/** Recover the original fence source from a rendered figure (round-trip). */ +export function decodeFigureSource(figureHtml: string): string | null { + const m = figureHtml.match(/\bdata-gstack-source="([A-Za-z0-9+/=]*)"/); + if (!m) return null; + try { + return Buffer.from(m[1], "base64").toString("utf8"); + } catch { + return null; + } +} + +function diagramLabel(fence: DiagramFence): string { + return fence.title ?? `diagram ${fence.ordinal}`; +} + +// ─── Render tab (bundle page lifecycle) ─────────────────────────────── + +const PAYLOAD_TMP_DIR = process.platform === "win32" ? os.tmpdir() : "/tmp"; +const READY_TIMEOUT_MS = 20_000; +// Expressions bigger than this ship via `browse eval ` instead of argv. +// 8KB is safe on every platform (Windows CreateProcess caps the WHOLE command +// line at 32,767 chars; Linux MAX_ARG_STRLEN is ~128KiB) and the tmp-file +// round-trip costs microseconds — one spawn regardless of payload size. +const MAX_ARGV_EXPR_BYTES = 8_000; + +export class RenderTab { + private constructor( + public readonly tabId: number, + private readonly stagedBundlePath: string, + ) {} + + /** + * Open a tab and load the diagram-render bundle. The bundle HTML is staged + * under /tmp (content-addressed, reused across runs — load-html only reads + * inside its safe dirs) and loaded by PATH, not --from-file: a 9MB JSON + * round-trip per run would be pure waste. + */ + static open(): RenderTab { + const bundleSrc = resolveBundlePath(); + const html = fs.readFileSync(bundleSrc); + const sha = crypto.createHash("sha256").update(html).digest("hex").slice(0, 16); + const staged = path.join(PAYLOAD_TMP_DIR, `gstack-diagram-render-${sha}.html`); + // Never trust an existing file at the predictable shared-/tmp name: verify + // its content hash and re-stage on mismatch (a pre-planted file would + // otherwise be loaded into the render tab as the bundle). + let needsWrite = true; + if (fs.existsSync(staged)) { + try { + const existing = crypto.createHash("sha256").update(fs.readFileSync(staged)).digest("hex").slice(0, 16); + needsWrite = existing !== sha; + } catch { + needsWrite = true; + } + } + if (needsWrite) { + // Concurrent-safe: write to a unique temp name, then atomic rename. + const tmp = `${staged}.${process.pid}.${crypto.randomBytes(4).toString("hex")}`; + fs.writeFileSync(tmp, html); + try { + fs.renameSync(tmp, staged); + } catch (renameErr) { + try { fs.unlinkSync(tmp); } catch { /* best-effort tmp cleanup */ } + // Only swallow the rename failure when the surviving file HASHES to + // the expected bundle (a concurrent writer won an OS-level race). + // Sticky-bit /tmp makes rename-over-foreign-file fail EPERM — if the + // survivor were trusted on existence alone, a pre-planted file would + // ride through the exact check added to stop it. + let survivorOk = false; + try { + const survivor = crypto.createHash("sha256").update(fs.readFileSync(staged)).digest("hex").slice(0, 16); + survivorOk = survivor === sha; + } catch { /* unreadable survivor = not ok */ } + if (!survivorOk) throw renameErr; + } + } + const tabId = browseClient.newtab(); + const tab = new RenderTab(tabId, staged); + tab.loadBundle(); + return tab; + } + + /** (Re)load the bundle page — also the reset path after a render error. */ + loadBundle(): void { + browseClient.loadHtmlFile({ file: this.stagedBundlePath, tabId: this.tabId }); + const ready = browseClient.waitForExpression({ + expression: "document.getElementById('status') !== null && document.getElementById('status').textContent === 'ready'", + tabId: this.tabId, + timeoutMs: READY_TIMEOUT_MS, + }); + if (!ready) { + throw new Error( + "diagram-render bundle did not become ready in the browse tab " + + `(${READY_TIMEOUT_MS}ms). Check \`browse js "window.__errors"\` on tab ${this.tabId}.`, + ); + } + } + + /** + * Call one of the bundle's async window functions with JSON-safe string + * args. Errors come back as a recognizable ERR: prefix so a render failure + * is data, not a thrown browse exit. + */ + call(fn: string, ...args: Array): string { + const argList = args.map((a) => JSON.stringify(a)).join(","); + const expression = + `window.${fn}(${argList})` + + `.then(r => "OK:" + r)` + + `.catch(e => "ERR:" + String((e && e.message) || e))`; + const result = this.js(expression); + if (result.startsWith("OK:")) return result.slice(3); + if (result.startsWith("ERR:")) throw new RenderCallError(result.slice(4)); + throw new RenderCallError(`unexpected bundle result: ${result.slice(0, 200)}`); + } + + private js(expression: string): string { + // Large payloads (scene JSON, SVG text, data URIs) blow past argv limits — + // browseClient.js shells out with the expression as an argv element. The + // limit is BYTES, not chars (CJK content is 3x its char count in UTF-8), + // and Windows caps the whole command line at 32,767 chars — so anything + // big ships via `browse eval ` instead: one spawn, any size. + if (Buffer.byteLength(expression, "utf8") <= MAX_ARGV_EXPR_BYTES) { + return browseClient.js({ expression, tabId: this.tabId }); + } + return this.jsViaFile(expression); + } + + /** argv-safe path for big expressions: stage to a tmp file under browse's + * safe dirs and run `browse eval ` (one spawn regardless of size). */ + private jsViaFile(expression: string): string { + const file = path.join( + PAYLOAD_TMP_DIR, + `gstack-diagram-expr-${process.pid}-${crypto.randomBytes(4).toString("hex")}.js`, + ); + fs.writeFileSync(file, expression, "utf8"); + try { + return browseClient.evalFile({ file, tabId: this.tabId }); + } finally { + try { fs.unlinkSync(file); } catch { /* best-effort tmp cleanup */ } + } + } + + close(): void { + try { + browseClient.closetab(this.tabId); + } catch { + // best-effort: orchestrator finally path + } + } +} + +export class RenderCallError extends Error { + constructor(msg: string) { + super(msg); + this.name = "RenderCallError"; + } +} + +/** Resolve dist/diagram-render.html: env override → repo-relative (dev) → global install. */ +export function resolveBundlePath(env: NodeJS.ProcessEnv = process.env): string { + const candidates = [ + env.GSTACK_DIAGRAM_BUNDLE, + // dev: make-pdf/src/* → repo root lib/. (In a compiled binary this is the + // virtual /$bunfs/root and simply never exists — harmless.) + path.resolve(import.meta.dir, "../../lib/diagram-render/dist/diagram-render.html"), + // compiled binary at /make-pdf/dist/pdf → /lib/… — same shape + // in the repo and in the ~/.claude/skills/gstack global install. argv[0] + // is the literal string "bun" in compiled binaries; execPath is real. + path.resolve(path.dirname(process.execPath), "../../lib/diagram-render/dist/diagram-render.html"), + path.join(os.homedir(), ".claude/skills/gstack/lib/diagram-render/dist/diagram-render.html"), + ].filter((p): p is string => !!p); + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + throw new Error( + "diagram-render bundle not found. Tried:\n" + + candidates.map((c) => ` - ${c}`).join("\n") + + "\nRun `bun run build:diagram-render` (repo) or re-run ./setup (install).", + ); +} + +// ─── Fence rendering ────────────────────────────────────────────────── + +/** + * Render every extracted fence to its slot HTML. One bundle tab serves all + * fences; a failed fence yields a diagnostic block and a bundle reload + * (reset contract) before the next fence renders. + */ +export function renderFenceSlots( + fences: DiagramFence[], + tab: RenderTab, + warn: (msg: string) => void, +): Map { + const slots = new Map(); + for (const fence of fences) { + try { + let svg: string; + if (fence.lang === "mermaid") { + svg = tab.call("__renderMermaid", `mermaid-fence-${fence.ordinal}`, fence.source); + } else { + JSON.parse(fence.source); // fail fast with a JSON diagnostic, not a bundle stack + svg = tab.call("__excalidrawToSvg", fence.source); + } + slots.set(fence.token, buildDiagramFigure(fence, svg)); + } catch (err: any) { + const msg = err?.message ?? String(err); + warn(`diagram ${fence.ordinal} (${fence.lang}) failed to render: ${firstLine(msg)}`); + slots.set(fence.token, buildDiagnosticBlock(fence, msg)); + // Reset contract: a poisoned page must not corrupt the next fence. + try { + tab.loadBundle(); + } catch (reloadErr: any) { + warn(`bundle reload after render error failed: ${firstLine(reloadErr?.message ?? String(reloadErr))}`); + } + } + } + return slots; +} + +// ─── DOCX rasterization (eng-review D6.5, P8) ───────────────────────── + +/** + * Replace inline diagram SVGs (and svg data-URI images) with PNG tags + * for the DOCX export — Word's SVG support is unreliable, so the content- + * fidelity contract embeds rasters at 300dpi of the placed width (the + * content box). Diagnostic blocks keep their text form. + */ +export function rasterizeDiagramFigures( + html: string, + tab: RenderTab, + contentWidthIn: number, + warn: (msg: string) => void, +): string { + const targetPx = Math.round(contentWidthIn * PRINT_DPI); + + // 1. Rendered diagram figures → with the figure's aria-label as alt. + let out = html.replace( + /
]*>[\s\S]*?<\/figure>/gi, + (figure) => { + const svgMatch = figure.match(//i); + if (!svgMatch) return figure; + const label = figure.match(/\baria-label\s*=\s*"([^"]*)"/i)?.[1] ?? "diagram"; + try { + const png = tab.call("__rasterize", svgMatch[0], targetPx); + return `

${label}

`; + } catch (err: any) { + const reason = firstLine(err?.message ?? String(err)); + warn(`docx: diagram rasterization failed (${reason}); embedding source text instead`); + // The converter drops
/ entirely, so returning the figure + // would make the diagram vanish without a trace — the exact invisible + // failure the diagnostic contract forbids. Surface the source. + const source = decodeFigureSource(figure) ?? "(source unavailable)"; + return [ + `

Diagram could not be rasterized for DOCX (${escapeHtml(reason)}) — source:

`, + `
${escapeHtml(source)}
`, + ].join("\n"); + } + }, + ); + + // 2. SVG data-URI images (inlined .svg files) → PNG. + out = out.replace(/]*>/gi, (tag) => { + const m = tag.match(SRC_RE); + const src = m?.[2] ?? m?.[3] ?? ""; + if (!src.startsWith("data:image/svg+xml")) return tag; + try { + const b64 = src.slice(src.indexOf(",") + 1); + const svgText = Buffer.from(b64, "base64").toString("utf8"); + const png = tab.call("__rasterize", svgText, targetPx); + // Function replacement: data URIs can contain $-patterns. + return tag.replace(SRC_RE, () => `src="${png}"`); + } catch (err: any) { + warn(`docx: svg image rasterization failed (${firstLine(err?.message ?? String(err))})`); + return tag; + } + }); + + return out; +} + +/** + * Diagnostic figures → plain

/

 for the DOCX converter, which drops
+ * 
elements it can't map. An invisible error is the one thing the + * diagnostic contract forbids. Pure — no render tab needed. + */ +export function convertDiagnosticsForDocx(html: string): string { + return html.replace( + /
]*>([\s\S]*?)<\/figure>/gi, + (_full, body: string) => { + const title = body.match(/]*>([\s\S]*?)<\/figcaption>/i)?.[1] ?? "Diagram failed to render"; + const detail = body.match(/]*>([\s\S]*?)<\/pre>/i)?.[1] ?? ""; + return `

${title}

\n
${detail}
`; + }, + ); +} + +// ─── Image inlining (eng-review D1 + D4 + D6.1) ─────────────────────── + +const IMG_TAG_RE = /]*>/gi; +const SRC_RE = /\bsrc\s*=\s*("([^"]*)"|'([^']*)')/i; + +/** + * Inline every local as a data URI, probe intrinsic dimensions from the + * bytes, and annotate the tag with data-gstack-px-width/-height for the width + * policy. Oversized rasters are downscaled to print resolution via the bundle + * tab. Missing files become visible placeholders (or throw under --strict); + * remote URLs warn (offline posture) unless --allow-network. + */ +export function inlineLocalImages(html: string, opts: PrepassImageOptions): string { + const maxPx = Math.round(opts.contentWidthIn * PRINT_DPI * DOWNSCALE_FACTOR); + const targetPx = Math.round(opts.contentWidthIn * PRINT_DPI); + // An image referenced N times is read/probed/downscaled once; the same data + // URI string is reused (also dedupes memory until the final join). + const memo = new Map(); + + return html.replace(IMG_TAG_RE, (tag) => { + const srcMatch = tag.match(SRC_RE); + if (!srcMatch) return tag; + const src = srcMatch[2] ?? srcMatch[3] ?? ""; + + if (src.startsWith("data:")) return annotateFromDataUri(tag, src); + + // Windows drive-letter paths (C:/x.png, C:\x.png) look like single-letter + // URL schemes — they are local paths, not URLs. + const isDrivePath = /^[a-zA-Z]:[\\/]/.test(src); + + if (!isDrivePath && /^[a-z][a-z0-9+.-]*:/i.test(src)) { + // Absolute URL with a scheme (http, https, file, …) + if (opts.allowNetwork && /^https?:/i.test(src)) return tag; + if (/^https?:/i.test(src)) { + const msg = `remote image blocked (offline posture): ${src}`; + if (opts.strict) throw new StrictModeError(msg + " — re-run without --strict or pass --allow-network"); + opts.warn(msg); + // Leaving the tag would make Chromium fetch it at print time anyway — + // the warn would be a lie. Replace with a visible placeholder. + return buildBlockedRemotePlaceholder(src); + } + // file:// and friends fall through to the local path branch + if (!src.startsWith("file:")) return tag; + } + + // decodeURIComponent throws on malformed escapes (foo%zz.png) — a broken + // URL must degrade to the missing-image path, not crash the run. + let decodedSrc = src; + try { + decodedSrc = decodeURIComponent(src); + } catch { /* keep raw src */ } + + const filePath = src.startsWith("file:") + ? fileURLToPath(src) + : isDrivePath + ? path.resolve(src) + : path.resolve(opts.inputDir, decodedSrc); + + const cached = memo.get(filePath); + if (cached !== undefined) return rewriteImgTag(tag, cached); + + if (!fs.existsSync(filePath)) { + const msg = `image not found: ${src} (resolved to ${filePath})`; + if (opts.strict) throw new StrictModeError(msg); + opts.warn(msg); + return buildMissingImagePlaceholder(src); + } + + // Out-of-tree reads are legal (local CLI semantics — like pandoc) but + // never silent: an agent PDF-ing untrusted markdown should not quietly + // embed ~/.ssh/config into a shareable document. --strict makes it fatal. + // Compare REAL paths — a symlink inside the input dir pointing outside + // would otherwise pass a string-prefix check (Codex adversarial finding). + // Runs after the existence check: realpath of a missing file can't + // resolve, and on macOS /var vs /private/var would false-positive. + const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep; + const realFilePath = safeRealpath(filePath); + if (!realFilePath.startsWith(inputRoot)) { + const msg = `image resolves OUTSIDE the input directory: ${src} → ${realFilePath}`; + if (opts.strict) throw new StrictModeError(msg + " — move it under the markdown's directory or drop --strict"); + opts.warn(msg); + } + + // Bound the read BEFORE reading: a markdown image pointing at a special + // file (fifo, device) would hang readFileSync, and a multi-GB file would + // exhaust memory before any policy ran. + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + opts.warn(`image unreadable: ${src}`); + return buildMissingImagePlaceholder(src); + } + if (!stat.isFile()) { + const msg = `image is not a regular file: ${src}`; + if (opts.strict) throw new StrictModeError(msg); + opts.warn(msg); + return buildMissingImagePlaceholder(src); + } + if (stat.size > MAX_IMAGE_BYTES) { + const msg = `image exceeds ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)}MB cap: ${src} (${Math.round(stat.size / 1024 / 1024)}MB)`; + if (opts.strict) throw new StrictModeError(msg); + opts.warn(msg); + return buildMissingImagePlaceholder(src); + } + + let buf = fs.readFileSync(filePath); + let dims = imageDims(buf); + let mime = dims?.mime ?? mimeFromExtension(filePath); + + // Print-resolution normalization (D4): rasters only — SVG scales free. + if (dims && mime !== "image/svg+xml" && dims.width > maxPx) { + const tab = opts.getTab(); + if (tab) { + try { + const dataUri = `data:${mime};base64,${buf.toString("base64")}`; + const scaled = tab.call("__downscaleRaster", dataUri, targetPx, mime); + const scaledB64 = scaled.replace(/^data:[^,]*,/, ""); + opts.warn( + `downscaled ${path.basename(filePath)} ${dims.width}px → ${targetPx}px ` + + `(print is ${PRINT_DPI}dpi; original exceeds ${maxPx}px content-box ceiling)`, + ); + buf = Buffer.from(scaledB64, "base64"); + mime = scaled.slice(5, scaled.indexOf(";")); + dims = { ...dims, height: Math.round((dims.height * targetPx) / dims.width), width: targetPx }; + } catch (err: any) { + opts.warn(`downscale failed for ${src}, inlining at full size: ${firstLine(err?.message ?? String(err))}`); + } + } + } + + const dataUri = `data:${mime};base64,${buf.toString("base64")}`; + const attrs = dims + ? ` data-gstack-px-width="${Math.round(dims.width)}" data-gstack-px-height="${Math.round(dims.height)}"` + : ""; + memo.set(filePath, { dataUri, attrs }); + return rewriteImgTag(tag, memo.get(filePath)!); + }); +} + +/** Apply a memoized inline result to an img tag. */ +function rewriteImgTag(tag: string, entry: { dataUri: string; attrs: string }): string { + // Function replacement: data URIs are user-content-derived; string-form + // replace() would expand $-patterns inside them. + let out = tag.replace(SRC_RE, () => `src="${entry.dataUri}"`); + if (entry.attrs) out = out.replace(/^ `` + + `[missing image: ${escapeHtml(src)}]` + ); +} + +function buildBlockedRemotePlaceholder(src: string): string { + return ( + `` + + `[remote image blocked (use --allow-network): ${escapeHtml(src)}]` + ); +} + +/** realpath that degrades to the input path when resolution fails. */ +function safeRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +function mimeFromExtension(p: string): string { + switch (path.extname(p).toLowerCase()) { + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".gif": return "image/gif"; + case ".webp": return "image/webp"; + case ".svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } +} + +// ─── Content-box math ───────────────────────────────────────────────── + +const PAGE_WIDTHS_IN: Record = { + letter: 8.5, + a4: 8.27, + legal: 8.5, + tabloid: 11, +}; + +/** Parse a CSS dimension ("1in" | "72pt" | "25mm" | "2.54cm") to inches. */ +export function dimToInches(dim: string | undefined, fallbackIn: number): number { + if (!dim) return fallbackIn; + const m = dim.trim().match(/^([0-9.]+)\s*(in|pt|cm|mm|px)?$/i); + if (!m) return fallbackIn; + const v = parseFloat(m[1]); + switch ((m[2] ?? "in").toLowerCase()) { + case "in": return v; + case "pt": return v / 72; + case "cm": return v / 2.54; + case "mm": return v / 25.4; + case "px": return v / 96; + default: return fallbackIn; + } +} + +export function contentWidthInches(opts: { + pageSize?: string; + margins?: string; + marginLeft?: string; + marginRight?: string; +}): number { + const pageW = PAGE_WIDTHS_IN[opts.pageSize ?? "letter"] ?? 8.5; + const left = dimToInches(opts.marginLeft ?? opts.margins, 1); + const right = dimToInches(opts.marginRight ?? opts.margins, 1); + return Math.max(1, pageW - left - right); +} + +const PAGE_HEIGHTS_IN: Record = { + letter: 11, + a4: 11.69, + legal: 14, + tabloid: 17, +}; + +/** + * Content box of the rotated (landscape) named page: portrait page HEIGHT + * becomes the landscape width; portrait WIDTH becomes the landscape height. + * Used by image-policy to vertically center promoted blocks. + */ +export function landscapeContentBox(opts: { + pageSize?: string; + margins?: string; + marginLeft?: string; + marginRight?: string; + marginTop?: string; + marginBottom?: string; +}): { contentWIn: number; contentHIn: number } { + const size = opts.pageSize ?? "letter"; + const pageH = PAGE_HEIGHTS_IN[size] ?? 11; + const pageW = PAGE_WIDTHS_IN[size] ?? 8.5; + const left = dimToInches(opts.marginLeft ?? opts.margins, 1); + const right = dimToInches(opts.marginRight ?? opts.margins, 1); + const top = dimToInches(opts.marginTop ?? opts.margins, 1); + const bottom = dimToInches(opts.marginBottom ?? opts.margins, 1); + return { + contentWIn: Math.max(1, pageH - left - right), + contentHIn: Math.max(1, pageW - top - bottom), + }; +} + +// ─── tiny helpers ───────────────────────────────────────────────────── +// escapeHtml is imported from ./render — single definition, no drift. + +function firstLine(s: string): string { + return s.split("\n")[0].slice(0, 200); +} diff --git a/make-pdf/src/image-policy.ts b/make-pdf/src/image-policy.ts new file mode 100644 index 0000000000..2700ae011a --- /dev/null +++ b/make-pdf/src/image-policy.ts @@ -0,0 +1,236 @@ +/** + * Image width policy + conservative auto-landscape (eng-review P4, D4 spec). + * + * Two pure passes over rendered HTML: + * + * 1. applyImageDirectives — runs inside render() right after marked, before + * the sanitizer. Translates the markdown-adjacent directive suffix + * `![alt](x.png){width=50%}` / `{page=landscape}` into data-gstack-* + * attributes (the sanitizer keeps data- attributes; the brace text is + * consumed so it never reaches smartypants or the page). + * + * 2. applyImagePolicy — runs in the orchestrator after image inlining (which + * annotates data-gstack-px-width/-height from real bytes). Applies the + * width rule and decides landscape promotion: + * + * WIDTH RULE: render at intrinsic CSS-px width, capped at the content box, + * never upscaled — that is exactly `figure img { max-width: 100% }` doing + * its job, so the default needs no inline style. Directives opt into more: + * width=full stretches to the content box; / set explicit width. + * + * LANDSCAPE (conservative, false negatives are cheap): + * promote only when ALL hold — + * aspect ratio ≥ 1.8 + * AND intrinsic CSS-px width > SHRINK_LIMIT × content box + * (content shrunk below ~40% of natural size = unreadable) + * AND diagram provenance (rendered fence) or an alt-text token from + * ALT_HINT_TOKENS (plain images) + * `{page=landscape}` forces, `{page=portrait}` vetoes — both skip the + * heuristics entirely. + * + * Promotion wraps the block in
whose CSS named + * page (`@page wide { size: landscape }`, print-css.ts) rotates + * just that page. Chromium only honors CSS page sizes when the print call + * passes preferCSSPageSize — the orchestrator sets it when hasLandscape. + */ + +import { svgTagDims } from "./image-size"; + +export interface ImagePolicyOptions { + /** Physical content-box width in inches (page width minus margins). */ + contentWidthIn: number; + /** + * Landscape named-page content box (inches). Used to vertically center a + * promoted block via a computed inline margin-top — CSS flex/min-height + * centering fragments into phantom landscape pages in Chromium, so the + * margin is computed here from the block's known aspect ratio instead. + */ + landscape: { contentWIn: number; contentHIn: number }; + warn: (msg: string) => void; +} + +export interface ImagePolicyResult { + html: string; + /** True when at least one block was promoted to the landscape named page. */ + hasLandscape: boolean; +} + +/** Aspect ratio floor for auto-promotion. */ +const MIN_ASPECT = 1.8; +/** + * Auto-promote only when the intrinsic CSS-px width exceeds this multiple of + * the content box (in CSS px @96dpi). 2.5 ≈ the plan's ~1600px threshold on a + * 6.5in letter box; calibrated against fixtures (design doc Open Question 4). + */ +const SHRINK_LIMIT = 2.5; +/** Alt-text tokens that mark a plain image as diagram-like (case-insensitive). */ +const ALT_HINT_TOKENS = ["diagram", "architecture", "flowchart", "chart", "graph"]; + +// ─── Pass 1: directive suffixes ─────────────────────────────────────── + +const IMG_WITH_SUFFIX_RE = /(]*>)\s*\{([^{}<>\n]{1,120})\}/gi; + +/** + * Consume `{...}` directive suffixes adjacent to tags. Unrecognized + * brace groups are left untouched (someone's literal prose). + */ +export function applyImageDirectives(html: string): string { + return html.replace(IMG_WITH_SUFFIX_RE, (full, imgTag: string, body: string) => { + const parsed = parseDirectives(body); + if (!parsed) return full; + let tag = imgTag; + if (parsed.width) tag = addAttr(tag, "data-gstack-width", parsed.width); + if (parsed.page) tag = addAttr(tag, "data-gstack-page", parsed.page); + return tag; + }); +} + +export function parseDirectives(body: string): { width?: string; page?: string } | null { + let width: string | undefined; + let page: string | undefined; + let recognized = false; + for (const part of body.trim().split(/\s+/)) { + const m = part.match(/^(width|page)=(.+)$/i); + if (!m) return null; // any unknown token ⇒ not a directive group + const key = m[1].toLowerCase(); + const value = m[2].toLowerCase(); + if (key === "width" && /^(full|\d{1,3}%|[0-9.]+(in|cm|mm|pt|px))$/.test(value)) { + width = value; + recognized = true; + } else if (key === "page" && /^(landscape|portrait)$/.test(value)) { + page = value; + recognized = true; + } else { + return null; // recognized key, malformed value ⇒ leave visible, not silent + } + } + return recognized ? { width, page } : null; +} + +function addAttr(imgTag: string, name: string, value: string): string { + return imgTag.replace(/^]*>/gi, (tag) => { + const width = attrValue(tag, "data-gstack-width"); + if (!width) return tag; + const css = width === "full" ? "100%" : width; + return mergeStyle(tag, `width: ${css}; height: auto;`); + }); + + // 2b. landscape promotion — standalone images (markdown images render as + //

; promote by swapping the paragraph for the wide wrapper). + out = out.replace(/

\s*(]*>)\s*<\/p>/gi, (full, tag: string) => { + const decision = decideImagePromotion(tag, widthThresholdPx); + if (!decision.promote) return full; + hasLandscape = true; + opts.warn(`promoting image to a landscape page (${decision.reason})`); + const w = num(attrValue(tag, "data-gstack-px-width")); + const h = num(attrValue(tag, "data-gstack-px-height")); + return wrapPageWide(tag, w && h ? h / w : null, opts.landscape); + }); + + // 2c. landscape promotion — rendered diagram figures (provenance is + // automatic; dims come from the SVG's width/height or viewBox). + out = out.replace( + /

]*>[\s\S]*?<\/figure>/gi, + (figure) => { + if (figure.includes("diagram-error")) return figure; + const decision = decideDiagramPromotion(figure, widthThresholdPx); + if (!decision.promote) return figure; + hasLandscape = true; + opts.warn(`promoting diagram to a landscape page (${decision.reason})`); + const dims = svgCssDims(figure); + return wrapPageWide(figure, dims ? dims.height / dims.width : null, opts.landscape); + }, + ); + + return { html: out, hasLandscape }; +} + +/** + * Wrap a promoted block in the wide-page div, vertically centered via a + * computed margin-top: placed height = landscape content width × aspect, + * centered in the landscape content height. Unknown aspect → no margin + * (top placement beats a wrong guess). + */ +function wrapPageWide( + inner: string, + aspectHoverW: number | null, + landscape: { contentWIn: number; contentHIn: number }, +): string { + if (!aspectHoverW) return `
${inner}
`; + const placedHIn = landscape.contentWIn * aspectHoverW; + const marginIn = Math.max(0, (landscape.contentHIn - placedHIn) / 2); + if (marginIn < 0.1) return `
${inner}
`; + return `
${inner}
`; +} + +interface PromotionDecision { + promote: boolean; + reason: string; +} + +function decideImagePromotion(tag: string, widthThresholdPx: number): PromotionDecision { + const page = attrValue(tag, "data-gstack-page"); + if (page === "portrait") return { promote: false, reason: "page=portrait veto" }; + if (page === "landscape") return { promote: true, reason: "page=landscape directive" }; + + const w = num(attrValue(tag, "data-gstack-px-width")); + const h = num(attrValue(tag, "data-gstack-px-height")); + if (!w || !h) return { promote: false, reason: "no intrinsic dimensions" }; + if (w / h < MIN_ASPECT) return { promote: false, reason: "aspect below floor" }; + if (w <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" }; + + const alt = (attrValue(tag, "alt") ?? "").toLowerCase(); + const hinted = ALT_HINT_TOKENS.some((t) => new RegExp(`\\b${t}\\b`).test(alt)); + if (!hinted) return { promote: false, reason: "no diagram hint in alt text" }; + + return { promote: true, reason: `wide diagram-like image (${Math.round(w)}px, alt hint)` }; +} + +function decideDiagramPromotion(figure: string, widthThresholdPx: number): PromotionDecision { + const page = attrValue(figure, "data-gstack-page"); + if (page === "portrait") return { promote: false, reason: "page=portrait veto" }; + if (page === "landscape") return { promote: true, reason: "page=landscape fence directive" }; + + const dims = svgCssDims(figure); + if (!dims) return { promote: false, reason: "no measurable SVG dimensions" }; + if (dims.width / dims.height < MIN_ASPECT) return { promote: false, reason: "aspect below floor" }; + if (dims.width <= widthThresholdPx) return { promote: false, reason: "fits portrait readably" }; + return { promote: true, reason: `wide diagram (${Math.round(dims.width)}px)` }; +} + +/** SVG dimension probing is shared with the byte prober — see image-size.ts. */ +const svgCssDims = svgTagDims; + +function attrValue(tag: string, name: string): string | null { + const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, "i")) + ?? tag.match(new RegExp(`\\b${name}\\s*=\\s*'([^']*)'`, "i")); + return m ? m[1] : null; +} + +function num(s: string | null): number | null { + if (s === null) return null; + const n = parseFloat(s); + return Number.isFinite(n) && n > 0 ? n : null; +} + +function mergeStyle(tag: string, css: string): string { + const existing = attrValue(tag, "style"); + if (existing !== null) { + // Function replacement (no $-pattern expansion from user-controlled style + // values) and the existing declarations are preserved verbatim — attrValue + // already returned the unquoted inner value. + return tag.replace(/\bstyle\s*=\s*(".*?"|'.*?')/i, () => `style="${existing}; ${css}"`); + } + return tag.replace(/^ `= 0xd0 && marker <= 0xd9)) { i += 2; continue; } + const len = b.readUInt16BE(i + 2); + if (len < 2) return null; + // SOF0-SOF15 except DHT(C4)/JPGA(C8)/DAC(CC) carry dimensions + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + if (i + 9 >= b.length) return null; + return { height: b.readUInt16BE(i + 5), width: b.readUInt16BE(i + 7), mime: "image/jpeg" }; + } + i += 2 + len; + } + return null; +} + +function gifDims(b: Buffer): ImageDims | null { + const sig = b.toString("ascii", 0, 6); + if (sig !== "GIF87a" && sig !== "GIF89a") return null; + return { width: b.readUInt16LE(6), height: b.readUInt16LE(8), mime: "image/gif" }; +} + +function webpDims(b: Buffer): ImageDims | null { + if (b.toString("ascii", 0, 4) !== "RIFF" || b.toString("ascii", 8, 12) !== "WEBP") return null; + const fmt = b.toString("ascii", 12, 16); + if (fmt === "VP8X" && b.length >= 30) { + // 24-bit little-endian width-1 / height-1 at offsets 24 / 27 + const w = 1 + (b[24] | (b[25] << 8) | (b[26] << 16)); + const h = 1 + (b[27] | (b[28] << 8) | (b[29] << 16)); + return { width: w, height: h, mime: "image/webp" }; + } + if (fmt === "VP8 " && b.length >= 30) { + // Lossy: dimensions at offset 26, 14 bits each, little-endian + return { + width: b.readUInt16LE(26) & 0x3fff, + height: b.readUInt16LE(28) & 0x3fff, + mime: "image/webp", + }; + } + if (fmt === "VP8L" && b.length >= 25) { + if (b[20] !== 0x2f) return null; + const bits = b.readUInt32LE(21); + return { + width: (bits & 0x3fff) + 1, + height: ((bits >> 14) & 0x3fff) + 1, + mime: "image/webp", + }; + } + return null; +} + +/** + * SVG: parse width/height attributes (px or unitless) off the root element, + * falling back to viewBox. CSS-unit widths (em, %, pt) are ignored — the + * width policy treats them as "no intrinsic size". + */ +function svgDims(b: Buffer): ImageDims | null { + const head = b.toString("utf8", 0, Math.min(b.length, 4096)); + const dims = svgTagDims(head); + return dims ? { ...dims, mime: "image/svg+xml" } : null; +} + +/** + * CSS-px dimensions of the first element in a markup string: explicit + * width/height attributes (px or unitless) first, else viewBox. Shared by the + * byte prober above and image-policy's diagram-figure measurements — one + * regex, no drift. + */ +export function svgTagDims(markup: string): { width: number; height: number } | null { + const tag = markup.match(/]*>/i)?.[0]; + if (!tag) return null; + const attr = (name: string): number | null => { + const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*["']\\s*([0-9.]+)(px)?\\s*["']`, "i")); + return m ? parseFloat(m[1]) : null; + }; + const w = attr("width"); + const h = attr("height"); + if (w && h) return { width: w, height: h }; + const vb = tag.match(/\bviewBox\s*=\s*["']\s*[-0-9.]+[\s,]+[-0-9.]+[\s,]+([0-9.]+)[\s,]+([0-9.]+)\s*["']/i); + if (vb) return { width: parseFloat(vb[1]), height: parseFloat(vb[2]) }; + return null; +} diff --git a/make-pdf/src/orchestrator.ts b/make-pdf/src/orchestrator.ts new file mode 100644 index 0000000000..12a21570d0 --- /dev/null +++ b/make-pdf/src/orchestrator.ts @@ -0,0 +1,399 @@ +/** + * Orchestrator — ties render, browseClient, and filesystem together. + * + * generate(opts): markdown → PDF on disk. Returns output path. + * preview(opts): markdown → HTML, opens it in a browser. + * + * Progress indication (per DX spec): + * - stdout: ONLY the output path, printed by cli.ts after this returns. + * - stderr: spinner + per-stage status lines, unless opts.quiet. + * - --verbose: stage timings. + * + * Tab lifecycle: every generate opens a dedicated tab via $B newtab --json, + * runs load-html/js/pdf against --tab-id , and closes the tab in a + * try/finally. Parallel $P generate calls never race on the active tab. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; +import { spawn } from "node:child_process"; + +import { render } from "./render"; +import { screenCss } from "./print-css"; +import type { GenerateOptions, PreviewOptions } from "./types"; +import { ExitCode } from "./types"; +import * as browseClient from "./browseClient"; +import { + RenderTab, + contentWidthInches, + convertDiagnosticsForDocx, + extractDiagramFences, + inlineLocalImages, + landscapeContentBox, + rasterizeDiagramFigures, + renderFenceSlots, + substituteSlots, +} from "./diagram-prepass"; +import { applyImagePolicy } from "./image-policy"; + +class ProgressReporter { + private readonly quiet: boolean; + private readonly verbose: boolean; + private readonly stageStart = new Map(); + private readonly totalStart: number; + constructor(opts: { quiet?: boolean; verbose?: boolean }) { + this.quiet = opts.quiet === true; + this.verbose = opts.verbose === true; + this.totalStart = Date.now(); + } + begin(stage: string): void { + this.stageStart.set(stage, Date.now()); + if (this.quiet) return; + process.stderr.write(`\r\x1b[K${stage}...`); + } + end(stage: string, extra?: string): void { + const start = this.stageStart.get(stage) ?? Date.now(); + const ms = Date.now() - start; + if (this.quiet) return; + if (this.verbose) { + process.stderr.write(`\r\x1b[K${stage} (${ms}ms)${extra ? ` — ${extra}` : ""}\n`); + } + } + done(extra: string): void { + if (this.quiet) return; + const total = ((Date.now() - this.totalStart) / 1000).toFixed(1); + process.stderr.write(`\r\x1b[KDone in ${total}s. ${extra}\n`); + } + fail(stage: string, err: Error): void { + if (!this.quiet) process.stderr.write("\r\x1b[K"); + // Always emit failure info, even in quiet mode — this is an error path. + process.stderr.write(`${stage} failed: ${err.message}\n`); + } +} + +/** + * generate — full pipeline. Returns the output PDF path on success. + */ +export async function generate(opts: GenerateOptions): Promise { + const progress = new ProgressReporter(opts); + const input = path.resolve(opts.input); + + if (!fs.existsSync(input)) { + throw new Error(`input file not found: ${input}`); + } + + const to = opts.to ?? "pdf"; + const outputPath = path.resolve( + opts.output ?? path.join(os.tmpdir(), `${deriveSlug(input)}.${to}`), + ); + + // Stage 1: read markdown + progress.begin("Reading markdown"); + const markdown = fs.readFileSync(input, "utf8"); + progress.end("Reading markdown"); + + // Stage 1.5: diagram pre-pass — extract ```mermaid/```excalidraw fences and + // swap in placeholder tokens. Rendering happens after the tab opens below. + const extraction = extractDiagramFences(markdown); + + // Stage 2: render HTML + progress.begin("Rendering HTML"); + const rendered = render({ + markdown: extraction.markdown, + title: opts.title, + author: opts.author, + date: opts.date, + cover: opts.cover, + toc: opts.toc, + watermark: opts.watermark, + noChapterBreaks: opts.noChapterBreaks, + confidential: opts.confidential, + pageSize: opts.pageSize, + margins: opts.margins, + marginTop: opts.marginTop, + marginRight: opts.marginRight, + marginBottom: opts.marginBottom, + marginLeft: opts.marginLeft, + pageNumbers: opts.pageNumbers, + footerTemplate: opts.footerTemplate, + }); + progress.end("Rendering HTML", `${rendered.meta.wordCount} words`); + + // Stage 2.5: render diagram fences in a dedicated bundle tab, substitute + // slots, then inline + probe + (if oversized) downscale local images. + // The bundle tab is lazy: image-only documents open it only when a raster + // actually needs print-resolution downscaling (eng-review D4). + const warn = (msg: string) => { + if (!opts.quiet) process.stderr.write(`\r\x1b[K[make-pdf] warning: ${msg}\n`); + }; + let renderTab: RenderTab | null = null; + let hasLandscape = false; + const getRenderTab = (): RenderTab | null => { + if (renderTab) return renderTab; + try { + renderTab = RenderTab.open(); + } catch (err: any) { + warn(`diagram-render tab unavailable: ${String(err?.message ?? err).split("\n")[0]}`); + return null; + } + return renderTab; + }; + + let finalHtml = rendered.html; + try { + if (extraction.fences.length > 0) { + progress.begin(`Rendering ${extraction.fences.length} diagram(s)`); + const tab = getRenderTab(); + if (tab) { + const slots = renderFenceSlots(extraction.fences, tab, warn); + finalHtml = substituteSlots(finalHtml, slots); + } else { + // No bundle/tab: visible diagnostic beats silent raw tokens. + const slots = new Map( + extraction.fences.map((f) => [ + f.token, + ``, + ]), + ); + finalHtml = substituteSlots(finalHtml, slots); + } + progress.end(`Rendering ${extraction.fences.length} diagram(s)`); + } + + progress.begin("Inlining images"); + const contentWidthIn = contentWidthInches(opts); + finalHtml = inlineLocalImages(finalHtml, { + inputDir: path.dirname(input), + strict: opts.strict === true, + allowNetwork: opts.allowNetwork === true, + contentWidthIn, + warn, + getTab: getRenderTab, + }); + progress.end("Inlining images"); + + // Width directives + conservative auto-landscape (image-policy). + const policy = applyImagePolicy(finalHtml, { + contentWidthIn, + landscape: landscapeContentBox(opts), + warn, + }); + finalHtml = policy.html; + hasLandscape = policy.hasLandscape; + + // DOCX needs rasters, not inline SVG (Word's SVG support is unreliable) — + // do it while the render tab is still open. + if (to === "docx") { + const needsRaster = /
", + `\n`, + ); + fs.writeFileSync(outputPath, withScreenLayer, "utf8"); + const kb = Math.round(fs.statSync(outputPath).size / 1024); + progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`); + return outputPath; + } + + // ─── --to docx: content-fidelity conversion (eng-review P8) ──────────── + if (to === "docx") { + // Print-only surfaces don't survive the conversion. The watermark div + // would degrade to a literal body paragraph reading "DRAFT" (worse than + // absent) — strip it. Warn once about print-only flags that were set. + finalHtml = finalHtml.replace(/
[\s\S]*?<\/div>/, ""); + const printOnly: string[] = []; + if (opts.watermark) printOnly.push("--watermark"); + if (opts.headerTemplate) printOnly.push("--header-template"); + if (opts.footerTemplate) printOnly.push("--footer-template"); + if (opts.pageSize) printOnly.push("--page-size"); + if (opts.margins || opts.marginTop || opts.marginRight || opts.marginBottom || opts.marginLeft) printOnly.push("--margins"); + if (printOnly.length > 0) { + warn(`docx is content-fidelity: ${printOnly.join(", ")} do not apply to Word output`); + } + progress.begin("Converting to DOCX"); + const { default: HTMLtoDOCX } = await import("html-to-docx"); + const buf = await HTMLtoDOCX(finalHtml, null, { + title: rendered.meta.title, + creator: rendered.meta.author || undefined, + }); + const bytes: Uint8Array = buf instanceof Uint8Array ? buf : new Uint8Array(await (buf as Blob).arrayBuffer()); + fs.writeFileSync(outputPath, bytes); + progress.end("Converting to DOCX"); + const kb = Math.round(fs.statSync(outputPath).size / 1024); + progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath} (content fidelity — layout is Word's)`); + return outputPath; + } + + // Stage 3: write HTML to a tmp file browse can read + // (We don't actually write it; we pass inline via --from-file JSON.) + // But for preview mode and debugging, we still write to tmp. + const htmlTmp = tmpFile("html"); + fs.writeFileSync(htmlTmp, finalHtml, "utf8"); + + // Stage 4: spin up a dedicated tab, load HTML, (wait for Paged.js if TOC), + // then emit PDF. Always close the tab. + progress.begin("Opening tab"); + const tabId = browseClient.newtab(); + progress.end("Opening tab", `tabId=${tabId}`); + + try { + progress.begin("Loading HTML into Chromium"); + browseClient.loadHtml({ + html: finalHtml, + waitUntil: "domcontentloaded", + tabId, + }); + progress.end("Loading HTML into Chromium"); + + if (opts.toc) { + progress.begin("Paginating with Paged.js"); + // Browse's $B pdf already waits internally when --toc is passed. + // We pass toc=true to browseClient.pdf() below. + progress.end("Paginating with Paged.js", "Paged.js after"); + } + + progress.begin("Generating PDF"); + browseClient.pdf({ + output: outputPath, + tabId, + format: opts.pageSize ?? "letter", + marginTop: opts.marginTop ?? opts.margins ?? "1in", + marginRight: opts.marginRight ?? opts.margins ?? "1in", + marginBottom: opts.marginBottom ?? opts.margins ?? "1in", + marginLeft: opts.marginLeft ?? opts.margins ?? "1in", + headerTemplate: opts.headerTemplate, + footerTemplate: opts.footerTemplate, + // CSS is the single source of truth for page numbers (see print-css.ts + // @bottom-center). Chromium's native numbering always off to avoid double + // footers. The CSS layer honors pageNumbers + footerTemplate via render(). + pageNumbers: false, + tagged: opts.tagged !== false, + outline: opts.outline !== false, + printBackground: !!opts.watermark, + // Named landscape pages only take effect when Chromium honors CSS page + // sizes. Flip it ONLY when a promotion exists — minimal behavior change + // for every other document. + preferCSSPageSize: hasLandscape ? true : undefined, + toc: opts.toc, + }); + progress.end("Generating PDF"); + + const stat = fs.statSync(outputPath); + const kb = Math.round(stat.size / 1024); + progress.done(`${rendered.meta.wordCount} words · ${kb}KB · ${outputPath}`); + } finally { + // Always clean up the tab — even on crash, timeout, or Chromium hang. + try { + browseClient.closetab(tabId); + } catch { + // best-effort; we already exited the main path + } + // Cleanup tmp HTML + try { fs.unlinkSync(htmlTmp); } catch { /* best-effort */ } + } + + return outputPath; +} + +/** + * preview — render HTML and open it. No PDF round trip. + */ +export async function preview(opts: PreviewOptions): Promise { + const progress = new ProgressReporter(opts); + const input = path.resolve(opts.input); + if (!fs.existsSync(input)) { + throw new Error(`input file not found: ${input}`); + } + + progress.begin("Rendering HTML"); + const markdown = fs.readFileSync(input, "utf8"); + // Preview deliberately skips the diagram/image pre-pass (no browse daemon + // round-trip — preview is the fast loop). Be loud about the divergence so + // nobody signs off on a preview that lacks what the PDF will have. + if (!opts.quiet) { + const fenceCount = extractDiagramFences(markdown).fences.length; + const hasLocalImages = /!\[[^\]]*\]\((?!https?:|data:)[^)]+\)/.test(markdown); + if (fenceCount > 0 || hasLocalImages) { + process.stderr.write( + `[make-pdf] preview note: ${fenceCount > 0 ? `${fenceCount} diagram fence(s) shown as code` : ""}` + + `${fenceCount > 0 && hasLocalImages ? "; " : ""}` + + `${hasLocalImages ? "local images may not resolve from the preview location" : ""}` + + ` — \`generate\` renders them fully.\n`, + ); + } + } + const rendered = render({ + markdown, + title: opts.title, + author: opts.author, + date: opts.date, + cover: opts.cover, + toc: opts.toc, + watermark: opts.watermark, + noChapterBreaks: opts.noChapterBreaks, + confidential: opts.confidential, + pageNumbers: opts.pageNumbers, + }); + progress.end("Rendering HTML", `${rendered.meta.wordCount} words`); + + // Write to a stable path under /tmp so the user can reload in the same tab. + const previewPath = path.join(os.tmpdir(), `make-pdf-preview-${deriveSlug(input)}.html`); + fs.writeFileSync(previewPath, rendered.html, "utf8"); + + progress.begin("Opening preview"); + tryOpen(previewPath); + progress.end("Opening preview"); + + progress.done(`Preview at ${previewPath}`); + return previewPath; +} + +// ─── helpers ────────────────────────────────────────────── + +function deriveSlug(p: string): string { + const base = path.basename(p).replace(/\.[^.]+$/, ""); + return base.replace(/[^a-zA-Z0-9-_]+/g, "-").slice(0, 64) || "document"; +} + +function tmpFile(ext: string): string { + const hash = crypto.randomBytes(6).toString("hex"); + return path.join(os.tmpdir(), `make-pdf-${process.pid}-${hash}.${ext}`); +} + +function tryOpen(pathOrUrl: string): void { + const platform = process.platform; + const cmd = platform === "darwin" ? "open" : + platform === "win32" ? "cmd" : + "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", pathOrUrl] : [pathOrUrl]; + try { + const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); + child.unref(); + } catch { + // Non-fatal; the caller already has the path and will print it. + } +} + +/** Setup-only re-export so cli.ts can dynamic-import without another file. */ +export { ExitCode }; diff --git a/make-pdf/src/pdftotext.ts b/make-pdf/src/pdftotext.ts new file mode 100644 index 0000000000..5cdb51e81c --- /dev/null +++ b/make-pdf/src/pdftotext.ts @@ -0,0 +1,312 @@ +/** + * pdftotext wrapper — the tool behind the copy-paste CI gate. + * + * Codex round 2 surfaced two real problems we address here: + * + * #18: pdftotext (Poppler) vs pdftotext (Xpdf) vs pdftotext-next vary on + * whitespace, line wrap, Unicode normalization, form feeds, and + * extraction order. Cross-platform exact diffing is a non-starter. + * We normalize aggressively and diff the normalized form. + * + * #19: the regex /(?:\b\w\s){4,}/ only catches one failure shape (letters + * spaced out). It misses word-order corruption, missing whitespace + * between paragraphs, and homoglyph substitution. We add a word-token + * diff and a paragraph-boundary assertion on top. + * + * Resolution order for the pdftotext binary (v1.24-aligned): + * 1. $GSTACK_PDFTOTEXT_BIN env override (preferred, matches v1.24 GSTACK_*_BIN pattern) + * 2. $PDFTOTEXT_BIN env override (back-compat alias) + * 3. PATH lookup via Bun.which('pdftotext') — handles Windows PATHEXT natively + * 4. standard POSIX paths (Homebrew + distro) — no Windows candidates because + * Poppler scatters across Scoop / Chocolatey / oschwartz10612-poppler-windows + * and guessing causes false positives. Set GSTACK_PDFTOTEXT_BIN explicitly. + * 5. throws a friendly "install poppler" error + * + * The wrapper is *optional at runtime*: production renders don't need it. + * Only the CI gate and unit tests invoke pdftotext. + */ + +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export class PdftotextUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "PdftotextUnavailableError"; + } +} + +export interface PdftotextInfo { + bin: string; + version: string; // "pdftotext version 24.02.0" or similar + flavor: "poppler" | "xpdf" | "unknown"; +} + +/** + * Probe a base path for executability, honoring Windows extension suffixes. + * Matches browseClient.ts:findExecutable — duplicated rather than shared + * because the two modules already duplicate isExecutable for compile-isolation. + */ +export function findExecutable(base: string): string | null { + if (isExecutable(base)) return base; + if (process.platform === "win32") { + for (const ext of [".exe", ".cmd", ".bat"]) { + const withExt = base + ext; + if (isExecutable(withExt)) return withExt; + } + } + return null; +} + +function resolveOverride(value: string | undefined, env: NodeJS.ProcessEnv): string | null { + if (!value?.trim()) return null; + const trimmed = value.trim().replace(/^"(.*)"$/, '$1'); + if (path.isAbsolute(trimmed)) return findExecutable(trimmed); + const PATH = env.PATH ?? env.Path ?? ''; + return Bun.which(trimmed, { PATH }) ?? null; +} + +/** + * Locate pdftotext. Throws PdftotextUnavailableError if none is found. + */ +export function resolvePdftotext(env: NodeJS.ProcessEnv = process.env): PdftotextInfo { + // 1 + 2: env overrides (GSTACK_PDFTOTEXT_BIN preferred, PDFTOTEXT_BIN back-compat). + const overrideRaw = env.GSTACK_PDFTOTEXT_BIN ?? env.PDFTOTEXT_BIN; + const override = resolveOverride(overrideRaw, env); + if (override) return describeBinary(override); + + // 3: PATH lookup via Bun.which — handles Windows PATHEXT natively. + const PATH = env.PATH ?? env.Path ?? ''; + const onPath = Bun.which('pdftotext', { PATH }); + if (onPath) return describeBinary(onPath); + + // 4: POSIX-only standard locations. No Windows candidates — Poppler installs + // scatter across Scoop/Chocolatey/portable zips and guessing causes false + // positives. Windows users set GSTACK_PDFTOTEXT_BIN explicitly. + const posixCandidates = [ + "/opt/homebrew/bin/pdftotext", // Apple Silicon Homebrew + "/usr/local/bin/pdftotext", // Intel Mac or Linuxbrew + "/usr/bin/pdftotext", // distro package + ]; + for (const candidate of posixCandidates) { + if (isExecutable(candidate)) return describeBinary(candidate); + } + + throw new PdftotextUnavailableError([ + "pdftotext not found.", + "", + "make-pdf needs pdftotext to run the copy-paste CI gate.", + "(Runtime rendering does NOT need it. This only affects tests.)", + "", + "To install:", + " macOS: brew install poppler", + " Ubuntu: sudo apt-get install poppler-utils", + " Fedora: sudo dnf install poppler-utils", + " Windows: scoop install poppler (or download from", + " https://github.com/oschwartz10612/poppler-windows)", + "", + "Or set GSTACK_PDFTOTEXT_BIN to an explicit path:", + process.platform === "win32" + ? ' setx GSTACK_PDFTOTEXT_BIN "C:\\path\\to\\pdftotext.exe"' + : " export GSTACK_PDFTOTEXT_BIN=/path/to/pdftotext", + ].join("\n")); +} + +/** + * Locate a poppler companion tool (pdffonts, pdfimages, pdftoppm) used by the + * emoji render gate. Mirrors resolvePdftotext's resolution order: + * 1. $GSTACK__BIN env override (e.g. GSTACK_PDFFONTS_BIN) + * 2. PATH via Bun.which + * 3. standard POSIX locations (Homebrew + distro) + * + * Returns null (does NOT throw) when the tool is missing — the emoji gate skips + * cleanly rather than failing on a box without full poppler-utils. + */ +export function resolvePopplerTool( + tool: "pdffonts" | "pdfimages" | "pdftoppm", + env: NodeJS.ProcessEnv = process.env, +): string | null { + const override = resolveOverride(env[`GSTACK_${tool.toUpperCase()}_BIN`], env); + if (override) return override; + + const PATH = env.PATH ?? env.Path ?? ""; + const onPath = Bun.which(tool, { PATH }); + if (onPath) return onPath; + + for (const dir of ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]) { + const candidate = findExecutable(path.join(dir, tool)); + if (candidate) return candidate; + } + return null; +} + +function isExecutable(p: string): boolean { + try { + fs.accessSync(p, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function describeBinary(bin: string): PdftotextInfo { + let version = "unknown"; + let flavor: PdftotextInfo["flavor"] = "unknown"; + try { + // pdftotext -v writes to stderr and exits 0 on poppler, 99 on some xpdf builds. + const result = execFileSync(bin, ["-v"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + version = (result || "").trim().split("\n")[0] || "unknown"; + } catch (err: any) { + // Many pdftotext builds exit non-zero on -v but still write to stderr. + const stderr = err?.stderr?.toString?.() ?? ""; + version = stderr.trim().split("\n")[0] || "unknown"; + } + const v = version.toLowerCase(); + if (v.includes("poppler")) flavor = "poppler"; + else if (v.includes("xpdf")) flavor = "xpdf"; + return { bin, version, flavor }; +} + +/** + * Run pdftotext on a PDF and return the extracted text. + * + * Uses `-layout` by default because that's what downstream normalization + * expects. Callers that need raw text can pass layout=false. + */ +export function pdftotext(pdfPath: string, opts?: { layout?: boolean }): string { + const info = resolvePdftotext(); + const layout = opts?.layout ?? true; + const args: string[] = []; + if (layout) args.push("-layout"); + args.push(pdfPath, "-"); // "-" = stdout + try { + return execFileSync(info.bin, args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }); + } catch (err: any) { + throw new Error(`pdftotext failed on ${pdfPath}: ${err.message}`); + } +} + +/** + * Normalize extracted text for cross-platform, cross-flavor diffing. + * + * What we strip / normalize: + * - Unicode: NFC canonical composition (macOS emits NFD; Linux emits NFC; + * this dodges the fundamental encoding diff). + * - CR and CRLF → LF (Windows Xpdf emits CRLF). + * - Form feeds (\f) → double newline (Poppler emits \f at page breaks). + * - Trailing spaces on every line. + * - Runs of 3+ blank lines → 2 blank lines. + * - Leading/trailing whitespace on the whole string. + * - Non-breaking space (U+00A0) → regular space. + * - Zero-width space (U+200B) and zero-width non-joiner (U+200C) → empty. + * - Soft hyphen (U+00AD) → empty (pdftotext -layout sometimes emits these + * for hyphens: auto breaks). + */ +export function normalize(raw: string): string { + let s = raw; + s = s.normalize("NFC"); + s = s.replace(/\r\n/g, "\n"); + s = s.replace(/\r/g, "\n"); + s = s.replace(/\f/g, "\n\n"); + s = s.replace(/\u00a0/g, " "); + s = s.replace(/[\u200b\u200c\u00ad]/g, ""); + s = s.replace(/[ \t]+$/gm, ""); + s = s.replace(/\n{3,}/g, "\n\n"); + s = s.trim(); + return s; +} + +/** + * The canonical copy-paste gate used in the E2E tests. + * + * Returns { ok: true } when all three assertions pass; returns + * { ok: false, reasons: [...] } with one or more failure reasons otherwise. + */ +export interface GateResult { + ok: boolean; + reasons: string[]; + extracted: string; +} + +export function copyPasteGate(pdfPath: string, expected: string): GateResult { + const extracted = normalize(pdftotext(pdfPath, { layout: true })); + const expectedNorm = normalize(expected); + const reasons: string[] = []; + + // Assertion 1: every expected paragraph appears as a whole line or + // contiguous block in the extracted text. + const expectedParagraphs = splitParagraphs(expectedNorm); + for (const paragraph of expectedParagraphs) { + const compact = collapseWhitespace(paragraph); + const extractedCompact = collapseWhitespace(extracted); + if (!extractedCompact.includes(compact)) { + reasons.push( + `expected paragraph not found in extracted text: ${truncate(paragraph, 80)}`, + ); + } + } + + // Assertion 2: no "S a i l i n g"-style single-char runs. + // Count groups of 4+ consecutive letter-then-space tokens. False positive + // risk on things like "A B C D" (initials) — mitigate by requiring the + // letters spell a known-word substring of the expected text. + const fragRegex = /((?:\b\w\s){4,})/g; + let fragMatch: RegExpExecArray | null; + while ((fragMatch = fragRegex.exec(extracted)) !== null) { + const letters = fragMatch[1].replace(/\s/g, ""); + // Only flag if the reassembled letters appear in the expected text. + if (expectedNorm.toLowerCase().includes(letters.toLowerCase()) && letters.length >= 4) { + reasons.push( + `per-glyph emission detected (the "S ai li ng" bug): "${fragMatch[1].trim()}" reassembles to "${letters}"`, + ); + } + } + + // Assertion 3: paragraph boundaries preserved. Count double-newlines + // in both; they should differ by no more than ±2 (header/footer noise). + const expectedBreaks = (expectedNorm.match(/\n\n/g) || []).length; + const extractedBreaks = (extracted.match(/\n\n/g) || []).length; + if (Math.abs(expectedBreaks - extractedBreaks) > 4) { + reasons.push( + `paragraph boundary count drift: expected ~${expectedBreaks}, got ${extractedBreaks}`, + ); + } + + return { ok: reasons.length === 0, reasons, extracted }; +} + +function splitParagraphs(s: string): string[] { + return s.split(/\n\n+/).map(p => p.trim()).filter(p => p.length > 0); +} + +function collapseWhitespace(s: string): string { + return s.replace(/\s+/g, " ").trim(); +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + "..." : s; +} + +/** + * Emit diagnostic info to stderr — useful for CI failure debugging. + * Call this once before running any gate in a CI log. + */ +export function logDiagnostics(): void { + try { + const info = resolvePdftotext(); + process.stderr.write( + `[pdftotext] bin=${info.bin} flavor=${info.flavor} version="${info.version}" ` + + `os=${os.platform()}-${os.arch()} node=${process.version}\n`, + ); + } catch (err: any) { + process.stderr.write(`[pdftotext] unavailable: ${err.message}\n`); + } +} diff --git a/make-pdf/src/print-css.ts b/make-pdf/src/print-css.ts new file mode 100644 index 0000000000..bf6f862bdc --- /dev/null +++ b/make-pdf/src/print-css.ts @@ -0,0 +1,452 @@ +/** + * Print stylesheet generator. + * + * Source of truth: .context/designs/make-pdf-print-reference.html and siblings. + * Mirror those CSS rules here. The HTML references were approved via + * /plan-design-review with explicit design decisions locked in the plan: + * + * - Helvetica first, with Liberation Sans as a metric-compatible Linux + * fallback (Helvetica and Arial aren't installed on most Linux distros; + * Liberation Sans ships via the fonts-liberation package and Playwright's + * install-deps). No bundled webfonts — dodges the per-glyph Tj bug that + * breaks copy-paste extraction. + * - All paragraphs flush-left. No first-line indent, no justify, no + * p+p indent. text-align: left everywhere. 12pt margin-bottom. + * - Cover page (v1.58.0.0 poster revision, user-directed): 56pt title, + * 13pt meta, padding-top 1.4in for poster placement. Still no flexbox + * and no vertical centering; the inset is a deliberate top-third drop. + * (Supersedes the original "no inset padding" lock from the first + * /plan-design-review — the 32pt cover read as too small in print.) + * - `@page :first` suppresses running header/footer but does NOT override + * the 1in margin. + * - No , no external CSS/fonts — everything inlined. + * - CJK fallback: Helvetica, Liberation Sans, Arial, Hiragino Kaku Gothic + * ProN, Noto Sans CJK JP, Microsoft YaHei, sans-serif. + * - Emoji fallback: the body and @top-center running-header stacks end in an + * emoji family group ("Apple Color Emoji", "Segoe UI Emoji", "Noto Color + * Emoji"), placed BEFORE the generic `sans-serif` so Chromium has a glyph + * source for emoji code points instead of emitting .notdef tofu (▯). The + * @bottom-* margin boxes hold only counters / a fixed "CONFIDENTIAL" + * string, so they get no emoji families. On Linux this requires an + * installed color-emoji font — `setup` installs fonts-noto-color-emoji. + * + * Font stacks are composed from the constants below so each family list has a + * single source of truth (DRY) and every stack stays in sync. + */ + +// Metric-compatible sans stack: Helvetica (macOS), Liberation Sans (Linux, +// ships via fonts-liberation), Arial (Windows). Shared by every text surface. +const SANS_STACK = `Helvetica, "Liberation Sans", Arial`; +// CJK fallback families, appended to the body stack only. +const CJK_STACK = `"Hiragino Kaku Gothic ProN", "Noto Sans CJK JP", "Microsoft YaHei"`; +// Color-emoji families: Apple (macOS), Segoe (Windows), Noto (Linux). +const EMOJI_FAMILIES = `"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji"`; + +export interface PrintCssOptions { + // Document structure + cover?: boolean; + toc?: boolean; + noChapterBreaks?: boolean; + + // Branding + watermark?: string; + confidential?: boolean; + + // Header (running title, top of page) + runningHeader?: string; + + // Page size (in CSS `@page size:` terms) + pageSize?: "letter" | "a4" | "legal" | "tabloid"; + + // Margins (default 1in) + margins?: string; + + // Whether to render "N of M" page numbers in the @page @bottom-center rule. + // Default true. Set false to suppress CSS numbering (used when the caller + // supplies a custom Chromium footerTemplate, or when --no-page-numbers). + pageNumbers?: boolean; +} + +/** + * Produce a CSS block (no `, + ``, + ``, + watermarkBlock, + coverBlock, + tocBlock, + chapterHtml, + ``, + ``, + ].filter(Boolean).join("\n"); + + return { + html: fullHtml, + printCss: css, + bodyHtml: typographicHtml, + meta: { + title: derivedTitle, + author: derivedAuthor, + date: derivedDate, + wordCount: countWords(stripTags(typographicHtml)), + }, + }; +} + +/** + * Decode the HTML entities that marked emits for text-node quotes/apostrophes. + * Only the four that matter for smartypants — leaves & alone because it + * can be legitimately doubled (&amp;) and we don't want to double-decode. + */ +function decodeTypographicEntities(html: string): string { + return html + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/g, "'"); +} + +// ─── Sanitizer ──────────────────────────────────────────────────────── + +/** + * Strip dangerous HTML from markdown-produced output. + * + * We can't use DOMPurify (server-side; adds a jsdom dep). A conservative + * regex sanitizer is fine for this use case because: + * 1. marked produces structured HTML (never malformed) + * 2. we only need to strip a fixed blacklist of elements + attrs + * 3. the output goes through Chromium's parser again, which normalizes + * + * What's stripped: + * - "); + expect(fig).not.toContain("

world

`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("hello

"); + expect(out).toContain("

world

"); + }); + + test("strips `; + expect(sanitizeUntrustedHtml(input)).not.toContain(" { + const input = `click`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("onclick"); + expect(out).toContain("href=\"#\""); + }); + + test("strips event handlers with mixed case (onClick, ONCLICK)", () => { + const input1 = `a`; + const input2 = `b`; + expect(sanitizeUntrustedHtml(input1)).not.toContain("onClick"); + expect(sanitizeUntrustedHtml(input2)).not.toContain("ONCLICK"); + }); + + test("rewrites javascript: URLs in href to #", () => { + const input = `bad`; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain("javascript:"); + expect(out).toContain('href="#"'); + }); + + test("strips inline SVG `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain(", , , , ,
", () => { + const input = ` + + + + + +
+ `; + const out = sanitizeUntrustedHtml(input); + expect(out).not.toContain(" { + const input = `
hi
`; + expect(sanitizeUntrustedHtml(input)).not.toContain("srcdoc"); + }); +}); + +// ─── end-to-end render ────────────────────────────────────────────── + +describe("render (end-to-end)", () => { + test("produces a full HTML document with title, body, and CSS", () => { + const result = render({ + markdown: `# Hello\n\nA paragraph with "quotes" and -- dashes.\n`, + }); + expect(result.html).toContain(""); + expect(result.html).toContain("Hello"); + expect(result.html).toContain("... + expect(result.html).toMatch(/ + + +
+

GStack browser readiness

+

This local page verifies navigation, reading, interaction, console, and network access.

+ +

WAITING

+
+ + +`; +} + +async function main() { + const fixture = createReadinessServer(); + const started = await fixture.start(); + process.stdout.write(`${JSON.stringify({ ...started, pid: process.pid })}\n`); + const stop = async () => { + await fixture.stop(); + process.exitCode = fixture.completed ? 0 : 2; + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`gstack browser readiness: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/skills/make-pdf/references/support/execution-result-contract.json b/skills/make-pdf/references/support/execution-result-contract.json new file mode 100644 index 0000000000..2e6a1d0256 --- /dev/null +++ b/skills/make-pdf/references/support/execution-result-contract.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gstack.dev/schemas/execution-result-v1.json", + "title": "GStack execution result", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "status", + "code", + "summary", + "evidence", + "data" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "status": { + "enum": [ + "success", + "degraded", + "unsupported", + "failed" + ] + }, + "code": { + "type": [ + "string", + "null" + ], + "pattern": "^[A-Z][A-Z0-9_]{2,63}$" + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "evidence": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "data": {} + }, + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "success" + } + } + }, + "then": { + "properties": { + "code": { + "type": "null" + }, + "evidence": { + "minItems": 1 + } + } + } + }, + { + "if": { + "properties": { + "status": { + "enum": [ + "degraded", + "unsupported", + "failed" + ] + } + } + }, + "then": { + "properties": { + "code": { + "type": "string" + } + } + } + } + ] +} diff --git a/skills/make-pdf/references/support/runtime-bootstrap.mjs b/skills/make-pdf/references/support/runtime-bootstrap.mjs new file mode 100644 index 0000000000..2629db959f --- /dev/null +++ b/skills/make-pdf/references/support/runtime-bootstrap.mjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +// Dependency-free bootstrap copied into each standards-installed GStack skill. +// It installs only the optional local runtime; host skill placement remains the +// responsibility of the Agent Skills installer. +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { createHash } from "node:crypto"; +import { constants as fsConstants, createReadStream } from "node:fs"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; + +export const BOOTSTRAP_SCHEMA_VERSION = 2; +export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; +// Keep the runtime compatibility version separate from the immutable release +// channel. Release candidates carry the 2.0.0 runtime contract while letting +// fresh-machine production journeys run before the stable v2.0.0 tag exists. +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; +export const OFFICIAL_MANIFEST_URL = + `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; +const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); +const CAPABILITY_DEPENDENCIES = Object.freeze({ + browser: Object.freeze([]), + "browser-visible": Object.freeze([]), + design: Object.freeze([]), + pdf: Object.freeze(["browser", "diagram"]), + diagram: Object.freeze(["browser"]), + ios: Object.freeze([]), +}); +export const COMPONENT_DEPENDENCIES = Object.freeze({ + core: Object.freeze([]), + "browser-code": Object.freeze(["core"]), + "browser-headless": Object.freeze(["browser-code"]), + "browser-visible": Object.freeze(["browser-code"]), + design: Object.freeze(["core"]), + diagram: Object.freeze(["browser-headless"]), + pdf: Object.freeze(["diagram"]), + ios: Object.freeze(["core"]), +}); +export const CAPABILITY_COMPONENTS = Object.freeze({ + browser: Object.freeze(["browser-code", "browser-headless"]), + "browser-visible": Object.freeze(["browser-code", "browser-visible"]), + design: Object.freeze(["design"]), + diagram: Object.freeze(["diagram"]), + pdf: Object.freeze(["pdf"]), + ios: Object.freeze(["ios"]), +}); +const ALLOWED_DOWNLOAD_HOSTS = new Set([ + "github.com", + "objects.githubusercontent.com", + "release-assets.githubusercontent.com", +]); +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; +const OFFICIAL_CERTIFICATE_IDENTITY = + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; +const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; + +export async function main(argv = process.argv.slice(2), options = {}) { + const io = { + stdout: options.stdout ?? process.stdout, + stderr: options.stderr ?? process.stderr, + }; + try { + const parsed = parseArgs(argv); + if (parsed.help) { + io.stdout.write(usage()); + return 0; + } + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); + } + + const platform = options.platform ?? process.platform; + if (parsed.capabilities.includes("ios") && platform !== "darwin") { + throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); + } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } + if (parsed.source) { + const sourceHome = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); + const active = await inspectReusableRuntime(sourceHome, BOOTSTRAP_RUNTIME_VERSION).catch(() => null); + if (!browserChoice && active?.browserChoice) { + browserChoice = await resolveBrowserChoice(active.browserChoice, { + platform, + env: options.env, + homeDir: options.homeDir, + }); + } + parsed.capabilities = mergeRetainedCapabilities(parsed.capabilities, active, browserChoice); + if (browserChoiceRequired(parsed.capabilities) && !browserChoice) { + throw bootstrapError( + "The active browser capability does not record a reusable browser provider; choose a browser provider before changing this runtime.", + "BOOTSTRAP_BROWSER_CHOICE_REQUIRED", + ); + } + if (browserChoice) assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + if (parsed.action === "preview") { + io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); + return 0; + } + if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); + io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); + return await installFromSource(parsed.source, parsed, { + ...options, + ...io, + prepared: false, + replaceCapabilities: true, + browserChoice, + }); + } + + const fetch_ = options.fetch ?? globalThis.fetch; + if (typeof fetch_ !== "function") throw bootstrapError("Node 18+ with fetch is required", "BOOTSTRAP_NODE_UNSUPPORTED"); + const target = platformTarget( + platform, + options.arch ?? process.arch, + options.libc ?? detectLinuxLibc(platform), + ); + const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; + assertOfficialUrl(manifestUrl, { manifest: true }); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); + validateManifest(manifest, target); + const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); + const active = await inspectReusableRuntime(home, manifest.version).catch(() => null); + const reusable = active?.releaseMatches ? active : null; + if (!browserChoice && active?.browserChoice) { + browserChoice = await resolveBrowserChoice(active.browserChoice, { + platform, + env: options.env, + homeDir: options.homeDir, + }); + } + parsed.capabilities = mergeRetainedCapabilities(parsed.capabilities, active, browserChoice); + if (browserChoiceRequired(parsed.capabilities) && !browserChoice) { + throw bootstrapError( + "The active browser capability does not record a reusable browser provider; preview browser setup options before changing this runtime.", + "BOOTSTRAP_BROWSER_CHOICE_REQUIRED", + ); + } + if (browserChoice) assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); + else printComponentPlan(io.stdout, plan); + if (parsed.action === "preview") return 0; + if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after reviewing this exact component plan", "BOOTSTRAP_CONSENT_REQUIRED"); + const temporary = await fs.mkdtemp(path.join(options.tmpDir ?? os.tmpdir(), "gstack-bootstrap-")); + try { + const root = path.join(temporary, "merged", "gstack"); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + const claimedFiles = new Set(); + if (reusable) await seedReusableRuntime(reusable, root, claimedFiles); + for (const item of plan.downloads) { + const archive = path.join(temporary, `${item.component}.tar.gz`); + await downloadVerified(fetch_, item.artifact.url, archive, item.artifact.sha256, item.artifact.bytes); + io.stdout.write(`Verified SHA-256 for ${item.component} (${target}).\n`); + await verifyCosignWhenAvailable(archive, item.artifact, path.join(temporary, item.component), { ...options, fetch: fetch_, ...io }); + const extracted = path.join(temporary, "extracted", item.component); + await fs.mkdir(extracted, { recursive: true, mode: 0o700 }); + await extractTarSafely(archive, extracted, options); + const componentRoot = safeArtifactRoot(extracted, item.artifact.root ?? "gstack"); + await assertNoLinks(componentRoot); + await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); + } + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); + } finally { + await fs.rm(temporary, { recursive: true, force: true }); + } + } catch (error) { + io.stderr.write(`gstack bootstrap: ${error?.message ?? error}\n`); + return 1; + } +} + +function parseArgs(argv) { + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (["-h", "--help"].includes(arg)) result.help = true; + else if (arg === "--yes") result.yes = true; + else if (arg === "--json") result.json = true; + else if (!result.action && !arg.startsWith("-")) result.action = arg; + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { + const value = argv[++index]; + if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); + if (arg === "--capability") result.capabilities.push(value); + else if (arg === "--source") result.source = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; + } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); + } + if (result.help) return result; + if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); + result.capabilities = [...new Set(result.capabilities)].sort(); + for (const capability of result.capabilities) { + if (!CAPABILITIES.has(capability)) throw bootstrapError(`Unknown capability: ${capability}`, "BOOTSTRAP_USAGE"); + } + const expanded = new Set(result.capabilities); + const pending = [...expanded]; + while (pending.length) { + for (const dependency of CAPABILITY_DEPENDENCIES[pending.pop()] ?? []) { + if (!expanded.has(dependency)) { + expanded.add(dependency); + pending.push(dependency); + } + } + } + result.capabilities = [...expanded].sort(); + return result; +} + +function validateManifest(manifest, target) { + if (manifest?.schemaVersion !== BOOTSTRAP_SCHEMA_VERSION || manifest?.version !== BOOTSTRAP_RUNTIME_VERSION || + manifest?.skillApi !== "2.0" || typeof manifest?.targets !== "object" || + !sameGraph(manifest.capabilityComponents, CAPABILITY_COMPONENTS) || + !sameGraph(manifest.componentDependencies, COMPONENT_DEPENDENCIES)) { + throw bootstrapError("Official runtime manifest is incompatible", "BOOTSTRAP_MANIFEST_INVALID"); + } + const targetRecord = manifest.targets[target]; + const expected = Object.keys(COMPONENT_DEPENDENCIES) + .filter((component) => component !== "ios" || target.startsWith("darwin-")) + .sort(); + if (!targetRecord || typeof targetRecord.components !== "object" || + JSON.stringify(Object.keys(targetRecord.components).sort()) !== JSON.stringify(expected)) { + throw bootstrapError(`No valid official runtime artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE"); + } + for (const [component, artifact] of Object.entries(targetRecord.components)) { + if (!artifact || artifact.format !== "tar.gz" || !/^[a-f0-9]{64}$/.test(artifact.sha256) || + !Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1 || artifact.bytes > 2 * 1024 * 1024 * 1024) { + throw bootstrapError(`Invalid ${component} artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE"); + } + assertOfficialReleaseAssetUrl(artifact.url); + if (artifact.cosignBundleUrl) { + assertOfficialReleaseAssetUrl(artifact.cosignBundleUrl); + if (artifact.certificateIdentity !== OFFICIAL_CERTIFICATE_IDENTITY || + artifact.certificateOidcIssuer !== GITHUB_OIDC_ISSUER) { + throw bootstrapError("Cosign metadata does not bind the official GStack release workflow", "BOOTSTRAP_MANIFEST_INVALID"); + } + } else if (artifact.certificateIdentity || artifact.certificateOidcIssuer) { + throw bootstrapError("Cosign certificate metadata requires a bundle URL", "BOOTSTRAP_MANIFEST_INVALID"); + } + } + return targetRecord; +} + +function sameGraph(actual, expected) { + if (!actual || typeof actual !== "object" || Array.isArray(actual)) return false; + const normalize = (graph) => Object.fromEntries(Object.entries(graph) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => [key, Array.isArray(values) ? [...values].sort() : values])); + return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); +} + +function selectedComponents(capabilities, browserChoice) { + const selected = new Set(["core"]); + for (const capability of capabilities) { + for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); + } + const pending = [...selected]; + while (pending.length) { + for (const dependency of COMPONENT_DEPENDENCIES[pending.pop()] ?? []) { + if (!selected.has(dependency)) { + selected.add(dependency); + pending.push(dependency); + } + } + } + return applyBrowserProviderToComponents([...selected], browserChoice); +} + +function mergeRetainedCapabilities(requested, reusable, browserChoice) { + const selected = new Set([ + ...(Array.isArray(reusable?.selectedCapabilities) ? reusable.selectedCapabilities : []), + ...requested, + ]); + if (browserChoice?.provider === "installed") selected.delete("browser-visible"); + const pending = [...selected]; + while (pending.length) { + for (const dependency of CAPABILITY_DEPENDENCIES[pending.pop()] ?? []) { + if (!selected.has(dependency)) { + selected.add(dependency); + pending.push(dependency); + } + } + } + return [...selected].sort(); +} + +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); + const retained = new Set(reusable?.components ?? []); + const downloads = components + .filter((component) => !retained.has(component)) + .map((component) => ({ component, artifact: manifest.targets[target].components[component] })); + const downloadBytes = downloads.reduce((total, item) => total + item.artifact.bytes, 0); + return { + target, + version: manifest.version, + capabilities, + browser: browserChoice, + components, + reusedComponents: components.filter((component) => retained.has(component)), + downloads, + downloadBytes, + }; +} + +function printComponentPlan(stdout, plan) { + stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); + stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } + stdout.write(`Components: ${plan.components.join(", ")}\n`); + if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); + stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); +} + +async function inspectReusableRuntime(home, version) { + const versions = path.join(home, "versions"); + const pointer = JSON.parse(await fs.readFile(path.join(versions, "current.json"), "utf8")); + if (pointer?.schemaVersion !== 2 || pointer?.status !== "active" || + typeof pointer.current !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(pointer.current)) return null; + const root = path.join(versions, pointer.current); + const stat = await fs.lstat(root); + if (!stat.isDirectory() || stat.isSymbolicLink()) return null; + const bundle = JSON.parse(await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8")); + const releaseMatches = bundle?.version === version || + (typeof bundle?.version === "string" && bundle.version.startsWith(`${version}-caps-`)); + if (bundle?.schemaVersion !== 2 || typeof bundle.version !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(bundle.version) || !Array.isArray(bundle.runtimeComponents) || + !Array.isArray(bundle.files)) return null; + const components = [...new Set(bundle.runtimeComponents)]; + if (!components.length || components.some((component) => !Object.hasOwn(COMPONENT_DEPENDENCIES, component))) return null; + const selectedCapabilities = Array.isArray(bundle.selectedCapabilities) + ? [...new Set(bundle.selectedCapabilities)] + : []; + if (selectedCapabilities.some((capability) => !CAPABILITIES.has(capability))) return null; + let browserChoice = null; + if (browserChoiceRequired(selectedCapabilities)) { + const explicit = bundle.browserChoice; + if (!explicit || !["managed", "installed"].includes(explicit.provider)) return null; + if (explicit.provider === "installed") { + if (selectedCapabilities.includes("browser-visible") || + typeof explicit.executablePath !== "string" || !path.isAbsolute(explicit.executablePath) || + components.includes("browser-headless") || components.includes("browser-visible")) return null; + browserChoice = { provider: "installed", executablePath: explicit.executablePath }; + } else { + if (!components.includes("browser-headless") && !components.includes("browser-visible")) return null; + browserChoice = { provider: "managed", executablePath: null }; + } + } + await assertNoLinks(root); + const files = []; + const seen = new Set(); + for (const entry of bundle.files) { + const relative = entry?.path; + if (typeof relative !== "string" || !relative || relative.includes("\\") || path.posix.isAbsolute(relative) || + path.posix.normalize(relative) !== relative || relative.split("/").includes("..") || seen.has(relative) || + !Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256)) return null; + seen.add(relative); + const file = path.join(root, ...relative.split("/")); + const fileStat = await fs.lstat(file).catch(() => null); + if (!fileStat?.isFile() || fileStat.isSymbolicLink() || fileStat.size !== entry.size || + await sha256File(file) !== entry.sha256) return null; + files.push(relative); + } + return { root, components, files, selectedCapabilities, browserChoice, releaseMatches }; +} + +async function seedReusableRuntime(reusable, destination, claimedFiles) { + for (const relative of reusable.files) { + if (claimedFiles.has(relative)) throw bootstrapError(`Runtime components overlap at ${relative}`, "BOOTSTRAP_MANIFEST_INVALID"); + claimedFiles.add(relative); + const target = path.join(destination, ...relative.split("/")); + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + await fs.copyFile(path.join(reusable.root, ...relative.split("/")), target, fsConstants.COPYFILE_EXCL); + } +} + +function sha256File(file) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(file); + stream.on("error", reject); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +async function fetchJson(fetch_, url, options = {}) { + const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); + assertFinalDownloadUrl(response.url || url); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } + const value = await response.json(); + if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); + return value; +} + +async function downloadVerified(fetch_, url, destination, expectedSha256, expectedBytes) { + const response = await fetch_(url, { redirect: "follow" }); + assertFinalDownloadUrl(response.url || url); + if (!response.ok) throw bootstrapError(`Artifact download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + const hash = createHash("sha256"); + const file = await fs.open(destination, "wx", 0o600); + let total = 0; + try { + if (response.body?.getReader) { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > 2 * 1024 * 1024 * 1024) throw bootstrapError("Runtime artifact exceeds the 2 GiB safety limit", "BOOTSTRAP_DOWNLOAD_FAILED"); + hash.update(value); + await file.write(value); + } + } else { + const bytes = new Uint8Array(await response.arrayBuffer()); + total = bytes.byteLength; + hash.update(bytes); + await file.write(bytes); + } + } catch (error) { + await file.close(); + await fs.rm(destination, { force: true }); + throw error; + } + await file.close(); + if (total !== expectedBytes) { + await fs.rm(destination, { force: true }); + throw bootstrapError(`Runtime artifact size mismatch (expected ${expectedBytes}, received ${total})`, "BOOTSTRAP_INTEGRITY_FAILED"); + } + const actual = hash.digest("hex"); + if (actual !== expectedSha256) { + await fs.rm(destination, { force: true }); + throw bootstrapError("Runtime artifact SHA-256 mismatch", "BOOTSTRAP_INTEGRITY_FAILED"); + } +} + +async function verifyCosignWhenAvailable(archive, artifact, temporary, options) { + if (!artifact.cosignBundleUrl) { + options.stdout.write("No Cosign bundle declared; continuing with verified release-manifest SHA-256.\n"); + return; + } + const available = await run(options.cosignCommand ?? "cosign", ["version"], { capture: true }).then(() => true, () => false); + if (!available) { + options.stdout.write("Cosign metadata is available but Cosign is not installed; SHA-256 verification succeeded.\n"); + return; + } + const bundle = path.join(temporary, "cosign.bundle"); + const response = await options.fetch(artifact.cosignBundleUrl, { redirect: "follow" }); + assertFinalDownloadUrl(response.url || artifact.cosignBundleUrl); + if (!response.ok) throw bootstrapError("Cosign bundle download failed", "BOOTSTRAP_ATTESTATION_FAILED"); + await fs.writeFile(bundle, new Uint8Array(await response.arrayBuffer()), { mode: 0o600, flag: "wx" }); + const args = ["verify-blob", "--bundle", bundle]; + if (artifact.certificateIdentity) args.push("--certificate-identity", artifact.certificateIdentity); + if (artifact.certificateOidcIssuer) args.push("--certificate-oidc-issuer", artifact.certificateOidcIssuer); + args.push(archive); + await run(options.cosignCommand ?? "cosign", args); + options.stdout.write("Verified Cosign release attestation.\n"); +} + +async function extractTarSafely(archive, destination, options) { + const tar = options.tarCommand ?? "tar"; + const listing = await run(tar, ["-tzf", archive], { capture: true }); + for (const name of listing.stdout.split(/\r?\n/).filter(Boolean)) { + const normalized = name.replaceAll("\\", "/"); + if (normalized.includes("\0") || normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized) || + normalized.split("/").includes("..")) { + throw bootstrapError("Runtime archive contains an unsafe path", "BOOTSTRAP_ARCHIVE_UNSAFE"); + } + } + const verbose = await run(tar, ["-tvzf", archive], { capture: true }); + for (const line of verbose.stdout.split(/\r?\n/).filter(Boolean)) { + if (!/^[-d]/.test(line)) { + throw bootstrapError("Runtime archive contains a link or special-file entry", "BOOTSTRAP_ARCHIVE_UNSAFE"); + } + } + await run(tar, ["-xzf", archive, "-C", destination]); +} + +async function installFromSource(source, parsed, options) { + const physical = await fs.realpath(path.resolve(source)); + const installer = path.join(physical, "runtime", "install.js"); + const stat = await fs.lstat(installer).catch(() => null); + if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); + const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } + if (parsed.home) args.push("--home", path.resolve(parsed.home)); + if (options.version) args.push("--version", options.version); + if (options.prepared) args.push("--prepared"); + if (options.prepared || options.replaceCapabilities) args.push("--replace-capabilities"); + await run(options.nodeCommand ?? process.execPath, args); + options.stdout.write(`Installed optional capabilities: ${parsed.capabilities.join(", ")}. No coding host was enrolled.\n`); + return 0; +} + +function platformTarget(platform, arch, libc) { + if (!["darwin", "linux", "win32"].includes(platform) || !["arm64", "x64"].includes(arch)) { + throw bootstrapError(`Unsupported platform: ${platform}-${arch}`, "BOOTSTRAP_PLATFORM_UNSUPPORTED"); + } + if (platform === "linux" && libc !== "glibc") { + throw bootstrapError( + "Official GStack runtime artifacts currently require glibc Linux; pure Agent Skills remain portable and the reviewed-source fallback may be used explicitly", + "BOOTSTRAP_PLATFORM_UNSUPPORTED", + ); + } + return `${platform === "win32" ? "windows" : platform}-${arch}`; +} + +function detectLinuxLibc(platform) { + if (platform !== "linux") return null; + const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null; + return report?.header?.glibcVersionRuntime ? "glibc" : "musl"; +} + +function safeArtifactRoot(extracted, relative) { + if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) { + throw bootstrapError("Manifest contains an unsafe artifact root", "BOOTSTRAP_MANIFEST_INVALID"); + } + const target = path.resolve(extracted, relative); + if (path.relative(extracted, target).startsWith(`..${path.sep}`)) throw bootstrapError("Artifact root escaped extraction", "BOOTSTRAP_ARCHIVE_UNSAFE"); + return target; +} + +async function mergeComponentRoot(source, destination, claimedFiles, component) { + async function visit(relative = "") { + for (const entry of await fs.readdir(path.join(source, relative), { withFileTypes: true })) { + const child = relative ? `${relative}/${entry.name}` : entry.name; + const from = path.join(source, ...child.split("/")); + const to = path.join(destination, ...child.split("/")); + if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) { + throw bootstrapError(`Runtime component ${component} contains a link or special file`, "BOOTSTRAP_ARCHIVE_UNSAFE"); + } + if (entry.isDirectory()) { + await fs.mkdir(to, { recursive: true, mode: 0o700 }); + await visit(child); + } else { + if (claimedFiles.has(child)) { + throw bootstrapError(`Runtime components overlap at ${child}`, "BOOTSTRAP_MANIFEST_INVALID"); + } + claimedFiles.add(child); + await fs.mkdir(path.dirname(to), { recursive: true, mode: 0o700 }); + await fs.copyFile(from, to, fsConstants.COPYFILE_EXCL); + } + } + } + await visit(); +} + +async function assertNoLinks(root) { + const pending = [root]; + while (pending.length) { + const target = pending.pop(); + const stat = await fs.lstat(target); + if (stat.isSymbolicLink()) throw bootstrapError("Runtime archive contains a symbolic link", "BOOTSTRAP_ARCHIVE_UNSAFE"); + if (stat.isDirectory()) for (const child of await fs.readdir(target)) pending.push(path.join(target, child)); + } +} + +function assertOfficialUrl(value, options = {}) { + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || !ALLOWED_DOWNLOAD_HOSTS.has(url.hostname)) { + throw bootstrapError("Bootstrap downloads are restricted to official GitHub release hosts", "BOOTSTRAP_URL_BLOCKED"); + } + if (options.manifest && url.hostname !== "github.com") throw bootstrapError("Manifest must come from the official GitHub release", "BOOTSTRAP_URL_BLOCKED"); +} + +function assertOfficialReleaseAssetUrl(value) { + assertOfficialUrl(value); + const url = new URL(value); + if (url.hostname !== "github.com" || !url.pathname.startsWith(OFFICIAL_RELEASE_PREFIX)) { + throw bootstrapError("Runtime assets must come from the official versioned GStack release", "BOOTSTRAP_URL_BLOCKED"); + } +} + +function assertFinalDownloadUrl(value) { + assertOfficialUrl(value); +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false, windowsHide: true, stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit" }); + let stdout = ""; + let stderr = ""; + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { stdout += chunk; }); + child.stderr?.on("data", (chunk) => { stderr += chunk; }); + child.once("error", reject); + child.once("close", (code) => code === 0 ? resolve({ stdout, stderr }) : reject(bootstrapError(`${command} failed (${code})`, "BOOTSTRAP_COMMAND_FAILED"))); + }); +} + +function bootstrapError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} + +function formatBytes(bytes) { + const units = ["B", "KiB", "MiB", "GiB"]; + let value = bytes; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + return `${value.toFixed(index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]} (${bytes} bytes)`; +} + +function usage() { + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; +} + +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + +async function isDirectExecution() { + if (!process.argv[1]) return false; + const [modulePath, invokedPath] = await Promise.all([ + fs.realpath(fileURLToPath(import.meta.url)), + fs.realpath(path.resolve(process.argv[1])).catch(() => path.resolve(process.argv[1])), + ]); + return modulePath === invokedPath; +} + +if (await isDirectExecution()) { + process.exitCode = await main(); +} diff --git a/skills/make-pdf/references/support/runtime-contract.json b/skills/make-pdf/references/support/runtime-contract.json new file mode 100644 index 0000000000..9eb8b82dba --- /dev/null +++ b/skills/make-pdf/references/support/runtime-contract.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "runtimeVersion": "2.0.0", + "skillApi": "2.0" +} From 114347cd66e5c2b4a806fca77257ede8ee090627 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:10:00 -0700 Subject: [PATCH 10/13] test: expect six discoverable skills (five dispatchers + make-pdf) Co-Authored-By: Claude Opus 4.8 (1M context) --- test/gen-skill-docs.test.ts | 3 ++- test/gstack2-skill-ux.test.ts | 3 ++- test/gstack2-skills.test.ts | 17 ++++++++++++++++- test/post-rename-doc-regen.test.ts | 3 ++- test/skill-validation.test.ts | 7 ++++--- 5 files changed, 26 insertions(+), 7 deletions(-) diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index 242eafd486..5180b7e531 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -149,7 +149,8 @@ describe('gen-skill-docs', () => { .filter(entry => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map(entry => entry.name) .sort(); - expect(publicSkills).toEqual([...PUBLIC_SKILL_NAMES]); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual([...PUBLIC_SKILL_NAMES, 'make-pdf'].sort()); for (const name of PUBLIC_SKILL_NAMES) { const content = fs.readFileSync(path.join(ROOT, 'skills', name, 'SKILL.md'), 'utf-8'); diff --git a/test/gstack2-skill-ux.test.ts b/test/gstack2-skill-ux.test.ts index 7758e9c5cc..109e9858e8 100644 --- a/test/gstack2-skill-ux.test.ts +++ b/test/gstack2-skill-ux.test.ts @@ -33,7 +33,8 @@ describe('GStack 2 canonical skill UX', () => { .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map((entry) => entry.name) .sort(); - expect(publicSkills).toEqual([...TREE_NAMES].sort()); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual([...TREE_NAMES, 'make-pdf'].sort()); for (const assignment of SOURCE_ASSIGNMENTS) { const body = fs.readFileSync(ownerModule(assignment.source), 'utf8'); diff --git a/test/gstack2-skills.test.ts b/test/gstack2-skills.test.ts index fe6a458a4c..4684a0b3cd 100644 --- a/test/gstack2-skills.test.ts +++ b/test/gstack2-skills.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { blobShaForPath, pinnedRevisionPath } from '../scripts/gstack2/render-legacy'; import { runParity } from '../scripts/gstack2/run-parity'; @@ -30,6 +30,21 @@ describe('GStack 2 skill parity', () => { expect(result.regressions).toBe(26); }, 30_000); + test('ships make-pdf as a sixth discoverable tool skill in the canonical tree', () => { + const discovered = readdirSync(join(ROOT, 'skills'), { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && existsSync(join(ROOT, 'skills', entry.name, 'SKILL.md'))) + .map((entry) => entry.name) + .sort(); + expect(discovered).toEqual(['debug', 'make-pdf', 'plan', 'qa', 'review', 'ship']); + + const skill = readFileSync(join(ROOT, 'skills', 'make-pdf', 'SKILL.md'), 'utf8'); + expect(skill).toMatch(/^---\nname: make-pdf\n/); + expect(skill).toContain('description: >-'); + expect(skill).toContain('references/RUNTIME.md'); + // The runtime handoff reference is packaged so the skill is self-contained. + expect(existsSync(join(ROOT, 'skills', 'make-pdf', 'references', 'RUNTIME.md'))).toBe(true); + }); + test('does not overclaim safety-hook enforcement in portable installs', () => { const debug = readFileSync(join(ROOT, 'skills', 'debug', 'SKILL.md'), 'utf8'); expect(debug).toContain('inline advisory policy unless the active host explicitly confirms an installed hook'); diff --git a/test/post-rename-doc-regen.test.ts b/test/post-rename-doc-regen.test.ts index 52b85c6093..0ece5d4a86 100644 --- a/test/post-rename-doc-regen.test.ts +++ b/test/post-rename-doc-regen.test.ts @@ -79,6 +79,7 @@ describe('post-rename doc-regen regression (codex Finding #12)', () => { .filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map((entry) => entry.name) .sort(); - expect(publicSkills).toEqual(['debug', 'plan', 'qa', 'review', 'ship']); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual(['debug', 'make-pdf', 'plan', 'qa', 'review', 'ship']); }); }); diff --git a/test/skill-validation.test.ts b/test/skill-validation.test.ts index 4248387bf2..b31d2440ad 100644 --- a/test/skill-validation.test.ts +++ b/test/skill-validation.test.ts @@ -31,14 +31,14 @@ function readShipUnion(): string { } describe('SKILL.md command validation', () => { - test('root SKILL.md is absent and the public surface is exactly five dispatchers', () => { + test('root SKILL.md is absent and the public surface is five dispatchers plus the make-pdf tool skill', () => { expect(fs.existsSync(path.join(ROOT, 'SKILL.md'))).toBe(false); const discovered = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true }) .filter((entry) => entry.isDirectory() && fs.existsSync(publicSkillPath(entry.name))) .map((entry) => entry.name) .sort(); - expect(discovered).toEqual([...PUBLIC_SKILLS]); + expect(discovered).toEqual([...PUBLIC_SKILLS, 'make-pdf'].sort()); for (const skill of PUBLIC_SKILLS) { const md = fs.readFileSync(publicSkillPath(skill), 'utf-8'); @@ -1619,7 +1619,8 @@ describe('Doc inventory cross-check', () => { .filter((entry) => entry.isDirectory() && fs.existsSync(publicSkillPath(entry.name))) .map((entry) => entry.name) .sort(); - expect(publicDirs).toEqual([...PUBLIC_SKILLS]); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicDirs).toEqual([...PUBLIC_SKILLS, 'make-pdf'].sort()); }); test('every legacy template is represented in the compatibility map', () => { From ec895b051c1eb43949dcad1b1a5e8eb17ec58f62 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:10:00 -0700 Subject: [PATCH 11/13] docs: note make-pdf as the sixth tool skill in the canonical tree Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 4 +++- CLAUDE.md | 7 +++++-- README.md | 2 +- docs/gstack-2/ARCHITECTURE.md | 5 +++++ docs/gstack-2/STATUS.md | 10 ++++++++-- 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 74e79c1efc..f8e7f32724 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ GStack 2 exposes exactly five default public skills. Treat this as a routing surface over preserved specialist judgment, not permission to simplify that -judgment into generic checklists. +judgment into generic checklists. The `make-pdf` tool skill also ships in the +canonical tree (`skills/make-pdf`), so `npx skills add time-attack/gstack/skills` +installs six discoverable skills: the five judgment dispatchers plus make-pdf. ## Public skills diff --git a/CLAUDE.md b/CLAUDE.md index 8214929ffb..dfc2d8096b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,12 +2,15 @@ ## GStack 2 canonical contract -The public surface is exactly `/plan`, `/qa`, `/debug`, `/review`, and `/ship`. +The judgment surface is exactly `/plan`, `/qa`, `/debug`, `/review`, and `/ship`. They are lazy dispatchers over the preserved modules under `skills/*/references/legacy/`; do not rewrite or summarize away the original question pressure, rubrics, pushback, approval gates, evidence, artifacts, mutation boundaries, recommendations, or voice. Compatibility files under -`compat/` route old names and contain no judgment. +`compat/` route old names and contain no judgment. `make-pdf` is a tool skill +(not a judgment dispatcher) emitted into `skills/make-pdf/`; it installs with the +same canonical tree, so `npx skills add time-attack/gstack/skills` yields six +discoverable skills (five dispatchers plus make-pdf). `/plan` has exactly six top-level modes: Discovery, Product, Engineering, DX, Specification, and Full chain. Do not expose its internal aliases as more diff --git a/README.md b/README.md index 3c80c1d953..1eb1c7ef78 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ > compatibility surface. GStack 2 presents one engineering judgment layer through exactly five public -skills: +skills (plus the `make-pdf` tool skill, which installs with the same tree): | Skill | Start here when… | |---|---| diff --git a/docs/gstack-2/ARCHITECTURE.md b/docs/gstack-2/ARCHITECTURE.md index 42d545352b..956302d2d6 100644 --- a/docs/gstack-2/ARCHITECTURE.md +++ b/docs/gstack-2/ARCHITECTURE.md @@ -12,6 +12,11 @@ GStack 2 is an engineering judgment layer with exactly five public skills: plan -> qa -> debug -> review -> ship ``` +The canonical tree also ships one tool skill, `make-pdf` (Markdown to +publication-quality PDF). It is not a mode-based judgment dispatcher, but it is +emitted into `skills/make-pdf/` so it installs with the same +`npx skills add time-attack/gstack/skills` (six discoverable skills total). + This is a navigation surface, not a linear workflow and not a compression of specialists into generic prompts. Each dispatcher selects a mode from structured stage, surface, artifact, mutation-authority, evidence, and deploy diff --git a/docs/gstack-2/STATUS.md b/docs/gstack-2/STATUS.md index 2ea68aac38..b9074621b7 100644 --- a/docs/gstack-2/STATUS.md +++ b/docs/gstack-2/STATUS.md @@ -23,9 +23,15 @@ PR, or PR-ready claim is authorized by this status. them candidate regressions. - [x] 755 unique open issue/PR records reconciled and deterministically mapped; all 16 required upstream PR snapshots traced. -- [x] Exactly five canonical skill directories: `plan`, `qa`, `debug`, - `review`, and `ship`. (The `/design` skill was retired; its runtime image / +- [x] Five canonical judgment dispatcher directories: `plan`, `qa`, `debug`, + `review`, and `ship`, plus the `make-pdf` tool skill emitted into + `skills/make-pdf/`. (The `/design` skill was retired; its runtime image / diagram / PDF capabilities remain available to the surviving skills.) + `make-pdf` is a tool skill, not a mode-based dispatcher, but it ships in the + same canonical tree, so `npx skills add time-attack/gstack/skills` now + surfaces six discoverable skills. The install-matrix and native-CI runs + recorded below ("exactly five skills") predate this addition; a fresh install + now finds six, and re-running those matrices to refresh the counts is pending. - [x] The six `/plan` top-level modes are exactly **Discovery, Product, Engineering, DX, Specification, and Full chain**. - [x] Standard installer matrix is green: 510/510 checks, 18 installs and two From fed07df158861270dd3cb37ffb676a255190aa60 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:17:10 -0700 Subject: [PATCH 12/13] test: recognize make-pdf in the standard installer surface matrix Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/gstack2/test-install-matrix.ts | 44 +++++++++++++++----------- test/gstack2-installation.test.ts | 7 ++-- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/scripts/gstack2/test-install-matrix.ts b/scripts/gstack2/test-install-matrix.ts index 6d05bce58b..a6b768c8a1 100644 --- a/scripts/gstack2/test-install-matrix.ts +++ b/scripts/gstack2/test-install-matrix.ts @@ -9,6 +9,10 @@ import { fileURLToPath } from 'node:url'; import { SOURCE_ASSIGNMENTS } from './assignments'; export const PUBLIC_SKILLS = ['debug', 'plan', 'qa', 'review', 'ship'] as const; +// make-pdf is a tool skill (not a mode-based dispatcher) that ships in the same +// canonical tree, so the standard installer surfaces it as a sixth skill. +export const TOOL_SKILLS = ['make-pdf'] as const; +export const ALL_PUBLIC_SKILLS: readonly string[] = [...PUBLIC_SKILLS, ...TOOL_SKILLS].sort(); export const COLLISION_SKILLS = ['qa', 'review', 'ship'] as const; export type PublicSkill = (typeof PUBLIC_SKILLS)[number]; export type InstallScope = 'project' | 'global'; @@ -205,16 +209,16 @@ export function inspectRepository(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspe const skillFiles = walkFiles(skillsRoot) .filter((file) => { const parts = file.split('/'); - return parts.length === 2 && parts[1] === 'SKILL.md' && (PUBLIC_SKILLS as readonly string[]).includes(parts[0]); + return parts.length === 2 && parts[1] === 'SKILL.md' && ALL_PUBLIC_SKILLS.includes(parts[0]); }) .sort(); - const expectedFiles = PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort(); + const expectedFiles = ALL_PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort(); record( checks, 'repository.public-skill-names', - JSON.stringify(publicSkills) === JSON.stringify([...PUBLIC_SKILLS]), - `expected ${PUBLIC_SKILLS.join(', ')}; found ${publicSkills.join(', ') || '(none)'}`, + JSON.stringify(publicSkills) === JSON.stringify([...ALL_PUBLIC_SKILLS]), + `expected ${ALL_PUBLIC_SKILLS.join(', ')}; found ${publicSkills.join(', ') || '(none)'}`, ); record( checks, @@ -224,7 +228,7 @@ export function inspectRepository(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspe ); const seenNames = new Map(); - for (const skill of PUBLIC_SKILLS) { + for (const skill of ALL_PUBLIC_SKILLS) { const file = path.join(skillsRoot, skill, 'SKILL.md'); const exists = fs.existsSync(file); record(checks, `repository.${skill}.exists`, exists, exists ? normalizeRelative(path.relative(repoRoot, file)) : `missing ${file}`); @@ -236,13 +240,17 @@ export function inspectRepository(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspe record(checks, `repository.${skill}.unique-name`, !prior, prior ? `${name} also appears in ${prior}` : `${name} is unique`); seenNames.set(name, normalizeRelative(path.relative(repoRoot, file))); } - const references = path.join(skillsRoot, skill, 'references', 'legacy'); - record( - checks, - `repository.${skill}.preserved-modules`, - fs.existsSync(references) && walkFiles(references).length > 0, - fs.existsSync(references) ? `${walkFiles(references).length} preserved module files` : `missing ${references}`, - ); + // Only judgment dispatchers carry preserved legacy specialist modules; the + // make-pdf tool skill hands off to a runtime capability instead. + if ((PUBLIC_SKILLS as readonly string[]).includes(skill)) { + const references = path.join(skillsRoot, skill, 'references', 'legacy'); + record( + checks, + `repository.${skill}.preserved-modules`, + fs.existsSync(references) && walkFiles(references).length > 0, + fs.existsSync(references) ? `${walkFiles(references).length} preserved module files` : `missing ${references}`, + ); + } } for (const skill of COLLISION_SKILLS) { @@ -443,8 +451,8 @@ function verifyInstalledCase( record( checks, `${id}.public-discovery-count`, - reported === PUBLIC_SKILLS.length, - `expected installer to report 6 public skills; found ${Number.isFinite(reported) ? reported : '(unparsed)'}`, + reported === ALL_PUBLIC_SKILLS.length, + `expected installer to report ${ALL_PUBLIC_SKILLS.length} public skills; found ${Number.isFinite(reported) ? reported : '(unparsed)'}`, ); } record( @@ -615,12 +623,12 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence const discoveryChecks: CheckResult[] = []; record(discoveryChecks, 'discovery.command', discoveryCommand.exitCode === 0, `exit=${discoveryCommand.exitCode}`); record(discoveryChecks, 'discovery.copy-supported', supportsCopy, supportsCopy ? '--copy is supported' : '--copy missing from CLI help'); - record(discoveryChecks, 'discovery.count', parsedDiscovery.count === PUBLIC_SKILLS.length, `expected 6; found ${parsedDiscovery.count ?? '(unparsed)'}`); + record(discoveryChecks, 'discovery.count', parsedDiscovery.count === ALL_PUBLIC_SKILLS.length, `expected ${ALL_PUBLIC_SKILLS.length}; found ${parsedDiscovery.count ?? '(unparsed)'}`); record( discoveryChecks, 'discovery.names', - JSON.stringify(parsedDiscovery.names) === JSON.stringify([...PUBLIC_SKILLS]), - `expected ${PUBLIC_SKILLS.join(', ')}; found ${parsedDiscovery.names.join(', ') || '(unparsed)'}`, + JSON.stringify(parsedDiscovery.names) === JSON.stringify([...ALL_PUBLIC_SKILLS]), + `expected ${ALL_PUBLIC_SKILLS.join(', ')}; found ${parsedDiscovery.names.join(', ') || '(unparsed)'}`, ); const installs: InstallCaseEvidence[] = []; @@ -637,7 +645,7 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence sourceKind, sourceArgument: path.join(sourceKind === 'source-symlink' ? sourceLink : sourceRoot, 'skills'), sourceRoot, - expectedSkills: PUBLIC_SKILLS, + expectedSkills: ALL_PUBLIC_SKILLS, explicitSelection: false, workspaceRoot, npmCache, diff --git a/test/gstack2-installation.test.ts b/test/gstack2-installation.test.ts index fa60f26e9e..f9ebfc1b35 100644 --- a/test/gstack2-installation.test.ts +++ b/test/gstack2-installation.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { AGENT_MATRIX, + ALL_PUBLIC_SKILLS, COLLISION_SKILLS, DEFAULT_REPO_ROOT, PUBLIC_SKILLS, @@ -40,8 +41,8 @@ describe('GStack 2 standard installer surface', () => { const result = inspectRepository(DEFAULT_REPO_ROOT); expect(result.passed).toBe(true); - expect(result.publicSkills).toEqual([...PUBLIC_SKILLS]); - expect(result.skillFiles).toEqual(PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort()); + expect(result.publicSkills).toEqual([...ALL_PUBLIC_SKILLS]); + expect(result.skillFiles).toEqual(ALL_PUBLIC_SKILLS.map((skill) => `${skill}/SKILL.md`).sort()); for (const skill of COLLISION_SKILLS) { expect(result.checks.find((check) => check.id === `repository.collision.${skill}.canonical`)).toMatchObject({ passed: true, @@ -61,7 +62,7 @@ describe('GStack 2 standard installer surface', () => { expect(fs.realpathSync(linked)).toBe(fs.realpathSync(projected)); expect(inspectRepository(projected)).toMatchObject({ passed: true, - publicSkills: [...PUBLIC_SKILLS], + publicSkills: [...ALL_PUBLIC_SKILLS], }); }); From 6d90174729bc52d06467a77ed1ec9fea335f1ec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 13:19:55 -0700 Subject: [PATCH 13/13] chore: recognize make-pdf tool skill in skill-check + catalog audit Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/skill-check.ts | 10 +++++++--- test/catalog-trim.test.ts | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/skill-check.ts b/scripts/skill-check.ts index d897463483..1e59500c58 100644 --- a/scripts/skill-check.ts +++ b/scripts/skill-check.ts @@ -17,6 +17,10 @@ import { execSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const ROOT_REALPATH = fs.realpathSync(ROOT); const GSTACK2_PUBLIC_SKILLS = ['debug', 'plan', 'qa', 'review', 'ship']; +// make-pdf is a tool skill (not a mode-based dispatcher) that ships in the same +// canonical tree, so the discoverable surface is six skills. +const GSTACK2_TOOL_SKILLS = ['make-pdf']; +const GSTACK2_ALL_SKILLS = [...GSTACK2_PUBLIC_SKILLS, ...GSTACK2_TOOL_SKILLS].sort(); const RETIRED_GSTACK2_MONOLITH_OUTPUTS = new Set(['SKILL.md', 'claude/SKILL.md']); function hasGStack2Package(): boolean { @@ -75,11 +79,11 @@ if (GSTACK2_PACKAGE) { .filter((entry) => fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md'))) .map((entry) => entry.name) .sort(); - if (JSON.stringify(publicSkills) === JSON.stringify(GSTACK2_PUBLIC_SKILLS)) { - console.log(` \u2705 skills/ public package — exactly five dispatchers (${publicSkills.join(', ')})`); + if (JSON.stringify(publicSkills) === JSON.stringify(GSTACK2_ALL_SKILLS)) { + console.log(` \u2705 skills/ public package — exactly five dispatchers (${GSTACK2_PUBLIC_SKILLS.join(', ')}) plus make-pdf tool skill`); } else { hasErrors = true; - console.log(` \u274c skills/ public package — expected ${GSTACK2_PUBLIC_SKILLS.join(', ')}, found ${publicSkills.join(', ') || 'none'}`); + console.log(` \u274c skills/ public package — expected ${GSTACK2_ALL_SKILLS.join(', ')}, found ${publicSkills.join(', ') || 'none'}`); } } diff --git a/test/catalog-trim.test.ts b/test/catalog-trim.test.ts index 36d46cde78..0bab9103f9 100644 --- a/test/catalog-trim.test.ts +++ b/test/catalog-trim.test.ts @@ -304,7 +304,8 @@ describe('proactive-suggestions.json determinism (regression for v1.45.0.0 CI fr .filter((entry: { isDirectory(): boolean; name: string }) => entry.isDirectory() && !entry.name.startsWith('.')) .map((entry: { name: string }) => entry.name) .sort(); - expect(publicSkills).toEqual(['debug', 'plan', 'qa', 'review', 'ship']); + // Five judgment dispatchers plus the make-pdf tool skill in the same tree. + expect(publicSkills).toEqual(['debug', 'make-pdf', 'plan', 'qa', 'review', 'ship']); }); test('schema + catalog_mode + note fields are stable', () => {