From bc723b51bf83d0197fe9625626211b2763b1afb8 Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 14:09:51 -0700 Subject: [PATCH 01/14] chore: add apache-2.0 license and governance docs --- .github/ISSUE_TEMPLATE/bug_report.yml | 53 ++++++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 34 ++++ .github/PULL_REQUEST_TEMPLATE.md | 16 ++ CODE_OF_CONDUCT.md | 85 +++++++++ CONTRIBUTING.md | 74 ++++++++ LICENSE | 202 +++++++++++++++++++++ SECURITY.md | 26 +++ package.json | 1 + 9 files changed, 499 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..a2604c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,53 @@ +name: Bug report +description: Something broke — a bad export, a validator miss, a crashed command +labels: [bug] +body: + - type: dropdown + id: surface + attributes: + label: Surface + description: Where did the problem show up? + options: + - htmlify skill (SKILL.md flow) + - deckify skill (SKILL.md flow) + - htmlify-answer CLI + - --validate CLI + - Pi / Oh-My-Pi extension + - Claude Code hook / plugin + - Generated artifact (HTML output) + - Other + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, what happened instead? + placeholder: Ran `htmlify-answer --validate deck.html --profile deck` and ... + validations: + required: true + - type: textarea + id: repro + attributes: + label: Reproduction + description: Smallest input that reproduces it (command, snippet, or a minimal HTML file). + render: shell + - type: input + id: version + attributes: + label: Version + placeholder: e.g. @zakelfassi/htmlify 1.0.0, or a commit SHA + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + placeholder: e.g. Node 22.4 / macOS 15 / Claude Code 2.x + - type: checkboxes + id: security + attributes: + label: Security check + options: + - label: This is not a security vulnerability (those go to SECURITY.md, not public issues) + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4c067c0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/zakelfassi/htmlify/security/advisories/new + about: Report security issues privately — never in a public issue. + - name: Live gallery + url: https://zakelfassi.github.io/htmlify/ + about: See what the skills produce before filing. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..07527ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,34 @@ +name: Feature request +description: A new artifact mode, validator check, integration, or improvement +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What are you trying to do that htmlify/deckify doesn't handle well today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would you like to happen? Sketch the SKILL.md rule, CLI flag, or artifact behavior. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - htmlify skill / artifact modes + - deckify skill / deck modes + - Validator / CLI + - Agent integrations (hooks, plugin) + - Visual identity / theme + - Docs / gallery + - Other + - type: textarea + id: alternatives + attributes: + label: Alternatives considered diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..2b5a82b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +## What + + + +## Type + + + +## Checklist + +- [ ] `pnpm lint && pnpm typecheck && pnpm test` is green locally +- [ ] Conventional commit message(s), one logical change per commit +- [ ] No breaking change to the compatibility contracts (`_internals`, CLI flags/exit codes, hook path, `pi`/`omp` entry points, `skills/` paths) — or it's marked `feat!:` with a `BREAKING CHANGE:` footer and a migration note +- [ ] Validator changes come with pass **and** fail fixtures under `test/fixtures/` +- [ ] Theme or example changes were eyeballed in light mode, dark mode, and print preview +- [ ] Committed examples still pass `node bin/htmlify-answer.js --validate` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..454995e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at zakelfassi@gmail.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f809bd2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing to htmlify + +Thanks for your interest in improving htmlify and deckify. This guide covers the workflow, conventions, and quality bar for contributions. + +## Quick start + +```bash +git clone https://github.com/zakelfassi/htmlify.git +cd htmlify +corepack enable # provisions the pinned pnpm version +pnpm install +pnpm test +``` + +Requirements: Node.js 20 or newer, pnpm (managed via Corepack — never npm or yarn). + +## Development workflow + +| Command | What it does | +| --- | --- | +| `pnpm test` | Runs the full test suite (`node --test`) | +| `pnpm lint` | Checks formatting and lint rules (Biome) | +| `pnpm lint:fix` | Applies safe formatting/lint fixes | +| `pnpm typecheck` | Type-checks the JSDoc annotations (`tsc --noEmit`) | + +All three checks run in CI and must pass before merge. + +### Project shape + +- `src/` — the runtime, plain CommonJS with JSDoc types. Zero runtime dependencies is a hard constraint: what ships is what you read. +- `index.js` — thin façade exposing the Pi/OMP extension factory and `_internals`. Do not add logic here. +- `bin/htmlify-answer.js` — the CLI (export + `--validate`). +- `hooks/` — agent lifecycle hooks (Claude Code Stop hook). Paths here are a public contract; do not move or rename them. +- `skills/htmlify/`, `skills/deckify/` — the agent skills (SKILL.md + references). These are product surface, not docs: changes to operating rules or validation steps are behavior changes. +- `examples/` — committed gallery artifacts. Every file must pass `node bin/htmlify-answer.js --validate` with its profile; CI enforces this. + +### Compatibility contracts + +Treat these as semver-relevant public API: + +- `package.json` `main`, `bin`, `pi.extensions`, `omp.extensions` +- every name exported on `index.js` `_internals` +- CLI flags and exit codes of `htmlify-answer` +- the hook script path `hooks/claude-code-stop-htmlify.js` and its env vars (`HTMLIFY_MIN_CHARS`, `HTMLIFY_EXPORT_ROOT`, `HTMLIFY_SKIP_OPEN`) +- skill directory paths `skills/htmlify` and `skills/deckify` + +## Commit conventions + +This repo uses [Conventional Commits](https://www.conventionalcommits.org/); [release-please](https://github.com/googleapis/release-please) turns them into versions and the CHANGELOG. + +- `feat:` new capability (minor bump) +- `fix:` bug fix (patch bump) +- `feat!:` / `BREAKING CHANGE:` footer — breaking change (major bump) +- `docs:`, `chore:`, `refactor:`, `test:`, `ci:` — no release impact + +Keep commits to one logical change. No `Co-Authored-By` or tool-attribution trailers. + +## Pull requests + +1. Branch from `main`. +2. Make sure `pnpm lint && pnpm typecheck && pnpm test` is green locally. +3. If you touched validation logic, add fixtures under `test/fixtures/` covering both the pass and fail paths. +4. If you changed an example artifact or the document theme, open the artifact in a browser and check light mode, dark mode (`prefers-color-scheme`), and print preview. +5. Fill in the PR template — especially the compatibility-contract checklist. + +Small, focused PRs review faster than large ones. For anything architectural, open an issue first to discuss. + +## Reporting bugs and requesting features + +Use the [issue forms](https://github.com/zakelfassi/htmlify/issues/new/choose). For security issues, **do not open a public issue** — see [SECURITY.md](SECURITY.md). + +## License + +By contributing, you agree that your contributions are licensed under the [Apache License 2.0](LICENSE). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..06c431d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| --- | --- | +| 1.x | Yes | +| < 1.0 | No — upgrade to 1.x | + +## Reporting a vulnerability + +Please report vulnerabilities privately via [GitHub Security Advisories](https://github.com/zakelfassi/htmlify/security/advisories/new) (preferred) or by email to zakelfassi@gmail.com. Do not open a public issue for security reports. + +You can expect an acknowledgment within 72 hours and a fix or mitigation plan within 14 days for confirmed issues. Credit is given in the release notes unless you prefer otherwise. + +## Threat model and built-in safeguards + +htmlify writes HTML files to disk and opens them in your browser, so the main risks are script injection through generated artifacts and untrusted content reaching a rendered page. The runtime defends against this by design: + +- **Validation before write.** Rich/model-generated HTML is rejected unless it is a standalone document, and the validator blocks ``; -} - -function injectAnnotationLayer(html, meta) { - const layer = buildAnnotationLayer(meta); - const source = String(html || ''); - if (source.includes(TRUSTED_ANNOTATION_MARKER)) return source; - if (/<\/body\s*>/i.test(source)) return source.replace(/<\/body\s*>/i, `${layer}\n`); - return `${source}\n${layer}`; -} - -function validateCommentBundle(bundle, expectedSourceId) { - if (!bundle || typeof bundle !== 'object') throw new Error('Comment bundle must be a JSON object.'); - if (bundle.version !== COMMENT_BUNDLE_VERSION) - throw new Error(`Comment bundle version must be ${COMMENT_BUNDLE_VERSION}.`); - if (!Array.isArray(bundle.comments)) throw new Error('Comment bundle must include a comments array.'); - if (expectedSourceId && bundle.sourceId && bundle.sourceId !== expectedSourceId) { - throw new Error('Comment bundle source does not match the last captured answer.'); - } - return { - version: COMMENT_BUNDLE_VERSION, - sourceId: String(bundle.sourceId || ''), - title: String(bundle.title || 'HTML Export').slice(0, 160), - exportUrl: String(bundle.exportUrl || ''), - comments: bundle.comments.map((comment, index) => { - if (!comment || typeof comment !== 'object') throw new Error(`Comment ${index + 1} must be an object.`); - const selectedText = String(comment.selectedText || '').trim(); - const body = String(comment.comment || '').trim(); - if (!selectedText || !body) throw new Error(`Comment ${index + 1} must include selectedText and comment.`); - return { - id: String(comment.id || `comment-${index + 1}`).slice(0, 80), - blockId: String(comment.blockId || '').slice(0, 80), - selectedText: selectedText.slice(0, 4000), - prefix: String(comment.prefix || '').slice(0, 1000), - suffix: String(comment.suffix || '').slice(0, 1000), - comment: body.slice(0, 4000), - createdAt: String(comment.createdAt || ''), - }; - }), - }; -} - -function buildCommentsPrompt(bundle) { - const lines = [ - 'I reviewed the HTML export and left comments.', - '', - `Source: ${bundle.title}`, - `Source ID: ${bundle.sourceId || 'unknown'}`, - bundle.exportUrl ? `Export: ${bundle.exportUrl}` : '', - '', - ].filter((line, index) => line || index < 4); - bundle.comments.forEach((comment, index) => { - lines.push( - `## Comment ${index + 1}`, - '', - 'Selected text:', - `> ${comment.selectedText.replace(/\n/g, '\n> ')}`, - '', - 'Nearby context:', - `> ${comment.prefix} [${comment.selectedText}] ${comment.suffix}`.trim(), - '', - 'Comment:', - comment.comment, - '' - ); - }); - return lines.join('\n'); -} - -function isLongAnswer(text, config) { - const source = String(text || '').trim(); - if (!source) return false; - return ( - source.length >= config.minChars || - countLines(source) >= config.minLines || - countParagraphs(source) >= config.minParagraphs - ); -} - -function parseArgs(rawArgs) { - if (Array.isArray(rawArgs)) return rawArgs.map((item) => String(item)); - if (typeof rawArgs === 'string') return rawArgs.trim().split(/\s+/).filter(Boolean); - if (rawArgs && typeof rawArgs === 'object' && Array.isArray(rawArgs.args)) { - return rawArgs.args.map((item) => String(item)); - } - return []; -} - -function parseHtmlCommandInput(text) { - const source = typeof text === 'string' ? text.trim() : ''; - if (/^\/(?:html-last-version|htmlify-version)\s*$/i.test(source)) { - return { command: 'version', args: '' }; - } - - let match = /^\/(?:html-last|htmlify|htmlify-last)(?:\s+([\s\S]*))?$/i.exec(source); - if (match) return { command: 'export', args: match[1] || '' }; - - match = /^\/(?:html-comments|htmlify-comments)(?:\s+([\s\S]*))?$/i.exec(source); - if (match) return { command: 'comments', args: match[1] || '' }; - - return null; -} - -async function resolveOpenCommand(command) { - if (!command) return null; - if (path.isAbsolute(command)) { - try { - await fs.access(command, fs.constants.X_OK); - return command; - } catch (_) { - return null; - } - } - - const searchPath = String(process.env.PATH || '') - .split(path.delimiter) - .filter(Boolean); - for (const directory of searchPath) { - const candidate = path.join(directory, command); - try { - await fs.access(candidate, fs.constants.X_OK); - return candidate; - } catch (_) { - // Keep searching PATH. - } - } - return null; -} - -function resolveForcedExportMode(rawArgs) { - const parsedArgs = parseArgs(rawArgs); - if (parsedArgs.some((arg) => /^(choose|choices|chooser|menu)$/i.test(arg))) return 'choose'; - if (parsedArgs.some((arg) => /^(gemini)$/i.test(arg))) return 'rich-gemini'; - if (parsedArgs.some((arg) => /^(pi|claude|current)$/i.test(arg))) return 'rich-pi'; - if (parsedArgs.some((arg) => /^(local|quick)$/i.test(arg))) return 'local'; - if (parsedArgs.some((arg) => /^(rich|enhanced|designed)$/i.test(arg))) return 'rich-pi'; - return null; -} - -function hasSelectableUi(ctx) { - return Boolean(ctx && ctx.ui && typeof ctx.ui.select === 'function'); -} - -function extractHtmlDocument(text) { - const source = String(text || '').trim(); - if (!source) return null; - - const fenced = source.match(/```html\s*([\s\S]*?)```/i); - if (fenced && fenced[1] && fenced[1].trim()) return fenced[1].trim(); - - if (/]/i.test(source) || /]/i.test(source)) { - return source; - } - - return null; -} - -function buildRichHtmlPrompt(lastEligible) { - return [ - 'Transform the following answer into a standalone, production-quality HTML artifact in the htmlify style.', - 'Return ONLY a single ```html fenced block and nothing else.', - 'Requirements:', - '- Preserve the factual content and conclusions.', - '- Prefer visual structure over prose walls: use scoreboards, timelines, matrices, diagrams, tabs, accordions, or side-by-side comparisons when they clarify the work.', - '- Treat HTML as an operator surface: make the result scannable, discussable, and actionable.', - '- Include the smallest useful artifact shape for the source: brief, deck, implementation map, review packet, report, explainer, or lightweight editor.', - '- Improve hierarchy, density, labels, and information scent without adding generic SaaS decoration.', - '- Use inline CSS only. No external assets, scripts, CDNs, or fonts.', - '- Make it responsive and print-friendly.', - '- Add simple inline SVG diagrams only if they materially improve comprehension.', - '- Use semantic sections, accessible contrast, stable spacing, and restrained motion-free presentation.', - '- Do not mention that this was transformed from another answer.', - '', - `Title suggestion: ${lastEligible.title}`, - '', - 'Source answer:', - '```text', - lastEligible.text, - '```', - ].join('\n'); -} - -module.exports = function htmlLongAnswerExtension(pi) { - const state = { - offerMode: 'ask', - lastEligible: null, - lastExport: null, - pendingRichExport: null, - lastPromptedSignature: null, - geminiAvailable: null, - config: { ...LONG_ANSWER_DEFAULTS }, - }; - - function rememberFromEntry(entry) { - if (!entry || entry.type !== 'custom') return; - if (entry.customType === PREF_ENTRY_TYPE && entry.data && typeof entry.data.offerMode === 'string') { - state.offerMode = entry.data.offerMode; - } - if (entry.customType === SOURCE_ENTRY_TYPE && entry.data && entry.data.text) { - state.lastEligible = entry.data; - } - if (entry.customType === EXPORT_ENTRY_TYPE && entry.data && entry.data.path) { - state.lastExport = entry.data; - } - } - - function hydrateLastEligibleFromBranch(branch) { - if (!Array.isArray(branch) || state.lastEligible) return; - for (let index = branch.length - 1; index >= 0; index -= 1) { - const info = extractMessageInfo(branch[index]); - if (info && info.text) { - state.lastEligible = buildSourceRecord(info.text); - return; - } - } - } - - async function restoreSessionState(ctx) { - try { - const branch = - ctx && ctx.sessionManager && typeof ctx.sessionManager.getBranch === 'function' - ? ctx.sessionManager.getBranch() - : []; - if (!Array.isArray(branch)) return; - for (const entry of branch) rememberFromEntry(entry); - hydrateLastEligibleFromBranch(branch); - } catch (_) { - // Best effort only. - } - } - - async function appendCustomEntry(type, data) { - if (typeof pi.appendEntry !== 'function') return; - try { - await pi.appendEntry(type, data); - } catch (_) { - // Do not fail the user flow on persistence issues. - } - } - - async function setOfferMode(mode) { - state.offerMode = mode; - await appendCustomEntry(PREF_ENTRY_TYPE, { offerMode: mode, savedAt: Date.now() }); - } - - async function rememberEligibleSource(source) { - state.lastEligible = source; - const { text: _text, ...persistedSource } = source; - await appendCustomEntry(SOURCE_ENTRY_TYPE, persistedSource); - } - - async function rememberExport(meta) { - state.lastExport = meta; - await appendCustomEntry(EXPORT_ENTRY_TYPE, meta); - } - - function notify(ctx, message, level) { - if (!ctx || !ctx.ui || typeof ctx.ui.notify !== 'function') return; - try { - const result = ctx.ui.notify(message, level || 'info'); - if (result && typeof result.then === 'function') { - result.catch(() => {}); - } - } catch (_) { - // Ignore UI failures. - } - } - - function notifyCommandError(ctx, error) { - notify( - ctx, - `${PRODUCT_NAME} command error: ${error && error.message ? error.message : String(error)} [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'error' - ); - } - - async function isGeminiCliAvailable() { - if (typeof state.geminiAvailable === 'boolean') return state.geminiAvailable; - try { - await execFileAsync('gemini', ['--help'], { timeout: 3000, maxBuffer: 512 * 1024 }); - state.geminiAvailable = true; - } catch (_) { - state.geminiAvailable = false; - } - return state.geminiAvailable; - } - - async function openArtifact(filePath) { - if (process.env.HTMLIFY_SKIP_OPEN === '1' || process.env.PI_HTML_LONG_ANSWER_SKIP_OPEN === '1') return false; - const command = process.platform === 'darwin' ? '/usr/bin/open' : process.platform === 'linux' ? 'xdg-open' : null; - const executable = await resolveOpenCommand(command); - if (!executable) return false; - - return new Promise((resolve) => { - let child; - let settled = false; - let timer; - - const settle = (opened) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - resolve(opened); - }; - - try { - child = spawn(executable, [filePath], { - detached: true, - stdio: 'ignore', - }); - } catch (_) { - settle(false); - return; - } - - child.once('error', () => settle(false)); - child.once('exit', (code) => settle(code === 0)); - child.unref(); - timer = setTimeout(() => settle(true), OPEN_FAILURE_WINDOW_MS); - }); - } - - async function maybeOpenArtifact(ctx, filePath, mode) { - const opened = await openArtifact(filePath); - if (!opened) return false; - notify( - ctx, - `${mode === 'local' ? 'Opened local HTML export' : 'Opened designed HTML export'} in your default browser. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'info' - ); - return true; - } - - function buildSourceRecord(text) { - const title = deriveTitle(text); - return { - id: sha(text), - title, - text, - recordedAt: Date.now(), - stats: { - characters: text.length, - lines: countLines(text), - paragraphs: countParagraphs(text), - words: wordCount(text), - }, - }; - } - - async function exportLocalHtml(ctx, source, mode) { - const bodyHtml = renderMarkdownish(source.text); - const filePath = await writeHtmlArtifact({ - title: source.title, - bodyHtml, - sourceText: source.text, - mode: mode || 'local', - }); - const meta = { - path: filePath, - mode: mode || 'local', - title: source.title, - sourceId: source.id, - exportedAt: Date.now(), - }; - await rememberExport(meta); - await notify( - ctx, - `HTML export written to ${filePath}. Use /html-last rich or /htmlify rich for a more designed HTML pass. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'info' - ); - await maybeOpenArtifact(ctx, filePath, 'local'); - return meta; - } - - async function exportRichHtmlResult(ctx, source, htmlText) { - const filePath = await writeRichHtmlArtifact({ - title: source.title, - htmlText, - sourceId: source.id, - }); - const meta = { - path: filePath, - mode: 'llm-enhanced', - title: source.title, - sourceId: source.id, - exportedAt: Date.now(), - }; - await rememberExport(meta); - await notify(ctx, `Designed HTML export written to ${filePath}. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, 'info'); - await maybeOpenArtifact(ctx, filePath, 'designed'); - return meta; - } - - function normalizeChoice(result, options) { - if (typeof result === 'string') return result; - if (typeof result === 'number') { - if (Array.isArray(options) && options[result]) return options[result].value; - return ['local', 'rich', 'inline', 'never'][result] || null; - } - if (result && typeof result === 'object') { - return result.value || result.id || result.key || result.choice || null; - } - return null; - } - - async function promptWithSelect(ui, summary) { - const geminiAvailable = await isGeminiCliAvailable(); - const options = [ - { label: 'Designed HTML with Gemini CLI', value: 'rich-gemini' }, - { label: 'Designed HTML with current Pi model', value: 'rich-pi' }, - { label: 'Quick local HTML', value: 'local' }, - { label: 'Keep inline', value: 'inline' }, - { label: 'Stop asking this session', value: 'never' }, - ]; - if (!geminiAvailable) { - options.shift(); - } - - const prompt = `Long answer detected: ${summary}`; - try { - const result = await ui.select(prompt, options); - return normalizeChoice(result, options) || null; - } catch (_) { - return null; - } - } - - async function promptUserForExport(ctx, source) { - if (!ctx || !ctx.ui || state.offerMode === 'never') return 'inline'; - const summary = [ - `${source.stats.words} words`, - `${source.stats.paragraphs} paragraphs`, - `${source.stats.lines} lines`, - ].join(' · '); - - if (typeof ctx.ui.select === 'function') { - const selected = await promptWithSelect(ctx.ui, summary); - if (selected) return selected; - } - - return 'inline'; - } - - async function queueRichExport(source, ctx, renderer) { - if (renderer === 'gemini') { - await notify(ctx, 'Generating designed HTML with Gemini CLI…', 'info'); - try { - const html = await runGeminiRichExport(source); - await exportRichHtmlResult(ctx, source, html); - } catch (error) { - await notify( - ctx, - `Gemini designed HTML failed: ${error && error.message ? error.message : String(error)}. Falling back to quick local HTML. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'warning' - ); - await exportLocalHtml(ctx, source, 'local'); - } - return; - } - - state.pendingRichExport = { - requestedAt: Date.now(), - source, - }; - await notify(ctx, 'Queued richer HTML generation as a follow-up turn.', 'info'); - if (typeof pi.sendUserMessage === 'function') { - await pi.sendUserMessage(buildRichHtmlPrompt(source), { deliverAs: 'followUp' }); - return; - } - if (typeof pi.sendMessage === 'function') { - await pi.sendMessage(buildRichHtmlPrompt(source), { deliverAs: 'followUp', triggerTurn: true }); - return; - } - throw new Error('No runtime message API is available for richer HTML generation.'); - } - - async function runGeminiRichExport(source) { - const { stdout } = await execFileAsync( - 'gemini', - ['--prompt', buildRichHtmlPrompt(source), '--output-format', 'text'], - { - timeout: 120000, - maxBuffer: 8 * 1024 * 1024, - } - ); - - const output = String(stdout || '').trim(); - if (!output) { - throw new Error('Gemini CLI returned no output.'); - } - - const html = extractHtmlDocument(output); - if (!html) { - throw new Error('Gemini CLI did not return HTML output.'); - } - - return html; - } - - async function chooseCommandExportMode(ctx) { - if (!ctx || !ctx.ui || typeof ctx.ui.select !== 'function') { - return 'local'; - } - - const geminiAvailable = await isGeminiCliAvailable(); - const options = [ - { label: 'Designed HTML with Gemini CLI', value: 'rich-gemini' }, - { label: 'Designed HTML with current Pi model', value: 'rich-pi' }, - { label: 'Quick local HTML', value: 'local' }, - ]; - if (!geminiAvailable) { - options.shift(); - } - - try { - const result = await ctx.ui.select('Choose HTML render mode', options); - return normalizeChoice(result, options) || 'local'; - } catch (_) { - return 'local'; - } - } - - async function handleChoice(choice, ctx, source) { - if (choice === 'never') { - await setOfferMode('never'); - await notify(ctx, 'htmlify prompting disabled for this session.', 'info'); - return; - } - if (choice === 'inline' || !choice) return; - if (choice === 'local') { - await exportLocalHtml(ctx, source, 'local'); - return; - } - if (choice === 'rich') { - await queueRichExport(source, ctx, 'pi'); - return; - } - if (choice === 'rich-gemini') { - await queueRichExport(source, ctx, 'gemini'); - return; - } - if (choice === 'rich-pi') { - await queueRichExport(source, ctx, 'pi'); - } - } - - async function maybeHandlePendingRichExport(event, ctx) { - if (!state.pendingRichExport) return false; - const info = extractMessageInfo(event); - if (!info) return false; - - const htmlDocument = extractHtmlDocument(info.text); - if (htmlDocument) { - try { - await exportRichHtmlResult(ctx, state.pendingRichExport.source, htmlDocument); - } catch (error) { - await notify( - ctx, - `Richer HTML pass was unsafe or invalid: ${error && error.message ? error.message : String(error)}. Wrote a fallback HTML export instead. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'warning' - ); - await exportLocalHtml(ctx, state.pendingRichExport.source, 'llm-enhanced-fallback'); - } - } else { - await exportLocalHtml( - ctx, - { - ...state.pendingRichExport.source, - text: info.text, - }, - 'llm-enhanced-fallback' - ); - await notify(ctx, 'Richer HTML pass returned plain text; wrote a fallback HTML export instead.', 'warning'); - } - state.pendingRichExport = null; - return true; - } - - async function handleAssistantMessage(event, ctx) { - if (await maybeHandlePendingRichExport(event, ctx)) return; - - const info = extractMessageInfo(event); - if (!info) return; - - const source = buildSourceRecord(info.text); - const signature = source.id; - if (signature === state.lastPromptedSignature) return; - - await rememberEligibleSource(source); - - if (!isLongAnswer(info.text, state.config)) return; - - state.lastPromptedSignature = signature; - // Avoid notifying from message_end: in OMP this can replace the just-finished assistant text. - // The answer is already captured; /html-last remains available when the user wants the export. - } - - async function exportLatestFromCommand(args, ctx) { - if (!state.lastEligible || !state.lastEligible.text) { - try { - const branch = - ctx && ctx.sessionManager && typeof ctx.sessionManager.getBranch === 'function' - ? ctx.sessionManager.getBranch() - : []; - hydrateLastEligibleFromBranch(branch); - } catch (_) { - // Ignore branch hydration failures here; warning below handles the miss. - } - } - - if (!state.lastEligible || !state.lastEligible.text) { - notify( - ctx, - `No eligible assistant answer has been captured yet in this session. Ask for a long answer first, then run /html-last or /htmlify. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'warning' - ); - return; - } - - const forcedMode = resolveForcedExportMode(args); - let mode = forcedMode || 'local'; - if (mode === 'choose') { - mode = hasSelectableUi(ctx) ? await chooseCommandExportMode(ctx) : 'local'; - } - - if (mode === 'rich-gemini') { - await queueRichExport(state.lastEligible, ctx, 'gemini'); - return; - } - if (mode === 'rich-pi') { - await queueRichExport(state.lastEligible, ctx, 'pi'); - return; - } - - await exportLocalHtml(ctx, state.lastEligible, 'local'); - } - - async function readCommentBundle(args) { - const raw = typeof args === 'string' ? args.trim() : parseArgs(args).join(' ').trim(); - if (!raw) throw new Error('Pass a comments JSON file path or pasted JSON after /html-comments.'); - if (/^\{[\s\S]*\}$/.test(raw)) return JSON.parse(raw); - const filePath = path.resolve(raw); - const text = await fs.readFile(filePath, 'utf8'); - return JSON.parse(text); - } - - async function importCommentsFromCommand(args, ctx) { - if (!state.lastEligible || !state.lastEligible.text) { - try { - const branch = - ctx && ctx.sessionManager && typeof ctx.sessionManager.getBranch === 'function' - ? ctx.sessionManager.getBranch() - : []; - hydrateLastEligibleFromBranch(branch); - } catch (_) { - // Warning below handles the miss. - } - } - const expectedSourceId = state.lastEligible && state.lastEligible.id; - const bundle = validateCommentBundle(await readCommentBundle(args), expectedSourceId); - const prompt = buildCommentsPrompt(bundle); - await appendCustomEntry(COMMENT_ENTRY_TYPE, { ...bundle, importedAt: Date.now() }); - if (typeof pi.sendUserMessage === 'function') { - await pi.sendUserMessage(prompt, { deliverAs: 'followUp' }); - notify( - ctx, - `Queued ${bundle.comments.length} HTML comment${bundle.comments.length === 1 ? '' : 's'} for the agent. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'info' - ); - return; - } - if (typeof pi.sendMessage === 'function') { - await pi.sendMessage(prompt, { deliverAs: 'followUp', triggerTurn: true }); - notify( - ctx, - `Queued ${bundle.comments.length} HTML comment${bundle.comments.length === 1 ? '' : 's'} for the agent. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, - 'info' - ); - return; - } - notify(ctx, prompt, 'info'); - } - - if (typeof pi.setLabel === 'function') { - try { - pi.setLabel(`${PRODUCT_NAME} ${EXTENSION_VERSION}`); - } catch (_) { - // Some hosts reject action methods during extension loading. - } - } - - const restoreHandler = async (_event, ctx) => { - await restoreSessionState(ctx); - }; - - if (typeof pi.on === 'function') { - pi.on('session_start', restoreHandler); - pi.on('session_branch', restoreHandler); - pi.on('session_tree', restoreHandler); - pi.on('input', async (event, ctx) => { - const parsedInput = parseHtmlCommandInput(event && event.text); - if (!parsedInput) return undefined; - - try { - if (parsedInput.command === 'version') { - notify(ctx, `${PRODUCT_NAME} ${EXTENSION_VERSION}`, 'info'); - } else if (parsedInput.command === 'comments') { - await importCommentsFromCommand(parsedInput.args, ctx); - } else { - await exportLatestFromCommand(parsedInput.args, ctx); - } - } catch (error) { - notifyCommandError(ctx, error); - } - - return { handled: true, action: 'handled' }; - }); - pi.on('message_end', async (event, ctx) => { - try { - await handleAssistantMessage(event, ctx); - } catch (error) { - await notify( - ctx, - `${PRODUCT_NAME} extension error: ${error && error.message ? error.message : String(error)}`, - 'error' - ); - } - }); - } - - if (typeof pi.registerCommand === 'function') { - const exportCommand = { - description: - 'Export the latest eligible assistant answer as HTML. Use `choose`, `gemini`, `pi`, or `local` to force a render path.', - handler: (args, ctx) => { - void exportLatestFromCommand(args, ctx).catch((error) => { - notifyCommandError(ctx, error); - }); - }, - }; - - pi.registerCommand('html-last', { - ...exportCommand, - }); - pi.registerCommand('htmlify', { ...exportCommand }); - pi.registerCommand('htmlify-last', { ...exportCommand }); - - const commentsCommand = { - description: 'Import downloaded HTML comments JSON and send the review prompt back to the agent.', - handler: (args, ctx) => { - void importCommentsFromCommand(args, ctx).catch((error) => { - notifyCommandError(ctx, error); - }); - }, - }; - - pi.registerCommand('html-comments', { ...commentsCommand }); - pi.registerCommand('htmlify-comments', { ...commentsCommand }); - - const versionCommand = { - description: 'Show the loaded htmlify extension version.', - handler: (_args, ctx) => { - notify(ctx, `${PRODUCT_NAME} ${EXTENSION_VERSION}`, 'info'); - }, - }; - - pi.registerCommand('html-last-version', { ...versionCommand }); - pi.registerCommand('htmlify-version', { ...versionCommand }); - } -}; +const createExtension = require('./src/extension'); +const { buildLocalHtmlDocument } = require('./src/document'); +const { formatInline, renderMarkdownish } = require('./src/markdown'); +const { getExportRoot, writeHtmlArtifact, writeRichHtmlArtifact } = require('./src/artifacts'); +const { validateRichHtmlDocument } = require('./src/validate'); +const { addCommentableAttributes, buildAnnotationLayer, injectAnnotationLayer } = require('./src/annotation'); +const { validateCommentBundle, buildCommentsPrompt } = require('./src/comments'); +const { extractHtmlDocument } = require('./src/extension/messages'); +const { parseArgs, parseHtmlCommandInput, resolveForcedExportMode, hasSelectableUi } = require('./src/extension/parse'); +const { resolveOpenCommand } = require('./src/extension/open'); +const { buildRichHtmlPrompt } = require('./src/extension/prompts'); + +module.exports = createExtension; module.exports._internals = { buildLocalHtmlDocument, diff --git a/src/annotation.js b/src/annotation.js new file mode 100644 index 0000000..4924c3a --- /dev/null +++ b/src/annotation.js @@ -0,0 +1,155 @@ +const { COMMENT_BUNDLE_VERSION, TRUSTED_ANNOTATION_MARKER } = require('./constants'); + +function addCommentableAttributes(html) { + let index = 0; + return String(html || '').replace( + /<(p|h[1-6]|li|pre|table|aside|blockquote)\b(?![^>]*\bdata-commentable=)([^>]*)>/gi, + (match, tag, attrs) => { + index += 1; + return `<${tag}${attrs} data-commentable="true" data-block-id="b-${index}">`; + } + ); +} + +function buildAnnotationLayer(meta) { + const sourceId = String(meta && meta.sourceId ? meta.sourceId : ''); + const title = String(meta && meta.title ? meta.title : 'HTML Export'); + return `${TRUSTED_ANNOTATION_MARKER} + + +
+ + +
+`; +} + +function injectAnnotationLayer(html, meta) { + const layer = buildAnnotationLayer(meta); + const source = String(html || ''); + if (source.includes(TRUSTED_ANNOTATION_MARKER)) return source; + if (/<\/body\s*>/i.test(source)) return source.replace(/<\/body\s*>/i, `${layer}\n`); + return `${source}\n${layer}`; +} + +module.exports = { + addCommentableAttributes, + buildAnnotationLayer, + injectAnnotationLayer, +}; diff --git a/src/artifacts.js b/src/artifacts.js new file mode 100644 index 0000000..27fd634 --- /dev/null +++ b/src/artifacts.js @@ -0,0 +1,58 @@ +const fs = require('fs/promises'); +const path = require('path'); + +const { DEFAULT_EXPORT_ROOT } = require('./constants'); +const { sha, slugify, wordCount } = require('./text'); +const { deriveExcerpt, buildOutlineHtml, buildLocalHtmlDocument } = require('./document'); +const { addCommentableAttributes, injectAnnotationLayer } = require('./annotation'); +const { validateRichHtmlDocument } = require('./validate'); + +async function ensureDir(dir) { + await fs.mkdir(dir, { recursive: true }); +} + +function getExportRoot() { + return process.env.HTMLIFY_EXPORT_ROOT || process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT; +} + +async function writeHtmlArtifact({ title, bodyHtml, sourceText, mode }) { + const exportRoot = getExportRoot(); + await ensureDir(exportRoot); + const now = new Date(); + const iso = now.toISOString().replace(/[:.]/g, '-'); + const fileName = `${iso}-${slugify(title)}-${mode}.html`; + const filePath = path.join(exportRoot, fileName); + const sourceId = sha(sourceText); + const annotatedBodyHtml = addCommentableAttributes(bodyHtml); + const html = buildLocalHtmlDocument(title, annotatedBodyHtml, { + exportedAt: now.toISOString(), + words: wordCount(sourceText), + characters: String(sourceText || '').length, + mode, + excerpt: deriveExcerpt(sourceText), + outlineHtml: buildOutlineHtml(sourceText), + sourceId, + }); + await fs.writeFile(filePath, injectAnnotationLayer(html, { sourceId, title }), 'utf8'); + return filePath; +} + +async function writeRichHtmlArtifact({ title, htmlText, sourceId }) { + const html = validateRichHtmlDocument(htmlText); + const exportRoot = getExportRoot(); + await ensureDir(exportRoot); + const now = new Date(); + const iso = now.toISOString().replace(/[:.]/g, '-'); + const fileName = `${iso}-${slugify(title)}-llm-enhanced.html`; + const filePath = path.join(exportRoot, fileName); + const annotatedHtml = addCommentableAttributes(html); + await fs.writeFile(filePath, injectAnnotationLayer(annotatedHtml, { sourceId, title }), 'utf8'); + return filePath; +} + +module.exports = { + ensureDir, + getExportRoot, + writeHtmlArtifact, + writeRichHtmlArtifact, +}; diff --git a/src/comments.js b/src/comments.js new file mode 100644 index 0000000..8214d91 --- /dev/null +++ b/src/comments.js @@ -0,0 +1,64 @@ +const { COMMENT_BUNDLE_VERSION } = require('./constants'); + +function validateCommentBundle(bundle, expectedSourceId) { + if (!bundle || typeof bundle !== 'object') throw new Error('Comment bundle must be a JSON object.'); + if (bundle.version !== COMMENT_BUNDLE_VERSION) + throw new Error(`Comment bundle version must be ${COMMENT_BUNDLE_VERSION}.`); + if (!Array.isArray(bundle.comments)) throw new Error('Comment bundle must include a comments array.'); + if (expectedSourceId && bundle.sourceId && bundle.sourceId !== expectedSourceId) { + throw new Error('Comment bundle source does not match the last captured answer.'); + } + return { + version: COMMENT_BUNDLE_VERSION, + sourceId: String(bundle.sourceId || ''), + title: String(bundle.title || 'HTML Export').slice(0, 160), + exportUrl: String(bundle.exportUrl || ''), + comments: bundle.comments.map((comment, index) => { + if (!comment || typeof comment !== 'object') throw new Error(`Comment ${index + 1} must be an object.`); + const selectedText = String(comment.selectedText || '').trim(); + const body = String(comment.comment || '').trim(); + if (!selectedText || !body) throw new Error(`Comment ${index + 1} must include selectedText and comment.`); + return { + id: String(comment.id || `comment-${index + 1}`).slice(0, 80), + blockId: String(comment.blockId || '').slice(0, 80), + selectedText: selectedText.slice(0, 4000), + prefix: String(comment.prefix || '').slice(0, 1000), + suffix: String(comment.suffix || '').slice(0, 1000), + comment: body.slice(0, 4000), + createdAt: String(comment.createdAt || ''), + }; + }), + }; +} + +function buildCommentsPrompt(bundle) { + const lines = [ + 'I reviewed the HTML export and left comments.', + '', + `Source: ${bundle.title}`, + `Source ID: ${bundle.sourceId || 'unknown'}`, + bundle.exportUrl ? `Export: ${bundle.exportUrl}` : '', + '', + ].filter((line, index) => line || index < 4); + bundle.comments.forEach((comment, index) => { + lines.push( + `## Comment ${index + 1}`, + '', + 'Selected text:', + `> ${comment.selectedText.replace(/\n/g, '\n> ')}`, + '', + 'Nearby context:', + `> ${comment.prefix} [${comment.selectedText}] ${comment.suffix}`.trim(), + '', + 'Comment:', + comment.comment, + '' + ); + }); + return lines.join('\n'); +} + +module.exports = { + validateCommentBundle, + buildCommentsPrompt, +}; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..9d37245 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,55 @@ +const path = require('path'); +const os = require('os'); + +const packageJson = require('../package.json'); + +const EXTENSION_VERSION = packageJson.version; +const PRODUCT_NAME = 'htmlify'; + +const DEFAULT_EXPORT_ROOT = path.join(os.tmpdir(), 'htmlify-exports'); +// Keep legacy custom entry types so existing Pi/OMP sessions can restore pre-rename exports. +const PREF_ENTRY_TYPE = 'html-long-answer-pref'; +const SOURCE_ENTRY_TYPE = 'html-long-answer-source'; +const EXPORT_ENTRY_TYPE = 'html-long-answer-export'; +const COMMENT_ENTRY_TYPE = 'html-long-answer-comments'; +const COMMENT_BUNDLE_VERSION = 1; +const LONG_ANSWER_DEFAULTS = { + minChars: 1800, + minLines: 24, + minParagraphs: 6, +}; +const MAX_RICH_HTML_CHARS = 512 * 1024; +const MAX_RICH_HTML_TAGS = 2500; +const BLOCKED_RICH_TAGS = + /<\s*\/?\s*(?:script|iframe|object|embed|link|base|form|input|button|textarea|select|option)\b/i; +const BLOCKED_META_REFRESH = /<\s*meta\b[^>]*http-equiv\s*=\s*(['"]?)refresh\1/i; +const EVENT_HANDLER_ATTR = /\s+on[a-z]+\s*=/i; +const JAVASCRIPT_URL_ATTR = /\s(?:href|src|xlink:href|action|formaction)\s*=\s*(['"]?)\s*javascript:/i; +const EXTERNAL_ASSET_ATTR = + /(?:\s(?:src|poster)\s*=\s*(['"]?)\s*(?:https?:)?\/\/|\ssrcset\s*=\s*(['"]?)[^'">]*(?:https?:)?\/\/|<\s*(?:image|use|feimage)\b[^>]*\s(?:href|xlink:href)\s*=\s*(['"]?)\s*(?:https?:)?\/\/)/i; +const EXTERNAL_CSS_URL = /(?:url\(\s*(['"]?)\s*(?:https?:)?\/\/|@import\s+(?:url\(\s*)?(['"]?)\s*(?:https?:)?\/\/)/i; +const OPEN_FAILURE_WINDOW_MS = 1000; + +const TRUSTED_ANNOTATION_MARKER = ''; + +module.exports = { + EXTENSION_VERSION, + PRODUCT_NAME, + DEFAULT_EXPORT_ROOT, + PREF_ENTRY_TYPE, + SOURCE_ENTRY_TYPE, + EXPORT_ENTRY_TYPE, + COMMENT_ENTRY_TYPE, + COMMENT_BUNDLE_VERSION, + LONG_ANSWER_DEFAULTS, + MAX_RICH_HTML_CHARS, + MAX_RICH_HTML_TAGS, + BLOCKED_RICH_TAGS, + BLOCKED_META_REFRESH, + EVENT_HANDLER_ATTR, + JAVASCRIPT_URL_ATTR, + EXTERNAL_ASSET_ATTR, + EXTERNAL_CSS_URL, + OPEN_FAILURE_WINDOW_MS, + TRUSTED_ANNOTATION_MARKER, +}; diff --git a/src/document.js b/src/document.js new file mode 100644 index 0000000..9754751 --- /dev/null +++ b/src/document.js @@ -0,0 +1,236 @@ +const { escapeHtml } = require('./text'); +const { formatInline } = require('./markdown'); + +function deriveTitle(text) { + const source = String(text || '').trim(); + if (!source) return 'HTML Export'; + const firstHeading = source.split('\n').find((line) => /^#{1,6}\s+/.test(line.trim())); + if (firstHeading) + return firstHeading + .replace(/^#{1,6}\s+/, '') + .trim() + .slice(0, 80); + const firstSentence = source.replace(/\s+/g, ' ').split(/(?<=[.!?])\s+/)[0] || source; + return firstSentence.slice(0, 80); +} + +function deriveExcerpt(text) { + const lines = String(text || '').split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + if (/^#{1,6}\s+/.test(trimmed)) continue; + if (/^(?:[-*]|\d+\.)\s+/.test(trimmed)) continue; + return trimmed.slice(0, 240); + } + return String(text || '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 240); +} + +function buildOutlineHtml(text) { + const headings = []; + const lines = String(text || '').split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + const match = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (match) headings.push({ level: match[1].length, label: match[2].trim() }); + } + if (!headings.length) return ''; + return `
Outline
    ${headings.map((item) => `
  • ${formatInline(item.label)}
  • `).join('')}
`; +} + +function buildLocalHtmlDocument(title, body, meta) { + const exportedAt = new Date(meta.exportedAt).toLocaleString(); + return ` + + + + + ${escapeHtml(title)} + + + +
+
+
+
htmlify export
+

${escapeHtml(title)}

+ ${meta.excerpt ? `

${escapeHtml(meta.excerpt)}

` : ''} +
+
Exported
${escapeHtml(exportedAt)}
+
Words
${escapeHtml(String(meta.words))}
+
Characters
${escapeHtml(String(meta.characters))}
+
Mode
${escapeHtml(meta.mode)}
+
+
+
+
+
+ ${body} +
+ +
+
+ +`; +} + +module.exports = { + deriveTitle, + deriveExcerpt, + buildOutlineHtml, + buildLocalHtmlDocument, +}; diff --git a/src/extension/index.js b/src/extension/index.js new file mode 100644 index 0000000..ac8fcbc --- /dev/null +++ b/src/extension/index.js @@ -0,0 +1,579 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); + +const { + EXTENSION_VERSION, + PRODUCT_NAME, + PREF_ENTRY_TYPE, + SOURCE_ENTRY_TYPE, + EXPORT_ENTRY_TYPE, + COMMENT_ENTRY_TYPE, + LONG_ANSWER_DEFAULTS, +} = require('../constants'); +const { sha, countLines, countParagraphs, wordCount } = require('../text'); +const { renderMarkdownish } = require('../markdown'); +const { deriveTitle } = require('../document'); +const { writeHtmlArtifact, writeRichHtmlArtifact } = require('../artifacts'); +const { validateCommentBundle, buildCommentsPrompt } = require('../comments'); +const { extractMessageInfo, isLongAnswer, extractHtmlDocument } = require('./messages'); +const { parseArgs, parseHtmlCommandInput, resolveForcedExportMode, hasSelectableUi } = require('./parse'); +const { openArtifact } = require('./open'); +const { buildRichHtmlPrompt } = require('./prompts'); + +const execFileAsync = promisify(execFile); + +module.exports = function htmlLongAnswerExtension(pi) { + const state = { + offerMode: 'ask', + lastEligible: null, + lastExport: null, + pendingRichExport: null, + lastPromptedSignature: null, + geminiAvailable: null, + config: { ...LONG_ANSWER_DEFAULTS }, + }; + + function rememberFromEntry(entry) { + if (!entry || entry.type !== 'custom') return; + if (entry.customType === PREF_ENTRY_TYPE && entry.data && typeof entry.data.offerMode === 'string') { + state.offerMode = entry.data.offerMode; + } + if (entry.customType === SOURCE_ENTRY_TYPE && entry.data && entry.data.text) { + state.lastEligible = entry.data; + } + if (entry.customType === EXPORT_ENTRY_TYPE && entry.data && entry.data.path) { + state.lastExport = entry.data; + } + } + + function hydrateLastEligibleFromBranch(branch) { + if (!Array.isArray(branch) || state.lastEligible) return; + for (let index = branch.length - 1; index >= 0; index -= 1) { + const info = extractMessageInfo(branch[index]); + if (info && info.text) { + state.lastEligible = buildSourceRecord(info.text); + return; + } + } + } + + async function restoreSessionState(ctx) { + try { + const branch = + ctx && ctx.sessionManager && typeof ctx.sessionManager.getBranch === 'function' + ? ctx.sessionManager.getBranch() + : []; + if (!Array.isArray(branch)) return; + for (const entry of branch) rememberFromEntry(entry); + hydrateLastEligibleFromBranch(branch); + } catch (_) { + // Best effort only. + } + } + + async function appendCustomEntry(type, data) { + if (typeof pi.appendEntry !== 'function') return; + try { + await pi.appendEntry(type, data); + } catch (_) { + // Do not fail the user flow on persistence issues. + } + } + + async function setOfferMode(mode) { + state.offerMode = mode; + await appendCustomEntry(PREF_ENTRY_TYPE, { offerMode: mode, savedAt: Date.now() }); + } + + async function rememberEligibleSource(source) { + state.lastEligible = source; + const { text: _text, ...persistedSource } = source; + await appendCustomEntry(SOURCE_ENTRY_TYPE, persistedSource); + } + + async function rememberExport(meta) { + state.lastExport = meta; + await appendCustomEntry(EXPORT_ENTRY_TYPE, meta); + } + + function notify(ctx, message, level) { + if (!ctx || !ctx.ui || typeof ctx.ui.notify !== 'function') return; + try { + const result = ctx.ui.notify(message, level || 'info'); + if (result && typeof result.then === 'function') { + result.catch(() => {}); + } + } catch (_) { + // Ignore UI failures. + } + } + + function notifyCommandError(ctx, error) { + notify( + ctx, + `${PRODUCT_NAME} command error: ${error && error.message ? error.message : String(error)} [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'error' + ); + } + + async function isGeminiCliAvailable() { + if (typeof state.geminiAvailable === 'boolean') return state.geminiAvailable; + try { + await execFileAsync('gemini', ['--help'], { timeout: 3000, maxBuffer: 512 * 1024 }); + state.geminiAvailable = true; + } catch (_) { + state.geminiAvailable = false; + } + return state.geminiAvailable; + } + + async function maybeOpenArtifact(ctx, filePath, mode) { + const opened = await openArtifact(filePath); + if (!opened) return false; + notify( + ctx, + `${mode === 'local' ? 'Opened local HTML export' : 'Opened designed HTML export'} in your default browser. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'info' + ); + return true; + } + + function buildSourceRecord(text) { + const title = deriveTitle(text); + return { + id: sha(text), + title, + text, + recordedAt: Date.now(), + stats: { + characters: text.length, + lines: countLines(text), + paragraphs: countParagraphs(text), + words: wordCount(text), + }, + }; + } + + async function exportLocalHtml(ctx, source, mode) { + const bodyHtml = renderMarkdownish(source.text); + const filePath = await writeHtmlArtifact({ + title: source.title, + bodyHtml, + sourceText: source.text, + mode: mode || 'local', + }); + const meta = { + path: filePath, + mode: mode || 'local', + title: source.title, + sourceId: source.id, + exportedAt: Date.now(), + }; + await rememberExport(meta); + await notify( + ctx, + `HTML export written to ${filePath}. Use /html-last rich or /htmlify rich for a more designed HTML pass. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'info' + ); + await maybeOpenArtifact(ctx, filePath, 'local'); + return meta; + } + + async function exportRichHtmlResult(ctx, source, htmlText) { + const filePath = await writeRichHtmlArtifact({ + title: source.title, + htmlText, + sourceId: source.id, + }); + const meta = { + path: filePath, + mode: 'llm-enhanced', + title: source.title, + sourceId: source.id, + exportedAt: Date.now(), + }; + await rememberExport(meta); + await notify(ctx, `Designed HTML export written to ${filePath}. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, 'info'); + await maybeOpenArtifact(ctx, filePath, 'designed'); + return meta; + } + + function normalizeChoice(result, options) { + if (typeof result === 'string') return result; + if (typeof result === 'number') { + if (Array.isArray(options) && options[result]) return options[result].value; + return ['local', 'rich', 'inline', 'never'][result] || null; + } + if (result && typeof result === 'object') { + return result.value || result.id || result.key || result.choice || null; + } + return null; + } + + async function promptWithSelect(ui, summary) { + const geminiAvailable = await isGeminiCliAvailable(); + const options = [ + { label: 'Designed HTML with Gemini CLI', value: 'rich-gemini' }, + { label: 'Designed HTML with current Pi model', value: 'rich-pi' }, + { label: 'Quick local HTML', value: 'local' }, + { label: 'Keep inline', value: 'inline' }, + { label: 'Stop asking this session', value: 'never' }, + ]; + if (!geminiAvailable) { + options.shift(); + } + + const prompt = `Long answer detected: ${summary}`; + try { + const result = await ui.select(prompt, options); + return normalizeChoice(result, options) || null; + } catch (_) { + return null; + } + } + + async function promptUserForExport(ctx, source) { + if (!ctx || !ctx.ui || state.offerMode === 'never') return 'inline'; + const summary = [ + `${source.stats.words} words`, + `${source.stats.paragraphs} paragraphs`, + `${source.stats.lines} lines`, + ].join(' · '); + + if (typeof ctx.ui.select === 'function') { + const selected = await promptWithSelect(ctx.ui, summary); + if (selected) return selected; + } + + return 'inline'; + } + + async function queueRichExport(source, ctx, renderer) { + if (renderer === 'gemini') { + await notify(ctx, 'Generating designed HTML with Gemini CLI…', 'info'); + try { + const html = await runGeminiRichExport(source); + await exportRichHtmlResult(ctx, source, html); + } catch (error) { + await notify( + ctx, + `Gemini designed HTML failed: ${error && error.message ? error.message : String(error)}. Falling back to quick local HTML. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'warning' + ); + await exportLocalHtml(ctx, source, 'local'); + } + return; + } + + state.pendingRichExport = { + requestedAt: Date.now(), + source, + }; + await notify(ctx, 'Queued richer HTML generation as a follow-up turn.', 'info'); + if (typeof pi.sendUserMessage === 'function') { + await pi.sendUserMessage(buildRichHtmlPrompt(source), { deliverAs: 'followUp' }); + return; + } + if (typeof pi.sendMessage === 'function') { + await pi.sendMessage(buildRichHtmlPrompt(source), { deliverAs: 'followUp', triggerTurn: true }); + return; + } + throw new Error('No runtime message API is available for richer HTML generation.'); + } + + async function runGeminiRichExport(source) { + const { stdout } = await execFileAsync( + 'gemini', + ['--prompt', buildRichHtmlPrompt(source), '--output-format', 'text'], + { + timeout: 120000, + maxBuffer: 8 * 1024 * 1024, + } + ); + + const output = String(stdout || '').trim(); + if (!output) { + throw new Error('Gemini CLI returned no output.'); + } + + const html = extractHtmlDocument(output); + if (!html) { + throw new Error('Gemini CLI did not return HTML output.'); + } + + return html; + } + + async function chooseCommandExportMode(ctx) { + if (!ctx || !ctx.ui || typeof ctx.ui.select !== 'function') { + return 'local'; + } + + const geminiAvailable = await isGeminiCliAvailable(); + const options = [ + { label: 'Designed HTML with Gemini CLI', value: 'rich-gemini' }, + { label: 'Designed HTML with current Pi model', value: 'rich-pi' }, + { label: 'Quick local HTML', value: 'local' }, + ]; + if (!geminiAvailable) { + options.shift(); + } + + try { + const result = await ctx.ui.select('Choose HTML render mode', options); + return normalizeChoice(result, options) || 'local'; + } catch (_) { + return 'local'; + } + } + + async function handleChoice(choice, ctx, source) { + if (choice === 'never') { + await setOfferMode('never'); + await notify(ctx, 'htmlify prompting disabled for this session.', 'info'); + return; + } + if (choice === 'inline' || !choice) return; + if (choice === 'local') { + await exportLocalHtml(ctx, source, 'local'); + return; + } + if (choice === 'rich') { + await queueRichExport(source, ctx, 'pi'); + return; + } + if (choice === 'rich-gemini') { + await queueRichExport(source, ctx, 'gemini'); + return; + } + if (choice === 'rich-pi') { + await queueRichExport(source, ctx, 'pi'); + } + } + + async function maybeHandlePendingRichExport(event, ctx) { + if (!state.pendingRichExport) return false; + const info = extractMessageInfo(event); + if (!info) return false; + + const htmlDocument = extractHtmlDocument(info.text); + if (htmlDocument) { + try { + await exportRichHtmlResult(ctx, state.pendingRichExport.source, htmlDocument); + } catch (error) { + await notify( + ctx, + `Richer HTML pass was unsafe or invalid: ${error && error.message ? error.message : String(error)}. Wrote a fallback HTML export instead. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'warning' + ); + await exportLocalHtml(ctx, state.pendingRichExport.source, 'llm-enhanced-fallback'); + } + } else { + await exportLocalHtml( + ctx, + { + ...state.pendingRichExport.source, + text: info.text, + }, + 'llm-enhanced-fallback' + ); + await notify(ctx, 'Richer HTML pass returned plain text; wrote a fallback HTML export instead.', 'warning'); + } + state.pendingRichExport = null; + return true; + } + + async function handleAssistantMessage(event, ctx) { + if (await maybeHandlePendingRichExport(event, ctx)) return; + + const info = extractMessageInfo(event); + if (!info) return; + + const source = buildSourceRecord(info.text); + const signature = source.id; + if (signature === state.lastPromptedSignature) return; + + await rememberEligibleSource(source); + + if (!isLongAnswer(info.text, state.config)) return; + + state.lastPromptedSignature = signature; + // Avoid notifying from message_end: in OMP this can replace the just-finished assistant text. + // The answer is already captured; /html-last remains available when the user wants the export. + } + + async function exportLatestFromCommand(args, ctx) { + if (!state.lastEligible || !state.lastEligible.text) { + try { + const branch = + ctx && ctx.sessionManager && typeof ctx.sessionManager.getBranch === 'function' + ? ctx.sessionManager.getBranch() + : []; + hydrateLastEligibleFromBranch(branch); + } catch (_) { + // Ignore branch hydration failures here; warning below handles the miss. + } + } + + if (!state.lastEligible || !state.lastEligible.text) { + notify( + ctx, + `No eligible assistant answer has been captured yet in this session. Ask for a long answer first, then run /html-last or /htmlify. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'warning' + ); + return; + } + + const forcedMode = resolveForcedExportMode(args); + let mode = forcedMode || 'local'; + if (mode === 'choose') { + mode = hasSelectableUi(ctx) ? await chooseCommandExportMode(ctx) : 'local'; + } + + if (mode === 'rich-gemini') { + await queueRichExport(state.lastEligible, ctx, 'gemini'); + return; + } + if (mode === 'rich-pi') { + await queueRichExport(state.lastEligible, ctx, 'pi'); + return; + } + + await exportLocalHtml(ctx, state.lastEligible, 'local'); + } + + async function readCommentBundle(args) { + const raw = typeof args === 'string' ? args.trim() : parseArgs(args).join(' ').trim(); + if (!raw) throw new Error('Pass a comments JSON file path or pasted JSON after /html-comments.'); + if (/^\{[\s\S]*\}$/.test(raw)) return JSON.parse(raw); + const filePath = path.resolve(raw); + const text = await fs.readFile(filePath, 'utf8'); + return JSON.parse(text); + } + + async function importCommentsFromCommand(args, ctx) { + if (!state.lastEligible || !state.lastEligible.text) { + try { + const branch = + ctx && ctx.sessionManager && typeof ctx.sessionManager.getBranch === 'function' + ? ctx.sessionManager.getBranch() + : []; + hydrateLastEligibleFromBranch(branch); + } catch (_) { + // Warning below handles the miss. + } + } + const expectedSourceId = state.lastEligible && state.lastEligible.id; + const bundle = validateCommentBundle(await readCommentBundle(args), expectedSourceId); + const prompt = buildCommentsPrompt(bundle); + await appendCustomEntry(COMMENT_ENTRY_TYPE, { ...bundle, importedAt: Date.now() }); + if (typeof pi.sendUserMessage === 'function') { + await pi.sendUserMessage(prompt, { deliverAs: 'followUp' }); + notify( + ctx, + `Queued ${bundle.comments.length} HTML comment${bundle.comments.length === 1 ? '' : 's'} for the agent. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'info' + ); + return; + } + if (typeof pi.sendMessage === 'function') { + await pi.sendMessage(prompt, { deliverAs: 'followUp', triggerTurn: true }); + notify( + ctx, + `Queued ${bundle.comments.length} HTML comment${bundle.comments.length === 1 ? '' : 's'} for the agent. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'info' + ); + return; + } + notify(ctx, prompt, 'info'); + } + + if (typeof pi.setLabel === 'function') { + try { + pi.setLabel(`${PRODUCT_NAME} ${EXTENSION_VERSION}`); + } catch (_) { + // Some hosts reject action methods during extension loading. + } + } + + const restoreHandler = async (_event, ctx) => { + await restoreSessionState(ctx); + }; + + if (typeof pi.on === 'function') { + pi.on('session_start', restoreHandler); + pi.on('session_branch', restoreHandler); + pi.on('session_tree', restoreHandler); + pi.on('input', async (event, ctx) => { + const parsedInput = parseHtmlCommandInput(event && event.text); + if (!parsedInput) return undefined; + + try { + if (parsedInput.command === 'version') { + notify(ctx, `${PRODUCT_NAME} ${EXTENSION_VERSION}`, 'info'); + } else if (parsedInput.command === 'comments') { + await importCommentsFromCommand(parsedInput.args, ctx); + } else { + await exportLatestFromCommand(parsedInput.args, ctx); + } + } catch (error) { + notifyCommandError(ctx, error); + } + + return { handled: true, action: 'handled' }; + }); + pi.on('message_end', async (event, ctx) => { + try { + await handleAssistantMessage(event, ctx); + } catch (error) { + await notify( + ctx, + `${PRODUCT_NAME} extension error: ${error && error.message ? error.message : String(error)}`, + 'error' + ); + } + }); + } + + if (typeof pi.registerCommand === 'function') { + const exportCommand = { + description: + 'Export the latest eligible assistant answer as HTML. Use `choose`, `gemini`, `pi`, or `local` to force a render path.', + handler: (args, ctx) => { + void exportLatestFromCommand(args, ctx).catch((error) => { + notifyCommandError(ctx, error); + }); + }, + }; + + pi.registerCommand('html-last', { + ...exportCommand, + }); + pi.registerCommand('htmlify', { ...exportCommand }); + pi.registerCommand('htmlify-last', { ...exportCommand }); + + const commentsCommand = { + description: 'Import downloaded HTML comments JSON and send the review prompt back to the agent.', + handler: (args, ctx) => { + void importCommentsFromCommand(args, ctx).catch((error) => { + notifyCommandError(ctx, error); + }); + }, + }; + + pi.registerCommand('html-comments', { ...commentsCommand }); + pi.registerCommand('htmlify-comments', { ...commentsCommand }); + + const versionCommand = { + description: 'Show the loaded htmlify extension version.', + handler: (_args, ctx) => { + notify(ctx, `${PRODUCT_NAME} ${EXTENSION_VERSION}`, 'info'); + }, + }; + + pi.registerCommand('html-last-version', { ...versionCommand }); + pi.registerCommand('htmlify-version', { ...versionCommand }); + } +}; diff --git a/src/extension/messages.js b/src/extension/messages.js new file mode 100644 index 0000000..4429a14 --- /dev/null +++ b/src/extension/messages.js @@ -0,0 +1,74 @@ +const { sha, countLines, countParagraphs } = require('../text'); + +function extractTextPart(part) { + if (!part) return ''; + if (typeof part === 'string') return part; + if (typeof part.text === 'string') return part.text; + if (typeof part.content === 'string') return part.content; + if (Array.isArray(part.parts)) return part.parts.map(extractTextPart).join(''); + if (Array.isArray(part.content)) return part.content.map(extractTextPart).join(''); + return ''; +} + +function normalizeRole(candidate) { + if (!candidate) return null; + const role = String(candidate).toLowerCase(); + if (role.includes('assistant') || role.includes('agent') || role.includes('model')) return 'assistant'; + if (role.includes('user')) return 'user'; + return role; +} + +function extractMessageInfo(event) { + const candidate = + event && typeof event === 'object' ? event.message || event.entry || event.payload || event.data || event : null; + if (!candidate || typeof candidate !== 'object') return null; + + const role = normalizeRole(candidate.role || candidate.author || candidate.kind || candidate.source); + const id = candidate.id || candidate.messageId || candidate.entryId || null; + const text = + [ + typeof candidate.text === 'string' ? candidate.text : '', + typeof candidate.content === 'string' ? candidate.content : '', + Array.isArray(candidate.content) ? candidate.content.map(extractTextPart).join('') : '', + Array.isArray(candidate.parts) ? candidate.parts.map(extractTextPart).join('') : '', + ].find((value) => typeof value === 'string' && value.trim().length > 0) || ''; + + if (!text.trim() || role !== 'assistant') return null; + return { + id: id || sha(text), + role, + text: text.trim(), + }; +} + +function isLongAnswer(text, config) { + const source = String(text || '').trim(); + if (!source) return false; + return ( + source.length >= config.minChars || + countLines(source) >= config.minLines || + countParagraphs(source) >= config.minParagraphs + ); +} + +function extractHtmlDocument(text) { + const source = String(text || '').trim(); + if (!source) return null; + + const fenced = source.match(/```html\s*([\s\S]*?)```/i); + if (fenced && fenced[1] && fenced[1].trim()) return fenced[1].trim(); + + if (/]/i.test(source) || /]/i.test(source)) { + return source; + } + + return null; +} + +module.exports = { + extractTextPart, + normalizeRole, + extractMessageInfo, + isLongAnswer, + extractHtmlDocument, +}; diff --git a/src/extension/open.js b/src/extension/open.js new file mode 100644 index 0000000..a5f9527 --- /dev/null +++ b/src/extension/open.js @@ -0,0 +1,71 @@ +const fs = require('fs/promises'); +const path = require('path'); +const { spawn } = require('child_process'); + +const { OPEN_FAILURE_WINDOW_MS } = require('../constants'); + +async function resolveOpenCommand(command) { + if (!command) return null; + if (path.isAbsolute(command)) { + try { + await fs.access(command, fs.constants.X_OK); + return command; + } catch (_) { + return null; + } + } + + const searchPath = String(process.env.PATH || '') + .split(path.delimiter) + .filter(Boolean); + for (const directory of searchPath) { + const candidate = path.join(directory, command); + try { + await fs.access(candidate, fs.constants.X_OK); + return candidate; + } catch (_) { + // Keep searching PATH. + } + } + return null; +} + +async function openArtifact(filePath) { + if (process.env.HTMLIFY_SKIP_OPEN === '1' || process.env.PI_HTML_LONG_ANSWER_SKIP_OPEN === '1') return false; + const command = process.platform === 'darwin' ? '/usr/bin/open' : process.platform === 'linux' ? 'xdg-open' : null; + const executable = await resolveOpenCommand(command); + if (!executable) return false; + + return new Promise((resolve) => { + let child; + let settled = false; + let timer; + + const settle = (opened) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(opened); + }; + + try { + child = spawn(executable, [filePath], { + detached: true, + stdio: 'ignore', + }); + } catch (_) { + settle(false); + return; + } + + child.once('error', () => settle(false)); + child.once('exit', (code) => settle(code === 0)); + child.unref(); + timer = setTimeout(() => settle(true), OPEN_FAILURE_WINDOW_MS); + }); +} + +module.exports = { + resolveOpenCommand, + openArtifact, +}; diff --git a/src/extension/parse.js b/src/extension/parse.js new file mode 100644 index 0000000..b85eff0 --- /dev/null +++ b/src/extension/parse.js @@ -0,0 +1,44 @@ +function parseArgs(rawArgs) { + if (Array.isArray(rawArgs)) return rawArgs.map((item) => String(item)); + if (typeof rawArgs === 'string') return rawArgs.trim().split(/\s+/).filter(Boolean); + if (rawArgs && typeof rawArgs === 'object' && Array.isArray(rawArgs.args)) { + return rawArgs.args.map((item) => String(item)); + } + return []; +} + +function parseHtmlCommandInput(text) { + const source = typeof text === 'string' ? text.trim() : ''; + if (/^\/(?:html-last-version|htmlify-version)\s*$/i.test(source)) { + return { command: 'version', args: '' }; + } + + let match = /^\/(?:html-last|htmlify|htmlify-last)(?:\s+([\s\S]*))?$/i.exec(source); + if (match) return { command: 'export', args: match[1] || '' }; + + match = /^\/(?:html-comments|htmlify-comments)(?:\s+([\s\S]*))?$/i.exec(source); + if (match) return { command: 'comments', args: match[1] || '' }; + + return null; +} + +function resolveForcedExportMode(rawArgs) { + const parsedArgs = parseArgs(rawArgs); + if (parsedArgs.some((arg) => /^(choose|choices|chooser|menu)$/i.test(arg))) return 'choose'; + if (parsedArgs.some((arg) => /^(gemini)$/i.test(arg))) return 'rich-gemini'; + if (parsedArgs.some((arg) => /^(pi|claude|current)$/i.test(arg))) return 'rich-pi'; + if (parsedArgs.some((arg) => /^(local|quick)$/i.test(arg))) return 'local'; + if (parsedArgs.some((arg) => /^(rich|enhanced|designed)$/i.test(arg))) return 'rich-pi'; + return null; +} + +function hasSelectableUi(ctx) { + return Boolean(ctx && ctx.ui && typeof ctx.ui.select === 'function'); +} + +module.exports = { + parseArgs, + parseHtmlCommandInput, + resolveForcedExportMode, + hasSelectableUi, +}; diff --git a/src/extension/prompts.js b/src/extension/prompts.js new file mode 100644 index 0000000..6dbc0ba --- /dev/null +++ b/src/extension/prompts.js @@ -0,0 +1,28 @@ +function buildRichHtmlPrompt(lastEligible) { + return [ + 'Transform the following answer into a standalone, production-quality HTML artifact in the htmlify style.', + 'Return ONLY a single ```html fenced block and nothing else.', + 'Requirements:', + '- Preserve the factual content and conclusions.', + '- Prefer visual structure over prose walls: use scoreboards, timelines, matrices, diagrams, tabs, accordions, or side-by-side comparisons when they clarify the work.', + '- Treat HTML as an operator surface: make the result scannable, discussable, and actionable.', + '- Include the smallest useful artifact shape for the source: brief, deck, implementation map, review packet, report, explainer, or lightweight editor.', + '- Improve hierarchy, density, labels, and information scent without adding generic SaaS decoration.', + '- Use inline CSS only. No external assets, scripts, CDNs, or fonts.', + '- Make it responsive and print-friendly.', + '- Add simple inline SVG diagrams only if they materially improve comprehension.', + '- Use semantic sections, accessible contrast, stable spacing, and restrained motion-free presentation.', + '- Do not mention that this was transformed from another answer.', + '', + `Title suggestion: ${lastEligible.title}`, + '', + 'Source answer:', + '```text', + lastEligible.text, + '```', + ].join('\n'); +} + +module.exports = { + buildRichHtmlPrompt, +}; diff --git a/src/markdown.js b/src/markdown.js new file mode 100644 index 0000000..64c9e89 --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,142 @@ +const { escapeHtml } = require('./text'); + +function isSeparatorRow(line) { + return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line || ''); +} + +function splitTableRow(line) { + return String(line || '') + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); +} + +function formatInline(raw) { + let text = escapeHtml(raw); + text = text.replace( + /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, + '$1' + ); + text = text.replace( + /(?)(https?:\/\/[^\s<)]+)/g, + '$1' + ); + text = text.replace(/`([^`]+)`/g, '$1'); + text = text.replace(/\*\*([^*]+)\*\*/g, '$1'); + text = text.replace(/(^|\W)\*([^*]+)\*(?=\W|$)/g, '$1$2'); + return text; +} + +function collectUntil(lines, start, predicate) { + const collected = []; + let index = start; + while (index < lines.length && predicate(lines[index], index)) { + collected.push(lines[index]); + index += 1; + } + return { collected, nextIndex: index }; +} + +function renderMarkdownish(text) { + const lines = String(text || '') + .replace(/\r/g, '') + .split('\n'); + const blocks = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + i += 1; + continue; + } + + if (trimmed.startsWith('```')) { + const language = trimmed.slice(3).trim(); + const codeLines = []; + i += 1; + while (i < lines.length && !lines[i].trim().startsWith('```')) { + codeLines.push(lines[i]); + i += 1; + } + if (i < lines.length) i += 1; + blocks.push( + `
${escapeHtml(language || 'code')}
${escapeHtml(codeLines.join('\n'))}
` + ); + continue; + } + + const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (headingMatch) { + const level = Math.min(6, headingMatch[1].length + 1); + blocks.push(`${formatInline(headingMatch[2])}`); + i += 1; + continue; + } + + if (/^>\s?/.test(trimmed)) { + const { collected, nextIndex } = collectUntil(lines, i, (current) => /^>\s?/.test((current || '').trim())); + const inner = collected.map((current) => current.trim().replace(/^>\s?/, '')).join(' '); + blocks.push( + `` + ); + i = nextIndex; + continue; + } + + const nextLine = lines[i + 1] || ''; + if (trimmed.includes('|') && isSeparatorRow(nextLine)) { + const header = splitTableRow(trimmed); + i += 2; + const body = []; + while (i < lines.length && (lines[i] || '').trim().includes('|')) { + body.push(splitTableRow(lines[i])); + i += 1; + } + const thead = `${header.map((cell) => `${formatInline(cell)}`).join('')}`; + const tbody = `${body.map((row) => `${row.map((cell) => `${formatInline(cell)}`).join('')}`).join('')}`; + blocks.push(`
${thead}${tbody}
`); + continue; + } + + if (/^(?:[-*]|\d+\.)\s+/.test(trimmed)) { + const ordered = /^\d+\.\s+/.test(trimmed); + const pattern = ordered ? /^\d+\.\s+/ : /^(?:[-*])\s+/; + const { collected, nextIndex } = collectUntil(lines, i, (current) => pattern.test((current || '').trim())); + const tag = ordered ? 'ol' : 'ul'; + blocks.push( + `<${tag}>${collected.map((current) => `
  • ${formatInline(current.trim().replace(pattern, ''))}
  • `).join('')}` + ); + i = nextIndex; + continue; + } + + const { collected, nextIndex } = collectUntil(lines, i, (current) => { + const currentTrimmed = (current || '').trim(); + if (!currentTrimmed) return false; + if (currentTrimmed.startsWith('```')) return false; + if (/^(#{1,6})\s+/.test(currentTrimmed)) return false; + if (/^(?:[-*]|\d+\.)\s+/.test(currentTrimmed)) return false; + if (/^>\s?/.test(currentTrimmed)) return false; + return true; + }); + + const paragraph = collected.map((current) => current.trim()).join(' '); + blocks.push(`

    ${formatInline(paragraph)}

    `); + i = nextIndex; + } + + return blocks.join('\n'); +} + +module.exports = { + isSeparatorRow, + splitTableRow, + formatInline, + collectUntil, + renderMarkdownish, +}; diff --git a/src/text.js b/src/text.js new file mode 100644 index 0000000..8d5a134 --- /dev/null +++ b/src/text.js @@ -0,0 +1,55 @@ +const crypto = require('crypto'); + +function sha(input) { + return crypto + .createHash('sha1') + .update(String(input || '')) + .digest('hex'); +} + +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function slugify(value) { + const normalized = String(value || 'export') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return normalized || 'export'; +} + +function countParagraphs(text) { + return String(text || '') + .split(/\n\s*\n/g) + .map((chunk) => chunk.trim()) + .filter(Boolean).length; +} + +function countLines(text) { + return String(text || '') + .split(/\r?\n/) + .filter((line) => line.trim().length > 0).length; +} + +function wordCount(text) { + const matches = String(text || '') + .trim() + .match(/\S+/g); + return matches ? matches.length : 0; +} + +module.exports = { + sha, + escapeHtml, + slugify, + countParagraphs, + countLines, + wordCount, +}; diff --git a/src/validate.js b/src/validate.js new file mode 100644 index 0000000..c2ade0a --- /dev/null +++ b/src/validate.js @@ -0,0 +1,47 @@ +const { + MAX_RICH_HTML_CHARS, + MAX_RICH_HTML_TAGS, + BLOCKED_RICH_TAGS, + BLOCKED_META_REFRESH, + EVENT_HANDLER_ATTR, + JAVASCRIPT_URL_ATTR, + EXTERNAL_ASSET_ATTR, + EXTERNAL_CSS_URL, +} = require('./constants'); + +function validateRichHtmlDocument(htmlText) { + const html = String(htmlText || '').trim(); + if (!html) { + throw new Error('Rich HTML output was empty.'); + } + if (html.length > MAX_RICH_HTML_CHARS) { + throw new Error(`Rich HTML output exceeded ${MAX_RICH_HTML_CHARS} characters.`); + } + const tagCount = (html.match(/<\/?[a-z][^>]*>/gi) || []).length; + if (tagCount > MAX_RICH_HTML_TAGS) { + throw new Error(`Rich HTML output exceeded ${MAX_RICH_HTML_TAGS} HTML tags.`); + } + if (!/]/i.test(html) || !/]/i.test(html)) { + throw new Error('Rich HTML output must be a standalone document with and .'); + } + if (BLOCKED_RICH_TAGS.test(html)) { + throw new Error('Rich HTML output contained a blocked HTML tag.'); + } + if (BLOCKED_META_REFRESH.test(html)) { + throw new Error('Rich HTML output contained a meta refresh.'); + } + if (EVENT_HANDLER_ATTR.test(html)) { + throw new Error('Rich HTML output contained an event-handler attribute.'); + } + if (JAVASCRIPT_URL_ATTR.test(html)) { + throw new Error('Rich HTML output contained a javascript: URL.'); + } + if (EXTERNAL_ASSET_ATTR.test(html) || EXTERNAL_CSS_URL.test(html)) { + throw new Error('Rich HTML output referenced an external asset.'); + } + return /^\n${html}`; +} + +module.exports = { + validateRichHtmlDocument, +}; From 8ee2c60d5d32223ca2c66fcb07bc4eb81bd48ce4 Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 14:29:26 -0700 Subject: [PATCH 04/14] refactor: add strict jsdoc type checking across the runtime Enables checkJs under strict tsc with zero compiler relaxations: shared typedefs in src/extension/types.js (PiHost, ExtensionCtx, ArtifactMeta, SourceRecord, ExportMeta, CommentBundle), annotations across src, bin, hooks, and tests, and an Object.assign export on index.js that keeps the extension factory and _internals contract intact. --- bin/htmlify-answer.js | 15 ++-- hooks/claude-code-stop-htmlify.js | 6 ++ index.js | 46 ++++++------ src/annotation.js | 13 ++++ src/artifacts.js | 11 +++ src/comments.js | 13 +++- src/document.js | 20 ++++++ src/extension/index.js | 112 +++++++++++++++++++++++++++++- src/extension/messages.js | 23 ++++++ src/extension/open.js | 11 +++ src/extension/parse.js | 18 ++++- src/extension/prompts.js | 4 ++ src/extension/types.js | 87 +++++++++++++++++++++++ src/markdown.js | 22 ++++++ src/text.js | 24 +++++++ src/validate.js | 4 ++ test/extension.test.js | 36 ++++++++-- tsconfig.json | 2 +- 18 files changed, 428 insertions(+), 39 deletions(-) create mode 100644 src/extension/types.js diff --git a/bin/htmlify-answer.js b/bin/htmlify-answer.js index f80502b..5ba98d3 100755 --- a/bin/htmlify-answer.js +++ b/bin/htmlify-answer.js @@ -15,6 +15,10 @@ function usage() { ].join('\n'); } +/** + * @param {string[]} argv + * @returns {{ title: string, outDir: string, mode: string, input: string, help: boolean }} + */ function parseArgs(argv) { const options = { title: '', @@ -52,6 +56,10 @@ async function readStdin() { return Buffer.concat(chunks).toString('utf8'); } +/** + * @param {{ input: string }} options + * @returns {Promise} + */ async function readInput(options) { if (options.input) { return fs.readFile(path.resolve(options.input), 'utf8'); @@ -77,11 +85,8 @@ async function main() { const title = options.title || - sourceText - .split(/\r?\n/) - .find((line) => line.trim()) - .trim() - .slice(0, 120) || + // sourceText is non-empty here, so a non-blank line always exists. + /** @type {string} */ (sourceText.split(/\r?\n/).find((line) => line.trim())).trim().slice(0, 120) || 'htmlify export'; const filePath = await writeHtmlArtifact({ title, diff --git a/hooks/claude-code-stop-htmlify.js b/hooks/claude-code-stop-htmlify.js index 95e393c..398e4fd 100755 --- a/hooks/claude-code-stop-htmlify.js +++ b/hooks/claude-code-stop-htmlify.js @@ -5,8 +5,10 @@ const htmlify = require('../index.js'); const { renderMarkdownish, writeHtmlArtifact } = htmlify._internals; +/** @returns {Promise} */ function readStdin() { return new Promise((resolve, reject) => { + /** @type {Buffer[]} */ const chunks = []; process.stdin.on('data', (chunk) => chunks.push(Buffer.from(chunk))); process.stdin.on('error', reject); @@ -14,6 +16,10 @@ function readStdin() { }); } +/** + * @param {any} text + * @returns {string} + */ function titleFrom(text) { const first = String(text || '') .split(/\r?\n/) diff --git a/index.js b/index.js index 2afd8df..967185e 100644 --- a/index.js +++ b/index.js @@ -10,26 +10,26 @@ const { parseArgs, parseHtmlCommandInput, resolveForcedExportMode, hasSelectable const { resolveOpenCommand } = require('./src/extension/open'); const { buildRichHtmlPrompt } = require('./src/extension/prompts'); -module.exports = createExtension; - -module.exports._internals = { - buildLocalHtmlDocument, - buildRichHtmlPrompt, - extractHtmlDocument, - formatInline, - getExportRoot, - parseArgs, - parseHtmlLastInput: parseHtmlCommandInput, - resolveOpenCommand, - hasSelectableUi, - renderMarkdownish, - resolveForcedExportMode, - validateRichHtmlDocument, - addCommentableAttributes, - buildAnnotationLayer, - buildCommentsPrompt, - injectAnnotationLayer, - validateCommentBundle, - writeHtmlArtifact, - writeRichHtmlArtifact, -}; +module.exports = Object.assign(createExtension, { + _internals: { + buildLocalHtmlDocument, + buildRichHtmlPrompt, + extractHtmlDocument, + formatInline, + getExportRoot, + parseArgs, + parseHtmlLastInput: parseHtmlCommandInput, + resolveOpenCommand, + hasSelectableUi, + renderMarkdownish, + resolveForcedExportMode, + validateRichHtmlDocument, + addCommentableAttributes, + buildAnnotationLayer, + buildCommentsPrompt, + injectAnnotationLayer, + validateCommentBundle, + writeHtmlArtifact, + writeRichHtmlArtifact, + }, +}); diff --git a/src/annotation.js b/src/annotation.js index 4924c3a..92f795b 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -1,5 +1,9 @@ const { COMMENT_BUNDLE_VERSION, TRUSTED_ANNOTATION_MARKER } = require('./constants'); +/** + * @param {any} html + * @returns {string} + */ function addCommentableAttributes(html) { let index = 0; return String(html || '').replace( @@ -11,6 +15,10 @@ function addCommentableAttributes(html) { ); } +/** + * @param {{ sourceId?: string, title?: string }} [meta] + * @returns {string} + */ function buildAnnotationLayer(meta) { const sourceId = String(meta && meta.sourceId ? meta.sourceId : ''); const title = String(meta && meta.title ? meta.title : 'HTML Export'); @@ -140,6 +148,11 @@ function buildAnnotationLayer(meta) { `; } +/** + * @param {any} html + * @param {{ sourceId?: string, title?: string }} [meta] + * @returns {string} + */ function injectAnnotationLayer(html, meta) { const layer = buildAnnotationLayer(meta); const source = String(html || ''); diff --git a/src/artifacts.js b/src/artifacts.js index 27fd634..24d0d6c 100644 --- a/src/artifacts.js +++ b/src/artifacts.js @@ -7,6 +7,9 @@ const { deriveExcerpt, buildOutlineHtml, buildLocalHtmlDocument } = require('./d const { addCommentableAttributes, injectAnnotationLayer } = require('./annotation'); const { validateRichHtmlDocument } = require('./validate'); +/** + * @param {string} dir + */ async function ensureDir(dir) { await fs.mkdir(dir, { recursive: true }); } @@ -15,6 +18,10 @@ function getExportRoot() { return process.env.HTMLIFY_EXPORT_ROOT || process.env.PI_HTML_LONG_ANSWER_EXPORT_ROOT || DEFAULT_EXPORT_ROOT; } +/** + * @param {{ title: string, bodyHtml: string, sourceText: string, mode: string }} input + * @returns {Promise} + */ async function writeHtmlArtifact({ title, bodyHtml, sourceText, mode }) { const exportRoot = getExportRoot(); await ensureDir(exportRoot); @@ -37,6 +44,10 @@ async function writeHtmlArtifact({ title, bodyHtml, sourceText, mode }) { return filePath; } +/** + * @param {{ title: string, htmlText: string, sourceId?: string }} input + * @returns {Promise} + */ async function writeRichHtmlArtifact({ title, htmlText, sourceId }) { const html = validateRichHtmlDocument(htmlText); const exportRoot = getExportRoot(); diff --git a/src/comments.js b/src/comments.js index 8214d91..77daa5d 100644 --- a/src/comments.js +++ b/src/comments.js @@ -1,5 +1,12 @@ const { COMMENT_BUNDLE_VERSION } = require('./constants'); +/** @typedef {import('./extension/types').CommentBundle} CommentBundle */ + +/** + * @param {any} bundle + * @param {string | null | undefined} [expectedSourceId] + * @returns {CommentBundle} + */ function validateCommentBundle(bundle, expectedSourceId) { if (!bundle || typeof bundle !== 'object') throw new Error('Comment bundle must be a JSON object.'); if (bundle.version !== COMMENT_BUNDLE_VERSION) @@ -13,7 +20,7 @@ function validateCommentBundle(bundle, expectedSourceId) { sourceId: String(bundle.sourceId || ''), title: String(bundle.title || 'HTML Export').slice(0, 160), exportUrl: String(bundle.exportUrl || ''), - comments: bundle.comments.map((comment, index) => { + comments: bundle.comments.map((/** @type {any} */ comment, /** @type {number} */ index) => { if (!comment || typeof comment !== 'object') throw new Error(`Comment ${index + 1} must be an object.`); const selectedText = String(comment.selectedText || '').trim(); const body = String(comment.comment || '').trim(); @@ -31,6 +38,10 @@ function validateCommentBundle(bundle, expectedSourceId) { }; } +/** + * @param {CommentBundle} bundle + * @returns {string} + */ function buildCommentsPrompt(bundle) { const lines = [ 'I reviewed the HTML export and left comments.', diff --git a/src/document.js b/src/document.js index 9754751..0a7022d 100644 --- a/src/document.js +++ b/src/document.js @@ -1,6 +1,12 @@ const { escapeHtml } = require('./text'); const { formatInline } = require('./markdown'); +/** @typedef {import('./extension/types').ArtifactMeta} ArtifactMeta */ + +/** + * @param {any} text + * @returns {string} + */ function deriveTitle(text) { const source = String(text || '').trim(); if (!source) return 'HTML Export'; @@ -14,6 +20,10 @@ function deriveTitle(text) { return firstSentence.slice(0, 80); } +/** + * @param {any} text + * @returns {string} + */ function deriveExcerpt(text) { const lines = String(text || '').split(/\r?\n/); for (const line of lines) { @@ -29,6 +39,10 @@ function deriveExcerpt(text) { .slice(0, 240); } +/** + * @param {any} text + * @returns {string} + */ function buildOutlineHtml(text) { const headings = []; const lines = String(text || '').split(/\r?\n/); @@ -41,6 +55,12 @@ function buildOutlineHtml(text) { return `
    Outline
      ${headings.map((item) => `
    • ${formatInline(item.label)}
    • `).join('')}
    `; } +/** + * @param {string} title + * @param {string} body + * @param {ArtifactMeta} meta + * @returns {string} + */ function buildLocalHtmlDocument(title, body, meta) { const exportedAt = new Date(meta.exportedAt).toLocaleString(); return ` diff --git a/src/extension/index.js b/src/extension/index.js index ac8fcbc..979204e 100644 --- a/src/extension/index.js +++ b/src/extension/index.js @@ -24,7 +24,26 @@ const { buildRichHtmlPrompt } = require('./prompts'); const execFileAsync = promisify(execFile); +/** @typedef {import('./types').PiHost} PiHost */ +/** @typedef {import('./types').ExtensionCtx} ExtensionCtx */ +/** @typedef {import('./types').SourceRecord} SourceRecord */ +/** @typedef {import('./types').ExportMeta} ExportMeta */ + +/** + * @param {PiHost} pi + */ module.exports = function htmlLongAnswerExtension(pi) { + /** + * @type {{ + * offerMode: string, + * lastEligible: SourceRecord | null, + * lastExport: ExportMeta | null, + * pendingRichExport: { requestedAt: number, source: SourceRecord } | null, + * lastPromptedSignature: string | null, + * geminiAvailable: boolean | null, + * config: { minChars: number, minLines: number, minParagraphs: number }, + * }} + */ const state = { offerMode: 'ask', lastEligible: null, @@ -35,6 +54,7 @@ module.exports = function htmlLongAnswerExtension(pi) { config: { ...LONG_ANSWER_DEFAULTS }, }; + /** @param {any} entry */ function rememberFromEntry(entry) { if (!entry || entry.type !== 'custom') return; if (entry.customType === PREF_ENTRY_TYPE && entry.data && typeof entry.data.offerMode === 'string') { @@ -48,6 +68,7 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** @param {any} branch */ function hydrateLastEligibleFromBranch(branch) { if (!Array.isArray(branch) || state.lastEligible) return; for (let index = branch.length - 1; index >= 0; index -= 1) { @@ -59,6 +80,7 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** @param {ExtensionCtx | undefined} ctx */ async function restoreSessionState(ctx) { try { const branch = @@ -73,6 +95,10 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** + * @param {string} type + * @param {object} data + */ async function appendCustomEntry(type, data) { if (typeof pi.appendEntry !== 'function') return; try { @@ -82,22 +108,30 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** @param {string} mode */ async function setOfferMode(mode) { state.offerMode = mode; await appendCustomEntry(PREF_ENTRY_TYPE, { offerMode: mode, savedAt: Date.now() }); } + /** @param {SourceRecord} source */ async function rememberEligibleSource(source) { state.lastEligible = source; const { text: _text, ...persistedSource } = source; await appendCustomEntry(SOURCE_ENTRY_TYPE, persistedSource); } + /** @param {ExportMeta} meta */ async function rememberExport(meta) { state.lastExport = meta; await appendCustomEntry(EXPORT_ENTRY_TYPE, meta); } + /** + * @param {ExtensionCtx | undefined} ctx + * @param {string} message + * @param {string} [level] + */ function notify(ctx, message, level) { if (!ctx || !ctx.ui || typeof ctx.ui.notify !== 'function') return; try { @@ -110,6 +144,10 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** + * @param {ExtensionCtx | undefined} ctx + * @param {any} error + */ function notifyCommandError(ctx, error) { notify( ctx, @@ -129,6 +167,11 @@ module.exports = function htmlLongAnswerExtension(pi) { return state.geminiAvailable; } + /** + * @param {ExtensionCtx | undefined} ctx + * @param {string} filePath + * @param {string} mode + */ async function maybeOpenArtifact(ctx, filePath, mode) { const opened = await openArtifact(filePath); if (!opened) return false; @@ -140,6 +183,10 @@ module.exports = function htmlLongAnswerExtension(pi) { return true; } + /** + * @param {string} text + * @returns {SourceRecord} + */ function buildSourceRecord(text) { const title = deriveTitle(text); return { @@ -156,6 +203,11 @@ module.exports = function htmlLongAnswerExtension(pi) { }; } + /** + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + * @param {string} [mode] + */ async function exportLocalHtml(ctx, source, mode) { const bodyHtml = renderMarkdownish(source.text); const filePath = await writeHtmlArtifact({ @@ -181,6 +233,11 @@ module.exports = function htmlLongAnswerExtension(pi) { return meta; } + /** + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + * @param {string} htmlText + */ async function exportRichHtmlResult(ctx, source, htmlText) { const filePath = await writeRichHtmlArtifact({ title: source.title, @@ -200,6 +257,11 @@ module.exports = function htmlLongAnswerExtension(pi) { return meta; } + /** + * @param {any} result + * @param {Array<{ label: string, value: string }>} options + * @returns {string | null} + */ function normalizeChoice(result, options) { if (typeof result === 'string') return result; if (typeof result === 'number') { @@ -212,6 +274,10 @@ module.exports = function htmlLongAnswerExtension(pi) { return null; } + /** + * @param {any} ui + * @param {string} summary + */ async function promptWithSelect(ui, summary) { const geminiAvailable = await isGeminiCliAvailable(); const options = [ @@ -234,6 +300,10 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + */ async function promptUserForExport(ctx, source) { if (!ctx || !ctx.ui || state.offerMode === 'never') return 'inline'; const summary = [ @@ -250,13 +320,18 @@ module.exports = function htmlLongAnswerExtension(pi) { return 'inline'; } + /** + * @param {SourceRecord} source + * @param {ExtensionCtx | undefined} ctx + * @param {string} renderer + */ async function queueRichExport(source, ctx, renderer) { if (renderer === 'gemini') { await notify(ctx, 'Generating designed HTML with Gemini CLI…', 'info'); try { const html = await runGeminiRichExport(source); await exportRichHtmlResult(ctx, source, html); - } catch (error) { + } catch (/** @type {any} */ error) { await notify( ctx, `Gemini designed HTML failed: ${error && error.message ? error.message : String(error)}. Falling back to quick local HTML. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, @@ -283,6 +358,10 @@ module.exports = function htmlLongAnswerExtension(pi) { throw new Error('No runtime message API is available for richer HTML generation.'); } + /** + * @param {SourceRecord} source + * @returns {Promise} + */ async function runGeminiRichExport(source) { const { stdout } = await execFileAsync( 'gemini', @@ -306,6 +385,7 @@ module.exports = function htmlLongAnswerExtension(pi) { return html; } + /** @param {ExtensionCtx | undefined} ctx */ async function chooseCommandExportMode(ctx) { if (!ctx || !ctx.ui || typeof ctx.ui.select !== 'function') { return 'local'; @@ -329,6 +409,11 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** + * @param {string | null} choice + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + */ async function handleChoice(choice, ctx, source) { if (choice === 'never') { await setOfferMode('never'); @@ -353,6 +438,10 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** + * @param {any} event + * @param {ExtensionCtx | undefined} ctx + */ async function maybeHandlePendingRichExport(event, ctx) { if (!state.pendingRichExport) return false; const info = extractMessageInfo(event); @@ -362,7 +451,7 @@ module.exports = function htmlLongAnswerExtension(pi) { if (htmlDocument) { try { await exportRichHtmlResult(ctx, state.pendingRichExport.source, htmlDocument); - } catch (error) { + } catch (/** @type {any} */ error) { await notify( ctx, `Richer HTML pass was unsafe or invalid: ${error && error.message ? error.message : String(error)}. Wrote a fallback HTML export instead. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, @@ -385,6 +474,10 @@ module.exports = function htmlLongAnswerExtension(pi) { return true; } + /** + * @param {any} event + * @param {ExtensionCtx | undefined} ctx + */ async function handleAssistantMessage(event, ctx) { if (await maybeHandlePendingRichExport(event, ctx)) return; @@ -404,6 +497,10 @@ module.exports = function htmlLongAnswerExtension(pi) { // The answer is already captured; /html-last remains available when the user wants the export. } + /** + * @param {any} args + * @param {ExtensionCtx | undefined} ctx + */ async function exportLatestFromCommand(args, ctx) { if (!state.lastEligible || !state.lastEligible.text) { try { @@ -444,6 +541,7 @@ module.exports = function htmlLongAnswerExtension(pi) { await exportLocalHtml(ctx, state.lastEligible, 'local'); } + /** @param {any} args */ async function readCommentBundle(args) { const raw = typeof args === 'string' ? args.trim() : parseArgs(args).join(' ').trim(); if (!raw) throw new Error('Pass a comments JSON file path or pasted JSON after /html-comments.'); @@ -453,6 +551,10 @@ module.exports = function htmlLongAnswerExtension(pi) { return JSON.parse(text); } + /** + * @param {any} args + * @param {ExtensionCtx | undefined} ctx + */ async function importCommentsFromCommand(args, ctx) { if (!state.lastEligible || !state.lastEligible.text) { try { @@ -498,6 +600,7 @@ module.exports = function htmlLongAnswerExtension(pi) { } } + /** @type {(event: any, ctx: ExtensionCtx | undefined) => Promise} */ const restoreHandler = async (_event, ctx) => { await restoreSessionState(ctx); }; @@ -527,7 +630,7 @@ module.exports = function htmlLongAnswerExtension(pi) { pi.on('message_end', async (event, ctx) => { try { await handleAssistantMessage(event, ctx); - } catch (error) { + } catch (/** @type {any} */ error) { await notify( ctx, `${PRODUCT_NAME} extension error: ${error && error.message ? error.message : String(error)}`, @@ -538,6 +641,7 @@ module.exports = function htmlLongAnswerExtension(pi) { } if (typeof pi.registerCommand === 'function') { + /** @type {{ description: string, handler: (args: any, ctx: ExtensionCtx | undefined) => void }} */ const exportCommand = { description: 'Export the latest eligible assistant answer as HTML. Use `choose`, `gemini`, `pi`, or `local` to force a render path.', @@ -554,6 +658,7 @@ module.exports = function htmlLongAnswerExtension(pi) { pi.registerCommand('htmlify', { ...exportCommand }); pi.registerCommand('htmlify-last', { ...exportCommand }); + /** @type {{ description: string, handler: (args: any, ctx: ExtensionCtx | undefined) => void }} */ const commentsCommand = { description: 'Import downloaded HTML comments JSON and send the review prompt back to the agent.', handler: (args, ctx) => { @@ -566,6 +671,7 @@ module.exports = function htmlLongAnswerExtension(pi) { pi.registerCommand('html-comments', { ...commentsCommand }); pi.registerCommand('htmlify-comments', { ...commentsCommand }); + /** @type {{ description: string, handler: (args: any, ctx: ExtensionCtx | undefined) => void }} */ const versionCommand = { description: 'Show the loaded htmlify extension version.', handler: (_args, ctx) => { diff --git a/src/extension/messages.js b/src/extension/messages.js index 4429a14..08a308a 100644 --- a/src/extension/messages.js +++ b/src/extension/messages.js @@ -1,5 +1,9 @@ const { sha, countLines, countParagraphs } = require('../text'); +/** + * @param {any} part + * @returns {string} + */ function extractTextPart(part) { if (!part) return ''; if (typeof part === 'string') return part; @@ -10,6 +14,10 @@ function extractTextPart(part) { return ''; } +/** + * @param {any} candidate + * @returns {string | null} + */ function normalizeRole(candidate) { if (!candidate) return null; const role = String(candidate).toLowerCase(); @@ -18,6 +26,12 @@ function normalizeRole(candidate) { return role; } +/** + * Events from the host stay loose; runtime guards handle the shape. + * + * @param {any} event + * @returns {{ id: string, role: string, text: string } | null} + */ function extractMessageInfo(event) { const candidate = event && typeof event === 'object' ? event.message || event.entry || event.payload || event.data || event : null; @@ -41,6 +55,11 @@ function extractMessageInfo(event) { }; } +/** + * @param {any} text + * @param {{ minChars: number, minLines: number, minParagraphs: number }} config + * @returns {boolean} + */ function isLongAnswer(text, config) { const source = String(text || '').trim(); if (!source) return false; @@ -51,6 +70,10 @@ function isLongAnswer(text, config) { ); } +/** + * @param {any} text + * @returns {string | null} + */ function extractHtmlDocument(text) { const source = String(text || '').trim(); if (!source) return null; diff --git a/src/extension/open.js b/src/extension/open.js index a5f9527..4753f40 100644 --- a/src/extension/open.js +++ b/src/extension/open.js @@ -4,6 +4,10 @@ const { spawn } = require('child_process'); const { OPEN_FAILURE_WINDOW_MS } = require('../constants'); +/** + * @param {string | null | undefined} command + * @returns {Promise} + */ async function resolveOpenCommand(command) { if (!command) return null; if (path.isAbsolute(command)) { @@ -30,6 +34,10 @@ async function resolveOpenCommand(command) { return null; } +/** + * @param {string} filePath + * @returns {Promise} + */ async function openArtifact(filePath) { if (process.env.HTMLIFY_SKIP_OPEN === '1' || process.env.PI_HTML_LONG_ANSWER_SKIP_OPEN === '1') return false; const command = process.platform === 'darwin' ? '/usr/bin/open' : process.platform === 'linux' ? 'xdg-open' : null; @@ -37,10 +45,13 @@ async function openArtifact(filePath) { if (!executable) return false; return new Promise((resolve) => { + /** @type {import('child_process').ChildProcess | undefined} */ let child; let settled = false; + /** @type {NodeJS.Timeout | undefined} */ let timer; + /** @param {boolean} opened */ const settle = (opened) => { if (settled) return; settled = true; diff --git a/src/extension/parse.js b/src/extension/parse.js index b85eff0..bd2ec8c 100644 --- a/src/extension/parse.js +++ b/src/extension/parse.js @@ -1,12 +1,20 @@ +/** + * @param {any} rawArgs + * @returns {string[]} + */ function parseArgs(rawArgs) { if (Array.isArray(rawArgs)) return rawArgs.map((item) => String(item)); if (typeof rawArgs === 'string') return rawArgs.trim().split(/\s+/).filter(Boolean); if (rawArgs && typeof rawArgs === 'object' && Array.isArray(rawArgs.args)) { - return rawArgs.args.map((item) => String(item)); + return rawArgs.args.map((/** @type {any} */ item) => String(item)); } return []; } +/** + * @param {any} text + * @returns {{ command: string, args: string } | null} + */ function parseHtmlCommandInput(text) { const source = typeof text === 'string' ? text.trim() : ''; if (/^\/(?:html-last-version|htmlify-version)\s*$/i.test(source)) { @@ -22,6 +30,10 @@ function parseHtmlCommandInput(text) { return null; } +/** + * @param {any} rawArgs + * @returns {string | null} + */ function resolveForcedExportMode(rawArgs) { const parsedArgs = parseArgs(rawArgs); if (parsedArgs.some((arg) => /^(choose|choices|chooser|menu)$/i.test(arg))) return 'choose'; @@ -32,6 +44,10 @@ function resolveForcedExportMode(rawArgs) { return null; } +/** + * @param {any} ctx + * @returns {boolean} + */ function hasSelectableUi(ctx) { return Boolean(ctx && ctx.ui && typeof ctx.ui.select === 'function'); } diff --git a/src/extension/prompts.js b/src/extension/prompts.js index 6dbc0ba..23d78a5 100644 --- a/src/extension/prompts.js +++ b/src/extension/prompts.js @@ -1,3 +1,7 @@ +/** + * @param {{ title: string, text: string }} lastEligible + * @returns {string} + */ function buildRichHtmlPrompt(lastEligible) { return [ 'Transform the following answer into a standalone, production-quality HTML artifact in the htmlify style.', diff --git a/src/extension/types.js b/src/extension/types.js new file mode 100644 index 0000000..ef023ad --- /dev/null +++ b/src/extension/types.js @@ -0,0 +1,87 @@ +/** + * JSDoc-only typedef module for htmlify. The empty export keeps this a + * CommonJS module so `import('./types')` works from JSDoc annotations. + */ + +/** + * Host surface provided by Pi/OMP. Every member is optional because the + * factory guards each one with `typeof` checks before use. + * + * @typedef {object} PiHost + * @property {(eventName: string, handler: (event: any, ctx: ExtensionCtx) => unknown) => unknown} [on] + * @property {(name: string, definition: { description: string, handler: (args: any, ctx: ExtensionCtx) => unknown }) => unknown} [registerCommand] + * @property {(type: string, data: object) => Promise} [appendEntry] + * @property {(label: string) => unknown} [setLabel] + * @property {(message: string, options?: object) => Promise} [sendUserMessage] + * @property {(message: string, options?: object) => Promise} [sendMessage] + */ + +/** + * Per-event context object passed by the host. Shape is host-dependent, so + * everything is optional and runtime-guarded. + * + * @typedef {object} ExtensionCtx + * @property {{ notify?: (message: string, level?: string) => any, select?: (prompt: string, options: Array<{ label: string, value: string }>) => any }} [ui] + * @property {{ getBranch?: () => unknown[] }} [sessionManager] + */ + +/** + * Metadata passed to buildLocalHtmlDocument. + * + * @typedef {object} ArtifactMeta + * @property {string} exportedAt + * @property {number} words + * @property {number} characters + * @property {string} mode + * @property {string} excerpt + * @property {string} outlineHtml + * @property {string} sourceId + */ + +/** + * Captured assistant answer eligible for export. + * + * @typedef {object} SourceRecord + * @property {string} id + * @property {string} title + * @property {string} text + * @property {number} recordedAt + * @property {{ characters: number, lines: number, paragraphs: number, words: number }} stats + */ + +/** + * Persisted record of a written HTML export. + * + * @typedef {object} ExportMeta + * @property {string} path + * @property {string} mode + * @property {string} title + * @property {string} sourceId + * @property {number} exportedAt + */ + +/** + * A single reviewer comment captured in the annotation layer. + * + * @typedef {object} CommentRecord + * @property {string} id + * @property {string} blockId + * @property {string} selectedText + * @property {string} prefix + * @property {string} suffix + * @property {string} comment + * @property {string} createdAt + */ + +/** + * Validated bundle of reviewer comments. + * + * @typedef {object} CommentBundle + * @property {number} version + * @property {string} sourceId + * @property {string} title + * @property {string} exportUrl + * @property {CommentRecord[]} comments + */ + +module.exports = {}; diff --git a/src/markdown.js b/src/markdown.js index 64c9e89..ccc3593 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -1,9 +1,17 @@ const { escapeHtml } = require('./text'); +/** + * @param {any} line + * @returns {boolean} + */ function isSeparatorRow(line) { return /^\s*\|?(?:\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line || ''); } +/** + * @param {any} line + * @returns {string[]} + */ function splitTableRow(line) { return String(line || '') .trim() @@ -13,6 +21,10 @@ function splitTableRow(line) { .map((cell) => cell.trim()); } +/** + * @param {any} raw + * @returns {string} + */ function formatInline(raw) { let text = escapeHtml(raw); text = text.replace( @@ -29,6 +41,12 @@ function formatInline(raw) { return text; } +/** + * @param {string[]} lines + * @param {number} start + * @param {(line: string, index: number) => boolean} predicate + * @returns {{ collected: string[], nextIndex: number }} + */ function collectUntil(lines, start, predicate) { const collected = []; let index = start; @@ -39,6 +57,10 @@ function collectUntil(lines, start, predicate) { return { collected, nextIndex: index }; } +/** + * @param {any} text + * @returns {string} + */ function renderMarkdownish(text) { const lines = String(text || '') .replace(/\r/g, '') diff --git a/src/text.js b/src/text.js index 8d5a134..b7efdc7 100644 --- a/src/text.js +++ b/src/text.js @@ -1,5 +1,9 @@ const crypto = require('crypto'); +/** + * @param {any} input + * @returns {string} + */ function sha(input) { return crypto .createHash('sha1') @@ -7,6 +11,10 @@ function sha(input) { .digest('hex'); } +/** + * @param {any} value + * @returns {string} + */ function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') @@ -16,6 +24,10 @@ function escapeHtml(value) { .replace(/'/g, '''); } +/** + * @param {any} value + * @returns {string} + */ function slugify(value) { const normalized = String(value || 'export') .toLowerCase() @@ -25,6 +37,10 @@ function slugify(value) { return normalized || 'export'; } +/** + * @param {any} text + * @returns {number} + */ function countParagraphs(text) { return String(text || '') .split(/\n\s*\n/g) @@ -32,12 +48,20 @@ function countParagraphs(text) { .filter(Boolean).length; } +/** + * @param {any} text + * @returns {number} + */ function countLines(text) { return String(text || '') .split(/\r?\n/) .filter((line) => line.trim().length > 0).length; } +/** + * @param {any} text + * @returns {number} + */ function wordCount(text) { const matches = String(text || '') .trim() diff --git a/src/validate.js b/src/validate.js index c2ade0a..bd1b2ff 100644 --- a/src/validate.js +++ b/src/validate.js @@ -9,6 +9,10 @@ const { EXTERNAL_CSS_URL, } = require('./constants'); +/** + * @param {any} htmlText + * @returns {string} + */ function validateRichHtmlDocument(htmlText) { const html = String(htmlText || '').trim(); if (!html) { diff --git a/test/extension.test.js b/test/extension.test.js index 04dadd0..10f6448 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -10,6 +10,10 @@ const packageJson = require('../package.json'); const extension = require('../index.js'); const internals = extension._internals; +/** + * @param {string} body + * @param {string} [head] + */ function richDocument(body, head = '') { return ` @@ -18,6 +22,9 @@ function richDocument(body, head = ' + + +
    +
    +

    First

    + +
    +
    +

    Second

    + +
    +
    + + + diff --git a/test/fixtures/deck-no-notes.html b/test/fixtures/deck-no-notes.html new file mode 100644 index 0000000..3632826 --- /dev/null +++ b/test/fixtures/deck-no-notes.html @@ -0,0 +1,21 @@ + + + + + + Missing Notes + + + +
    +
    +

    Substantive slide without notes

    +
    cell
    +
    +
    +
    + + + diff --git a/test/fixtures/deck-valid.html b/test/fixtures/deck-valid.html new file mode 100644 index 0000000..48c528c --- /dev/null +++ b/test/fixtures/deck-valid.html @@ -0,0 +1,42 @@ + + + + + + Fixture Talk + + + +
    1 / 3
    +
    +
    +

    Fixture Talk

    +

    A minimal but complete deck used by the validator test suite to prove that a template-shaped deck passes. This opening slide carries enough prose to count as substantive under the two-hundred-character heuristic used by the deck profile, which is exactly the point of this sentence going on a little longer than it otherwise would.

    + +
    +
    +
    +

    Closing

    +
    RecapDone
    + +
    +
    + + + diff --git a/test/fixtures/rich-script.html b/test/fixtures/rich-script.html new file mode 100644 index 0000000..4e6a3c4 --- /dev/null +++ b/test/fixtures/rich-script.html @@ -0,0 +1,13 @@ + + + + + Script In Rich + + +

    This document carries an inline script, which the rich profile must reject.

    + + + diff --git a/test/fixtures/rich-valid.html b/test/fixtures/rich-valid.html new file mode 100644 index 0000000..ad12822 --- /dev/null +++ b/test/fixtures/rich-valid.html @@ -0,0 +1,15 @@ + + + + + + Rich Fixture + + + +
    +

    Rich Fixture

    +

    A valid, script-free standalone document.

    +
    + + diff --git a/test/validate-cli.test.js b/test/validate-cli.test.js new file mode 100644 index 0000000..3077eb0 --- /dev/null +++ b/test/validate-cli.test.js @@ -0,0 +1,101 @@ +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..'); +const cliPath = path.join(repoRoot, 'bin', 'htmlify-answer.js'); +const fixturesDir = path.join(__dirname, 'fixtures'); + +/** + * @param {string[]} args + * @returns {Promise<{ code: number | null, stdout: string, stderr: string }>} + */ +function runCli(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...args], { + cwd: repoRoot, + env: { ...process.env, HTMLIFY_SKIP_OPEN: '1' }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString('utf8'); + }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); + child.stdin.end(''); + }); +} + +/** @param {string} name */ +function fixture(name) { + return path.join(fixturesDir, name); +} + +test('cli validates a valid deck with exit code 0', async () => { + const result = await runCli(['--validate', fixture('deck-valid.html'), '--profile', 'deck']); + assert.equal(result.code, 0); + assert.match(result.stdout, /valid/); + assert.match(result.stdout, /profile: deck/); +}); + +test('cli exits 1 with named issue codes on an invalid deck', async () => { + const result = await runCli(['--validate', fixture('deck-no-notes.html'), '--profile', 'deck']); + assert.equal(result.code, 1); + assert.match(result.stdout, /error missing-notes/); + assert.match(result.stdout, /INVALID/); +}); + +test('cli exits 2 on a missing file and on an unknown flag', async () => { + const missing = await runCli(['--validate', fixture('does-not-exist.html')]); + assert.equal(missing.code, 2); + assert.match(missing.stderr, /Cannot read/); + + const badFlag = await runCli(['--frobnicate']); + assert.equal(badFlag.code, 2); + assert.match(badFlag.stderr, /Unknown argument/); + + const mixed = await runCli(['--validate', fixture('deck-valid.html'), '--title', 'x']); + assert.equal(mixed.code, 2); + assert.match(mixed.stderr, /cannot be combined/); +}); + +test('cli json output parses and includes deck stats', async () => { + const result = await runCli(['--validate', fixture('deck-valid.html'), '--profile', 'deck', '--format', 'json']); + assert.equal(result.code, 0); + const report = JSON.parse(result.stdout); + assert.equal(report.valid, true); + assert.equal(report.profile, 'deck'); + assert.equal(report.stats.slides, 3); +}); + +test('cli auto profile detection picks deck, app, and rich per file', async () => { + const result = await runCli([ + '--validate', + fixture('deck-valid.html'), + fixture('rich-script.html'), + fixture('rich-valid.html'), + '--format', + 'json', + ]); + const reports = JSON.parse(result.stdout); + assert.equal(reports.length, 3); + assert.equal(reports[0].profile, 'deck'); + assert.equal(reports[1].profile, 'app'); + assert.equal(reports[2].profile, 'rich'); + assert.equal(result.code, 0); +}); + +test('cli rich profile fails a scripted document that the app profile accepts', async () => { + const rich = await runCli(['--validate', fixture('rich-script.html'), '--profile', 'rich']); + assert.equal(rich.code, 1); + assert.match(rich.stdout, /blocked-tag/); + + const app = await runCli(['--validate', fixture('rich-script.html'), '--profile', 'app']); + assert.equal(app.code, 0); +}); From 532f5fa539a8746a03a2cd475a3e26079f9f74ca Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 14:36:37 -0700 Subject: [PATCH 06/14] feat!: move htmlify skill into skills/ and add deckify skill The repo becomes a skill family: skills/htmlify (documents) and skills/deckify (presentation decks with speaker notes, run-of-show, and guide mode), each self-contained with its own references. The Hardcopy visual spec ships identically in both skills. The deckify skill is adapted from a local fork, de-personalized, and given a canonical deck template contract that the deck validation profile enforces. Both SKILL.md files now require validating output via htmlify-answer --validate. npm files now ship src/, skills/, and the plugin manifest. BREAKING CHANGE: the root SKILL.md and references/ are removed. Clones that installed the repo root as a skill directory must re-install by pointing at skills/htmlify (and optionally skills/deckify). The htmlify-answer CLI, hook path, and Pi/OMP entry points are unchanged. --- package.json | 9 +- references/agent-integrations.md | 121 ----------- skills/deckify/SKILL.md | 192 ++++++++++++++++++ skills/deckify/references/deck-template.md | 101 +++++++++ skills/deckify/references/hardcopy.md | 113 +++++++++++ SKILL.md => skills/htmlify/SKILL.md | 25 ++- .../htmlify/references/agent-integrations.md | 135 ++++++++++++ skills/htmlify/references/hardcopy.md | 113 +++++++++++ .../htmlify/references}/htmlify-principles.md | 0 test/extension.test.js | 9 +- 10 files changed, 685 insertions(+), 133 deletions(-) delete mode 100644 references/agent-integrations.md create mode 100644 skills/deckify/SKILL.md create mode 100644 skills/deckify/references/deck-template.md create mode 100644 skills/deckify/references/hardcopy.md rename SKILL.md => skills/htmlify/SKILL.md (76%) create mode 100644 skills/htmlify/references/agent-integrations.md create mode 100644 skills/htmlify/references/hardcopy.md rename {references => skills/htmlify/references}/htmlify-principles.md (100%) diff --git a/package.json b/package.json index a4b1c50..afd3b5e 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,14 @@ "typecheck": "tsc --noEmit" }, "files": [ - "SKILL.md", + "index.js", + "src/", "bin/", "hooks/", - "index.js", - "README.md", + "skills/", + ".claude-plugin/", "assets/", - "references/" + "README.md" ], "repository": { "type": "git", diff --git a/references/agent-integrations.md b/references/agent-integrations.md deleted file mode 100644 index c358d52..0000000 --- a/references/agent-integrations.md +++ /dev/null @@ -1,121 +0,0 @@ -# Agent Integrations - -htmlify supports two integration styles: - -- Invokable skill: the user asks for `$htmlify` or names the skill when they want a browser-ready artifact. -- Automatic hook: the agent's lifecycle hook detects a long final answer and writes an HTML export. - -Prefer the invokable skill for production use. Hooks are useful when the user repeatedly wants long final answers archived as HTML, but hooks are agent-specific and should be installed deliberately. - -## Codex - -Install as a local Codex skill: - -```bash -mkdir -p ~/.codex/skills -git clone https://github.com/zakelfassi/htmlify.git ~/.codex/skills/htmlify -``` - -Local development checkout: - -```bash -ln -sfn /Users/zakelfassi/Documents/Code/htmlify ~/.codex/skills/htmlify -``` - -Invoke in a prompt: - -```text -$htmlify turn this implementation summary into an operator brief -``` - -Project-level opt-in via `AGENTS.md`: - -```md -For long operator handoffs, build plans, PR/release packets, incident timelines, -or status reports, use `$htmlify` and write a self-contained HTML file instead -of returning a long markdown-only answer. -``` - -Codex does not need a hook for the normal skill path. If you want automatic behavior, keep it as an instruction in `AGENTS.md` so the agent can choose HTML only when the answer actually benefits from it. - -## Claude Code - -Install as a Claude skill: - -```bash -mkdir -p ~/.claude/skills -git clone https://github.com/zakelfassi/htmlify.git ~/.claude/skills/htmlify -``` - -Invoke directly: - -```text -Use the htmlify skill to turn this release state into a single-file HTML brief. -``` - -Optional automatic long-answer hook: - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node /Users/zakelfassi/.claude/skills/htmlify/hooks/claude-code-stop-htmlify.js", - "timeout": 30 - } - ] - } - ] - } -} -``` - -Put that in `~/.claude/settings.json` for all projects, `.claude/settings.json` for a committed project hook, or `.claude/settings.local.json` for a project-local uncommitted hook. - -The hook reads Claude Code's `Stop` event JSON, checks `last_assistant_message`, and writes an HTML artifact only when the answer length is at least `HTMLIFY_MIN_CHARS` characters. Defaults: - -```bash -export HTMLIFY_MIN_CHARS=2500 -export HTMLIFY_EXPORT_ROOT="$HOME/htmlify-exports" -``` - -Claude Code's official hook model passes JSON to command hooks on stdin, and the `Stop` event includes `last_assistant_message`, so the hook does not parse transcript files. - -## Cursor, Windsurf, Aider, And Other Agents - -Use the portable skill folder when the agent supports Agent Skills: - -```bash -git clone https://github.com/zakelfassi/htmlify.git ~/.agent-skills/htmlify -``` - -Then point the agent at `htmlify/SKILL.md` or add this project rule: - -```md -When the user asks for a long report, review packet, implementation plan, -incident timeline, or decision brief, use the local htmlify skill at -~/.agent-skills/htmlify/SKILL.md and produce a self-contained HTML artifact. -``` - -For agents without native skills, use the CLI as a local hook target: - -```bash -printf '%s' "$LONG_ANSWER_TEXT" | npx @zakelfassi/htmlify htmlify-answer --title "Agent Answer" -``` - -From a checked-out repo: - -```bash -printf '%s' "$LONG_ANSWER_TEXT" | node /path/to/htmlify/bin/htmlify-answer.js --title "Agent Answer" -``` - -## Hook Safety - -- Keep hooks local unless the whole team wants the behavior. -- Do not force every long answer into HTML; small terminal answers should stay in the terminal. -- Set `HTMLIFY_EXPORT_ROOT` to a predictable folder if artifacts should be archived. -- Use `HTMLIFY_MIN_CHARS` to tune threshold by team. Start with `2500`. -- Generated rich HTML is still validated by htmlify before writing when it comes through the Pi/OMP runtime. diff --git a/skills/deckify/SKILL.md b/skills/deckify/SKILL.md new file mode 100644 index 0000000..2f657a0 --- /dev/null +++ b/skills/deckify/SKILL.md @@ -0,0 +1,192 @@ +--- +name: deckify +description: Create self-contained HTML presentation decks and companion downloadable guides for content creators, educators, technical talks, YouTube lives, workshops, explainers, launch narratives, and media-rich essays. Use when the user wants a presentable deck, speaker notes, run-of-show, demo plan, PDF/guide output, image-generation plan, or screenshot-backed visual narrative rather than a flat markdown answer. +compatibility: Works in agentskills.io-compatible clients. Part of the htmlify skill family; specialized for media-rich deck/guide production. +license: Apache-2.0 +metadata: + version: "0.3.1" # x-release-please-version + source: "https://github.com/zakelfassi/htmlify" +--- + +# deckify + +Deckify turns dense context into a browser-ready HTML deck plus a downloadable guide. It is an off-the-shelf content-creator skill: the output should be usable for a YouTube talk, live workshop, recorded lecture, webinar, internal enablement session, or publishable companion PDF. + +Deckify generates **HTML** by default. It extends its sibling skill `htmlify` with: + +- deck-first narrative structure +- speaker notes and detachable notes +- run-of-show and chapter timing +- demo/lab panels +- downloadable guide/PDF mode +- 40-60% visual coverage planning +- image-generation illustration slots +- browser screenshot capture when useful for visual references +- validation for presentation and print/download use + +## Operating Rules + +1. Gather evidence first. Read the supplied source, repo files, docs, screenshots, existing deck/page, or prior artifact before designing the deck. +2. Choose the smallest deck mode that fits: + - `talk-deck`: YouTube/live presentation with speaker notes and run-of-show. + - `workshop-deck`: talk plus exercises, labs, checkpoints, and handouts. + - `essay-deck`: presentation plus downloadable long-form guide. + - `demo-deck`: presentation centered around live demos and fallback screenshots. + - `launch-deck`: product narrative, proof, risks, roadmap, and CTA. + - `teaching-guide`: PDF-first guide with optional slide mode. +3. Keep the output self-contained unless the user explicitly wants external assets. Inline CSS and JS. If generated images or screenshots are used, save them into the project and reference them locally. +4. Make the first viewport presentation-ready: title, promise, audience, timing, and navigation controls. +5. Add speaker notes for every substantive slide. Include detachable notes when the deck is for live presentation. +6. Include a run-of-show with timestamps and chapter labels for YouTube. +7. Include a guide/PDF mode when the user asks for a download, companion essay, handout, or post-watch material. +8. Aim for 40-60% visual coverage for content decks unless the user asks for a text-only brief. Count full-slide visuals, companion graphics, screenshots, diagrams, demos, charts, and worksheet panels. +9. Use visuals to clarify systems, flows, tradeoffs, proof, or examples. Do not add decorative stock-like images. +10. When an image, screenshot, or figure references a link, article, documentation page, post, paper, or source screenshot, put the source in the visible caption/ref near the image (e.g. `Source: Exact source title`). Do not leave source URLs only in a hidden manifest. +11. Validate before final response: standalone HTML, keyboard navigation, notes, guide/print mode, media references, visual coverage, and no obvious layout overlap. + +## Visual Direction + +Default to the **Hardcopy** design system in its deck tempo: paper-field slides, serif display headlines (one idea per slide), mono-uppercase metadata, a compressed plate footer with slide number and progress, one international-orange accent for the active state, and carbon (dark) surfaces for presenter chrome and speaker notes so chrome never competes with the slide. Load [references/hardcopy.md](references/hardcopy.md) for tokens, devices, and print rules before styling. + +When the project supplies `AGENTS.md`, `PRODUCT.md`, `DESIGN.md`, brand tokens, or an existing design system, treat them as authoritative over Hardcopy. + +## Image Generation Workflow + +Use an image-generation skill or tool (if one is available in the environment) when the deck or guide benefits from AI-created bitmap visuals: + +1. Generate images as reusable slide/guide assets. Prefer 16:9 landscape for slides and 4:3 or wide worksheet panels for guides. +2. For project-bound assets, move or copy selected outputs into the workspace and reference them locally; never reference only a tool's temporary output path. +3. Avoid embedded text in generated images unless exact text is essential. Put labels and explanations in HTML captions. +4. Store the final prompt near the consuming markup: `data-imagegen-prompt="..."`, an adjacent source manifest, or a `visuals.md`/`visuals.json` file when there are many assets. +5. Inspect generated images before using them. Check subject, style, readability, text artifacts, composition, and whether the asset supports the point. +6. If image generation is rate-limited or unavailable, do not draw SVG illustration substitutes for what should be a bitmap. Surface the blocker clearly, keep a non-illustrated pending visual slot with `data-imagegen-prompt`, and report that generated bitmap creation remains pending. (Deterministic system diagrams as inline SVG are always fine — this rule is about illustrations.) + +Recommended prompt scaffold: + +```text +Use case: scientific-educational +Asset type: slide and PDF companion illustration +Primary request: +Visual style: flat technical-plate illustration, warm paper field, dark ink linework, +hairline structure, a single international-orange accent, schema-like boxes and arrows. +Composition: +Text policy: no readable embedded text; captions and labels will be in HTML. +Avoid: photorealism, glossy gradients, stock-photo look, logos, decorative blobs, tiny illegible labels. +Aspect: 16:9 landscape unless guide-specific. +``` + +## Screenshot Workflow + +Use browser automation/screenshot tooling (whatever the environment provides) when the visual would be stronger with real evidence: + +- Capture screenshots of documents, dashboards, tools, traces, eval reports, product screens, source docs, or demo states when they help explain or prove the talk. +- Redact sensitive data before publishing. Do not capture credentials, private user data, inbox content, tokens, secrets, or tenant data unless explicitly approved. +- Save screenshots into the project and reference them locally. Include source URL/file, capture date, and any redaction note. +- If screenshot tooling is unavailable, use deterministic diagrams or mark screenshot capture as pending rather than inventing evidence. + +## HTML Deck Shape + +Use one HTML file with deck and guide modes. The full contract — required DOM, keyboard handler, notes panel, guide mode, print rules, and what the validator checks — is in [references/deck-template.md](references/deck-template.md). Skeleton: + +```html + + + + + + Exact Talk Title + + + +
    + +
    +
    +
    + + +
    +
    + +
    + +
    + + + +``` + +## Validate the Deck + +Before the final response, run the bundled validator with the deck profile: + +```bash +npx -y @zakelfassi/htmlify htmlify-answer --validate path/to/deck.html --profile deck +``` + +From a repo or plugin checkout, use `node /bin/htmlify-answer.js` (in Claude Code plugin context: `node "${CLAUDE_PLUGIN_ROOT}/bin/htmlify-answer.js"`). The deck profile checks standalone structure, slide sections, keyboard navigation, speaker notes on substantive slides, script safety, external-asset bans, and size. Fix every reported error before responding; report remaining warnings. If the validator cannot run in the environment, perform the checklist below manually and say so. + +## Recommended Sections + +For most talk artifacts: + +1. Opening title: audience, promise, duration, core thesis. +2. Cold open or problem story. +3. Mental model or map. +4. Main teaching acts, each with 2-4 slides. +5. Tradeoff matrices or decision tables. +6. Live demo or prepared demo section. +7. Failure modes and how to detect them. +8. Run-of-show with timestamps. +9. Closing checklist. +10. Downloadable guide: summary, modules, exercises, checklist, sources. +11. Source shelf and verification notes. + +## Slide Rules + +- One idea per slide. +- Use big claims sparingly and support them with diagrams, tables, proof, or demos. +- Keep slide text presentable at 1080p and readable in a YouTube player. +- Put extra explanation in speaker notes or guide mode. +- Every slide should have a reason to exist in the spoken arc. +- Every visual should either explain a system, compare options, show proof, or create a memory hook. + +## Guide/PDF Rules + +- The guide is not a transcript. It should be useful after the video. +- Include summaries, exercises, checklists, references, and implementation heuristics. +- Print CSS must hide deck controls and print the guide. +- Links should be visible and useful. For offline handouts, include source titles and dates when relevant. + +## Validation Checklist + +Run lightweight validation before final response (the `--profile deck` validator covers the structural items automatically): + +- HTML parser accepts the file. +- Embedded JavaScript syntax checks. +- Exactly one `` and one normal `` tag. +- Slide count is intentional. +- Speaker notes exist for each substantive slide. +- Guide/PDF mode exists when requested. +- Print CSS exists for guide/download artifacts. +- Visual coverage is counted and is roughly 40-60% when requested. +- Every local image/screenshot reference exists. +- Every image that references a link, post, doc, paper, or screenshot source has a visible source link in its caption/ref. +- No external fonts, CDNs, analytics, or remote assets unless approved. +- Screenshot capture was used when useful and available, or its absence is reported. +- Image generation was used for bitmap visuals when available, or pending slots are marked with prompts without SVG illustration substitutes. + +## Final Response + +Report: + +- HTML file path. +- Artifact mode. +- Slide count, visual coverage, and guide/PDF status. +- Generated-image outputs or pending prompt slots. +- Screenshot sources used, or why they were not used. +- Validation performed (including the validator command and result) and any remaining gaps. diff --git a/skills/deckify/references/deck-template.md b/skills/deckify/references/deck-template.md new file mode 100644 index 0000000..c1f3da6 --- /dev/null +++ b/skills/deckify/references/deck-template.md @@ -0,0 +1,101 @@ +# Deck Template Contract + +The canonical DOM shape for deckify output, and the contract the `--profile deck` validator checks. A deck that follows this template passes validation; a deck that deviates structurally should have a reason. + +## Required structure + +```html + + + + + + Exact Talk Title + + + +
    + Talk title + 1 / 18 + + + +
    + +
    +
    +

    One idea

    + +
    PLATE 01 / 18 · TALK-DECK · deck title
    + +
    +
    + … + +
    + +
    + + + + + + + + +``` + +## Contract items (validator-enforced) + +| Item | Requirement | Validator check | +| --- | --- | --- | +| Standalone | one ``, one ``, ``, non-empty ``, viewport meta | error if missing | +| Slides | at least 2 `<section class="slide …">`, each with `data-title` (recommended) | error if < 2 slides | +| Keyboard nav | an inline `<script>` registering a `keydown` listener (arrow keys advance/rewind; Home/End jump) | error if absent | +| Speaker notes | every substantive slide (≥ 200 chars of text or containing `h2/h3/table/figure`) contains `<aside class="notes">` | error, reported per slide | +| Script safety | inline `<script>` allowed; `<script src=…>`, inline `on*=` handler attributes, and `javascript:` URLs are banned | error | +| Self-contained | no external `src`/`srcset`/`poster`/CSS `url()`/`@import`/`<link rel>` to remote origins; no external fonts | error | +| Local assets | relative `src` references should exist on disk next to the file | warning | +| Print | `@media print` rules present (hide controls, print the guide) | warning if absent | +| Size | ≤ 2 MiB hard limit; > 512 KiB warns | error / warning | + +## Keyboard navigation reference + +The minimal handler the validator expects to find (shape, not exact code): + +```js +const slides = Array.from(document.querySelectorAll('.slide')); +let current = Math.max(0, slides.findIndex((slide) => slide.classList.contains('active'))); +function show(index) { + current = Math.min(slides.length - 1, Math.max(0, index)); + slides.forEach((slide, i) => slide.classList.toggle('active', i === current)); + const progress = document.getElementById('progress'); + if (progress) progress.textContent = `${current + 1} / ${slides.length}`; +} +document.addEventListener('keydown', (event) => { + if (event.key === 'ArrowRight' || event.key === 'PageDown' || event.key === ' ') show(current + 1); + if (event.key === 'ArrowLeft' || event.key === 'PageUp') show(current - 1); + if (event.key === 'Home') show(0); + if (event.key === 'End') show(slides.length - 1); +}); +show(current); +``` + +Also support click/tap targets for next/previous on touch devices, and an `n`/`g` key or visible button for the notes and guide toggles when those panels exist. + +## Notes and guide behavior + +- `.notes` stays visually hidden in presentation mode (not `display:none` in print if the printed handout should include notes — decide per deck). +- The notes panel mirrors the active slide's `.notes` content; presenter chrome uses carbon (dark) surfaces per Hardcopy. +- Guide mode hides the deck shell and shows `article.guide` as a flowing document; `window.print()` from guide mode produces the PDF handout. +- Print CSS hides `.topbar`, navigation buttons, and the notes panel; shows the guide; `@page { margin: 14mm }`. + +## Run-of-show + +Include a run-of-show slide or guide section as a table: timestamp, chapter label, beat, demo/fallback. For YouTube, chapter labels should be copy-pastable into a description (`00:00 Opening`, `02:15 The problem`, …). diff --git a/skills/deckify/references/hardcopy.md b/skills/deckify/references/hardcopy.md new file mode 100644 index 0000000..cd775ce --- /dev/null +++ b/skills/deckify/references/hardcopy.md @@ -0,0 +1,113 @@ +# Hardcopy — the htmlify visual identity + +**Hardcopy** is the default design system for every artifact produced by the htmlify and deckify skills, for the project's own pages, and for the bundled local renderer. The premise: agent output is ephemeral terminal text; an artifact is *stdout, made permanent*. So the visual language is that of printed technical matter — engineering plates, datasheets, drawing title blocks — executed with browser-native precision. Not CRT kitsch. Not SaaS gradients. A document, not an app. + +Treat this file as the single source of truth. When a project supplies its own `DESIGN.md`, brand tokens, or design system, those win; otherwise, Hardcopy applies. + +## Tokens + +Copy this block into every artifact: + +```css +:root { + color-scheme: light dark; + /* Paper (light) */ + --paper: #faf7f0; + --surface: #ffffff; + --ink: #1c1a15; + --ink-2: #6e6759; + --rule: #d9d2c3; + --rule-strong: #1c1a15; + --signal: #e84b0f; + --signal-wash: #fbeae1; + --ok: #2c7a52; + --warn: #a87514; + --risk: #b5341b; + --code-bg: #211e18; + --code-ink: #e8e2d4; + --font-display: "Charter", "Bitstream Charter", "Sitka Text", Cambria, Georgia, serif; + --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +@media (prefers-color-scheme: dark) { + :root { + /* Carbon (dark) */ + --paper: #161410; + --surface: #1e1b16; + --ink: #ede8dc; + --ink-2: #a39a88; + --rule: #3a352b; + --rule-strong: #ede8dc; + --signal: #ff6b2c; + --signal-wash: #3a2114; + --ok: #5bbe8c; + --warn: #d9a93e; + --risk: #e2603f; + --code-bg: #0e0d0a; + --code-ink: #d8d2c4; + } +} +``` + +Rules of use: + +- `--paper` is the page; `--surface` only for panels that must separate from it (sparingly). +- `--signal` (international orange) is the **only** accent. Use it in at most three places per viewport: typically the plate's mode cell, the active nav state, and one emphasis rule. Color restraint is the brand. +- `--ok` / `--warn` / `--risk` are for status semantics only, never decoration. +- Code always sits in a **carbon well**: `--code-bg` background, `--code-ink` text, in both light and dark modes. The terminal lives *inside* the document. + +## Type + +Three voices, zero downloaded fonts (artifacts must stay self-contained): + +| Voice | Stack | Use | Treatment | +| --- | --- | --- | --- | +| Display | `--font-display` (Charter/Georgia serif) | h1–h3, slide headlines, pull numbers | tight: `line-height 1.05–1.15`, `letter-spacing -0.015em`; h1 `clamp(34px, 5vw, 56px)` | +| Body | `--font-body` (system grotesque) | paragraphs, cells, UI | 15–16px / 1.6 | +| Metadata | `--font-mono` | eyebrows, labels, timestamps, paths, plate cells, stamps, slide numbers | uppercase, 11–12px, `letter-spacing 0.08em`, color `--ink-2` | + +The metadata voice is the connective tissue to the terminal: *metadata speaks terminal; content speaks document*. Anything that is "about" the artifact (mode, date, source, counts) is mono uppercase. Anything that *is* the artifact is serif/grotesque. + +## The seven devices + +1. **The Plate.** The signature device: a title block, as on an engineering drawing. A grid framed by a `2px solid var(--rule-strong)` border with `1px solid var(--rule)` internal hairlines, cells of mono-uppercase label + value: MODE · REPO/SOURCE · DATE · GENERATOR · SOURCE HASH · COUNTS. Documents open with it; decks carry a compressed plate as the slide footer (`PLATE 04 / 18 · TALK-DECK · HTMLIFY`); pages use it as header and footer. One cell — usually MODE — gets `background: var(--signal); color: #fff` (use `#1c1a15` text on dark-mode signal if contrast demands). +2. **Hairlines over shadows.** No `box-shadow`. Structure comes from `1px var(--rule)` hairlines, `2px var(--rule-strong)` section-opening rules, and whitespace. `border-radius: 2px` maximum (stamps, code wells); everything else square. Sharp corners read "document"; rounded corners read "app". +3. **Crop marks.** Printer's registration marks in the page corners: pure CSS (`position: fixed/absolute` `::before`/`::after` elements drawing 1px lines in `--rule`, ~14px long, inset ~10px). Quiet identity on screen; literal in print. +4. **The Stamp.** Status chips as rubber stamps: `1.5px solid currentColor`, mono uppercase 11px, `padding: 2px 8px`, `border-radius: 2px`, text in the status color (`--ok`/`--warn`/`--risk`/`--signal`), transparent or wash fill. `PASS` · `RISK` · `BLOCKED` · `NEEDS VERIFICATION` · `SHIPPED`. Never pill-shaped, never filled solid. +5. **The Index.** Outlines and navigation as a drawing index: numbered `1.0 / 2.0 / 2.1`, mono, hairline-separated rows, sticky in a side rail on desktop, collapsed above the content on mobile. Numbers in `--ink-2`, labels in `--ink`. +6. **Carbon wells.** Code blocks and terminal excerpts: `--code-bg` background, a mono uppercase meta strip (language/file) separated by a hairline in a lighter ink, `border-radius: 2px`, no outer border in light mode (the dark field is its own boundary). +7. **Figure discipline.** Diagrams are inline SVG in ink + hairline + one signal accent on paper; every figure gets a mono caption with a visible source link (`FIG 3 · SOURCE: <a>…</a>`). No decorative imagery. + +## Layout + +- Page gutter generous; content measure ~68–74ch for prose, full-width for tables/boards. +- Sections open with a `2px var(--rule-strong)` top rule + mono section number/label, then the serif heading. +- Grids of cards become grids of **cells**: shared hairline borders (border-collapse feel), not floating cards with gaps. +- Density is a feature for operator artifacts; whitespace is a feature for decks and essays. Same tokens, different tempo. + +## Decks (deckify tempo) + +- Slide = a plate: paper field, serif headline (one idea), supporting figure/table, compressed plate footer with slide number + deck title + progress. +- Active nav / current chapter marked with a `--signal` underline or cell, nothing else orange. +- Speaker-notes panel and presenter chrome are carbon (dark) surfaces, so presentation chrome never competes with the paper slide. +- 40–60% of slides should be visual-led (figure, table, demo, screenshot) for content decks. + +## Print + +```css +@media print { + :root { --paper: #ffffff; } + /* crop marks render literally; keep them */ + .no-print, nav, .controls { display: none; } + .plate { break-inside: avoid; } + @page { margin: 14mm; } +} +``` + +- Stamps keep their borders (grayscale-legible). +- Carbon wells gain a `1px var(--rule)` border and may lighten to white-on-dark only if the printer dithers badly — prefer keeping the dark field. +- The plate prints as the document header; the index prints as a table of contents. + +## Anti-patterns + +No drop shadows · no border-radius > 2px · no gradients · no purple · no glassmorphism · no emoji as iconography · no stock or decorative imagery · no filled status pills · no more than one accent color · no fake paper texture or noise filters (warmth comes from `--paper` and the serif, not effects). diff --git a/SKILL.md b/skills/htmlify/SKILL.md similarity index 76% rename from SKILL.md rename to skills/htmlify/SKILL.md index 437a6d2..5ddd525 100644 --- a/SKILL.md +++ b/skills/htmlify/SKILL.md @@ -2,8 +2,9 @@ name: htmlify description: Create self-contained HTML artifacts from agent or repo context, including operator briefs, build plans, implementation maps, PR/release packets, incident timelines, decision briefs, reports, explainers, diagrams, prototypes, and lightweight editors. Use when the user asks to turn dense text, code evidence, plans, reviews, or status into browser-ready HTML instead of a markdown wall. compatibility: Works in agentskills.io-compatible clients. Optional Pi/OMP extension runtime is in index.js and requires Node 20+. +license: Apache-2.0 metadata: - version: "0.3.1" + version: "0.3.1" # x-release-please-version source: "https://github.com/zakelfassi/htmlify" --- @@ -30,11 +31,27 @@ Use HTML when the answer needs shape, scanning, comparison, annotation, print/PD 5. Make it operator useful. The first viewport should reveal the subject, current status, and where attention should go. 6. Keep copy tight. Labels, numbers, and short evidence-backed statements beat generic prose. 7. Include print CSS for artifacts intended to share, archive, or export as PDF. -8. Add keyboard navigation for deck-style artifacts. +8. Add keyboard navigation for deck-style artifacts. For full presentation decks with speaker notes and run-of-show, use the companion `deckify` skill instead. 9. Make mobile acceptable, but optimize dense operational artifacts for desktop review. 10. When an image, screenshot, figure, or thumbnail references a link, article, documentation page, post, paper, dashboard, or source page, attach the source in the visible caption/ref near the image. Prefer a caption anchor such as `Source: <a href="...">Exact source title</a>` or weave the linked title into the caption. Do not leave source URLs only in hidden metadata. 11. Validate before final response: doctype, standalone `<html>`/`<body>`, no missing local assets, expected sections, source-linked captions for referenced images, and no obvious layout overlap. +## Visual Direction + +Default to the **Hardcopy** design system — the document language of engineering plates and datasheets: warm paper field, ink hairlines, serif display headings, mono-uppercase metadata, one international-orange accent, carbon code wells, stamps for status, crop marks, and a plate-style title block. Load [references/hardcopy.md](references/hardcopy.md) for the token block, the seven devices, and print rules before styling any artifact. + +When the project supplies `DESIGN.md`, brand tokens, or an established design system, those are authoritative over Hardcopy. + +## Validate the Artifact + +Before the final response, run the bundled validator on the written file: + +```bash +npx -y @zakelfassi/htmlify htmlify-answer --validate path/to/artifact.html --profile rich +``` + +Use `--profile app` for artifacts that legitimately carry inline interactivity (editors, prototypes, boards). From a repo or plugin checkout, use `node <checkout>/bin/htmlify-answer.js` (in Claude Code plugin context: `node "${CLAUDE_PLUGIN_ROOT}/bin/htmlify-answer.js"`). Fix every reported error before responding; report any remaining warnings in the final response. If the validator cannot run in the environment, perform rule 11 manually and say so. + ## HTML Shape Use semantic structure: @@ -98,6 +115,6 @@ Use these modules when useful: ## Final Response -Report the HTML file path, artifact mode, evidence sources checked, and validation performed. State any verification not run. +Report the HTML file path, artifact mode, evidence sources checked, and validation performed (including the validator command and result). State any verification not run. -If using the bundled Pi/OMP runtime, see [README.md](README.md) for `/htmlify` commands and install paths. +If using the bundled Pi/OMP runtime, see the repository README for `/htmlify` commands and install paths. diff --git a/skills/htmlify/references/agent-integrations.md b/skills/htmlify/references/agent-integrations.md new file mode 100644 index 0000000..a69ac18 --- /dev/null +++ b/skills/htmlify/references/agent-integrations.md @@ -0,0 +1,135 @@ +# Agent Integrations + +The htmlify repository ships two skills — `skills/htmlify` (documents) and `skills/deckify` (presentation decks) — plus three integration styles: + +- Invokable skill: the user asks for `$htmlify` / `$deckify` or names the skill when they want a browser-ready artifact. +- Automatic hook: the agent's lifecycle hook detects a long final answer and writes an HTML export. +- CLI: pipe text through `htmlify-answer`, or validate any artifact with `htmlify-answer --validate`. + +Prefer the invokable skills for production use. Hooks are useful when the user repeatedly wants long final answers archived as HTML, but hooks are agent-specific and should be installed deliberately. + +> Each skill folder is self-contained (SKILL.md + references/). Install the folders, not the repo root — the repo root is the runtime, docs, and gallery. + +## Claude Code (plugin — recommended) + +```text +/plugin marketplace add zakelfassi/htmlify +/plugin install htmlify@htmlify +``` + +The plugin ships both skills. The optional Stop hook is NOT auto-enabled; to archive long answers automatically, add to `~/.claude/settings.json` (all projects), `.claude/settings.json` (committed project hook), or `.claude/settings.local.json` (project-local): + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"$HOME/path/to/htmlify/hooks/claude-code-stop-htmlify.js\"", + "timeout": 30 + } + ] + } + ] + } +} +``` + +The hook reads Claude Code's `Stop` event JSON from stdin, checks `last_assistant_message`, and writes an HTML artifact only when the answer length is at least `HTMLIFY_MIN_CHARS` characters. Defaults: + +```bash +export HTMLIFY_MIN_CHARS=2500 +export HTMLIFY_EXPORT_ROOT="$HOME/htmlify-exports" +``` + +### Claude Code (bare skills, no plugin) + +```bash +git clone https://github.com/zakelfassi/htmlify.git ~/.htmlify +mkdir -p ~/.claude/skills +cp -R ~/.htmlify/skills/htmlify ~/.claude/skills/htmlify +cp -R ~/.htmlify/skills/deckify ~/.claude/skills/deckify +``` + +## Codex + +```bash +git clone https://github.com/zakelfassi/htmlify.git ~/.htmlify +mkdir -p ~/.codex/skills +ln -sfn ~/.htmlify/skills/htmlify ~/.codex/skills/htmlify +ln -sfn ~/.htmlify/skills/deckify ~/.codex/skills/deckify +``` + +Invoke in a prompt: + +```text +$htmlify turn this implementation summary into an operator brief +$deckify turn this design doc into a talk deck with speaker notes +``` + +Project-level opt-in via `AGENTS.md`: + +```md +For long operator handoffs, build plans, PR/release packets, incident timelines, +or status reports, use `$htmlify` and write a self-contained HTML file instead +of returning a long markdown-only answer. For presentations, use `$deckify`. +``` + +Codex does not need a hook for the normal skill path. If you want automatic behavior, keep it as an instruction in `AGENTS.md` so the agent can choose HTML only when the answer actually benefits from it. + +## Cursor, Windsurf, Aider, And Other Agents + +Use the portable skill folders when the agent supports Agent Skills: + +```bash +git clone https://github.com/zakelfassi/htmlify.git ~/.agent-skills/htmlify +``` + +Then point the agent at `skills/htmlify/SKILL.md` / `skills/deckify/SKILL.md` or add this project rule: + +```md +When the user asks for a long report, review packet, implementation plan, +incident timeline, or decision brief, use the local htmlify skill at +~/.agent-skills/htmlify/skills/htmlify/SKILL.md and produce a self-contained +HTML artifact. For presentation decks, use skills/deckify/SKILL.md. +``` + +For agents without native skills, use the CLI as a local hook target: + +```bash +printf '%s' "$LONG_ANSWER_TEXT" | npx -y @zakelfassi/htmlify htmlify-answer --title "Agent Answer" +``` + +From a checked-out repo: + +```bash +printf '%s' "$LONG_ANSWER_TEXT" | node /path/to/htmlify/bin/htmlify-answer.js --title "Agent Answer" +``` + +## Pi / Oh-My-Pi + +```bash +pi install npm:@zakelfassi/htmlify +``` + +The runtime registers `/htmlify`, `/html-last`, `/html-comments`, and `/htmlify-version`. See the repository README for command details and render modes. + +## Validating artifacts from any agent + +Every artifact — skill-authored or hand-written — can be checked against the safety/structure profiles: + +```bash +npx -y @zakelfassi/htmlify htmlify-answer --validate artifact.html --profile auto +``` + +Profiles: `rich` (no scripts), `app` (inline scripts allowed; external scripts/handlers banned), `deck` (app plus the deckify slide contract), `auto` (detect per file). Exit codes: 0 valid, 1 errors, 2 usage/IO. + +## Hook Safety + +- Keep hooks local unless the whole team wants the behavior. +- Do not force every long answer into HTML; small terminal answers should stay in the terminal. +- Set `HTMLIFY_EXPORT_ROOT` to a predictable folder if artifacts should be archived. +- Use `HTMLIFY_MIN_CHARS` to tune threshold by team. Start with `2500`. +- Generated rich HTML is still validated before writing when it comes through the Pi/OMP runtime, and any artifact can be re-checked with `--validate`. diff --git a/skills/htmlify/references/hardcopy.md b/skills/htmlify/references/hardcopy.md new file mode 100644 index 0000000..cd775ce --- /dev/null +++ b/skills/htmlify/references/hardcopy.md @@ -0,0 +1,113 @@ +# Hardcopy — the htmlify visual identity + +**Hardcopy** is the default design system for every artifact produced by the htmlify and deckify skills, for the project's own pages, and for the bundled local renderer. The premise: agent output is ephemeral terminal text; an artifact is *stdout, made permanent*. So the visual language is that of printed technical matter — engineering plates, datasheets, drawing title blocks — executed with browser-native precision. Not CRT kitsch. Not SaaS gradients. A document, not an app. + +Treat this file as the single source of truth. When a project supplies its own `DESIGN.md`, brand tokens, or design system, those win; otherwise, Hardcopy applies. + +## Tokens + +Copy this block into every artifact: + +```css +:root { + color-scheme: light dark; + /* Paper (light) */ + --paper: #faf7f0; + --surface: #ffffff; + --ink: #1c1a15; + --ink-2: #6e6759; + --rule: #d9d2c3; + --rule-strong: #1c1a15; + --signal: #e84b0f; + --signal-wash: #fbeae1; + --ok: #2c7a52; + --warn: #a87514; + --risk: #b5341b; + --code-bg: #211e18; + --code-ink: #e8e2d4; + --font-display: "Charter", "Bitstream Charter", "Sitka Text", Cambria, Georgia, serif; + --font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; + --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; +} +@media (prefers-color-scheme: dark) { + :root { + /* Carbon (dark) */ + --paper: #161410; + --surface: #1e1b16; + --ink: #ede8dc; + --ink-2: #a39a88; + --rule: #3a352b; + --rule-strong: #ede8dc; + --signal: #ff6b2c; + --signal-wash: #3a2114; + --ok: #5bbe8c; + --warn: #d9a93e; + --risk: #e2603f; + --code-bg: #0e0d0a; + --code-ink: #d8d2c4; + } +} +``` + +Rules of use: + +- `--paper` is the page; `--surface` only for panels that must separate from it (sparingly). +- `--signal` (international orange) is the **only** accent. Use it in at most three places per viewport: typically the plate's mode cell, the active nav state, and one emphasis rule. Color restraint is the brand. +- `--ok` / `--warn` / `--risk` are for status semantics only, never decoration. +- Code always sits in a **carbon well**: `--code-bg` background, `--code-ink` text, in both light and dark modes. The terminal lives *inside* the document. + +## Type + +Three voices, zero downloaded fonts (artifacts must stay self-contained): + +| Voice | Stack | Use | Treatment | +| --- | --- | --- | --- | +| Display | `--font-display` (Charter/Georgia serif) | h1–h3, slide headlines, pull numbers | tight: `line-height 1.05–1.15`, `letter-spacing -0.015em`; h1 `clamp(34px, 5vw, 56px)` | +| Body | `--font-body` (system grotesque) | paragraphs, cells, UI | 15–16px / 1.6 | +| Metadata | `--font-mono` | eyebrows, labels, timestamps, paths, plate cells, stamps, slide numbers | uppercase, 11–12px, `letter-spacing 0.08em`, color `--ink-2` | + +The metadata voice is the connective tissue to the terminal: *metadata speaks terminal; content speaks document*. Anything that is "about" the artifact (mode, date, source, counts) is mono uppercase. Anything that *is* the artifact is serif/grotesque. + +## The seven devices + +1. **The Plate.** The signature device: a title block, as on an engineering drawing. A grid framed by a `2px solid var(--rule-strong)` border with `1px solid var(--rule)` internal hairlines, cells of mono-uppercase label + value: MODE · REPO/SOURCE · DATE · GENERATOR · SOURCE HASH · COUNTS. Documents open with it; decks carry a compressed plate as the slide footer (`PLATE 04 / 18 · TALK-DECK · HTMLIFY`); pages use it as header and footer. One cell — usually MODE — gets `background: var(--signal); color: #fff` (use `#1c1a15` text on dark-mode signal if contrast demands). +2. **Hairlines over shadows.** No `box-shadow`. Structure comes from `1px var(--rule)` hairlines, `2px var(--rule-strong)` section-opening rules, and whitespace. `border-radius: 2px` maximum (stamps, code wells); everything else square. Sharp corners read "document"; rounded corners read "app". +3. **Crop marks.** Printer's registration marks in the page corners: pure CSS (`position: fixed/absolute` `::before`/`::after` elements drawing 1px lines in `--rule`, ~14px long, inset ~10px). Quiet identity on screen; literal in print. +4. **The Stamp.** Status chips as rubber stamps: `1.5px solid currentColor`, mono uppercase 11px, `padding: 2px 8px`, `border-radius: 2px`, text in the status color (`--ok`/`--warn`/`--risk`/`--signal`), transparent or wash fill. `PASS` · `RISK` · `BLOCKED` · `NEEDS VERIFICATION` · `SHIPPED`. Never pill-shaped, never filled solid. +5. **The Index.** Outlines and navigation as a drawing index: numbered `1.0 / 2.0 / 2.1`, mono, hairline-separated rows, sticky in a side rail on desktop, collapsed above the content on mobile. Numbers in `--ink-2`, labels in `--ink`. +6. **Carbon wells.** Code blocks and terminal excerpts: `--code-bg` background, a mono uppercase meta strip (language/file) separated by a hairline in a lighter ink, `border-radius: 2px`, no outer border in light mode (the dark field is its own boundary). +7. **Figure discipline.** Diagrams are inline SVG in ink + hairline + one signal accent on paper; every figure gets a mono caption with a visible source link (`FIG 3 · SOURCE: <a>…</a>`). No decorative imagery. + +## Layout + +- Page gutter generous; content measure ~68–74ch for prose, full-width for tables/boards. +- Sections open with a `2px var(--rule-strong)` top rule + mono section number/label, then the serif heading. +- Grids of cards become grids of **cells**: shared hairline borders (border-collapse feel), not floating cards with gaps. +- Density is a feature for operator artifacts; whitespace is a feature for decks and essays. Same tokens, different tempo. + +## Decks (deckify tempo) + +- Slide = a plate: paper field, serif headline (one idea), supporting figure/table, compressed plate footer with slide number + deck title + progress. +- Active nav / current chapter marked with a `--signal` underline or cell, nothing else orange. +- Speaker-notes panel and presenter chrome are carbon (dark) surfaces, so presentation chrome never competes with the paper slide. +- 40–60% of slides should be visual-led (figure, table, demo, screenshot) for content decks. + +## Print + +```css +@media print { + :root { --paper: #ffffff; } + /* crop marks render literally; keep them */ + .no-print, nav, .controls { display: none; } + .plate { break-inside: avoid; } + @page { margin: 14mm; } +} +``` + +- Stamps keep their borders (grayscale-legible). +- Carbon wells gain a `1px var(--rule)` border and may lighten to white-on-dark only if the printer dithers badly — prefer keeping the dark field. +- The plate prints as the document header; the index prints as a table of contents. + +## Anti-patterns + +No drop shadows · no border-radius > 2px · no gradients · no purple · no glassmorphism · no emoji as iconography · no stock or decorative imagery · no filled status pills · no more than one accent color · no fake paper texture or noise filters (warmth comes from `--paper` and the serif, not effects). diff --git a/references/htmlify-principles.md b/skills/htmlify/references/htmlify-principles.md similarity index 100% rename from references/htmlify-principles.md rename to skills/htmlify/references/htmlify-principles.md diff --git a/test/extension.test.js b/test/extension.test.js index 10f6448..3c929ed 100644 --- a/test/extension.test.js +++ b/test/extension.test.js @@ -82,13 +82,14 @@ test('package metadata preserves npm Pi and OMP entry contracts', () => { assert.ok(packageJson.keywords.includes('pi-extension')); assert.ok(packageJson.keywords.includes('agentskills')); assert.deepEqual(packageJson.files, [ - 'SKILL.md', + 'index.js', + 'src/', 'bin/', 'hooks/', - 'index.js', - 'README.md', + 'skills/', + '.claude-plugin/', 'assets/', - 'references/', + 'README.md', ]); assert.ok(packageJson.scripts.test.includes('node --test')); From 7129d5337d9fcf74e03124844436b2b9d95f4bfb Mon Sep 17 00:00:00 2001 From: Zak El Fassi <zakelfassi@gmail.com> Date: Thu, 11 Jun 2026 14:39:39 -0700 Subject: [PATCH 07/14] feat: add hardcopy visual identity and reskin the document theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces Hardcopy — the project design system (engineering-plate language: warm paper, ink hairlines, serif display, mono metadata, one signal-orange accent, carbon code wells, stamps, crop marks) — specified in references/hardcopy.md inside both skills. The local document renderer and the trusted annotation layer are reskinned to it, including dark mode and print rules. Repo assets are replaced with a new logomark, hero, skill-family, how-it-works, and social-preview set. --- assets/flow.svg | 50 ------- assets/hero.svg | 152 ++++++++++---------- assets/how-it-works.svg | 47 ++++++ assets/logomark.svg | 24 ++++ assets/render-modes.svg | 32 ----- assets/skill-family.svg | 51 +++++++ assets/social-preview.png | Bin 0 -> 56597 bytes assets/social-preview.svg | 70 +++++++++ src/annotation.js | 24 ++-- src/document.js | 290 +++++++++++++++++++++++++------------- 10 files changed, 475 insertions(+), 265 deletions(-) delete mode 100644 assets/flow.svg create mode 100644 assets/how-it-works.svg create mode 100644 assets/logomark.svg delete mode 100644 assets/render-modes.svg create mode 100644 assets/skill-family.svg create mode 100644 assets/social-preview.png create mode 100644 assets/social-preview.svg diff --git a/assets/flow.svg b/assets/flow.svg deleted file mode 100644 index b166b25..0000000 --- a/assets/flow.svg +++ /dev/null @@ -1,50 +0,0 @@ -<svg width="1400" height="980" viewBox="0 0 1400 980" fill="none" xmlns="http://www.w3.org/2000/svg"> - <defs> - <linearGradient id="card" x1="0" y1="0" x2="1" y2="1"> - <stop stop-color="#FFFFFF"/> - <stop offset="1" stop-color="#F6F9FC"/> - </linearGradient> - <marker id="arrow" markerWidth="12" markerHeight="12" refX="8" refY="6" orient="auto"> - <path d="M0 0L12 6L0 12" fill="#6B7280"/> - </marker> - </defs> - - <rect width="1400" height="980" fill="#F7FAFC"/> - <text x="90" y="96" fill="#0F172A" font-family="Inter, Arial, sans-serif" font-size="42" font-weight="800">What users should expect</text> - <text x="90" y="138" fill="#64748B" font-family="Inter, Arial, sans-serif" font-size="24">Long answers stay visible. The extension only leaves a hint and waits for an explicit export command.</text> - - <g> - <rect x="90" y="200" width="1220" height="150" rx="28" fill="url(#card)" stroke="#D8DEEA"/> - <rect x="120" y="228" width="90" height="28" rx="14" fill="#DDF6EE"/> - <text x="143" y="247" fill="#1C7C72" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">STEP 1</text> - <text x="120" y="292" fill="#0F172A" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="800">Long answer finishes</text> - <text x="120" y="332" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="21">The assistant message stays on screen. The extension only captures enough state to export it later.</text> - </g> - - <path d="M700 350V404" stroke="#6B7280" stroke-width="4" marker-end="url(#arrow)"/> - - <g> - <rect x="90" y="430" width="1220" height="150" rx="28" fill="url(#card)" stroke="#D8DEEA"/> - <rect x="120" y="458" width="90" height="28" rx="14" fill="#DDF6EE"/> - <text x="143" y="477" fill="#1C7C72" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">STEP 2</text> - <text x="120" y="522" fill="#0F172A" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="800">The answer stays visible</text> - <text x="120" y="562" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="21">There is no modal prompt or replacement notice. The export stays available on demand.</text> - </g> - - <path d="M700 580V634" stroke="#6B7280" stroke-width="4" marker-end="url(#arrow)"/> - - <g> - <rect x="90" y="660" width="1220" height="150" rx="28" fill="url(#card)" stroke="#D8DEEA"/> - <rect x="120" y="688" width="90" height="28" rx="14" fill="#DDF6EE"/> - <text x="143" y="707" fill="#1C7C72" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">STEP 3</text> - <text x="120" y="752" fill="#0F172A" font-family="Inter, Arial, sans-serif" font-size="34" font-weight="800">Run /html-last</text> - <text x="120" y="792" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="21">Choose Gemini CLI, the current Pi model, or the quick local renderer.</text> - </g> - - <path d="M700 810V864" stroke="#6B7280" stroke-width="4" marker-end="url(#arrow)"/> - - <g> - <rect x="90" y="890" width="1220" height="90" rx="28" fill="#0F172A"/> - <text x="120" y="945" fill="#F8FAFC" font-family="Inter, Arial, sans-serif" font-size="30" font-weight="800">STEP 4 • The HTML file is written and opened in your browser automatically.</text> - </g> -</svg> diff --git a/assets/hero.svg b/assets/hero.svg index 4c044f0..0c83807 100644 --- a/assets/hero.svg +++ b/assets/hero.svg @@ -1,89 +1,85 @@ -<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg"> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 420" role="img" aria-label="A terminal answer becomes a designed, self-contained HTML document"> <defs> - <linearGradient id="bg" x1="0" y1="0" x2="1600" y2="900" gradientUnits="userSpaceOnUse"> - <stop stop-color="#0B1220"/> - <stop offset="1" stop-color="#153249"/> - </linearGradient> - <linearGradient id="mint" x1="0" y1="0" x2="1" y2="1"> - <stop stop-color="#2EE6B8"/> - <stop offset="1" stop-color="#7AE1FF"/> - </linearGradient> - <linearGradient id="warm" x1="0" y1="0" x2="1" y2="1"> - <stop stop-color="#FFD36B"/> - <stop offset="1" stop-color="#FF9F67"/> - </linearGradient> - <filter id="shadow" x="0" y="0" width="2000" height="1200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB"> - <feDropShadow dx="0" dy="28" stdDeviation="30" flood-color="#020617" flood-opacity="0.35"/> - </filter> + <style> + .mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; } + .serif { font-family: Georgia, 'Times New Roman', serif; } + .body { font-family: -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; } + </style> </defs> - - <rect width="1600" height="900" fill="url(#bg)"/> - <circle cx="1380" cy="120" r="260" fill="#2EE6B8" fill-opacity="0.12"/> - <circle cx="210" cy="760" r="260" fill="#7AE1FF" fill-opacity="0.08"/> - - <g opacity="0.95"> - <rect x="88" y="72" width="400" height="44" rx="22" fill="#102032" stroke="#28405A"/> - <text x="122" y="101" fill="#A5B4C8" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="600">htmlify exports for agents, Pi, and OMP</text> + <rect width="960" height="420" fill="#FAF7F0"/> + <!-- crop marks --> + <g stroke="#6E6759" stroke-width="1.5" opacity="0.6" fill="none"> + <path d="M12 26 V12 H26"/> + <path d="M934 12 H948 V26"/> + <path d="M948 394 V408 H934"/> + <path d="M26 408 H12 V394"/> </g> - <text x="88" y="200" fill="#F8FAFC" font-family="Inter, Arial, sans-serif" font-size="74" font-weight="800">Turn dense answers</text> - <text x="88" y="284" fill="#F8FAFC" font-family="Inter, Arial, sans-serif" font-size="74" font-weight="800">into useful HTML</text> - <text x="88" y="350" fill="#96A9C4" font-family="Inter, Arial, sans-serif" font-size="28">Capture long responses, choose Gemini or Pi for a designed pass,</text> - <text x="88" y="390" fill="#96A9C4" font-family="Inter, Arial, sans-serif" font-size="28">and open the result instantly in your browser.</text> - - <g filter="url(#shadow)"> - <rect x="88" y="464" width="650" height="328" rx="28" fill="#08111E" stroke="#233247"/> - <rect x="112" y="492" width="602" height="42" rx="14" fill="#0E1A2B"/> - <circle cx="142" cy="513" r="8" fill="#FF6B6B"/> - <circle cx="168" cy="513" r="8" fill="#FFD166"/> - <circle cx="194" cy="513" r="8" fill="#06D6A0"/> - <text x="224" y="520" fill="#97A7BE" font-family="ui-monospace, Menlo, monospace" font-size="18">terminal / html-last</text> - - <text x="126" y="584" fill="#2EE6B8" font-family="ui-monospace, Menlo, monospace" font-size="22">$</text> - <text x="154" y="584" fill="#F8FAFC" font-family="ui-monospace, Menlo, monospace" font-size="22">/html-last</text> - <text x="126" y="632" fill="#C9D4E3" font-family="ui-monospace, Menlo, monospace" font-size="19">Choose HTML render mode</text> - <text x="126" y="674" fill="#FFD36B" font-family="ui-monospace, Menlo, monospace" font-size="19">❯ Designed HTML with Gemini CLI</text> - <text x="126" y="712" fill="#95A5BC" font-family="ui-monospace, Menlo, monospace" font-size="19"> Designed HTML with current Pi model</text> - <text x="126" y="750" fill="#95A5BC" font-family="ui-monospace, Menlo, monospace" font-size="19"> Quick local HTML</text> + <!-- LEFT: carbon terminal well --> + <rect x="48" y="78" width="360" height="264" rx="2" fill="#211E18"/> + <rect x="48" y="78" width="360" height="30" rx="2" fill="#161410"/> + <text x="64" y="98" class="mono" font-size="11" letter-spacing="1.5" fill="#A39A88">AGENT · STDOUT</text> + <g class="mono" font-size="12.5" fill="#E8E2D4"> + <text x="64" y="136">$ summarize the release state</text> + <text x="64" y="162" fill="#A39A88">## Release readiness — v2.4</text> + <text x="64" y="184" fill="#A39A88">The deploy pipeline completed all</text> + <text x="64" y="202" fill="#A39A88">checks. Three PRs merged since the</text> + <text x="64" y="220" fill="#A39A88">last tag. Two risks remain open…</text> + <text x="64" y="242" fill="#A39A88">| check | state | owner |</text> + <text x="64" y="260" fill="#A39A88">|-------|-------|-------|</text> + <text x="64" y="278" fill="#A39A88">…another 240 lines of markdown…</text> + <text x="64" y="316" fill="#E8E2D4">$ <tspan fill="#FF6B2C">/htmlify</tspan><tspan fill="#E8E2D4"> ▮</tspan></text> </g> - <g filter="url(#shadow)"> - <rect x="858" y="130" width="654" height="662" rx="30" fill="#F8FAFC"/> - <rect x="902" y="172" width="168" height="30" rx="15" fill="#DDF6EE"/> - <text x="928" y="192" fill="#1C7C72" font-family="Inter, Arial, sans-serif" font-size="14" font-weight="800">HTMLIFY EXPORT</text> - - <text x="902" y="250" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="44" font-weight="800">Audit all local branches</text> - <text x="902" y="302" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="22">A designed HTML export with excerpt, outline, clickable links,</text> - <text x="902" y="334" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="22">and a visual shell people can actually skim.</text> - - <rect x="902" y="382" width="166" height="78" rx="18" fill="#FFFFFF" stroke="#D8DEEA"/> - <rect x="1084" y="382" width="166" height="78" rx="18" fill="#FFFFFF" stroke="#D8DEEA"/> - <rect x="1266" y="382" width="166" height="78" rx="18" fill="#FFFFFF" stroke="#D8DEEA"/> - <text x="924" y="414" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">EXPORTED</text> - <text x="924" y="442" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="700">Apr 20, 2026</text> - <text x="1106" y="414" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">WORDS</text> - <text x="1106" y="442" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="700">1,842</text> - <text x="1288" y="414" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="700">MODE</text> - <text x="1288" y="442" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="700">Gemini</text> - - <rect x="902" y="500" width="420" height="232" rx="22" fill="#FFFFFF" stroke="#D8DEEA"/> - <text x="934" y="544" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="800">What changed</text> - <text x="934" y="588" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="20">• Raw links are now clickable</text> - <text x="934" y="624" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="20">• No modal at long-answer edge</text> - <text x="934" y="660" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="20">• Designed HTML opens automatically</text> - - <rect x="1350" y="500" width="118" height="232" rx="22" fill="#FFFFFF" stroke="#D8DEEA"/> - <text x="1372" y="542" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">OUTLINE</text> - <text x="1372" y="582" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="17" font-weight="700">1. Audit</text> - <text x="1372" y="614" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="15">2. Root cause</text> - <text x="1372" y="646" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="15">3. Fixes</text> - <text x="1372" y="678" fill="#5D6B82" font-family="Inter, Arial, sans-serif" font-size="15">4. Verify</text> + <!-- ARROW --> + <g stroke="#E84B0F" stroke-width="3" fill="none"> + <path d="M428 210 H496"/> + <path d="M486 198 L500 210 L486 222"/> </g> + <!-- RIGHT: plate document --> + <rect x="520" y="56" width="392" height="308" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2.5"/> + <!-- plate title block --> + <line x1="520" y1="116" x2="912" y2="116" stroke="#1C1A15" stroke-width="2"/> + <rect x="522" y="58" width="92" height="56" fill="#E84B0F"/> + <text x="568" y="82" class="mono" font-size="10" letter-spacing="1.2" fill="#FFFFFF" text-anchor="middle">MODE</text> + <text x="568" y="100" class="mono" font-size="11" letter-spacing="0.8" fill="#FFFFFF" text-anchor="middle">RELEASE</text> + <line x1="614" y1="58" x2="614" y2="114" stroke="#1C1A15" stroke-width="1"/> + <text x="630" y="84" class="serif" font-size="22" fill="#1C1A15">Release readiness — v2.4</text> + <text x="630" y="104" class="mono" font-size="10" letter-spacing="1" fill="#6E6759">2026-06-11 · REPO@MAIN · ONE FILE</text> + <!-- scoreboard cells --> <g> - <rect x="88" y="822" width="328" height="40" rx="20" fill="url(#mint)" fill-opacity="0.16" stroke="#1F4F5A"/> - <text x="112" y="848" fill="#C8F6EA" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="700">Works with Oh My Pi and legacy Pi</text> - <rect x="438" y="822" width="258" height="40" rx="20" fill="url(#warm)" fill-opacity="0.16" stroke="#6E4C26"/> - <text x="464" y="848" fill="#FFE2AF" font-family="Inter, Arial, sans-serif" font-size="18" font-weight="700">Gemini + Pi rich renderers</text> + <rect x="544" y="138" width="108" height="56" fill="none" stroke="#D9D2C3" stroke-width="1"/> + <rect x="652" y="138" width="108" height="56" fill="none" stroke="#D9D2C3" stroke-width="1"/> + <rect x="760" y="138" width="108" height="56" fill="none" stroke="#D9D2C3" stroke-width="1"/> + <text x="556" y="156" class="mono" font-size="9.5" letter-spacing="1.2" fill="#6E6759">CHECKS</text> + <text x="556" y="182" class="serif" font-size="22" fill="#2C7A52">14 / 14</text> + <text x="664" y="156" class="mono" font-size="9.5" letter-spacing="1.2" fill="#6E6759">PRS MERGED</text> + <text x="664" y="182" class="serif" font-size="22" fill="#1C1A15">3</text> + <text x="772" y="156" class="mono" font-size="9.5" letter-spacing="1.2" fill="#6E6759">OPEN RISKS</text> + <text x="772" y="182" class="serif" font-size="22" fill="#B5341B">2</text> </g> + <!-- timeline + stamps --> + <g> + <line x1="544" y1="214" x2="868" y2="214" stroke="#1C1A15" stroke-width="2"/> + <text x="544" y="232" class="mono" font-size="9.5" letter-spacing="1.2" fill="#6E6759">2.0 · TIMELINE</text> + <g class="body" font-size="12" fill="#1C1A15"> + <circle cx="550" cy="252" r="3" fill="#E84B0F"/> + <text x="562" y="256">Deploy pipeline green on main</text> + <circle cx="550" cy="276" r="3" fill="#1C1A15"/> + <text x="562" y="280">Auth migration behind flag</text> + <circle cx="550" cy="300" r="3" fill="#1C1A15"/> + <text x="562" y="304">Rollback drill validated</text> + </g> + <rect x="756" y="240" width="112" height="26" rx="2" fill="none" stroke="#2C7A52" stroke-width="1.5"/> + <text x="812" y="257" class="mono" font-size="11" letter-spacing="1.5" fill="#2C7A52" text-anchor="middle">SHIP READY</text> + <rect x="726" y="276" width="142" height="26" rx="2" fill="none" stroke="#A87514" stroke-width="1.5"/> + <text x="797" y="293" class="mono" font-size="11" letter-spacing="1.2" fill="#A87514" text-anchor="middle">2 RISKS OPEN</text> + </g> + <!-- plate footer --> + <line x1="520" y1="336" x2="912" y2="336" stroke="#1C1A15" stroke-width="1"/> + <text x="536" y="354" class="mono" font-size="9.5" letter-spacing="1.2" fill="#6E6759">ONE FILE · INLINE CSS+JS · PRINTS TO PDF · OPENS ANYWHERE</text> + + <!-- caption --> + <text x="48" y="386" class="mono" font-size="11" letter-spacing="1.5" fill="#6E6759">STDOUT, MADE PERMANENT</text> </svg> diff --git a/assets/how-it-works.svg b/assets/how-it-works.svg new file mode 100644 index 0000000..10c0e80 --- /dev/null +++ b/assets/how-it-works.svg @@ -0,0 +1,47 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 240" role="img" aria-label="How htmlify works: gather evidence, author one self-contained HTML file, validate and open"> + <defs> + <style> + .mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; } + .serif { font-family: Georgia, 'Times New Roman', serif; } + .body { font-family: -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; } + </style> + </defs> + <rect width="960" height="240" fill="#FAF7F0"/> + <g stroke="#6E6759" stroke-width="1.5" opacity="0.6" fill="none"> + <path d="M12 26 V12 H26"/> + <path d="M934 12 H948 V26"/> + <path d="M948 214 V228 H934"/> + <path d="M26 228 H12 V214"/> + </g> + + <!-- step 1 --> + <rect x="56" y="56" width="248" height="128" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2"/> + <text x="76" y="84" class="mono" font-size="10" letter-spacing="1.5" fill="#E84B0F">1.0 · GATHER</text> + <text x="76" y="112" class="serif" font-size="19" fill="#1C1A15">Evidence first</text> + <text x="76" y="136" class="body" font-size="12.5" fill="#6E6759">Repo, git state, PRs, CI, logs,</text> + <text x="76" y="154" class="body" font-size="12.5" fill="#6E6759">docs, screenshots — then design.</text> + + <g stroke="#E84B0F" stroke-width="2.5" fill="none"> + <path d="M312 120 H344"/> + <path d="M336 110 L348 120 L336 130"/> + </g> + + <!-- step 2 --> + <rect x="356" y="56" width="248" height="128" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2"/> + <text x="376" y="84" class="mono" font-size="10" letter-spacing="1.5" fill="#E84B0F">2.0 · AUTHOR</text> + <text x="376" y="112" class="serif" font-size="19" fill="#1C1A15">One HTML file</text> + <text x="376" y="136" class="body" font-size="12.5" fill="#6E6759">Inline CSS+JS, Hardcopy design,</text> + <text x="376" y="154" class="body" font-size="12.5" fill="#6E6759">document or deck. No build step.</text> + + <g stroke="#E84B0F" stroke-width="2.5" fill="none"> + <path d="M612 120 H644"/> + <path d="M636 110 L648 120 L636 130"/> + </g> + + <!-- step 3 --> + <rect x="656" y="56" width="248" height="128" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2"/> + <text x="676" y="84" class="mono" font-size="10" letter-spacing="1.5" fill="#E84B0F">3.0 · VALIDATE</text> + <text x="676" y="112" class="serif" font-size="19" fill="#1C1A15">Prove it, open it</text> + <text x="676" y="136" class="body" font-size="12.5" fill="#6E6759">htmlify-answer --validate, then</text> + <text x="676" y="154" class="body" font-size="12.5" fill="#6E6759">review, annotate, print to PDF.</text> +</svg> diff --git a/assets/logomark.svg b/assets/logomark.svg new file mode 100644 index 0000000..82d3466 --- /dev/null +++ b/assets/logomark.svg @@ -0,0 +1,24 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="htmlify logomark: a plate title block with crop marks and one signal cell"> + <!-- crop marks --> + <g stroke="#6E6759" stroke-width="2" opacity="0.55"> + <path d="M2 10 V2 H10" fill="none"/> + <path d="M54 2 H62 V10" fill="none"/> + <path d="M62 54 V62 H54" fill="none"/> + <path d="M10 62 H2 V54" fill="none"/> + </g> + <!-- plate --> + <rect x="12" y="12" width="40" height="40" fill="#FAF7F0" stroke="#1C1A15" stroke-width="3"/> + <!-- internal hairlines --> + <line x1="12" y1="27" x2="52" y2="27" stroke="#1C1A15" stroke-width="1.5"/> + <line x1="32" y1="27" x2="32" y2="52" stroke="#1C1A15" stroke-width="1.5"/> + <!-- signal cell --> + <rect x="13.5" y="13.5" width="13" height="12" fill="#E84B0F"/> + <!-- text rows suggested as ink bars --> + <rect x="31" y="17" width="17" height="2.5" fill="#1C1A15"/> + <rect x="31" y="21.5" width="11" height="2.5" fill="#6E6759"/> + <rect x="17" y="33" width="11" height="2.5" fill="#6E6759"/> + <rect x="17" y="38" width="9" height="2.5" fill="#6E6759"/> + <rect x="37" y="33" width="11" height="2.5" fill="#1C1A15"/> + <rect x="37" y="38" width="7" height="2.5" fill="#6E6759"/> + <rect x="17" y="44" width="28" height="2.5" fill="#1C1A15"/> +</svg> diff --git a/assets/render-modes.svg b/assets/render-modes.svg deleted file mode 100644 index 58cd3c6..0000000 --- a/assets/render-modes.svg +++ /dev/null @@ -1,32 +0,0 @@ -<svg width="1400" height="980" viewBox="0 0 1400 980" fill="none" xmlns="http://www.w3.org/2000/svg"> - <rect width="1400" height="980" fill="#0B1220"/> - <text x="90" y="96" fill="#F8FAFC" font-family="Inter, Arial, sans-serif" font-size="42" font-weight="800">Three render modes</text> - <text x="90" y="138" fill="#93A8C6" font-family="Inter, Arial, sans-serif" font-size="24">Choose speed, continuity, or polish depending on the answer and the moment.</text> - - <g> - <rect x="90" y="200" width="1220" height="195" rx="28" fill="#F8FAFC"/> - <rect x="124" y="230" width="128" height="28" rx="14" fill="#DDF6EE"/> - <text x="148" y="249" fill="#1C7C72" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">QUICK LOCAL</text> - <text x="124" y="304" fill="#13253C" font-family="Inter, Arial, sans-serif" font-size="38" font-weight="800">Fastest path</text> - <text x="124" y="352" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="22">Designed shell + faithful local renderer.</text> - <text x="124" y="386" fill="#475569" font-family="Inter, Arial, sans-serif" font-size="22">Best when you want an export immediately and a browser window opens right away.</text> - </g> - - <g> - <rect x="90" y="430" width="1220" height="195" rx="28" fill="#112132" stroke="#29415E"/> - <rect x="124" y="460" width="170" height="28" rx="14" fill="#20384E"/> - <text x="148" y="479" fill="#A7F3D0" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">CURRENT PI MODEL</text> - <text x="124" y="534" fill="#FFFFFF" font-family="Inter, Arial, sans-serif" font-size="38" font-weight="800">Best in-session</text> - <text x="124" y="582" fill="#CBD5E1" font-family="Inter, Arial, sans-serif" font-size="22">Uses the current Pi model for a richer second-pass HTML render.</text> - <text x="124" y="616" fill="#CBD5E1" font-family="Inter, Arial, sans-serif" font-size="22">Good when you want continuity inside the same session and model context.</text> - </g> - - <g> - <rect x="90" y="660" width="1220" height="195" rx="28" fill="#1B1A14" stroke="#4A3820"/> - <rect x="124" y="690" width="146" height="28" rx="14" fill="#3A2B12"/> - <text x="148" y="709" fill="#FDE68A" font-family="Inter, Arial, sans-serif" font-size="13" font-weight="800">GEMINI CLI</text> - <text x="124" y="764" fill="#FFF6DB" font-family="Inter, Arial, sans-serif" font-size="38" font-weight="800">Most polished</text> - <text x="124" y="812" fill="#E5D5B8" font-family="Inter, Arial, sans-serif" font-size="22">Uses Gemini CLI for a designed export outside the current Pi model path.</text> - <text x="124" y="846" fill="#E5D5B8" font-family="Inter, Arial, sans-serif" font-size="22">If Gemini does not return valid HTML, the extension falls back to the local renderer.</text> - </g> -</svg> diff --git a/assets/skill-family.svg b/assets/skill-family.svg new file mode 100644 index 0000000..e98ea40 --- /dev/null +++ b/assets/skill-family.svg @@ -0,0 +1,51 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 360" role="img" aria-label="The htmlify skill family: htmlify for documents, deckify for presentation decks, one shared validated core"> + <defs> + <style> + .mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; } + .serif { font-family: Georgia, 'Times New Roman', serif; } + .body { font-family: -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; } + </style> + </defs> + <rect width="960" height="360" fill="#FAF7F0"/> + <g stroke="#6E6759" stroke-width="1.5" opacity="0.6" fill="none"> + <path d="M12 26 V12 H26"/> + <path d="M934 12 H948 V26"/> + <path d="M948 334 V348 H934"/> + <path d="M26 348 H12 V334"/> + </g> + + <!-- htmlify panel --> + <rect x="56" y="48" width="400" height="200" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2.5"/> + <line x1="56" y1="92" x2="456" y2="92" stroke="#1C1A15" stroke-width="2"/> + <rect x="58" y="50" width="74" height="40" fill="#E84B0F"/> + <text x="95" y="75" class="mono" font-size="11" letter-spacing="1" fill="#FFFFFF" text-anchor="middle">SKILL</text> + <text x="148" y="77" class="serif" font-size="24" fill="#1C1A15">htmlify</text> + <g class="body" font-size="13" fill="#1C1A15"> + <text x="76" y="122">Operator briefs · build plans · PR review packets</text> + <text x="76" y="146">Incident timelines · decision briefs · status reports</text> + <text x="76" y="170">Implementation maps · explainers · prototypes · editors</text> + </g> + <text x="76" y="216" class="mono" font-size="10" letter-spacing="1.2" fill="#6E6759">10 DOCUMENT MODES · PRINT-READY · ANNOTATABLE</text> + + <!-- deckify panel --> + <rect x="504" y="48" width="400" height="200" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2.5"/> + <line x1="504" y1="92" x2="904" y2="92" stroke="#1C1A15" stroke-width="2"/> + <rect x="506" y="50" width="74" height="40" fill="#1C1A15"/> + <text x="543" y="75" class="mono" font-size="11" letter-spacing="1" fill="#FAF7F0" text-anchor="middle">SKILL</text> + <text x="596" y="77" class="serif" font-size="24" fill="#1C1A15">deckify</text> + <g class="body" font-size="13" fill="#1C1A15"> + <text x="524" y="122">Talk decks · workshop decks · essay decks</text> + <text x="524" y="146">Demo decks · launch decks · teaching guides</text> + <text x="524" y="170">Speaker notes · run-of-show · downloadable guide/PDF</text> + </g> + <text x="524" y="216" class="mono" font-size="10" letter-spacing="1.2" fill="#6E6759">6 DECK MODES · KEYBOARD NAV · 40–60% VISUAL</text> + + <!-- shared core --> + <g stroke="#1C1A15" stroke-width="1.5" fill="none"> + <path d="M256 248 V276"/> + <path d="M704 248 V276"/> + </g> + <rect x="146" y="276" width="668" height="48" rx="2" fill="#211E18"/> + <text x="480" y="296" class="mono" font-size="11" letter-spacing="1.5" fill="#E8E2D4" text-anchor="middle">SHARED CORE · htmlify-answer --validate · RICH / APP / DECK PROFILES</text> + <text x="480" y="313" class="mono" font-size="10" letter-spacing="1.2" fill="#A39A88" text-anchor="middle">ZERO DEPENDENCIES · ZERO BUILD · ONE SELF-CONTAINED FILE</text> +</svg> diff --git a/assets/social-preview.png b/assets/social-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..a99be46094ef804fe435691e94733f0362ba6fd3 GIT binary patch literal 56597 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 literal 0 HcmV?d00001 diff --git a/assets/social-preview.svg b/assets/social-preview.svg new file mode 100644 index 0000000..cc222e4 --- /dev/null +++ b/assets/social-preview.svg @@ -0,0 +1,70 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 640" role="img" aria-label="htmlify — stdout, made permanent"> + <defs> + <style> + .mono { font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; } + .serif { font-family: Georgia, 'Times New Roman', serif; } + .body { font-family: -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; } + </style> + </defs> + <rect width="1280" height="640" fill="#FAF7F0"/> + <!-- crop marks --> + <g stroke="#6E6759" stroke-width="2" opacity="0.6" fill="none"> + <path d="M24 48 V24 H48"/> + <path d="M1232 24 H1256 V48"/> + <path d="M1256 592 V616 H1232"/> + <path d="M48 616 H24 V592"/> + </g> + + <!-- name + tagline --> + <text x="96" y="208" class="serif" font-size="118" letter-spacing="-2" fill="#1C1A15">htmlify</text> + <rect x="100" y="238" width="240" height="6" fill="#E84B0F"/> + <text x="96" y="298" class="body" font-size="30" fill="#1C1A15">Agent answers become self-contained</text> + <text x="96" y="338" class="body" font-size="30" fill="#1C1A15">HTML documents and presentation decks.</text> + <text x="96" y="392" class="mono" font-size="17" letter-spacing="3" fill="#6E6759">STDOUT, MADE PERMANENT</text> + + <!-- plate block bottom-left --> + <g> + <rect x="96" y="448" width="600" height="96" fill="#FFFFFF" stroke="#1C1A15" stroke-width="3"/> + <line x1="96" y1="496" x2="696" y2="496" stroke="#1C1A15" stroke-width="1.5"/> + <line x1="296" y1="448" x2="296" y2="544" stroke="#1C1A15" stroke-width="1.5"/> + <line x1="496" y1="448" x2="496" y2="544" stroke="#1C1A15" stroke-width="1.5"/> + <rect x="99" y="451" width="194" height="44" fill="#E84B0F"/> + <text x="196" y="479" class="mono" font-size="15" letter-spacing="2" fill="#FFFFFF" text-anchor="middle">SKILL FAMILY</text> + <text x="396" y="479" class="mono" font-size="15" letter-spacing="2" fill="#1C1A15" text-anchor="middle">htmlify</text> + <text x="596" y="479" class="mono" font-size="15" letter-spacing="2" fill="#1C1A15" text-anchor="middle">deckify</text> + <text x="196" y="526" class="mono" font-size="13" letter-spacing="1.5" fill="#6E6759" text-anchor="middle">0 DEPENDENCIES</text> + <text x="396" y="526" class="mono" font-size="13" letter-spacing="1.5" fill="#6E6759" text-anchor="middle">0 BUILD STEP</text> + <text x="596" y="526" class="mono" font-size="13" letter-spacing="1.5" fill="#6E6759" text-anchor="middle">1 FILE</text> + </g> + + <!-- mini artifact mosaic right --> + <g> + <!-- document --> + <rect x="800" y="120" width="300" height="216" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2.5"/> + <line x1="800" y1="164" x2="1100" y2="164" stroke="#1C1A15" stroke-width="1.5"/> + <rect x="803" y="123" width="64" height="38" fill="#E84B0F"/> + <rect x="824" y="184" width="160" height="10" fill="#1C1A15"/> + <rect x="824" y="206" width="252" height="6" fill="#D9D2C3"/> + <rect x="824" y="222" width="252" height="6" fill="#D9D2C3"/> + <rect x="824" y="238" width="180" height="6" fill="#D9D2C3"/> + <rect x="824" y="262" width="116" height="44" fill="none" stroke="#D9D2C3" stroke-width="1.5"/> + <rect x="952" y="262" width="116" height="44" fill="none" stroke="#D9D2C3" stroke-width="1.5"/> + <!-- deck --> + <rect x="864" y="380" width="300" height="180" fill="#FFFFFF" stroke="#1C1A15" stroke-width="2.5"/> + <rect x="888" y="408" width="200" height="14" fill="#1C1A15"/> + <rect x="888" y="436" width="140" height="8" fill="#D9D2C3"/> + <circle cx="920" cy="496" r="22" fill="none" stroke="#E84B0F" stroke-width="2.5"/> + <path d="M952 496 H1030" stroke="#1C1A15" stroke-width="2"/> + <path d="M1022 488 L1034 496 L1022 504" stroke="#1C1A15" stroke-width="2" fill="none"/> + <rect x="1056" y="474" width="44" height="44" fill="none" stroke="#1C1A15" stroke-width="2"/> + <line x1="864" y1="532" x2="1164" y2="532" stroke="#1C1A15" stroke-width="1.5"/> + <text x="888" y="550" class="mono" font-size="11" letter-spacing="1.5" fill="#6E6759">PLATE 04 / 18 · TALK-DECK</text> + <!-- carbon code well --> + <rect x="744" y="252" width="180" height="120" rx="2" fill="#211E18"/> + <text x="760" y="280" class="mono" font-size="12" fill="#A39A88">$ /htmlify</text> + <rect x="760" y="296" width="120" height="6" fill="#3A352B"/> + <rect x="760" y="312" width="140" height="6" fill="#3A352B"/> + <rect x="760" y="328" width="96" height="6" fill="#3A352B"/> + <text x="760" y="356" class="mono" font-size="12" fill="#FF6B2C">→ artifact.html</text> + </g> +</svg> diff --git a/src/annotation.js b/src/annotation.js index 92f795b..78dc3ad 100644 --- a/src/annotation.js +++ b/src/annotation.js @@ -24,18 +24,22 @@ function buildAnnotationLayer(meta) { const title = String(meta && meta.title ? meta.title : 'HTML Export'); return `${TRUSTED_ANNOTATION_MARKER} <style> - .hla-comment-bar { position: fixed; right: 18px; bottom: 18px; z-index: 99999; display: flex; gap: 8px; flex-wrap: wrap; max-width: min(420px, calc(100vw - 36px)); font: 13px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } - .hla-comment-bar button, .hla-comment-panel button { border: 1px solid #b9c4d6; background: #fbfdff; color: #162033; border-radius: 999px; padding: 8px 11px; cursor: pointer; box-shadow: 0 8px 22px -18px rgba(15,23,42,.55); } - .hla-comment-bar button:hover, .hla-comment-panel button:hover { background: #f4f7fb; } - .hla-comment-panel { position: fixed; top: 16px; right: 16px; bottom: 72px; z-index: 99998; width: min(390px, calc(100vw - 32px)); overflow: auto; background: #fbfdff; color: #162033; border: 1px solid #cfd7e6; border-radius: 18px; box-shadow: 0 24px 70px -34px rgba(15,23,42,.55); padding: 16px; font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + :root { --hla-paper: #faf7f0; --hla-ink: #1c1a15; --hla-ink-2: #6e6759; --hla-rule: #d9d2c3; --hla-signal: #e84b0f; --hla-wash: #fbeae1; } + @media (prefers-color-scheme: dark) { + :root { --hla-paper: #1e1b16; --hla-ink: #ede8dc; --hla-ink-2: #a39a88; --hla-rule: #3a352b; --hla-signal: #ff6b2c; --hla-wash: #3a2114; } + } + .hla-comment-bar { position: fixed; right: 18px; bottom: 18px; z-index: 99999; display: flex; gap: 8px; flex-wrap: wrap; max-width: min(420px, calc(100vw - 36px)); font: 11px/1.4 ui-monospace, "SF Mono", Menlo, Consolas, monospace; } + .hla-comment-bar button, .hla-comment-panel button { font: inherit; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; border: 1.5px solid var(--hla-ink); background: var(--hla-paper); color: var(--hla-ink); border-radius: 2px; padding: 6px 10px; cursor: pointer; } + .hla-comment-bar button:hover, .hla-comment-panel button:hover { border-color: var(--hla-signal); color: var(--hla-signal); } + .hla-comment-panel { position: fixed; top: 16px; right: 16px; bottom: 72px; z-index: 99998; width: min(390px, calc(100vw - 32px)); overflow: auto; background: var(--hla-paper); color: var(--hla-ink); border: 2px solid var(--hla-ink); border-radius: 2px; padding: 16px; font: 13px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } .hla-comment-panel[hidden] { display: none; } - .hla-comment-panel h2 { margin: 0 0 10px; font-size: 16px; line-height: 1.2; } - .hla-comment-panel textarea { width: 100%; min-height: 90px; resize: vertical; border: 1px solid #cfd7e6; border-radius: 12px; padding: 10px; font: inherit; color: inherit; background: #fbfdff; } + .hla-comment-panel h2 { margin: 0 0 10px; font-family: Charter, "Bitstream Charter", Cambria, Georgia, serif; font-weight: 400; font-size: 19px; line-height: 1.2; } + .hla-comment-panel textarea { width: 100%; min-height: 90px; resize: vertical; border: 1px solid var(--hla-rule); border-radius: 2px; padding: 10px; font: inherit; color: inherit; background: var(--hla-paper); } .hla-comment-list { display: grid; gap: 10px; margin-top: 12px; } - .hla-comment-card { border: 1px solid #dce3ef; border-radius: 14px; padding: 10px; background: #f8fafc; } - .hla-comment-card blockquote { margin: 0 0 8px; padding: 8px 10px; border: 1px solid #c8d7d4; border-radius: 10px; color: #40506a; background: #eef7f5; } - .hla-highlight { background: #fff0a8; border-radius: 3px; } - .hla-comment-target { outline: 2px solid #1c7c72; outline-offset: 3px; } + .hla-comment-card { border: 1px solid var(--hla-rule); border-radius: 2px; padding: 10px; background: var(--hla-paper); } + .hla-comment-card blockquote { margin: 0 0 8px; padding: 8px 10px; border-left: 3px solid var(--hla-signal); color: var(--hla-ink-2); background: var(--hla-wash); } + .hla-highlight { background: var(--hla-wash); border-radius: 2px; } + .hla-comment-target { outline: 2px solid var(--hla-signal); outline-offset: 3px; } </style> <section class="hla-comment-panel" id="hla-comment-panel" hidden aria-label="HTML export comments"> <h2>HTML comments</h2> diff --git a/src/document.js b/src/document.js index 0a7022d..4a0be16 100644 --- a/src/document.js +++ b/src/document.js @@ -52,10 +52,15 @@ function buildOutlineHtml(text) { if (match) headings.push({ level: match[1].length, label: match[2].trim() }); } if (!headings.length) return ''; - return `<div class="aside-panel"><div class="aside-label">Outline</div><ul class="outline-list">${headings.map((item) => `<li class="outline-item outline-level-${Math.min(item.level, 4)}">${formatInline(item.label)}</li>`).join('')}</ul></div>`; + return `<div class="aside-panel"><div class="aside-label">Index</div><ul class="outline-list">${headings.map((item) => `<li class="outline-item outline-level-${Math.min(item.level, 4)}">${formatInline(item.label)}</li>`).join('')}</ul></div>`; } /** + * Render the captured answer as a Hardcopy-styled standalone document: + * plate title block, numbered index rail, carbon code wells, hairline + * structure, dark mode, and print rules. See references/hardcopy.md in the + * bundled skills for the canonical design spec. + * * @param {string} title * @param {string} body * @param {ArtifactMeta} meta @@ -71,95 +76,175 @@ function buildLocalHtmlDocument(title, body, meta) { <title>${escapeHtml(title)}
    -
    -
    +
    +
    htmlify export
    -

    ${escapeHtml(title)}

    - ${meta.excerpt ? `

    ${escapeHtml(meta.excerpt)}

    ` : ''} -
    -
    Exported
    ${escapeHtml(exportedAt)}
    -
    Words
    ${escapeHtml(String(meta.words))}
    -
    Characters
    ${escapeHtml(String(meta.characters))}
    -
    Mode
    ${escapeHtml(meta.mode)}
    +
    +

    ${escapeHtml(title)}

    + ${meta.excerpt ? `

    ${escapeHtml(meta.excerpt)}

    ` : ''}
    -
    +
    +
    Exported
    ${escapeHtml(exportedAt)}
    +
    Words
    ${escapeHtml(String(meta.words))}
    +
    Characters
    ${escapeHtml(String(meta.characters))}
    +
    Mode
    ${escapeHtml(meta.mode)}
    +
    +
    ${body} From 827733e9423b1a86dd8e772f1197e99d5e92c55a Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 14:40:25 -0700 Subject: [PATCH 08/14] feat: add claude code plugin manifest and marketplace listing .claude-plugin/plugin.json + marketplace.json make the repo installable via /plugin marketplace add zakelfassi/htmlify; both skills are auto-discovered from skills/. hooks/hooks.json documents the opt-in Stop hook with ${CLAUDE_PLUGIN_ROOT}. A manifest integrity suite keeps the plugin version synced to package.json, skill frontmatter valid, reference links resolvable, the two hardcopy.md copies identical, and every bin/hook require inside the published file set. --- .claude-plugin/marketplace.json | 20 +++++++++ .claude-plugin/plugin.json | 13 ++++++ hooks/hooks.json | 16 +++++++ test/manifest.test.js | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100644 hooks/hooks.json create mode 100644 test/manifest.test.js diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..65c2ed2 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "htmlify", + "owner": { + "name": "Zak El Fassi", + "url": "https://github.com/zakelfassi" + }, + "metadata": { + "description": "Self-contained HTML artifacts and presentation decks from agent context", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "htmlify", + "source": "./", + "description": "htmlify + deckify skills: agent answers become self-contained HTML documents and decks, validated by a bundled CLI. Optional Stop hook archives long answers automatically.", + "category": "productivity", + "keywords": ["html", "artifacts", "decks", "reports", "presentations"] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..70a17e2 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "name": "htmlify", + "version": "0.3.1", + "description": "Turn agent answers into self-contained HTML artifacts and presentation decks (htmlify + deckify skills, plus an optional long-answer Stop hook).", + "author": { + "name": "Zak El Fassi", + "url": "https://github.com/zakelfassi" + }, + "homepage": "https://zakelfassi.github.io/htmlify/", + "repository": "https://github.com/zakelfassi/htmlify", + "license": "Apache-2.0", + "keywords": ["html", "artifacts", "decks", "reports", "skills"] +} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..b7f7a91 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "description": "Optional: archive long final answers as HTML artifacts. Tune with HTMLIFY_MIN_CHARS (default 2500) and HTMLIFY_EXPORT_ROOT.", + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/claude-code-stop-htmlify.js\"", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/test/manifest.test.js b/test/manifest.test.js new file mode 100644 index 0000000..152eea6 --- /dev/null +++ b/test/manifest.test.js @@ -0,0 +1,80 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..'); +const packageJson = require('../package.json'); + +/** @param {string} relPath */ +function read(relPath) { + return fs.readFileSync(path.join(repoRoot, relPath), 'utf8'); +} + +/** @param {string} relPath */ +function readJson(relPath) { + return JSON.parse(read(relPath)); +} + +test('claude plugin manifest is valid and version-synced', () => { + const plugin = readJson('.claude-plugin/plugin.json'); + assert.equal(plugin.name, 'htmlify'); + assert.equal(plugin.version, packageJson.version); + assert.equal(plugin.license, 'Apache-2.0'); + assert.ok(plugin.description.length > 0); + + const marketplace = readJson('.claude-plugin/marketplace.json'); + assert.equal(marketplace.name, 'htmlify'); + assert.equal(marketplace.plugins.length, 1); + assert.equal(marketplace.plugins[0].name, 'htmlify'); + assert.equal(marketplace.plugins[0].source, './'); +}); + +test('hooks.json wires the stop hook to a file that exists and stays opt-in', () => { + const hooks = readJson('hooks/hooks.json'); + const stop = hooks.hooks.Stop[0].hooks[0]; + assert.equal(stop.type, 'command'); + assert.match(stop.command, /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/claude-code-stop-htmlify\.js/); + assert.ok(fs.existsSync(path.join(repoRoot, 'hooks', 'claude-code-stop-htmlify.js'))); +}); + +test('both skills have valid frontmatter and resolvable reference links', () => { + for (const skill of ['htmlify', 'deckify']) { + const skillDir = path.join(repoRoot, 'skills', skill); + const text = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'); + const frontmatter = /^---\n([\s\S]*?)\n---/.exec(text); + assert.ok(frontmatter, `${skill} SKILL.md must start with frontmatter`); + assert.match(frontmatter[1], new RegExp(`^name: ${skill}$`, 'm')); + assert.match(frontmatter[1], /^description: .{40,}/m); + assert.match(frontmatter[1], /^license: Apache-2.0$/m); + assert.match(frontmatter[1], /version: "\d+\.\d+\.\d+" # x-release-please-version/); + + for (const link of text.matchAll(/\]\((references\/[^)]+)\)/g)) { + assert.ok(fs.existsSync(path.join(skillDir, link[1])), `${skill}: missing reference ${link[1]}`); + } + } +}); + +test('the hardcopy spec ships identically in both skills', () => { + const a = read('skills/htmlify/references/hardcopy.md'); + const b = read('skills/deckify/references/hardcopy.md'); + assert.equal(a, b, 'skills/*/references/hardcopy.md copies have drifted — sync them'); +}); + +test('npm files cover everything bin, hooks, and the plugin need', () => { + for (const entry of ['index.js', 'src/', 'bin/', 'hooks/', 'skills/', '.claude-plugin/']) { + assert.ok(packageJson.files.includes(entry), `package.json files must include ${entry}`); + } + for (const script of ['bin/htmlify-answer.js', 'hooks/claude-code-stop-htmlify.js']) { + const source = read(script); + for (const match of source.matchAll(/require\('([^']+)'\)/g)) { + const target = match[1]; + if (!target.startsWith('.')) continue; + const resolved = path.relative(repoRoot, path.resolve(repoRoot, path.dirname(script), target)); + assert.ok( + packageJson.files.some((entry) => resolved === entry || resolved.startsWith(entry.replace(/\/$/, ''))), + `${script} requires ${resolved}, which is outside the published file set` + ); + } + } +}); From 430098d13c1b3bc158e87eb7d65bab72128e899d Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 15:01:09 -0700 Subject: [PATCH 09/14] feat: add validated example gallery Nine committed artifacts, all generated by the skills about this repository: seven htmlify documents (operator brief of the v1 launch, review packet for the real PR #1, incident report for the capture-notification bug, the monorepo decision brief, a runtime implementation map with an inline-SVG module graph, the HTML-vs-markdown explainer, and an interactive launch triage board) plus two deckify decks (the launch talk and a skill-authoring workshop with guide mode). Every file passes htmlify-answer --validate with its profile; CI enforces this. --- examples/README.md | 30 + examples/deckify/talk-deck.html | 854 ++++++++++++++++++ examples/deckify/workshop-deck.html | 1043 ++++++++++++++++++++++ examples/htmlify/decision-brief.html | 637 +++++++++++++ examples/htmlify/explainer.html | 570 ++++++++++++ examples/htmlify/implementation-map.html | 630 +++++++++++++ examples/htmlify/incident-timeline.html | 520 +++++++++++ examples/htmlify/launch-board.html | 589 ++++++++++++ examples/htmlify/operator-brief.html | 616 +++++++++++++ examples/htmlify/pr-review-packet.html | 481 ++++++++++ 10 files changed, 5970 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/deckify/talk-deck.html create mode 100644 examples/deckify/workshop-deck.html create mode 100644 examples/htmlify/decision-brief.html create mode 100644 examples/htmlify/explainer.html create mode 100644 examples/htmlify/implementation-map.html create mode 100644 examples/htmlify/incident-timeline.html create mode 100644 examples/htmlify/launch-board.html create mode 100644 examples/htmlify/operator-brief.html create mode 100644 examples/htmlify/pr-review-packet.html diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..baa01dd --- /dev/null +++ b/examples/README.md @@ -0,0 +1,30 @@ +# Gallery + +Every artifact in this folder was generated by the skills in this repository, about this repository — real PRs, real bugs, real decisions. Each file is self-contained (inline CSS+JS, no external assets) and passes `node bin/htmlify-answer.js --validate` in CI. + +Browse the rendered gallery at **https://zakelfassi.github.io/htmlify/** or open any file directly in a browser. + +## htmlify (documents) + +| Artifact | Mode | What to look for | +| --- | --- | --- | +| [operator-brief.html](htmlify/operator-brief.html) | `operator-brief` | The plate title block, status stamps, numbered index rail, risk board | +| [pr-review-packet.html](htmlify/pr-review-packet.html) | `pr-review-packet` | A real merged PR (#1): diff tour, file-by-file annotations, reviewer checklist | +| [incident-timeline.html](htmlify/incident-timeline.html) | `incident-report` | A real bug (`bd7c5c6`): discovery→fix timeline, root cause, follow-ups | +| [decision-brief.html](htmlify/decision-brief.html) | `decision-brief` | The monorepo decision: options matrix, tradeoffs, the call that was made | +| [implementation-map.html](htmlify/implementation-map.html) | `implementation-map` | Inline SVG module graph of the runtime, hot path, file tour | +| [explainer.html](htmlify/explainer.html) | `explainer` | TL;DR box, HTML-vs-markdown comparison matrix, FAQ | +| [launch-board.html](htmlify/launch-board.html) | `prototype` (app profile) | Interactive triage board: status cycling, localStorage persistence, JSON export | + +## deckify (decks) + +| Artifact | Mode | What to look for | +| --- | --- | --- | +| [talk-deck.html](deckify/talk-deck.html) | `talk-deck` | Arrow-key navigation, speaker notes (N), run-of-show, plate slide footers | +| [workshop-deck.html](deckify/workshop-deck.html) | `workshop-deck` | Exercises and checkpoints, guide mode (G), print-to-PDF handout | + +## Validate locally + +```bash +node bin/htmlify-answer.js --validate examples/htmlify/*.html examples/deckify/*.html --profile auto +``` diff --git a/examples/deckify/talk-deck.html b/examples/deckify/talk-deck.html new file mode 100644 index 0000000..ac22a20 --- /dev/null +++ b/examples/deckify/talk-deck.html @@ -0,0 +1,854 @@ + + + + + +stdout, made permanent — the case for HTML artifacts + + + + +
    + stdout, made permanent + + + + 1 / 14 + +
    + +
    + + +
    +
    +

    1.0Launch talk · 15 min · for developers who run coding agents

    +

    stdout, made permanent

    +

    The case for HTML artifacts: when an agent's answer has shape, give it a document — one self-contained file that opens anywhere and lives forever.

    +
    +
    Mode
    TALK-DECK
    +
    Source
    github.com/zakelfassi/htmlify
    +
    Date
    2026-06-11
    +
    Runtime
    ~15:00
    +
    +
    +
    Plate 01 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    2.0Cold open

    +

    You asked a good question. The answer is already gone.

    +
    + agent session · scrollback · 14,200 tokens ago +
    ## Comparison: queue vs. cron vs. event-driven rebuild
    +### Option A — durable queue
    +| Option | Latency | Failure mode | Ops cost |
    +|--------|---------|--------------|----------|
    +| Queue  | ~2s     | redelivery   | medium   |
    +| Cron   | ~60s    | silent skip  | low      |
    +- Tradeoff 14 of 23: backpressure vs. simplicity...
    +- See also the migration plan in section 6, which...
    +### Rollback strategy (do not lose this part)
    +$ █
    +
    +
    +
    Plate 02 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    3.0The problem

    +

    Terminal answers fail twice

    +
    +
    +

    Ephemeral

    +
      +
    • Scrollback is a buffer, not a record
    • +
    • Close the tab, lose the analysis
    • +
    • Context compaction eats the middle of long sessions
    • +
    • Nothing to send to a teammate or a meeting
    • +
    +
    +
    +

    Unreadable at length

    +
      +
    • Comparisons become pipe-table ASCII art
    • +
    • Architecture has no spatial layout in a stream
    • +
    • No side-by-side, no jump links, no collapse
    • +
    • 2,000-word markdown is a wall, not a document
    • +
    +
    +
    +

    Short answers are fine in the terminal. The failure starts exactly when the answer becomes worth keeping.

    +
    +
    Plate 03 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    4.0The thesis

    +

    Answers with shape deserve a document.

    +
    +

    If the reader needs to compare, navigate, review, archive, or hand the answer to someone else — it should be one self-contained HTML file, not a scroll of markdown. The browser is the most widely deployed document reader on earth, and it is already on your machine.

    +
    +
    Plate 04 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    5.0Mental model

    +

    Evidence → author → validate. One file out.

    +
    + + + + + + + + + + + + + 1 · EVIDENCE + 2 · AUTHOR + 3 · VALIDATE + + + repo · diff · logs + docs · CI · prior answer + read before designing + one HTML file + inline CSS + JS + visual before verbose + profile: rich | app | deck + structure · safety · size + 0 errors or no ship + + + + + + + + + + + + + answer + .html + + OPENS ANYWHERE · NO SERVER · NO BUILD · LIVES IN THE REPO OR THE EXPORT DIR + +
    Fig 1 · The htmlify pipeline · Source: skills/htmlify/SKILL.md operating rules
    +
    +
    +
    Plate 05 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    6.0What an artifact is

    +

    Anatomy of a Hardcopy document

    +
    + + + + + + + + + + + + + + + + MODE + SOURCE + DATE + GENERATOR + HASH + COUNTS + + + + + + + + + + + + BASH + $ node bin/htmlify-answer.js --validate ... + + + PASS + + + PLATE · MODE · TITLE · PROGRESS + + + + + + + + + + + CROP MARKS — print registration, quiet on screen + THE PLATE — title block: mode, source, date, hash + DISPLAY SERIF — one idea, Charter/Georgia + CARBON WELL — the terminal lives inside the doc + STAMP — PASS / RISK / NEEDS VERIFICATION + PLATE FOOTER — page n / total, mode, title + + +
    Fig 2 · Hardcopy devices, labeled · Source: skills/deckify/references/hardcopy.md
    +
    +
    +
    Plate 06 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    7.0The skill family

    +

    Two skills, one contract

    +
    +
    +

    htmlify — operator artifacts

    +
      +
    • operator-brief · status-report — what happened, what is next
    • +
    • build-plan · implementation-map — phases, files, data flow
    • +
    • pr-review-packet · release-brief — diff tour, deploy proof
    • +
    • incident-report · decision-brief — timeline, options, tradeoffs
    • +
    • explainer · prototype / editor — concepts, clickable flows
    • +
    +
    +
    +

    deckify — presentation artifacts

    +
      +
    • talk-deck — speaker notes + run-of-show (this file)
    • +
    • workshop-deck — exercises, labs, checkpoints
    • +
    • essay-deck — deck plus long-form guide
    • +
    • demo-deck — live demos with fallbacks
    • +
    • launch-deck · teaching-guide — narrative, PDF-first
    • +
    +
    +
    +

    Shared rules: smallest mode that fits, evidence first, self-contained file, validate before shipping.

    +
    +
    Plate 07 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    8.0Self-containment is a security posture

    +

    Generated HTML is untrusted until validated

    +
    +
    + + + + + + + + + +
    Banned in every profileWhy
    <script src> · CDNsno remote code, ever
    inline on* handler attributesscript goes in one auditable block
    javascript: URLs · meta refreshno sneaky execution or redirects
    iframe · object · embed · baseno foreign documents inside yours
    external fonts, images, css url()no phone-home, works offline forever
    +
    +
    + + + + + + + + RICH — documents + APP — interactive + DECK — presentations + + + no scripts at all · pure reading + + one inline script, still no remote + + slides, keyboard nav, + speaker notes enforced + + + THIS DECK + +
    Fig 3 · Validation profiles, strictest outward · Source: src/validate.js
    +
    +
    +
    +
    Plate 08 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    9.0The annotation loop

    +

    The document talks back

    +
    + + + + + + + + + ARTIFACT IN BROWSER + SELECT + COMMENT + EXPORT COMMENTS + AGENT APPLIES EDITS + + + answer.html, file:// + read like a reviewer + highlight any passage, + attach a note in place + one markdown bundle: + quote + comment + anchor + paste bundle into session; + agent revises the artifact + + + + + + + + + + + REVISED ARTIFACT + +
    Fig 4 · Comment in the browser, markdown back to the agent · Source: htmlify export-comments workflow
    +
    +
    +
    Plate 09 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    10.0Demo · live

    +

    /htmlify, end to end

    +
    +
    + + + + + + + + + +
    StepBeat
    1Ask the agent a question with shape — a six-option architecture comparison
    2Run /htmlify on the answer
    3Validator runs; artifact opens in the default browser
    4Highlight a weak claim, leave a comment, export the bundle
    5Paste the bundle back; the agent ships a revised artifact
    +
    +
    + Visual slot · 16:9 · fallback if the live demo fails + Pending visual +

    Screenshot of the rendered decision-brief artifact with an annotation open. To be captured from a real session before recording — not faked, not illustrated.

    +
    +
    +
    +
    Plate 10 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    11.0Failure modes

    +

    When to stay in the terminal

    + + + + + + + + + +
    Answer shapeRight channelWhy
    Short, linear, command-likestdoutyou will act, not re-read
    Yes/no plus one reasonstdouta file is ceremony
    Mid-loop iteration, half-formedstdoutartifacts assert; drafts should not
    Comparison, plan, timeline, reviewartifactshape needs layout
    Anything headed to a meeting or archiveartifactpermanence is the point
    +

    Rule of thumb: if you would never scroll back to it, do not make a file out of it.

    +
    +
    Plate 11 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    12.0Install

    +

    One command per agent

    +
    + Claude Code · skill +
    git clone https://github.com/zakelfassi/htmlify.git ~/.claude/skills/htmlify
    +
    +
    + Codex · skill +
    git clone https://github.com/zakelfassi/htmlify.git ~/.codex/skills/htmlify
    +
    +
    + Pi · extension +
    pi install npm:@zakelfassi/htmlify
    +
    +
    + Any agent · CLI pipe +
    printf '%s' "$ANSWER" | npx @zakelfassi/htmlify htmlify-answer --title "Answer"
    +
    +
    +
    Plate 12 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    13.0Run of show · chapters copy-pastable for YouTube

    +

    15 minutes, chaptered

    + + + + + + + + + + + + + + + + +
    TimeChapterBeat
    00:00Cold openthe wall of markdown, scrolling away
    01:10The problemephemeral + unreadable at length
    02:40The thesisanswers with shape deserve a document
    04:00Mental modelevidence, author, validate — one file
    05:30Anatomy of an artifactplate, wells, stamps, crop marks
    07:00The skill familyhtmlify modes, deckify modes
    08:20Security posturewhat the validator bans; three profiles
    09:50The annotation loopcomment in browser, markdown back
    11:10Demo/htmlify end to end, fallback ready
    13:00When to stay in the terminalfailure modes, rule of thumb
    13:50Installone command per agent
    14:30Closechecklist + repo
    +
    +
    Plate 13 / 14Talk-Deckstdout, made permanent
    + +
    + + +
    +
    +

    14.0Close

    +

    Before your next long answer evaporates

    +
      +
    • Notice the shape: comparison, plan, timeline, review, system
    • +
    • Give it a document — one self-contained HTML file
    • +
    • Validate before shipping: --profile rich | app | deck, zero errors
    • +
    • Review in the browser; send comments back as markdown
    • +
    • Keep short, linear, command-like answers in the terminal
    • +
    +
    +

    Star it, install it, htmlify your next real answer:
    github.com/zakelfassi/htmlify

    +
    +
    Plate 14 / 14Talk-Deckstdout, made permanent
    + +
    + +
    + + + + + + diff --git a/examples/deckify/workshop-deck.html b/examples/deckify/workshop-deck.html new file mode 100644 index 0000000..21d69e0 --- /dev/null +++ b/examples/deckify/workshop-deck.html @@ -0,0 +1,1043 @@ + + + + + +Authoring Agent Skills: A Hands-On Workshop + + + + +
    + Workshop-Deck + Authoring Agent Skills + + + + + Keys: arrows · space · home/end · N notes · G guide + 1 / 17 +
    + +
    + + +
    +

    Workshop · 60 minutes · hands-on

    +

    Authoring agent skills: a hands-on workshop

    +

    How to write a SKILL.md that an agent actually triggers, follows, and can verify — using the htmlify repo's own skills as the worked example.

    +
    +
    +

    Who this is for

    +

    Developers who use Claude Code, Codex, or any agentskills.io-compatible client and want to package a repeatable workflow as a skill.

    +
    +
    +

    What you leave with

    +

    A drafted frontmatter description, three rules rewritten to be testable, and a validator lab you ran yourself. Bring a laptop with Node 20+ and a clone of the repo.

    +
    +
    +

    3 acts · 3 checkpoints · printable handout under the Guide button (G)

    +
    Plate 01 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Cold open

    +

    You have explained this workflow to your agent fourteen times

    +
    +
    +

    “Remember to inline the CSS… no external fonts… check it opens standalone… oh, and add print styles again.”

    +

    Every session starts from zero. The workflow lives in your head and leaks into chat one correction at a time.

    +
    +
    +

    A skill is that workflow written down once — with a trigger, rules, and a way to check the result.

    +

    The agent reads it when the request matches, follows the rules, and validates its own output. No re-explaining.

    +
    +
    +

    Today's worked example: the htmlify and deckify skills that produced the deck you are looking at.

    +
    Plate 02 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Orientation

    +

    Three acts, three checkpoints

    +
    + + + + + + + + + + + + + OPEN + ACT 1 ANATOMY + ACT 2 RULES + ACT 3 VERIFY + CLOSE + + CP1 DESCRIBE + CP2 RULES + CP3 LAB + + 00:00 + 15:00 + 32:00 + 47:00 + 60:00 + +
    Fig 1 · The hour at a glance — checkpoints in orange are yours, not mine
    +
    +

    Act 1: what a skill file is. Act 2: rules an agent will actually follow. Act 3: making the output check itself. Each act ends with you doing the thing.

    +
    Plate 03 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 1 · Anatomy

    +

    A skill is three layers in one file

    +
    + + + + + + --- FRONTMATTER --- + name · description · license + metadata.version · metadata.source + + + ## OPERATING RULES + 1. gather evidence first + 2. smallest mode that fits + + 11. validate before final response + + + ## POINTERS + load references/… when needed + + + TRIGGER SURFACE + how the agent decides to load it + + BEHAVIOR CONTRACT + what it must do once loaded + + PROGRESSIVE DISCLOSURE + depth without context cost + +
    Fig 2 · Anatomy of a SKILL.md · Source: zakelfassi/htmlify, skills/htmlify/SKILL.md
    +
    +
    Plate 04 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 1 · Anatomy

    +

    The description is the trigger surface

    +
    +
    yaml · skills/htmlify/SKILL.md (frontmatter, abridged)
    +
    ---
    +name: htmlify
    +description: Create self-contained HTML artifacts from agent or repo
    +  context, including operator briefs, build plans, implementation maps,
    +  PR/release packets, incident timelines, … Use when the user asks
    +  to turn dense text, code evidence, plans, reviews, or status into
    +  browser-ready HTML instead of a markdown wall.
    +license: Apache-2.0
    +metadata:
    +  version: "0.3.1"
    +  source: "https://github.com/zakelfassi/htmlify"
    +---
    +
    +
      +
    • Specific verbs — “create self-contained HTML artifacts”, not “helps with HTML”.
    • +
    • Artifact nouns — the things users actually ask for, by name.
    • +
    • “Use when…” — describes the request, from the user's side of the conversation.
    • +
    • Versioned and licensedmetadata.version is bumped by release automation, not by hand.
    • +
    +
    Plate 05 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 1 · Anatomy

    +

    SKILL.md stays short; references/ carry the depth

    +
    + + + + SKILL.md + ~190 LINES · ALWAYS LOADED + + + + + + + + references/hardcopy.md + DESIGN TOKENS · THE SEVEN DEVICES + + references/deck-template.md + DOM CONTRACT THE VALIDATOR ENFORCES + + references/htmlify-principles.md + MODE SELECTION · DEEPER PATTERNS + + references/agent-integrations.md + INSTALL PATHS FOR OTHER AGENTS + + LOADED ON + DEMAND ONLY + +
    Fig 3 · Progressive disclosure in the htmlify skill family · Source: zakelfassi/htmlify, skills/
    +
    +

    Every token in SKILL.md is paid on every triggered request. Reference files are paid only when the task needs them — so the skill says exactly when to load each one.

    +
    Plate 06 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    + Checkpoint 1 · 7 min +

    Write the frontmatter description for your skill idea

    +
    +
    +
    Worksheet · also in the handout
    +

    name:

    +
    +

    description: (3–5 sentences)

    +
    +
    +
    +
    +
    +

    Scoring criteria

    +
      +
    1. Specific verbs: what it produces or does, concretely.
    2. +
    3. Artifact nouns: the outputs named the way users name them.
    4. +
    5. “Use when…” clause: request shapes, quoted in user language.
    6. +
    7. Bounded: says what it is not for if a sibling skill exists.
    8. +
    +

    Test: would a stranger reading nothing but your description know exactly which requests should fire it — and which should not?

    +
    +
    +
    Plate 07 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 2 · Rules

    +

    Rules an agent will actually follow

    +
    +
    markdown · skills/htmlify/SKILL.md (operating rules, abridged)
    +
    ## Operating Rules
    +
    +1. Gather evidence first. Read the repo, docs, git state, PR/CI/deploy
    +   state, logs, … Mark uncertain claims as `needs verification`.
    +2. Pick the smallest artifact mode that fits the request:
    +   - `operator-brief`: what happened, what is next, blockers, risks…
    +   - `build-plan`: problem, target shape, phases, owners, validation…
    +4. Keep it self-contained. Inline CSS and JS; no external fonts,
    +   CDNs, assets, analytics, or build step unless the user explicitly asks.
    +11. Validate before final response: doctype, standalone
    +    <html>/<body>, no missing local assets, expected sections…
    +
    +

    Numbered, imperative, and ordered as a procedure: evidence → mode choice → constraints → validation. An agent can cite “rule 4” back to you; it cannot cite a vibe.

    +
    Plate 08 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 2 · Rules

    +

    A mode menu is a decision procedure

    + + + + + + + + + + +
    deckify modeChoose when the user wants…
    talk-decka YouTube/live presentation with speaker notes and run-of-show
    workshop-decka talk plus exercises, labs, checkpoints, and handouts — this deck
    essay-decka presentation plus a downloadable long-form guide
    demo-decka session centered on live demos with fallback screenshots
    launch-deckproduct narrative, proof, risks, roadmap, and a CTA
    teaching-guidea PDF-first guide with an optional slide mode
    +

    “Smallest mode that fits” converts a fuzzy judgment call into a lookup. The agent stops guessing scope; the user gets predictable shapes.

    +
    Plate 09 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 2 · Rules

    +

    Anti-pattern: rules you cannot test

    + + + + + + + + + + + + + + + + + + + + +
    Vague vibeTestable rule (from this repo's skills)
    “Make it look professional”Default to the Hardcopy tokens: one accent color, hairlines over shadows, border-radius ≤ 2px, serif display headings.
    “Should work offline”Inline all CSS/JS; no external fonts, CDNs, or remote assets — the validator errors on any remote reference.
    “Help the presenter”Every substantive slide contains <aside class="notes">; the validator reports each missing one by slide number.
    “Keep it reasonable in size”Warn above 512 KiB; hard error above 2 MiB.
    +

    Litmus test: could a script check it? If yes, it is a rule. If no, it is a hope. Unbounded scope (“and anything else useful”) fails the same test.

    +
    Plate 10 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    + Checkpoint 2 · 7 min +

    Convert three vague instructions into testable rules

    +
    +
    Rewrite each so a script — or a strict reviewer — could verify compliance
    +
      +
    1. “Make the output look nice.”
    2. +
    3. “Don't make the file too big.”
    4. +
    5. “Add some interactivity if it helps.”
    6. +
    +
    +

    A good rewrite has

    +
      +
    • a named standard (a token set, a template, a contract file) instead of an adjective,
    • +
    • a number or enumerable condition where one exists,
    • +
    • a consequence: what happens, or what check fails, when it is violated.
    • +
    +
    Plate 11 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 3 · Verify

    +

    A skill that can check its own output beats one that hopes

    +
    + + + AGENT WRITES + artifact.html + + RUNS VALIDATOR + --validate --profile deck + + ERRORS? + read codes + + SHIP + 0 ERRORS + + + + + + + + NO + + + + YES → FIX & RE-RUN + + + CI RE-VALIDATES COMMITTED EXAMPLES + + SKILL.MD ORDERS THIS LOOP + + +
    Fig 4 · The validation loop the deckify skill mandates before any final response
    +
    +
    Plate 12 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Act 3 · Verify

    +

    Case study: what --profile deck enforces

    + + + + + + + + + + + + +
    Contract itemCheckSeverity
    Standalone documentdoctype, one <html>/<body>, non-empty title, viewport metaerror
    Slides≥ 2 <section class="slide"> elementserror
    Keyboard nava script registering a keydown listenererror
    Speaker notesevery substantive slide has <aside class="notes">, reported per slideerror
    Script safetyinline script only; no handler attributes; no javascript: URLserror
    Self-containedno remote assets, fonts, or stylesheet linkserror
    Print mode@media print rules for the handoutwarning
    Size> 512 KiB warns; > 2 MiB errorsboth
    +

    The contract lives in references/deck-template.md; the code lives in src/validate.js. Doc and check ship together, in the same repo, versioned together.

    +
    Plate 13 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    + Checkpoint 3 · Lab · 8 min +

    Break it, validate it, fix it

    +
    +
    +
    +
    shell · from the repo root
    +
    node bin/htmlify-answer.js --validate \
    +  lab/broken-deck.html --profile deck
    +
    +
    +
    expected validator output
    +
    lab/broken-deck.html: INVALID — 4 errors
    +  [no-viewport]      missing viewport meta tag
    +  [no-slides]        found 1, need ≥ 2
    +  [no-keyboard-nav]  no keydown listener
    +  [missing-notes]    slide 1 ("Demo")
    +
    +
    +
    +

    Steps

    +
      +
    1. Copy the broken snippet from the handout (Exercise 3) into lab/broken-deck.html.
    2. +
    3. Run the validator. Read each error code.
    4. +
    5. Fix one error at a time; re-run after each fix.
    6. +
    7. Stop at: valid — 0 errors.
    8. +
    +

    Finished early? Run it against this deck file and read what a passing report looks like.

    +
    +
    +
    Plate 14 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Field guide

    +

    Three ways skills die

    + + + + + + + + + + + + + + + + + + + +
    FailureSymptomFix
    Never triggersDescription written from the implementation's side (“parses AST…”); users' actual words appear nowhere.Rewrite from the requester's side: verbs, artifact nouns, quoted “use when” phrases.
    Triggers too muchGrabby verbs and unbounded nouns (“helps with any web content”); fires on requests it handles badly.Narrow the nouns; name what it is not for; point sideways at sibling skills.
    Monolithic2,000-line SKILL.md taxing every invocation; agents skim and miss the rules that matter.Keep SKILL.md to rules and pointers; move depth to references/ with load-when conditions.
    +

    htmlify and deckify dodge the second failure by pointing at each other: htmlify's rule 8 sends full presentation requests to deckify.

    +
    Plate 15 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Logistics

    +

    Run of show

    + + + + + + + + + + + + + +
    ClockChapterBeat
    00:00OpeningPromise, audience, setup check (Node 20+, repo clone)
    05:00Act 1 · AnatomyThree layers; frontmatter as trigger surface; references/
    15:00Checkpoint 1Write a frontmatter description · debrief two aloud
    22:00Act 2 · RulesNumbered rules; smallest mode; vague-vs-testable
    32:00Checkpoint 2Three vague instructions → testable rules · debrief
    39:00Act 3 · VerifyValidation loop; the deck-profile case study
    47:00Checkpoint 3Lab: validate and fix the broken snippet
    55:00CloseFailure modes recap; checklist; print the handout
    60:00End
    +
    Plate 16 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + + +
    +

    Close

    +

    Ship your skill against this checklist

    +
    +
    +
      +
    1. Description has verbs, artifact nouns, and a “use when” clause.
    2. +
    3. license and metadata.version are set.
    4. +
    5. Rules are numbered, imperative, and ordered as a procedure.
    6. +
    7. Evidence-first is rule 1; a mode menu replaces “use judgment”.
    8. +
    9. Every rule passes the litmus test: a script could check it.
    10. +
    11. Depth lives in references/ with load-when pointers.
    12. +
    13. A validation command exists — and the skill orders the agent to run it.
    14. +
    15. CI re-validates whatever examples you commit.
    16. +
    +
    +
    +

    Sources

    +
      +
    • github.com/zakelfassi/htmlify — the worked example
    • +
    • agentskills.io — the skill format
    • +
    • skills/htmlify/SKILL.md · skills/deckify/SKILL.md
    • +
    • skills/deckify/references/deck-template.md
    • +
    • skills/deckify/references/hardcopy.md
    • +
    +

    Press G for the handout; print it from there for the PDF.

    +
    +
    +
    Plate 17 / 17 · Workshop-DeckAuthoring Agent Skills
    + +
    + +
    + + + + +
    +

    Workshop handout · keep after the session

    +

    Authoring Agent Skills — Workshop Guide

    +
    +
    Modeworkshop-deck
    +
    Duration60 minutes
    +
    Source repozakelfassi/htmlify
    +
    Formatagentskills.io
    +
    +
    + +
    +

    This is not a transcript. It is the part of the workshop you will want at your desk next week: the three act summaries, the three exercises with hints and solution sketches, the skill-authoring checklist, and references.

    + +

    1.0Act 1 — Anatomy of a skill

    +

    A skill is a single SKILL.md with three layers that fail in three different ways:

    +
      +
    • Frontmatter (name, description, license, metadata.version, metadata.source). The description is the trigger surface: the harness matches incoming requests against this text alone. Write it from the requester's side of the conversation — specific verbs, artifact nouns, and a literal “Use when…” clause quoting how users actually ask.
    • +
    • Body: numbered operating rules, a mode menu, validation orders, and a final-response contract. This is the behavior contract once the skill is loaded.
    • +
    • Pointers into references/: depth that is loaded on demand. Every token in SKILL.md is paid on every triggered request; reference files are paid only when needed. Each pointer should name the file and the condition: “Load references/hardcopy.md … before styling any artifact.”
    • +
    + +

    2.0Act 2 — Operating rules agents follow

    +

    Rules that survive contact with an agent share three properties:

    +
      +
    • Numbered and imperative. “1. Gather evidence first.” An agent can cite rule numbers; it cannot cite vibes. The ordering encodes the workflow: evidence → mode → constraints → validation.
    • +
    • Smallest mode that fits. Replace “use judgment” with an enumerated menu plus a selection rule. htmlify names ten artifact modes; deckify names six deck modes. The menu turns scope guessing into a lookup.
    • +
    • Testable, not vague. The litmus test: could a script check it? “Look professional” is a hope; “one accent color, hairlines over shadows, radius ≤ 2px” is a rule. Unbounded scope (“and anything else useful”) fails the same test.
    • +
    + +

    3.0Act 3 — Making output verifiable

    +

    A skill that can check its own output beats one that hopes. The htmlify repo demonstrates the full pattern:

    +
      +
    • The skill orders the loop: “Before the final response, run the bundled validator … Fix every reported error before responding; report remaining warnings.”
    • +
    • The contract is documented next to the code: references/deck-template.md describes the DOM contract; src/validate.js enforces it; both version together.
    • +
    • Two enforcement points: the agent validates before responding (skill rule), and CI re-validates every committed example (repo rule). The skill can be ignored; CI cannot.
    • +
    • Error messages are agent UX: “Substantive slide 7 has no speaker notes” is fixable in one step; “invalid deck” is not.
    • +
    + +

    4.0Exercise 1 — Write a trigger description

    +
    +
    Exercise 1 · 7 min
    +

    Task. Pick a workflow you repeat with your agent. Write the frontmatter name and a 3–5 sentence description.

    +

    Hints. Start with the verb and the artifact (“Generate release notes…”). List the output nouns the way users say them. End with a “Use when the user asks…” clause quoting 2–3 real request phrasings. If a sibling skill exists, say what this one is not for.

    +

    Solution sketch (for a release-notes skill):

    +
    yaml · sample answer
    name: release-notes
    +description: Generate release notes and changelogs from merged PRs
    +  and commit history, producing version-grouped markdown and a
    +  paste-ready announcement. Use when the user asks to "write release
    +  notes", "summarize what shipped", or turn git history into an
    +  announcement. Not for live deploy status — use a status skill.
    +
    + +

    5.0Exercise 2 — Vague to testable

    +
    +
    Exercise 2 · 7 min
    +

    Task. Rewrite each instruction so a script — or a strict reviewer — could verify compliance.

    +
      +
    1. “Make the output look nice.”
    2. +
    3. “Don't make the file too big.”
    4. +
    5. “Add some interactivity if it helps.”
    6. +
    +

    Hints. Name a standard instead of an adjective; attach a number where one exists; state which check fails on violation.

    +

    Solution sketches (each maps to a shipped rule in this repo):

    +
      +
    1. “Default to the Hardcopy design tokens: serif display headings, mono-uppercase metadata, at most one accent color, hairlines instead of shadows, border-radius ≤ 2px. No gradients, no stock imagery.”
    2. +
    3. “Keep the artifact under 512 KiB; the validator warns above that and errors above 2 MiB.”
    4. +
    5. “For deck-style artifacts, add keyboard navigation (arrows, Home/End) registered with addEventListener in one inline script; inline handler attributes and external scripts are validator errors.”
    6. +
    +
    + +

    6.0Exercise 3 — Validator lab

    +
    +
    Exercise 3 · Lab · 8 min
    +

    Task. From a clone of github.com/zakelfassi/htmlify, save this deliberately broken snippet as lab/broken-deck.html:

    +
    html · lab/broken-deck.html (broken on purpose)
    <!DOCTYPE html>
    +<html lang="en">
    +<head>
    +  <meta charset="utf-8" />
    +  <title>Lab deck</title>
    +</head>
    +<body>
    +  <main class="deck-shell">
    +    <section class="slide active" data-title="Demo">
    +      <h2>One idea, no notes</h2>
    +      <table><tr><td>evidence</td></tr></table>
    +    </section>
    +  </main>
    +</body>
    +</html>
    +

    Run.

    +
    shell · repo root
    node bin/htmlify-answer.js --validate lab/broken-deck.html --profile deck
    +

    Expect four errors, then fix one at a time, re-running after each:

    +
      +
    1. no-viewport — add the viewport <meta> to <head>.
    2. +
    3. no-slides — a deck needs at least two <section class="slide"> elements; add a second slide.
    4. +
    5. no-keyboard-nav — add one inline <script> that registers a keydown listener with addEventListener and toggles the active class on arrow keys.
    6. +
    7. missing-notes — each substantive slide (200+ chars of text, or containing h2/h3/table/figure) needs <aside class="notes"> with real speaker notes.
    8. +
    +

    Done when the report reads valid — 0 errors. Then run the same command against examples/deckify/workshop-deck.html to see a passing report for a full deck.

    +
    + +

    7.0Skill-authoring checklist

    +
      +
    1. Frontmatter has name and a description written from the requester's side: specific verbs, artifact nouns, “use when” phrasings.
    2. +
    3. license, metadata.version, and metadata.source are set; version bumping is automated where possible.
    4. +
    5. Operating rules are numbered, imperative, and ordered as the actual workflow.
    6. +
    7. Rule 1 is evidence-first: read the source material before producing anything.
    8. +
    9. A mode menu (“smallest mode that fits”) replaces open-ended judgment.
    10. +
    11. Every rule passes the litmus test: a script could check compliance.
    12. +
    13. Output constraints are explicit: self-contained, size-bounded, format-contracted.
    14. +
    15. Depth lives in references/; each pointer names the file and the load-when condition; SKILL.md stays short.
    16. +
    17. A validation command exists, the skill orders the agent to run it before the final response, and errors must reach zero.
    18. +
    19. CI re-validates committed examples, so the contract holds even when nobody is watching.
    20. +
    21. The skill defines its final-response contract: what to report (paths, mode, validation results, gaps).
    22. +
    23. Sibling skills point at each other to resolve boundary disputes (htmlify rule 8 routes full decks to deckify).
    24. +
    + +

    8.0References

    +
      +
    • github.com/zakelfassi/htmlify — the worked-example repo: skills, validator, CI, and this deck
    • +
    • agentskills.io — the SKILL.md format and compatible clients
    • +
    • skills/htmlify/SKILL.md — trigger description and operating rules studied in Act 1–2
    • +
    • skills/deckify/SKILL.md — the skill that produced this deck
    • +
    • skills/deckify/references/deck-template.md — the DOM contract the deck validator enforces
    • +
    • skills/deckify/references/hardcopy.md — the visual identity used by this handout
    • +
    • bin/htmlify-answer.js · src/validate.js — the validation CLI from checkpoint 3
    • +
    +

    Generated with the deckify skill · validated with --profile deck · 0 errors

    +
    + + + + diff --git a/examples/htmlify/decision-brief.html b/examples/htmlify/decision-brief.html new file mode 100644 index 0000000..86ded17 --- /dev/null +++ b/examples/htmlify/decision-brief.html @@ -0,0 +1,637 @@ + + + + + +Decision Brief — How should deckify be combined with htmlify? + + + + + + + + +
    +
    +

    Decision-Brief · htmlify · 2026-06-11

    +

    How should deckify be combined with htmlify?

    +

    Packaging decision for the 1.0 open-source release: where the deckify presentation skill lives relative to the htmlify document skill, and what that means for installation, maintenance, and the shared runtime.

    + +
    +
    + Mode + Decision-Brief +
    +
    + Decision + deckify packaging +
    +
    + Status + Decided · Option A +
    +
    + Date + 2026-06-11 +
    +
    + Source + github.com/zakelfassi/htmlify +
    +
    +
    + +
    +
    + 1.0 · Context +

    Where the two skills stood before 1.0

    +
    +

    deckify existed only as a divergent local SKILL.md fork of htmlify 0.3.1 on the author's machine — unversioned, untested, unpublished. htmlify was a single-skill repository with its SKILL.md at the repo root. The goal: a launch-grade 1.0 open-source release that gives deckify a real home without compromising either skill.

    +
    +
    +
    htmlify · before
    +

    Single-skill repo, SKILL.md at root, published as one npm package with the --validate CLI, hooks, and Pi/OMP runtime.

    +
    +
    +
    deckify · before
    +

    Local-only fork of htmlify 0.3.1, drifted from upstream, no CI, no release flow, no distribution path. UNVERSIONED

    +
    +
    +
    + +
    +
    + 2.0 · Options +

    Three packaging shapes considered

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Option A — chosen + Monorepo skill familyOption B + Fold into one SKILL.mdOption C + Two repos
    Shapeskills/htmlify + skills/deckify in one repo, one npm package, shared runtime and validator.deckify becomes a “deck mode” section inside htmlify's single SKILL.md.Separate deckify repo depending on htmlify.
    Pros
      +
    • One CI / release / docs surface
    • +
    • deckify gets versioned, tested, published
    • +
    • Shared --validate CLI becomes a real common core
    • +
      +
    • Simplest possible change
    • +
      +
    • Cleanest separation of concerns
    • +
    Cons
      +
    • Breaking: root SKILL.md removed; existing clones must re-install pointing at skills/htmlify
    • +
    • Slightly larger package
    • +
      +
    • Bloats one SKILL.md
    • +
    • Weakens deckify's trigger-matching / discoverability in agents
    • +
    • Loses distinct identity
    • +
      +
    • Doubles maintenance surface: two CIs, READMEs, release flows
    • +
    • All that for one file of divergence
    • +
    CostOne-time migration at the 1.0 boundary; entry points unchanged.Ongoing discoverability tax on every agent session.Ongoing 2× release and docs overhead, forever.
    +
    + +
    +
    + 3.0 · Criteria +

    What the options were weighed against

    +
    +
    +
    + 3.1 + Maintenance surface + One CI, one release flow, one docs site beats two of each. Favors A; disqualifies C's standing overhead. +
    +
    + 3.2 + Skill discoverability + Agents trigger-match on distinct skill descriptions. A separate deckify SKILL.md keeps deck requests routing correctly; B buries them. +
    +
    + 3.3 + Version / test coverage for deckify + deckify must stop being an untracked local fork. A and C both fix this; B leaves it as a section without its own identity. +
    +
    + 3.4 + Migration cost + A breaks root-path installs once. Acceptable only at a major-version boundary — which 1.0 is. +
    +
    + 3.5 + 1.0 timing + The release deadline rewards the option that ships both skills launch-grade now, not after a second repo is stood up. +
    +
    +
    + +
    +
    + 4.0 · Decision +

    Decision panel

    +
    +
    +
    + Resolution + DECIDED +
    +
    +

    Option A — monorepo skill family: skills/htmlify + skills/deckify, one npm package, shared runtime and validator.

    +

    The breaking change (root SKILL.md removed) is accepted at the 1.0 boundary, with a migration note shipped alongside. C's separation buys nothing for one file of divergence; B's simplicity costs deckify its agent-facing identity permanently.

    +
    +
    +
    + Option + A · MONOREPO SKILL FAMILY +
    +
    + Date + 2026-06-11 +
    +
    + Decider + Zak El Fassi (maintainer) +
    +
    +
    +
    + +
    +
    + 5.0 · Consequences & Migration +

    What changes, and what existing installs must do

    +
    +
    +
    +
    Breaking change BREAKING
    +

    The root SKILL.md is removed. Installs that point at the repository root stop resolving at 1.0.

    +
    +
    +
    Migration ONE-TIME
    +

    Re-install pointing at skills/htmlify; optionally also skills/deckify. CLI, hook, and Pi entry points are unchanged.

    +
    +
    +
    Vendorability
    +

    Each skill folder must be individually vendorable: an agent that copies only skills/deckify gets a complete, working skill.

    +
    +
    +
    Shared references TEST-ENFORCED
    +

    Shared references — the Hardcopy spec — ship as identical copies in both skills, enforced byte-identical by a test.

    +
    +
    +
    +
    Migration · shell
    +
    # Before 1.0 (root SKILL.md)        # From 1.0 (skill folders)
    +github.com/zakelfassi/htmlify   →   github.com/zakelfassi/htmlify → skills/htmlify
    +                                    github.com/zakelfassi/htmlify → skills/deckify  (optional)
    +
    +# Unchanged entry points
    +npx -y @zakelfassi/htmlify htmlify-answer --validate <file> --profile rich
    +
    +
    + +
    +
    + 6.0 · Revisit Triggers +

    Conditions that reopen this decision

    +
    +
    +
    + 6.1 + deckify's runtime needs diverge materially from htmlify's — its own validator profile family, build step, or dependencies the shared core should not carry. + → Revisit Option C +
    +
    + 6.2 + The byte-identical-copy test for shared references becomes a recurring friction point — frequent intentional divergence of the Hardcopy spec between skills. + → Extract shared spec package +
    +
    + 6.3 + A third skill joins the family and the single npm package's size or release coupling starts penalizing consumers of one skill. + → Revisit per-skill packages +
    +
    +
    + + +
    + + diff --git a/examples/htmlify/explainer.html b/examples/htmlify/explainer.html new file mode 100644 index 0000000..ece3975 --- /dev/null +++ b/examples/htmlify/explainer.html @@ -0,0 +1,570 @@ + + + + + +When HTML Beats Markdown + + + + + + + + +
    +
    +
    + Mode + EXPLAINER +
    +
    + Topic + HTML VS MARKDOWN +
    +
    + Audience + AGENT USERS +
    +
    + Date + 2026-06-11 +
    + +
    + +
    +

    When HTML beats markdown

    +

    The htmlify decision model: which answers should leave the terminal as a self-contained HTML artifact, and which should stay as plain text. Distilled from the project's principles reference.

    + + +
    + +
    + 1.0 · Decision criteria +

    Choose HTML when…

    +

    At least one of these five conditions must hold. If none do, stay in markdown.

    +
      +
    • + + C-01 + Side-by-side comparison. The reader needs to compare options, diffs, designs, risks, timelines, or plans next to each other. +
    • +
    • + + C-02 + The work has a shape. Architecture, flow, ownership, dependencies, lifecycle, or an incident timeline — spatial structure that linear prose flattens. +
    • +
    • + + C-03 + It travels. The artifact will be reviewed in a meeting, printed to PDF, archived, or handed to another implementer. +
    • +
    • + + C-04 + Interaction helps. Tabs, filters, toggles, collapsible detail, comments, drag/drop ordering, or copy/export buttons would genuinely serve the user. +
    • +
    • + + C-05 + It can be a tool. The output can become a reusable local tool rather than a static answer. +
    • +
    +

    Inverse rule: stay in markdown when the answer is short, linear, or command-like.

    +
    + +
    + 2.0 · Comparison matrix +

    Markdown vs HTML, by situation

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SituationMarkdownHTMLVerdict
    Comparing options side by sideSequential sections; the reader scrolls back and forth to hold both in mind.Columns, matrices, and decision panels put tradeoffs in one viewport.HTML
    Work with shape / architectureASCII diagrams and nested lists approximate structure, poorly.Inline SVG flowcharts, module maps, ownership lanes, dependency graphs.HTML
    Meeting, PDF, or archive useRenders differently per viewer; no layout control for print.One self-contained file with print CSS; opens anywhere, archives intact.HTML
    Interaction needsStatic by definition; no tabs, filters, or collapsible detail.Browser-native tabs, toggles, accordions, and export controls (per profile).HTML
    Reusable toolAn answer you read once.A local editor, triage board, or tuner you keep and reopen.HTML
    Short, linear, or command-like answerInstant to read, trivial to copy, fits the terminal it was born in.Overhead with no payoff; the file ceremony adds nothing.MARKDOWN
    +
    + +
    + 3.0 · Artifact families +

    Nine families of HTML artifact

    +
    +
    + F-01 +

    Exploration & planning

    +

    Side-by-side approaches, visual directions, implementation plans with milestones, data flow, risky code, gates, rollback.

    +
    +
    + F-02 +

    Code review & understanding

    +

    Annotated diffs, module maps, file tours, call graphs, reviewer focus lists, severity tags, jump links.

    +
    +
    + F-03 +

    Design

    +

    Tokens, swatches, type scales, spacing systems, component variants, states, and accessibility notes as live surfaces.

    +
    +
    + F-04 +

    Prototyping

    +

    Small clickable flows or animation sandboxes, only when interaction changes the decision. Exportable state if it edits.

    +
    +
    + F-05 +

    Diagrams

    +

    Inline SVG flowcharts, architecture maps, lifecycle diagrams, figure sheets. Readable labels, no decorative complexity.

    +
    +
    + F-06 +

    Decks

    +

    Section slides with arrow-key navigation, progress, print CSS, and dense meeting-ready copy.

    +
    +
    + F-07 +

    Research & learning

    +

    Explainers with TL;DR boxes, collapsible path steps, tabbed code samples, examples, glossary, and FAQ.

    +
    +
    + F-08 +

    Reports

    +

    Status cards, small charts, timelines, proof snippets, shipped/slipped/carryover columns, next-action boards.

    +
    +
    + F-09 +

    Custom editors

    +

    Local-only UI for manipulating data: triage boards, flag editors, prompt tuners, ordering tools. Always with copy/export.

    +
    +
    +
    + +
    + 4.0 · Design registers +

    Pick the register before colors

    +
    +
    + Operational + Dense, scannable, restrained, proof-forward. +
    +
    + Product + Efficient repeated use, clear controls, stable states. +
    +
    + Brand + Image-led or object-led, strong first viewport, memorable but not noisy. +
    +
    + Learning + Calm hierarchy, examples, progressive detail, glossary. +
    +
    +
    + +
    + 5.0 · Glossary +

    Terms

    +
    +
    +
    Artifact
    +
    A single browser-ready HTML file produced from agent or repo context — a brief, plan, map, report, explainer, diagram, prototype, or editor. Stdout, made permanent.
    +
    +
    +
    Self-contained
    +
    Everything inline: CSS and any permitted JS live in the file. No external fonts, CDNs, assets, analytics, or build step unless the user explicitly asks.
    +
    +
    +
    Operator surface
    +
    The first viewport, designed for the person running the work: it must reveal the subject, current status, and where attention should go before any scrolling.
    +
    +
    +
    Validation profile
    +
    The rule set an artifact is checked against. rich for script-free documents, app for artifacts with legitimate inline interactivity, deck for slide decks.
    +
    +
    +
    Plate
    +
    The signature Hardcopy device: an engineering-drawing title block — a hairline grid of mono-uppercase cells (mode, source, date, counts) that opens documents and footers decks.
    +
    +
    +
    + +
    + 6.0 · FAQ +

    Questions agents and operators actually ask

    +
    +
    +

    Why not just markdown?

    +

    Often you should — markdown is the correct format for short, linear, command-like answers, and htmlify says so explicitly. HTML replaces markdown only when the work is visual, spatial, comparative, interactive, or reusable in the browser. The five criteria in section 1.0 are the gate; if none apply, an HTML file is ceremony without payoff.

    +
    +
    +

    Is the script ban absolute?

    +

    No — it is per profile. The rich profile forbids scripts: explainers, briefs, reports, and plans should not need them. The app profile allows inline scripts for artifacts that legitimately carry interactivity — editors, prototypes, triage boards — provided the script is authored directly in the artifact. The deck profile covers slide decks with keyboard navigation. In all cases generated HTML is treated as untrusted until validated.

    +
    +
    +

    Can I print these?

    +

    Yes — print CSS is required for briefs, reports, plans, and decks, and recommended for anything meant to be shared, archived, or exported as PDF. Under Hardcopy, the paper turns white, crop marks render literally, the plate prints as the document header, and stamps stay grayscale-legible. This page carries its own @media print rules.

    +
    +
    +

    What about my own design system?

    +

    Yours wins. Hardcopy is the default visual identity, not a mandate: when a project supplies DESIGN.md, brand tokens, or an established design system, those are authoritative and the artifact should be styled with them instead.

    +
    +
    +

    How do agents validate output?

    +

    With the bundled validator, run before the final response. It checks for a standalone doctype/html/body structure, inline-only assets, profile-appropriate interactivity, and structural soundness. Every reported error must be fixed before the artifact ships; remaining warnings are reported to the user.

    +
    +
    shell
    +
    npx -y @zakelfassi/htmlify htmlify-answer --validate artifact.html --profile rich
    +
    +
    +
    +
    + +
    +
    + Generator + HTMLIFY · EXPLAINER +
    +
    + Basis + REFERENCES/HTMLIFY-PRINCIPLES.MD +
    +
    + Issued + 2026-06-11 +
    +
    +
    + + diff --git a/examples/htmlify/implementation-map.html b/examples/htmlify/implementation-map.html new file mode 100644 index 0000000..5ac1444 --- /dev/null +++ b/examples/htmlify/implementation-map.html @@ -0,0 +1,630 @@ + + + + + + htmlify runtime — implementation map + + + +
    + +
    +
    +
    Implementation-Map
    +
    +

    htmlify runtime — how an answer becomes an artifact

    +

    Module map of the htmlify Node runtime: a dependency-layered core under src/, one facade, three entry surfaces (CLI, Claude Code hook, Pi/OMP extension), and a validator that gates every model-generated document before it touches disk.

    +
    +
    +
    +
    Scoperuntime · src/
    +
    Modules17 files · 2,638 LOC
    +
    Date2026-06-11
    +
    Branchv1
    + +
    +
    + + +
    +
    1.0 · System ShapeHot Path Marked
    +

    Module dependency graph

    +
    +
    + + + + + + + + + ENTRY POINTS + RUNTIME CORE + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + bin/htmlify-answer.js + hooks/claude-code-stop-… + Pi / OMP host + + + CLI · EXPORT + --VALIDATE + CLAUDE CODE STOP HOOK + LOADS createExtension(pi) + + + + + index.js — facade + EXPORTS createExtension · RE-EXPORTS EVERY MODULE VIA _internals + + + + + + + + + + + + + + + + + + + + + + + + src/text.js + src/constants.js + src/comments.js + src/markdown.js + src/document.js + src/annotation.js + src/validate.js + src/artifacts.js + src/extension/index.js + extension/messages.js + extension/parse.js + extension/open.js + extension/prompts.js + + + SHA · ESCAPE · COUNTS + LIMITS · SAFETY REGEXES + BUNDLE → REVIEW PROMPT + renderMarkdownish + HARDCOPY DOC SHELL + COMMENT LAYER INJECT + RICH · APP · DECK + writeHtmlArtifact + EVENTS · COMMANDS · STATE + extractMessageInfo + COMMAND + ARG PARSING + openArtifact + buildRichHtmlPrompt + + + + + + + + + + + + + + 01 + 02 + 04 + 05 + 06 + 07 + 08 + + + + + HOT PATH — LONG ANSWER → OPENED ARTIFACT · BADGES MATCH STEPS IN SHEET 2.0 + +
    +
    FIG 1 · Module dependency graph, arrows point from dependency to consumer · Source: zakelfassi/htmlify @ v1, require() statements in index.js, src/, bin/, hooks/
    +
    +

    Layering is strict and acyclic: constants and text have no internal dependencies; markdown, document, annotation, and validate build on them; artifacts composes all four to write files; src/extension/index.js is the only stateful module and the only one that talks to a host. bin/htmlify-answer.js and hooks/claude-code-stop-htmlify.js never import core modules directly — both go through the index.js facade's _internals.

    +
    + + +
    +
    2.0 · Hot Pathmessage_end → opened file
    +

    Long answer to opened artifact, in eight calls

    +
    +
    +
    01
    +
    extractMessageInfo(event)
    +
    src/extension/messages.js:35
    +
    On message_end, normalizes loose host event shapes (message / entry / payload / data), keeps only assistant-role text, returns { id, role, text } with a SHA-1 fallback id.
    +
    +
    +
    02
    +
    buildSourceRecord(text)
    +
    src/extension/index.js:190
    +
    Derives a title, computes character/line/paragraph/word stats, and stores the record as state.lastEligible; a custom session entry (html-long-answer-source, sans text) persists it across reloads. isLongAnswer gates at 1,800 chars / 24 lines / 6 paragraphs.
    +
    +
    +
    03
    +
    /htmlify → exportLatestFromCommand
    +
    src/extension/index.js:504 · parse.js:18
    +
    parseHtmlCommandInput matches /htmlify, /html-last, /htmlify-last; resolveForcedExportMode maps args to choose / rich-gemini / rich-pi / local. Default path is the local render.
    +
    +
    +
    04
    +
    renderMarkdownish(source.text)
    +
    src/markdown.js:64
    +
    Single-pass line scanner: fenced code becomes a carbon well with a language meta strip, headings shift one level down, blockquotes become callouts, pipe tables become real tables, lists and inline links/bold/code are formatted — all through escapeHtml first.
    +
    +
    +
    05
    +
    writeHtmlArtifact({ title, bodyHtml, sourceText, mode })
    +
    src/artifacts.js:25
    +
    Resolves the export root (HTMLIFY_EXPORT_ROOT → legacy PI_HTML_LONG_ANSWER_EXPORT_ROOT → os tmpdir), builds a timestamp-slug-mode filename, and stamps the answer's SHA-1 as sourceId so later comments can be matched to it.
    +
    +
    +
    06
    +
    buildLocalHtmlDocument(title, body, meta)
    +
    src/document.js:69
    +
    Wraps the body in the Hardcopy skin: plate title block with mode/word/character cells, crop marks, numbered index rail from the headings, carbon code wells, dark-mode tokens, and print rules.
    +
    +
    +
    07
    +
    addCommentableAttributes + injectAnnotationLayer
    +
    src/annotation.js:7,160
    +
    Tags every block element with data-commentable / data-block-id="b-N", then appends the trusted annotation layer before </body>: a localStorage-backed comment panel keyed by sourceId, exporting Markdown or JSON for /htmlify-comments. Guarded by a marker comment so it is never injected twice.
    +
    +
    +
    08
    +
    writeFile → openArtifact(filePath)
    +
    src/artifacts.js:43 · src/extension/open.js:41
    +
    File hits disk; openArtifact resolves /usr/bin/open (macOS) or xdg-open (Linux) from PATH, spawns it detached, and treats survival past a 1,000 ms failure window as success. HTMLIFY_SKIP_OPEN=1 suppresses it.
    +
    +
    +

    The rich variant forks at step 03: queueRichExport either shells out to the Gemini CLI or sends buildRichHtmlPrompt back to the host as a follow-up turn, then maybeHandlePendingRichExport catches the next assistant message, extracts the fenced HTML, and routes it through the validation path below — falling back to the local render if the document is unsafe or plain text.

    +
    + + +
    +
    3.0 · Validation PathGate Before Disk
    +

    Two doors, one set of collectors

    +
    +
    +

    Runtime gate — model HTML

    +
      +
    • extractHtmlDocument (messages.js) pulls a fenced ```html block or bare document out of the model's reply.
    • +
    • writeRichHtmlArtifact (artifacts.js) calls validateRichHtmlDocument (validate.js:216), which runs the rich collector and throws on the first error — nothing invalid is ever written.
    • +
    • On throw, the extension notifies and writes an llm-enhanced-fallback local render instead, so the user always gets a file.
    • +
    +

    What the rich collector rejects

    +
      +
    • Blocked tags: script, iframe, object, embed, link, base, and all form controls (BLOCKED_RICH_TAGS).
    • +
    • Event-handler attributes and javascript:-scheme URLs.
    • +
    • External assets in markup or CSS (EXTERNAL_ASSET_ATTR, EXTERNAL_CSS_URL), meta refresh.
    • +
    • Size: over 512 KB or 2,500 tags; not a standalone document. Missing doctype and missing local assets are warnings.
    • +
    +
    +
    +

    CLI gate — --validate

    +
      +
    • bin/htmlify-answer.js validates files against a profile; detectProfile (validate.js:247) sniffs each document when set to auto: slides + speaker notes → deck, any inline script → app, otherwise rich.
    • +
    • app profile (allowInlineScript: true) permits inline scripts and form controls for editors/prototypes, but still bans external scripts, embeds, event-handler attributes, and non-data: link hrefs.
    • +
    • deck profile adds the deckify contract via collectDeckIssues: non-empty title, viewport meta, ≥2 slide sections, a keydown listener for navigation, speaker notes on every substantive slide, 2 MB budget; missing print CSS is a warning.
    • +
    • Exit codes: 0 valid · 1 validation errors · 2 usage/IO error. --format json emits machine-readable reports.
    • +
    +
    +
    +
    +
    shell · validate this very file
    +
    node bin/htmlify-answer.js --validate examples/htmlify/implementation-map.html --profile rich
    +# examples/htmlify/implementation-map.html: valid — 0 errors, 0 warnings (profile: rich)
    +
    +
    + + +
    +
    4.0 · File TourLOC Measured 2026-06-11
    +

    Seventeen files, one job each

    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    PathLOCResponsibilityKey exports
    index.js45Public facade: the extension factory is the default export; every internal symbol is re-exported under _internals for the CLI, hook, and tests.createExtension · _internals
    src/constants.js72All tunables and safety regexes in one place: long-answer thresholds, size budgets, blocked-tag patterns, session entry types, the trusted-annotation marker.LONG_ANSWER_DEFAULTS · BLOCKED_RICH_TAGS · MAX_RICH_HTML_CHARS
    src/text.js79Dependency-free primitives: SHA-1 ids, HTML escaping, slugs, paragraph/line/word counts.sha · escapeHtml · slugify · wordCount
    src/markdown.js164Markdownish-to-HTML renderer for the local export path: fences, headings, callouts, pipe tables, lists, inline formatting.renderMarkdownish · formatInline
    src/document.js356The Hardcopy document shell: plate, crop marks, index rail, carbon wells, dark mode, print CSS; plus title/excerpt/outline derivation.buildLocalHtmlDocument · deriveTitle · buildOutlineHtml
    src/validate.js265Issue collectors and throwing validators for the rich/app/deck profiles, plus shape-sniffing profile detection and local-asset checks.collectRichHtmlIssues · collectDeckIssues · validateRichHtmlDocument · detectProfile
    src/annotation.js172Review layer: marks blocks commentable and injects the localStorage comment panel (the one place trusted inline script is generated).addCommentableAttributes · injectAnnotationLayer
    src/comments.js75Round trip for reviewer comments: validates a downloaded JSON bundle against the captured sourceId and renders it as an agent prompt.validateCommentBundle · buildCommentsPrompt
    src/artifacts.js69The only module that writes files: resolves the export root, names artifacts, composes document + annotation, validates rich HTML before write.writeHtmlArtifact · writeRichHtmlArtifact · getExportRoot
    src/extension/index.js685Pi/OMP extension runtime: session state, event wiring (message_end, session restore), slash commands, mode choice UI, Gemini shell-out, rich-export follow-up loop.module.exports = createExtension(pi)
    src/extension/messages.js97Host-event normalization: extract assistant text from arbitrary event shapes, long-answer detection, fenced-HTML extraction.extractMessageInfo · isLongAnswer · extractHtmlDocument
    src/extension/parse.js60Slash-command and argument parsing; maps user args to forced export modes.parseHtmlCommandInput · resolveForcedExportMode
    src/extension/open.js82Opens the written artifact in the default browser via a PATH-resolved platform opener, detached, with a short failure window.openArtifact · resolveOpenCommand
    src/extension/prompts.js32The single prompt template asking a model to redesign a captured answer as a standalone HTML artifact.buildRichHtmlPrompt
    src/extension/types.js87JSDoc typedefs for the host surface and records — no runtime code.PiHost · ExtensionCtx · SourceRecord · ExportMeta
    bin/htmlify-answer.js228CLI: pipe text in to export an artifact, or --validate files against rich/app/deck/auto profiles with text or JSON reports.main (via _internals)
    hooks/claude-code-stop-htmlify.js70Claude Code Stop hook: reads hook JSON from stdin, exports last_assistant_message when it clears HTMLIFY_MIN_CHARS (default 2,500); never fails the agent turn.main (via _internals)
    TOTAL2,638wc -l, 2026-06-11 · orange rows sit on the hot path
    +
    +
    + + +
    +
    5.0 · Edit SequencesWhere To Cut
    +

    Three common changes, in order

    +
    +
    + SEQ A +

    Add an artifact mode

    +
      +
    1. Modes are strings, not enums — define the new mode and its sections in skills/htmlify/SKILL.md first; the runtime carries it through untouched.
    2. +
    3. If users should force it from a slash command, add an alias in resolveForcedExportMode (src/extension/parse.js) and a branch in handleChoice / exportLatestFromCommand (src/extension/index.js).
    4. +
    5. The mode lands in the filename suffix and plate cell via writeHtmlArtifact (src/artifacts.js); adjust the mode copy switch in buildLocalHtmlDocument (src/document.js:341) if the local shell should describe it differently.
    6. +
    +
    +
    + SEQ B +

    Add a validator check

    +
      +
    1. Put the regex or limit in src/constants.js — collectors never define their own patterns.
    2. +
    3. Add the check in collectRichHtmlIssues or collectDeckIssues (src/validate.js), choosing error (blocks the write, fails CI) vs warning (reported only). Mind the allowInlineScript fork for app/deck.
    4. +
    5. No further wiring: validateRichHtmlDocument, the CLI --validate path, and the runtime gate all consume the collectors. Only a brand-new collector needs an index.js facade entry and a CLI profile branch.
    6. +
    +
    +
    + SEQ C +

    Add an agent integration

    +
      +
    1. Copy the shape of hooks/claude-code-stop-htmlify.js: a small executable that reads the agent's payload from stdin and exits 0 even on failure.
    2. +
    3. Call the facade — require('../index.js')._internals — for renderMarkdownish + writeHtmlArtifact; expose thresholds and paths as env vars (HTMLIFY_MIN_CHARS, HTMLIFY_EXPORT_ROOT).
    4. +
    5. Register it in the agent's hook config, then document install steps in skills/htmlify/references/agent-integrations.md alongside the existing Codex / Claude Code / Cursor entries.
    6. +
    +
    +
    +
    + +
    + Implementation-Map · htmlify @ v1 · 17 files · 2,638 LOC + Evidence: direct read of index.js, src/, src/extension/, bin/, hooks/ · 2026-06-11 +
    + +
    + + diff --git a/examples/htmlify/incident-timeline.html b/examples/htmlify/incident-timeline.html new file mode 100644 index 0000000..accf46d --- /dev/null +++ b/examples/htmlify/incident-timeline.html @@ -0,0 +1,520 @@ + + + + + + Incident Report — Capture Notification Overwrote the Answer + + + + + + + + +
    +
    +

    Incident-Report · github.com/zakelfassi/htmlify · pi-html-long-answer-extension · 2026-04-26

    +

    Capture Notification Overwrote the Answer

    +

    + The long-answer capture extension notified the user from the message_end event. + In Oh-My-Pi, that notification could replace the just-finished assistant text in the terminal — + the answer the user was reading visibly vanished. Fixed by removing the notification entirely: + capture is now silent, and /html-last remains the explicit export path. +

    + +
    +
    + Mode + INCIDENT-REPORT +
    +
    + Incident + capture-notification overwrite +
    +
    + Fixed + bd7c5c6 · 2026-04-26 +
    +
    + Severity + user-visible +
    + +
    +
    + +
    +
    + 1.0 · Impact +

    What the user experienced

    +
    +
    +
    + Severity + Answer loss on screen +

    The finished assistant answer was visibly replaced by the capture notice in the terminal. USER-VISIBLE

    +
    +
    + Scope + OMP hosts only +

    The replacement behavior manifested in Oh-My-Pi rendering of message_end-time notifications.

    +
    +
    + Duration + 6 days +

    Notification shipped 2026-04-20 (08e669b); removed 2026-04-26 (bd7c5c6).

    +
    +
    + Data loss + None +

    The answer was always captured into session state; /html-last could still export it. RECOVERABLE

    +
    +
    +
    + +
    +
    + 2.0 · Timeline +

    Discovery to validation

    +
    +
    +
    + 2026-04-20 + 08e669b +

    Introduced. Extension ships with notifyLongAnswerAvailable() called from the message_end handler: every long answer ends with an informational notice.

    + SHIPPED +
    +
    + 2026-04-24 + f5fcac7…7b9e546 +

    Hardening pass. PR #1 hardens the extension (sandboxed open, SVG href blocking); the message_end notification behavior is retained unchanged.

    + UNCHANGED +
    +
    + ≤ 2026-04-26 + +

    Discovery. In Oh-My-Pi, the capture notice could replace the just-finished assistant text on screen. Exact report time is not recorded in git history.

    + NEEDS VERIFICATION +
    +
    + 2026-04-26 + bd7c5c6 +

    Diagnosis. Root cause isolated to the notification side-effect fired from message_end, racing the host’s rendering of the message that just ended.

    + CONFIRMED +
    +
    + 2026-04-26 00:26 + bd7c5c6 +

    Fix. notifyLongAnswerAvailable() deleted; the call site replaced by an explanatory comment. Capture stays silent and headless-safe. Version 0.2.0 → 0.2.1; extension build 2026-04-20e2026-04-26a.

    + FIXED +
    +
    + 2026-04-26 + bd7c5c6 +

    Validation. 42 test lines added in test/extension.test.js: the capture test now asserts no notification fires, and a new chooser-fallback test proves export still works end to end. README and flow diagram updated to describe silent capture.

    + PASS +
    +
    +
    + +
    +
    + 3.0 · Root Cause +

    A UI side-effect racing the host’s render

    +
    +

    + The message_end handler did two jobs: capture the long answer into session state + (correct) and immediately call notify() with a multi-line usage hint (the defect). + message_end fires at the exact moment the host is finalizing the on-screen rendering + of that same message. In Oh-My-Pi, the notification emitted inside that window could land on the + region holding the just-finished assistant text and replace it — the user watched their + answer disappear in favor of a notice about exporting it. +

    +

    + The capture itself never needed the UI. The earlier guard if (!ctx || !ctx.hasUI) return; + existed only to gate the notification, which means the defective side-effect was also silently + blocking capture on headless hosts — a secondary bug removed by the same fix. +

    +
    + +
    +
    + 4.0 · The Fix +

    Remove the notification, keep the capture

    +
    +
    +
    diff · index.js · bd7c5c6 · excerpt
    +
    @@ message_end handler @@
    +   await rememberEligibleSource(source);
    +
    +   if (!isLongAnswer(info.text, state.config)) return;
    +-  if (!ctx || !ctx.hasUI) return;                01
    +
    +   state.lastPromptedSignature = signature;
    +-  notifyLongAnswerAvailable(ctx, source);       02
    ++  // Avoid notifying from message_end: in OMP this can replace
    ++  // the just-finished assistant text.      03
    ++  // The answer is already captured; /html-last remains
    ++  // available when the user wants the export.
    +
    +@@ helper removed @@
    +-  function notifyLongAnswerAvailable(ctx, source) {
    +-    notify(ctx, `Long answer captured for HTML export
    +-      (${source.stats.words} words). Run /html-last ...`, 'info');
    +-  }
    +
    +
      +
    • 01The UI guard is gone: capture no longer depends on a UI being present, so headless hosts now record long answers too.
    • +
    • 02The single defective side-effect — the only notify() issued from message_end — is deleted along with its helper.
    • +
    • 03The rule replaces the call as a code comment, so the constraint survives at the exact place a future change would re-violate it.
    • +
    +

    + Excerpt is condensed for the page; the comment in the repository is two lines. Full diff: git show bd7c5c6. + The same commit also adds a choices alias for the chooser, a PI_HTML_LONG_ANSWER_SKIP_OPEN + test escape hatch, and makes /html-last choose fall back to local export when no chooser UI exists. +

    +
    + +
    +
    + 5.0 · Mitigations & Follow-ups +

    Keeping this class of bug out

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Rule / actionWhere it livesStatus
    message_end handlers must stay side-effect-free toward the UI — capture state, never render.Code comment at the former call site in index.js (bd7c5c6)IN PLACE
    Regression test asserts the capture path emits zero notifications (assertion flipped from expecting one to forbidding it).test/extension.test.jsIN PLACE
    Export remains user-initiated only: /html-last, /html-last choose, gemini, pi, local.README manual re-test flows, updated in the same commitIN PLACE
    User-facing docs and flow diagram no longer promise a notice; they promise the answer stays visible.README.md, assets/flow.svgUPDATED
    +
    + +
    +
    + 6.0 · Validation +

    Proof the fix holds

    +
    +
      +
    • Tests: +42 lines in test/extension.test.js (1 line changed). The long-message capture test now asserts no Long answer captured notification is emitted; a new test drives message_end/html-last choose with a failing chooser and proves a local export is written and announced only then. PASS
    • +
    • Behavior re-verified: capture still records the source entry (html-long-answer-source) on long answers, with no UI required and no model turn started. PASS
    • +
    • Releases: package 0.2.1, extension build 2026-04-26a. Subsequent commits (1c6f029, e1df5c3 on 2026-05-05) build on the silent-capture behavior without reintroducing a message_end notification.
    • +
    • Gap: no automated OMP terminal-rendering test exists; the overwrite itself was verified manually in OMP, not in CI. MANUAL ONLY
    • +
    +
    + +
    + INCIDENT-REPORT · HTMLIFY · HARDCOPY + EVIDENCE: GIT SHOW BD7C5C6 · GIT LOG INDEX.JS + GENERATED 2026-06-11 +
    +
    + + diff --git a/examples/htmlify/launch-board.html b/examples/htmlify/launch-board.html new file mode 100644 index 0000000..846283c --- /dev/null +++ b/examples/htmlify/launch-board.html @@ -0,0 +1,589 @@ + + + + + + htmlify v1.0.0 — Launch Board + + + + + + + + +
    +
    +

    htmlify · launch board · branch v1 · 2026-06-11

    +

    v1.0.0 Launch Checklist

    + +
    +
    + Mode + Prototype · Editor +
    +
    + Board + v1.0.0 Launch +
    +
    + Items + 17 +
    +
    + Date + 2026-06-11 +
    + +
    +
    + +
    +
    8/17Done
    +
    1/17In Progress
    +
    6/17Todo
    +
    2/17Blocked
    +
    + +
    + + + Click a stamp to cycle status · state persists in this browser +
    + +
    +

    1.0 · Board Items

    +

    Triage board

    +
      +
    1. + 01 +
      + Governance docs + LICENSE · CONTRIBUTING · CODE_OF_CONDUCT · SECURITY +
      + +
    2. +
    3. + 02 +
      + Biome / tsc tooling + Lint, format, and typecheck wired into package scripts +
      + +
    4. +
    5. + 03 +
      + src extraction + Runtime split from index.js into src/ modules +
      + +
    6. +
    7. + 04 +
      + JSDoc strict types + checkJs strict pass with zero tsc errors +
      + +
    8. +
    9. + 05 +
      + Skills restructure + deckify + skills/htmlify and skills/deckify in agentskills.io layout +
      + +
    10. +
    11. + 06 +
      + --validate CLI + htmlify-answer --validate with rich / app / deck profiles +
      + +
    12. +
    13. + 07 +
      + Hardcopy identity + theme reskin + Tokens, plate, stamps, crop marks across renderer and docs +
      + +
    14. +
    15. + 08 +
      + Plugin manifest + .claude-plugin/plugin.json for Claude Code marketplaces +
      + +
    16. +
    17. + 09 +
      + Example gallery + examples/htmlify/* — including this board +
      + +
    18. +
    19. + 10 +
      + Landing page + Pages + docs/ landing page deployed via GitHub Pages +
      + +
    20. +
    21. + 11 +
      + CI matrix rewrite + Lint, typecheck, test across supported Node versions +
      + +
    22. +
    23. + 12 +
      + release-please + Automated version bumps and changelog PRs +
      + +
    24. +
    25. + 13 +
      + README rewrite + Install matrix, validator docs, gallery links +
      + +
    26. +
    27. + 14 +
      + Push + open PR + Branch v1 → main +
      + +
    28. +
    29. + 15 +
      + Merge + release PR + Merge v1, then land the release-please PR +
      + +
    30. +
    31. + 16 +
      + npm publish + Publish @zakelfassi/htmlify to the npm registry + Owner: Zak · needs NPM_TOKEN repo secret +
      + +
    32. +
    33. + 17 +
      + agentskills.io submission + Submit the skill listing after the npm release + Owner: Zak · gated by npm publish +
      + +
    34. +
    +
    + +
    + htmlify · launch-board · profile: app + storage key: htmlify-launch-board + generated 2026-06-11 +
    +
    + + + + diff --git a/examples/htmlify/operator-brief.html b/examples/htmlify/operator-brief.html new file mode 100644 index 0000000..1aceadf --- /dev/null +++ b/examples/htmlify/operator-brief.html @@ -0,0 +1,616 @@ + + + + + +State of the htmlify v1.0.0 launch + + + + + + + + +
    +
    +
    ModeOperator-Brief
    +
    ProjectHTMLIFY
    +
    Date2026-06-11
    +
    Sourcegithub.com/zakelfassi/htmlify @ v1
    +
    Generatorhtmlify skill
    +
    +

    State of the htmlify v1.0.0 launch

    +

    Pi long-answer extension → cross-agent skill family · 8 commits on v1, not yet pushed · release: 1.0.0 on merge

    +
    +
    + +
    + + +
    + +
    +
    + 1.0 Scoreboard +

    All gates green on the branch

    +
    +
    +
    + Tests +
    35/35
    +
    node:test · 0 fail
    +
    +
    + Lint +
    0 err
    +
    biome · 29 files
    +
    +
    + Typecheck +
    Clean
    +
    tsc --noEmit · strict JSDoc
    +
    +
    + Commits on v1 +
    8
    +
    ahead of main
    +
    +
    + Skills shipped +
    2
    +
    htmlify · deckify
    +
    +
    + Validation profiles +
    3
    +
    rich · app · deck
    +
    +
    +
    + +
    +
    + 2.0 Shipped +

    The v1 branch, commit by commit

    +
    +
      +
    1. + bc723b5 +
      +
      Apache-2.0 license + governance docs Shipped
      +
      LICENSE, CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md land at the repo root.
      +
      +
    2. +
    3. + 0d743bb +
      +
      Biome + TypeScript tooling Shipped
      +
      biome.json formatting/lint and tsconfig.json with pnpm lint / typecheck scripts.
      +
      +
    4. +
    5. + f0ee0b9 +
      +
      src/ extraction from the monolith Shipped
      +
      The 1,421-line index.js becomes nine src/ modules (document, markdown, validate, comments, annotation, artifacts, text, constants, extension/) behind a 45-line entry shim. 14 files changed, +1,621 / −1,466.
      +
      +
    6. +
    7. + 8ee2c60 +
      +
      Strict JSDoc type checking Shipped
      +
      checkJs-strict typing across the runtime; tsc --noEmit is a green gate.
      +
      +
    8. +
    9. + 713d771 +
      +
      --validate CLI with three profiles Shipped
      +
      htmlify-answer --validate FILE --profile rich|app|deck|auto. rich bans all scripts; app allows inline only; deck adds the deckify contract. Exit codes 0/1/2.
      +
      +
    10. +
    11. + 532f5fa +
      +
      skills/ restructure + deckify Breaking
      +
      Repo becomes a skill family: skills/htmlify and skills/deckify, each with its own references. Root SKILL.md and references/ removed (see 4.0). Both skills now require validator runs.
      +
      +
    12. +
    13. + 7129d53 +
      +
      Hardcopy visual identity Shipped
      +
      references/hardcopy.md spec (plate, stamps, crop marks, carbon wells, one-accent rule) plus a reskin of the bundled document theme.
      +
      +
    14. +
    15. + 827733e +
      +
      Claude Code plugin manifest Shipped
      +
      .claude-plugin/plugin.json + marketplace.json; both skills auto-discovered from skills/; manifest integrity test suite keeps versions, frontmatter, and reference links in sync.
      +
      +
    16. +
    +
    + +
    +
    + 3.0 On deck +

    Remaining work before merge

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    #ItemGateState
    3.1Example gallery under examples/ — one artifact per mode, all validator-cleannode bin/htmlify-answer.js --validate, 0 errorsIn progress
    3.2Landing page + GitHub Pages deployPages build greenOn deck
    3.3CI matrix rewrite (test / lint / typecheck / example validation)All jobs green on PROn deck
    3.4release-please wiring for automated versioningrelease-please-config.json honored on mainOn deck
    3.5README rewrite for the skill-family shapeInstall paths verified for each agentOn deck
    3.6Push v1, open PR, merge to mainCI green + reviewOn deck
    +
    + +
    +
    + 4.0 Risks +

    What can bite, and the mitigation

    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    RiskEvidenceMitigationSeverity
    Root SKILL.md removal breaks existing installs that point at the repo root532f5fa · BREAKING CHANGE footerMigration: re-install pointing at skills/htmlify (and optionally skills/deckify). CLI, hook path, and Pi/OMP entry points are unchanged. Document in README + release notes.Breaking
    npm publish of 1.0.0 requires the NPM_TOKEN repo secretno secret configured yetOwner action before merge: add NPM_TOKEN to GitHub repo secrets so the release workflow can publish.Owner action
    agentskills.io submission process unverifiednot yet attemptedTreat listing as post-merge follow-up; do not gate 1.0.0 on it.Needs verification
    +
    + +
    +
    + 5.0 Validation +

    Proof, run on this branch today

    +
    +

    Full suite green locally; CI will re-run the same matrix plus example validation once 3.3 lands.

    +
    +
    SHELL · pnpm test · v1 @ 827733e
    +
    $ pnpm test
    +ℹ tests 35
    +ℹ pass 35
    +ℹ fail 0
    +ℹ cancelled 0
    +ℹ skipped 0
    +
    +$ pnpm lint
    +Checked 29 files. No fixes applied.   # 0 errors
    +
    +$ pnpm typecheck
    +tsc --noEmit                          # clean exit
    +
    + + + + + + + + +
    CheckResult
    Unit + integration tests (node:test: validate-cli, deck-validate, extension, manifest)Pass · 35/35
    Biome lint over 29 filesPass · 0 errors
    tsc --noEmit with strict JSDocPass
    Examples validated in CIPending 3.3
    +
    + +
    +
    + 6.0 Closeout +

    Recommendation

    +
    +
    +
    Call
    Continue
    +
    Release
    1.0.0 on merge to main
    +
    Next focus
    Example gallery, then CI, then push + PR
    +
    +
    + + + +
    +
    +
    + + diff --git a/examples/htmlify/pr-review-packet.html b/examples/htmlify/pr-review-packet.html new file mode 100644 index 0000000..8e69744 --- /dev/null +++ b/examples/htmlify/pr-review-packet.html @@ -0,0 +1,481 @@ + + + + + + PR #1 — Harden HTML export extension · htmlify review packet + + + + + + + + +
    +
    +
    +
    +

    PR-Review-Packet · zakelfassi/htmlify · Generated 2026-06-11

    +

    Harden HTML export extension

    +

    PR #1 · s-tier-extension-hardeningmain · 4 commits · merge 7b9e546

    +
    +
    +
    ModePR-Review-Packet
    +
    PR#1
    +
    Merged2026-04-25
    +
    Delta+553 −87
    + +
    Generatorhtmlify 0.3.1
    +
    +
    + +
    + +
    +

    1.0 · Motivation

    +

    Untrusted model HTML crosses a trust boundary

    +

    + The extension takes model-generated “rich” HTML, writes it to disk, and opens it in the + user's default browser. That output is untrusted: a model can be prompted (or simply + hallucinate) into emitting active content — script and embed tags, inline event handlers, + javascript: URLs, meta-refresh redirects, or asset references that phone out to + external hosts the moment the file opens. Before this PR, the rich-export path trusted the + model's document as-is. +

    +

    + This PR inserts a validation gate before any write or open: size caps + (512 KB / 2,500 tags), a blocked-tag list, and pattern checks for every known + active-content vector. It also adds the test suite and CI that keep those rules honest, and + prepares the package for npm publishing. Review follow-ups during the PR closed three + real bypasses: srcset, CSS @import, and SVG external hrefs. +

    +
    + +
    +

    2.0 · Review tour — read the commits in order

    +

    Four commits, narrowing from gate to bypasses

    +
      +
    1. +
      f5fcac7
      +
      +

      Harden HTML export extension

      +

      The substance. Introduces the validation constants and the reject-before-write gate; + makes the export root overridable via env var; switches process opening from shell + string to spawn; adds minimal CI and a 216-line node:test suite.

      +

      Review focus · the blocked-pattern regexes and the accept/reject fixtures that pin them

      +
      +
      +521 −86 · 7 files
      +
    2. +
    3. +
      ff59d70
      +
      +

      Prepare npm package publishing

      +

      Publish metadata in package.json (name, files allowlist, engines), + README install guidance for Pi/OMP, and test adjustments to match the packaged entry.

      +

      Review focus · the files allowlist — nothing private ships in the tarball

      +
      +
      +27 −7 · 3 files
      +
    4. +
    5. +
      c754578
      +
      +

      Address PR review hardening

      +

      Closes review findings: srcset could still reference external images and + CSS @import could still pull remote stylesheets — both regexes widened. + Also replaces a loose ctx.hasUI flag with a real capability check + (hasSelectableUi) before offering the export-mode picker.

      +

      Review focus · excerpt A below — each bypass gains a rejection fixture

      +
      +
      +12 −3 · 2 files
      +
    6. +
    7. +
      8bb6989
      +
      +

      Block SVG external asset hrefs

      +

      The last bypass: SVG image, use, and feImage + elements fetch via href/xlink:href, which the asset regex + (keyed to src/poster/srcset) never inspected. + One regex extension, two hostile fixtures.

      +

      Review focus · excerpt B below — the third regex alternative

      +
      +
      +3 −1 · 2 files
      +
    8. +
    +
    + +
    +

    3.0 · File tour — 7 files touched

    +

    Where the +553 −87 landed

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    FileWhat changedWhy it matters
    .github/workflows/ci.ymlNew, +28. PNPM install, syntax checks, full test run on push and PR.The validation rules are regex-shaped and regression-prone; every future change now runs the hostile fixtures.
    .gitignore+1, ignores node_modules.Keeps the repo install-clean once dev dependencies arrive with the test tooling.
    README.mdReworked (~95 lines). Pi/OMP install paths, export modes, npm usage.Documents the hardened export contract users actually get — including the local-export default.
    index.js+248 first commit, then review fixes. Validation constants, reject-before-write gate, env-overridable export root, capability-checked UI picker.This file is the security boundary; everything else in the PR exists to protect or prove it.
    package.jsonPublish metadata, files allowlist, engines, PNPM test script.Makes the extension installable from npm without shipping repo-private files.
    pnpm-lock.yaml+5, lockfile for the new dev toolchain.CI runs are reproducible; a drifting transitive dep can't silently change test results.
    test/extension.test.jsNew, +216 then +13 across review commits. node:test suite.Every blocking rule has at least one hostile fixture; the review bypasses each landed with a failing-first test.
    +
    + +
    +

    4.0 · Diff excerpts — real hunks, trimmed

    +

    The two bypass-closing changes

    + +
    +
    Excerpt A · index.js · c754578 — close srcset + @import bypassesdiff
    +
    @@ -23,8 +23,8 @@
    +-const EXTERNAL_ASSET_ATTR = /\s(?:src|poster)\s*=\s*(['\"]?)\s*(?:https?:)?\/\//i;
    +-const EXTERNAL_CSS_URL = /url\(\s*(['\"]?)\s*(?:https?:)?\/\//i;
    ++const EXTERNAL_ASSET_ATTR = /\s(?:(?:src|poster)\s*=\s*(['\"]?)\s*(?:https?:)?\/\/|srcset\s*=\s*(['\"]?)[^'\">]*(?:https?:)?\/\/)/i;
    ++const EXTERNAL_CSS_URL = /(?:url\(\s*(['\"]?)\s*(?:https?:)?\/\/|@import\s+(?:url\(\s*)?(['\"]?)\s*(?:https?:)?\/\/)/i;
    +
    + Fig A · before, only bare src/poster attributes and url() values were inspected; responsive image sets and stylesheet imports slipped through. + +
    +
    Excerpt B · index.js + test/extension.test.js · 8bb6989 — block SVG external hrefsdiff
    +
    @@ -23,7 +23,7 @@ index.js
    +-const EXTERNAL_ASSET_ATTR = /\s(?:(?:src|poster)\s*=\s*(['\"]?)\s*(?:https?:)?\/\/|srcset\s*=\s*(['\"]?)[^'\">]*(?:https?:)?\/\/)/i;
    ++const EXTERNAL_ASSET_ATTR = /(?:\s(?:src|poster)\s*=\s*(['\"]?)\s*(?:https?:)?\/\/|\ssrcset\s*=\s*(['\"]?)[^'\">]*(?:https?:)?\/\/|<\s*(?:image|use|feimage)\b[^>]*\s(?:href|xlink:href)\s*=\s*(['\"]?)\s*(?:https?:)?\/\/)/i;
    +
    +@@ -169,6 +169,8 @@ test/extension.test.js · rich validation rejects dangerous or over-large HTML
    ++    richDocument('<svg><image href="https://example.com/a.png" /></svg>'),
    ++    richDocument('<svg><use xlink:href="https://example.com/s.svg#icon" /></svg>'),
    +
    + Fig B · the third regex alternative inspects SVG fetching elements; both new fixtures must be rejected for the suite to pass. +
    + +
    +

    5.0 · Reviewer checklist — validation coverage

    +

    Every active-content vector, with a fixture behind it

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StatusRuleEvidence
    PassBlocked tagsscript, iframe, object, embed, link, base, and all form controls rejected; closing-tag and whitespace variants covered.
    PassMeta refreshhttp-equiv refresh redirects rejected in head or body, quoted or bare.
    PassEvent handlersAny inline on*-attribute rejects the document outright.
    Passjavascript: URLsChecked across href, src, xlink:href, action, and formaction attributes.
    PassExternal assetssrc/poster, srcset (c754578), CSS url() and @import (c754578) all reject http, https, and protocol-relative URLs.
    PassSVG external hrefsimage, use, and feImage href / xlink:href blocked (8bb6989) — the last reviewed bypass.
    PassTests addedNew node:test suite (+222 lines total); every rule above has at least one hostile rejection fixture, run in CI on every PR.
    +
    + +
    +

    6.0 · Verdict

    +

    Disposition

    +
    +
    + Merged + 2026-04-25 · 05:07 UTC + Merge 7b9e546 · +553 −87 +
    +
    +

    + Merged to main after two review rounds; both rounds produced real bypass + fixes (c754578, 8bb6989) rather than cosmetics, each landing with rejection fixtures. + Verification recorded on the PR: +

    +
      +
    • pnpm test
    • +
    • node --check index.js · node --check test/extension.test.js
    • +
    • pi -e ./index.js --offline --no-tools -p "/html-last" — completed, known model-pattern warning only
    • +
    • omp -e ./index.js --offline --no-tools -p "/html-last"
    • +
    • merge commit SSH-signed and locally verified against an allowed-signers file
    • +
    +
    +
    +
    + +
    + htmlify · PR-Review-Packet · PR #1 + Evidence: gh pr view · git log · git show 7b9e546 f5fcac7 ff59d70 c754578 8bb6989 + github.com/zakelfassi/htmlify +
    +
    + + From b582a9be5c922c35573f2b7e7347597319ed1863 Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 15:03:41 -0700 Subject: [PATCH 10/14] feat: add self-contained landing page and pages deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.html is itself an htmlify artifact — plate header, install tabs per agent, the nine-artifact gallery, skill-family split, principles, light/dark toggle — one file, inline CSS+JS, zero external assets. The Pages workflow copies index.html, examples/, and assets/ into the site artifact (a copy, not a build) and deploys via actions/deploy-pages. --- .github/workflows/pages.yml | 35 +++ index.html | 511 ++++++++++++++++++++++++++++++++++++ 2 files changed, 546 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 index.html diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a58e130 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,35 @@ +name: Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Assemble site (copy, not build) + run: | + mkdir -p _site + cp index.html _site/ + cp -R examples _site/examples + cp -R assets _site/assets + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/index.html b/index.html new file mode 100644 index 0000000..6869f68 --- /dev/null +++ b/index.html @@ -0,0 +1,511 @@ + + + + + + htmlify — stdout, made permanent + + + + + +
    +
    + +
    +
    + Project + HTMLIFY +
    +
    + Contents + SKILL FAMILY · HTML ARTIFACTS & DECKS +
    +
    + License + APACHE-2.0 +
    + +
    + +
    +

    stdout, made permanent.

    + +

    htmlify is a skill family for coding agents. Instead of answering in walls of markdown, your agent ships self-contained HTML — operator briefs, review packets, incident timelines, presentation decks with speaker notes — one file you can open, print, annotate, and keep.

    +
    + SELF-CONTAINED + 0 DEPENDENCIES + 0 BUILD + 1 FILE +
    + +
    +
    + + + + + +
    +
    +
    /plugin marketplace add zakelfassi/htmlify
    +/plugin install htmlify@htmlify
    + +
    +
    +
    git clone https://github.com/zakelfassi/htmlify.git ~/.htmlify
    +ln -sfn ~/.htmlify/skills/htmlify ~/.codex/skills/htmlify
    +ln -sfn ~/.htmlify/skills/deckify ~/.codex/skills/deckify
    + +
    +
    +
    git clone https://github.com/zakelfassi/htmlify.git ~/.agent-skills/htmlify
    +# then point a project rule at:
    +#   ~/.agent-skills/htmlify/skills/htmlify/SKILL.md
    +#   ~/.agent-skills/htmlify/skills/deckify/SKILL.md
    + +
    +
    +
    # pipe any long answer into a designed artifact
    +printf '%s' "$LONG_ANSWER" | npx -y @zakelfassi/htmlify htmlify-answer --title "Review"
    +# validate any artifact (rich, app, or deck profile)
    +npx -y @zakelfassi/htmlify htmlify-answer --validate artifact.html --profile auto
    + +
    +
    +
    pi install npm:@zakelfassi/htmlify
    +# then: /htmlify · /html-last · /html-comments
    + +
    +
    This page is itself an htmlify artifact: one HTML file, inline CSS+JS, no external assets. View source to audit everything it does. Repository →
    +
    +
    + +
    +
    + 1.0 · Method +

    Evidence in, one file out

    +
    +
    +
    + 1.1 · Gather +

    Evidence first

    +

    The skill reads the repo, git state, PRs, CI, logs, and docs before designing anything. Uncertain claims get marked needs verification, not asserted.

    +
    +
    + 1.2 · Author +

    One HTML file

    +

    Inline CSS and JS in the Hardcopy design language: plates, hairlines, stamps, carbon code wells. Document modes from htmlify, deck modes from deckify.

    +
    +
    + 1.3 · Validate +

    Prove it, open it

    +

    The bundled validator checks structure, script safety, external-asset bans, deck contracts, and size — then the artifact opens in your browser.

    +
    +
    +
    + + + +
    +
    + 3.0 · The family +

    Two skills, one validated core

    +
    +
    +
    + skills/htmlify +

    htmlify — documents

    +
      +
    • 10 modes: operator-brief, build-plan, implementation-map, pr-review-packet, release-brief, incident-report, decision-brief, status-report, explainer, prototype/editor
    • +
    • Print CSS for PDF/archival on every shareable artifact
    • +
    • Browser-native annotation layer: select text, comment, send back to the agent
    • +
    • Validated with --profile rich (or app for interactive artifacts)
    • +
    +
    +
    + skills/deckify +

    deckify — decks

    +
      +
    • 6 modes: talk-deck, workshop-deck, essay-deck, demo-deck, launch-deck, teaching-guide
    • +
    • Speaker notes per slide, run-of-show with timestamps, keyboard navigation
    • +
    • Downloadable guide/PDF companion — not a transcript, a handout
    • +
    • 40–60% visual coverage target; validated with --profile deck
    • +
    +
    +
    +
    + +
    +
    + 4.0 · Principles +

    Why this exists

    +
    +
    +

    HTML beats markdown when the work has shape. Comparison, architecture, timelines, ownership, review — these are spatial. A wall of markdown flattens them; a designed page restores them.

    +

    Self-containment is a security posture, not a style. One file, inline everything, no CDNs, no analytics, no external fonts. The validator enforces it: blocked script sources, no event-handler attributes, no remote assets. What you open is what you can read in view-source.

    +

    Evidence before design. Artifacts are built from repo state, git history, CI, and logs — and claims the agent can't verify are stamped needs verification instead of asserted.

    +

    The terminal stays primary. Your answer remains in the terminal; the artifact is an explicit export, never a replacement. Hooks only archive answers past a threshold you set.

    +
    +
    + + +
    + + + + From 67ab8221ede294dd4d694a012c05376eb6f03e43 Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 15:04:00 -0700 Subject: [PATCH 11/14] ci: split lint, typecheck, test matrix, and example validation jobs Replaces the single test job with four: biome lint, tsc typecheck, a test matrix (Node 20/22/24 on ubuntu plus Node 22 on macOS for the darwin open-command path), and a job that runs every committed gallery artifact through htmlify-answer --validate. --- .github/workflows/ci.yml | 75 +++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89ebc32..4786d4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,71 @@ name: CI on: - pull_request: push: - branches: - - main + branches: [main] + pull_request: jobs: - test: + lint: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm lint - - name: Enable Corepack - run: corepack enable + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck - - name: Install dependencies - run: pnpm install --frozen-lockfile + test: + strategy: + matrix: + node: [20, 22, 24] + os: [ubuntu-latest] + include: + - node: 22 + os: macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm test - - name: Run tests - run: pnpm test + validate-examples: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Validate committed gallery artifacts + run: | + set -euo pipefail + shopt -s nullglob + files=(examples/htmlify/*.html examples/deckify/*.html) + if [ ${#files[@]} -eq 0 ]; then + echo "No example artifacts found" >&2 + exit 1 + fi + node bin/htmlify-answer.js --validate "${files[@]}" --profile auto From ac79a3d610f1dd1903b535f6bd49859104f7bebf Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 15:04:00 -0700 Subject: [PATCH 12/14] ci: add release-please with npm provenance publishing Conventional commits on main drive an auto-maintained release PR, CHANGELOG, version bump (synced into the plugin manifest and both SKILL.md frontmatter blocks via release-please markers), a GitHub release, and an npm publish with provenance on release creation. --- .github/workflows/release-please.yml | 43 ++++++++++++++++++++++++++++ .release-please-manifest.json | 3 ++ CHANGELOG.md | 3 ++ release-please-config.json | 25 ++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 release-please-config.json diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..0f1731e --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + steps: + - id: release + uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + publish: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm publish --access public --no-git-checks --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..816df2d --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.3.1" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4d77fa3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Managed by release-please from this release onward. For history before 1.0.0, see the git log. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..86f0c10 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "node", + "include-component-in-tag": false, + "packages": { + ".": { + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "json", + "path": ".claude-plugin/plugin.json", + "jsonpath": "$.version" + }, + { + "type": "generic", + "path": "skills/htmlify/SKILL.md" + }, + { + "type": "generic", + "path": "skills/deckify/SKILL.md" + } + ] + } + } +} From a6151ce43bb0a15219e0add72318cf59753931c3 Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 15:05:16 -0700 Subject: [PATCH 13/14] docs: rewrite readme for the v1 skill family Repositions the project around the htmlify+deckify skill family and the live gallery: badges, install matrix per agent, validation profiles, artifact-mode table with gallery deep links, a compressed Pi/OMP runtime section, and a 0.x migration guide. --- README.md | 254 +++++++++++++++++++----------------------------------- 1 file changed, 89 insertions(+), 165 deletions(-) diff --git a/README.md b/README.md index cc01648..1cf499e 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,134 @@ -# htmlify - -Turn long agent answers into self-contained HTML artifacts people can scan, discuss, annotate, and ship. - -

    - Hero graphic showing a terminal chooser on the left and a designed HTML export preview on the right +

    + htmlify logomark

    -## What It Is +

    htmlify

    -htmlify is both: +

    stdout, made permanent.
    +Agent answers become self-contained HTML documents and presentation decks — one file you can open, print, annotate, and keep.

    -- a Pi / Oh My Pi extension for exporting long assistant replies as local HTML -- an agentskills.io-compatible skill for asking coding agents to produce useful single-file HTML briefs, maps, reviews, reports, explainers, and lightweight editors +

    + CI + npm version + Apache-2.0 + Live gallery +

    -It merges three ideas: +

    + A long terminal answer on the left becomes a designed, self-contained HTML document on the right +

    -- Long answers should stay visible in the terminal until the user explicitly exports them. -- HTML beats markdown when the work is spatial, comparative, interactive, or meeting-facing. -- Operator artifacts should be evidence-first, visual, self-contained, and production-safe. +Coding agents answer in walls of markdown. When the answer has *shape* — a comparison, an architecture, a timeline, a review — that wall flattens it. **htmlify** is a skill family that makes agents ship designed, self-contained HTML instead: operator briefs, PR review packets, incident timelines, decision briefs, explainers, interactive boards, and full presentation decks with speaker notes. Zero dependencies, zero build step, one auditable file. -## Skill Install +**See it live: [the gallery](https://zakelfassi.github.io/htmlify/)** — every artifact there was generated by these skills, about this repository. -This repository root is a valid Agent Skill directory because it contains `SKILL.md`. +## What's in the box -```bash -mkdir -p ~/.codex/skills -git clone https://github.com/zakelfassi/htmlify.git ~/.codex/skills/htmlify -``` +| Piece | What it is | +| --- | --- | +| [`skills/htmlify`](skills/htmlify/SKILL.md) | Document skill — 10 modes: operator-brief, build-plan, implementation-map, pr-review-packet, release-brief, incident-report, decision-brief, status-report, explainer, prototype/editor | +| [`skills/deckify`](skills/deckify/SKILL.md) | Deck skill — 6 modes: talk-deck, workshop-deck, essay-deck, demo-deck, launch-deck, teaching-guide; speaker notes, run-of-show, downloadable guide/PDF | +| `htmlify-answer` CLI | Pipe any text into a designed artifact; **validate** any artifact against the rich/app/deck safety profiles | +| Pi / OMP extension | `/htmlify` commands, render modes, browser annotation layer | +| Claude Code plugin | One-command install of both skills, optional long-answer Stop hook | +| [Hardcopy](skills/htmlify/references/hardcopy.md) | The design system every artifact ships in — engineering-plate language: warm paper, ink hairlines, serif display, mono metadata, one signal-orange accent | -For other clients that support the agentskills.io format, install or copy this folder into that client's skills directory. The required skill entrypoint is: +

    + htmlify and deckify skills sharing one validated core +

    -```text -htmlify/SKILL.md -``` +## Install -The skill uses progressive disclosure: `SKILL.md` is the activation surface, and `references/htmlify-principles.md` is loaded only when deeper artifact guidance is needed. +| Agent | Install | +| --- | --- | +| **Claude Code** | `/plugin marketplace add zakelfassi/htmlify` then `/plugin install htmlify@htmlify` | +| **Codex** | `git clone https://github.com/zakelfassi/htmlify.git ~/.htmlify && ln -sfn ~/.htmlify/skills/htmlify ~/.codex/skills/htmlify && ln -sfn ~/.htmlify/skills/deckify ~/.codex/skills/deckify` | +| **Cursor / Windsurf** | Clone the repo, point a project rule at `skills/htmlify/SKILL.md` / `skills/deckify/SKILL.md` | +| **Aider / anything** | `printf '%s' "$ANSWER" \| npx -y @zakelfassi/htmlify htmlify-answer --title "Review"` | +| **Pi / Oh-My-Pi** | `pi install npm:@zakelfassi/htmlify` | -## Coding Agent Integrations +Per-agent recipes, project rules, and hook setup: [agent-integrations.md](skills/htmlify/references/agent-integrations.md). -htmlify can be used two ways across coding agents: +## How it works -- Skill/manual mode: install the folder and invoke `htmlify` when a response should become a browser-ready artifact. -- Hook/automatic mode: configure the agent to write an HTML artifact when a final answer is longer than a threshold. +

    + Three steps: gather evidence, author one HTML file, validate and open +

    -Codex local skill install: +1. **Evidence first.** The skill reads the repo, git state, PRs, CI, and logs before designing anything; unverifiable claims are stamped `needs verification`. +2. **One HTML file.** Inline CSS+JS in the Hardcopy design language. No CDNs, no fonts, no analytics, no build. +3. **Prove it.** The bundled validator gates the output — then it opens in your browser. ```bash -mkdir -p ~/.codex/skills -git clone https://github.com/zakelfassi/htmlify.git ~/.codex/skills/htmlify +npx -y @zakelfassi/htmlify htmlify-answer --validate artifact.html --profile auto ``` -Claude Code skill install: - -```bash -mkdir -p ~/.claude/skills -git clone https://github.com/zakelfassi/htmlify.git ~/.claude/skills/htmlify -``` +| Profile | Rules | +| --- | --- | +| `rich` | No scripts at all — for model-generated documents | +| `app` | Inline scripts allowed (editors, boards); external scripts, `on*=` handlers, `javascript:` URLs still banned | +| `deck` | `app` plus the deck contract: ≥2 slides, keyboard nav, speaker notes on substantive slides, print CSS | +| `auto` | Detected per file from the document shape | -Claude Code optional Stop hook: - -```json -{ - "hooks": { - "Stop": [ - { - "hooks": [ - { - "type": "command", - "command": "node /Users/zakelfassi/.claude/skills/htmlify/hooks/claude-code-stop-htmlify.js", - "timeout": 30 - } - ] - } - ] - } -} -``` +Exit codes: `0` valid · `1` validation errors · `2` usage/IO. Add `--format json` for agent consumption. -The Claude hook reads `last_assistant_message` from the Stop event and writes an HTML artifact when it is at least `HTMLIFY_MIN_CHARS` characters. Default threshold is `2500`. +## Artifact modes -For Cursor, Windsurf, Aider, and other agents, point the agent at `SKILL.md` or use the bundled CLI: +| Mode | Use when | Live example | +| --- | --- | --- | +| `operator-brief` | What happened, what's next, risks, attention | [operator-brief](https://zakelfassi.github.io/htmlify/examples/htmlify/operator-brief.html) | +| `pr-review-packet` | Motivation, diff tour, reviewer checklist | [PR #1 packet](https://zakelfassi.github.io/htmlify/examples/htmlify/pr-review-packet.html) | +| `incident-report` | Impact, timeline, root cause, follow-ups | [capture bug](https://zakelfassi.github.io/htmlify/examples/htmlify/incident-timeline.html) | +| `decision-brief` | Options, tradeoffs, the call | [monorepo decision](https://zakelfassi.github.io/htmlify/examples/htmlify/decision-brief.html) | +| `implementation-map` | Modules, data flow, hot path | [runtime map](https://zakelfassi.github.io/htmlify/examples/htmlify/implementation-map.html) | +| `explainer` | Concepts, comparisons, glossary, FAQ | [HTML vs markdown](https://zakelfassi.github.io/htmlify/examples/htmlify/explainer.html) | +| `prototype` / `editor` | Interactive triage, tuning, ordering — with export | [launch board](https://zakelfassi.github.io/htmlify/examples/htmlify/launch-board.html) | +| `talk-deck` (deckify) | Talks with speaker notes + run-of-show | [launch talk](https://zakelfassi.github.io/htmlify/examples/deckify/talk-deck.html) | +| `workshop-deck` (deckify) | Teaching with exercises + printable guide | [skill workshop](https://zakelfassi.github.io/htmlify/examples/deckify/workshop-deck.html) | -```bash -printf '%s' "$LONG_ANSWER_TEXT" | npx @zakelfassi/htmlify htmlify-answer --title "Agent Answer" -``` +Plus `build-plan`, `release-brief`, `status-report`, and deckify's `essay-deck`, `demo-deck`, `launch-deck`, `teaching-guide`. -See [references/agent-integrations.md](references/agent-integrations.md) for project-level rules, hook safety, and per-agent recipes. +## The Pi / OMP runtime (optional) -## Extension Install +The npm package doubles as a Pi / Oh-My-Pi extension that captures long answers and exports them on demand: -Native Pi npm install: +| Command | Result | +| --- | --- | +| `/htmlify` | Quick local HTML of the last long answer (Hardcopy-styled, outline rail, annotation layer) | +| `/htmlify choose` | Render-mode chooser — `local`, `pi` (current model second pass), `gemini` (Gemini CLI, falls back to local) | +| `/htmlify-comments ` | Import browser review comments back to the agent as a structured prompt | +| `/htmlify-version` | Show loaded version | -```bash -pi install npm:@zakelfassi/htmlify -``` +Legacy aliases (`/html-last`, `/html-comments`, `/html-last-version`) keep working. Exports include a trusted annotation layer: select text in the browser, comment, copy Markdown for the agent, or download a JSON bundle. Long answers stay visible in the terminal — export is always explicit. -Native Pi git install: +For agents with hooks, the bundled Claude Code Stop hook archives answers past a threshold (`HTMLIFY_MIN_CHARS`, default 2500) to `HTMLIFY_EXPORT_ROOT` — opt-in, see [agent-integrations.md](skills/htmlify/references/agent-integrations.md). -```bash -pi install git:https://github.com/zakelfassi/htmlify.git -``` +## Migrating from 0.x -Oh My Pi / OMP global install: +1.0 restructures the repo into a skill family. **Breaking:** the root `SKILL.md` and `references/` moved to `skills/htmlify/`; clones installed as a skill directory at the repo root must re-install: ```bash -mkdir -p ~/.omp/agent/extensions -git clone https://github.com/zakelfassi/htmlify.git ~/.omp/agent/extensions/htmlify +ln -sfn /path/to/htmlify/skills/htmlify ~/.codex/skills/htmlify # and skills/deckify ``` -Then ask for a long answer and run: - -```text -/htmlify-version -/htmlify local -``` - -Legacy command aliases remain available: `/html-last`, `/html-last-version`, and `/html-comments`. - -## Render Modes - -

    - Three render mode cards for quick local, current Pi model, and Gemini CLI -

    - -| Mode | What it does | Best for | -|---|---|---| -| `local` | Fast local render with a designed shell, outline rail, excerpt hero, and clickable links | Speed and reliability | -| `pi` | Uses the current Pi model for a richer second-pass HTML render | Staying in the current session/model context | -| `gemini` | Uses Gemini CLI for a richer external render; falls back to local HTML if valid HTML is not returned | Maximum polish when Gemini is available | - -## Commands - -| Command | Result | -|---|---| -| `/htmlify` | Opens quick local HTML without starting a Pi model turn | -| `/htmlify choose` | Opens a render-mode chooser | -| `/htmlify local` | Forces quick local HTML | -| `/htmlify pi` | Forces designed HTML via the current Pi model | -| `/htmlify gemini` | Forces designed HTML via Gemini CLI | -| `/htmlify-version` | Shows the loaded extension version | -| `/htmlify-comments ` | Imports downloaded HTML review comments and sends them back to the current agent | - -## Runtime Behavior - -

    - Flow diagram showing the extension behavior: long answer finishes normally, user runs htmlify, browser opens the export -

    - -- Long answers are detected from message length, line count, or paragraph count. -- Long answers are captured into session state so export commands can work after the answer finishes. -- Local and designed exports open automatically in the browser after the file is written. -- Exports include a trusted local annotation layer: highlight text, add comments, copy Markdown for the agent, or download a comments JSON bundle. -- Rich Pi/Gemini renders must be standalone HTML documents with inline CSS only. -- Rich HTML is validated before writing: scripts, event-handler attributes, `javascript:` URLs, external assets, external CSS URLs, unsafe tags, oversized output, and overly complex output are rejected or routed to fallback behavior. - -## Repo Layout - -```text -htmlify/ -├── .github/workflows/ci.yml -├── assets/ -├── bin/ -│ └── htmlify-answer.js -├── hooks/ -│ └── claude-code-stop-htmlify.js -├── references/ -│ ├── agent-integrations.md -│ └── htmlify-principles.md -├── test/ -│ └── extension.test.js -├── index.js -├── package.json -├── pnpm-lock.yaml -├── README.md -└── SKILL.md -``` +Unchanged: the `htmlify-answer` CLI flags and stdin behavior (`--validate` is additive), the hook path `hooks/claude-code-stop-htmlify.js` (existing `settings.json` entries keep working), the Pi/OMP entry points, all `/htmlify` commands, and the env vars (including the legacy `PI_HTML_LONG_ANSWER_*` aliases). ## Development -Use PNPM: - ```bash -pnpm install -pnpm test +corepack enable && pnpm install +pnpm test # node --test, 35 tests +pnpm lint # biome +pnpm typecheck # strict tsc over JSDoc types +node bin/htmlify-answer.js --validate examples/htmlify/*.html examples/deckify/*.html --profile auto ``` -If you modify the runtime, re-test these flows: +The runtime is dependency-free CommonJS under `src/` with strict JSDoc type checking — what ships is what you read. Releases are cut by [release-please](https://github.com/googleapis/release-please) from conventional commits, published to npm with provenance. See [CONTRIBUTING.md](CONTRIBUTING.md). -- long answer -> answer remains visible; no automatic replacement notice appears -- `/htmlify` -> local HTML writes and opens without starting a Pi model turn -- `/htmlify choose` -> chooser appears when supported -- `/htmlify pi` -> second-pass render path queues/runs and validates rich HTML -- `/htmlify gemini` -> Gemini render path succeeds or cleanly falls back -- `/htmlify-comments ` -> browser comments validate and queue a structured review prompt -- `/htmlify-version` -> version shown in-session - -## Publishing - -The unscoped npm name `htmlify` is already taken. Publish this package under the scoped name: - -```bash -NPM_CONFIG_CACHE=/private/tmp/htmlify-npm-cache npm publish --access public -``` +## Trust and security -## Trust And Security +Extensions and hooks run with your user permissions — install from sources you trust and pin a ref when you need reproducibility. Model-generated HTML is treated as untrusted until validated: scripts (in the `rich` profile), event-handler attributes, `javascript:` URLs, external assets/CSS, meta refresh, and oversized output are rejected, with fallback to the local renderer. Interactive profiles still ban every external-execution vector. Found a way around the validator? That's a security report we want: [SECURITY.md](SECURITY.md). -Extensions run with your user permissions. Only install from sources you trust, review the source before installing, and pin a git ref or tag when you need reproducible behavior. +## License -Rich HTML generated by Pi or Gemini is treated as untrusted until it passes validation. The validator is intentionally conservative: if rich output includes active scripts, event handlers, external assets, or unsafe URLs, htmlify falls back to local HTML rather than writing the rich document. +[Apache-2.0](LICENSE) © Zak El Fassi From 11d0e6b556b868ea90c885c9aed490230c612d2e Mon Sep 17 00:00:00 2001 From: Zak El Fassi Date: Thu, 11 Jun 2026 15:06:06 -0700 Subject: [PATCH 14/14] chore: prepare the 1.0.0 release Final metadata sweep: skill-family description, gallery homepage, and deckify/claude-code/presentation keywords. Release-As: 1.0.0 --- package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index afd3b5e..074a377 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@zakelfassi/htmlify", "version": "0.3.1", "private": false, - "description": "Turn long agent answers into self-contained, browser-ready HTML artifacts for Pi, OMP, and agentskills-compatible workflows.", + "description": "Turn agent answers into self-contained HTML artifacts and presentation decks — operator briefs, review packets, incident timelines, decision briefs, and talks. Zero dependencies, zero build, one file.", "license": "Apache-2.0", "type": "commonjs", "main": "index.js", @@ -29,13 +29,18 @@ "type": "git", "url": "git+https://github.com/zakelfassi/htmlify.git" }, - "homepage": "https://github.com/zakelfassi/htmlify#readme", + "homepage": "https://zakelfassi.github.io/htmlify/", "bugs": { "url": "https://github.com/zakelfassi/htmlify/issues" }, "keywords": [ "agentskills", "agent-skill", + "deckify", + "claude-code", + "claude-code-plugin", + "presentation", + "deck", "oh-my-pi", "omp", "pi",