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/.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/.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 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/.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/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/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 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 ` + + 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 +
+
+ + diff --git a/hooks/claude-code-stop-htmlify.js b/hooks/claude-code-stop-htmlify.js index ca50c6f..398e4fd 100755 --- a/hooks/claude-code-stop-htmlify.js +++ b/hooks/claude-code-stop-htmlify.js @@ -3,13 +3,12 @@ const path = require('path'); const htmlify = require('../index.js'); -const { - renderMarkdownish, - writeHtmlArtifact, -} = htmlify._internals; +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); @@ -17,9 +16,20 @@ function readStdin() { }); } +/** + * @param {any} text + * @returns {string} + */ function titleFrom(text) { - const first = String(text || '').split(/\r?\n/).find((line) => line.trim()); - return first ? first.trim().replace(/^#+\s*/, '').slice(0, 120) : 'Claude Code answer'; + const first = String(text || '') + .split(/\r?\n/) + .find((line) => line.trim()); + return first + ? first + .trim() + .replace(/^#+\s*/, '') + .slice(0, 120) + : 'Claude Code answer'; } async function main() { @@ -46,10 +56,12 @@ async function main() { mode: 'claude-code-stop-hook', }); - process.stdout.write(JSON.stringify({ - systemMessage: `htmlify wrote a long-answer HTML artifact: ${filePath}`, - suppressOutput: false, - })); + process.stdout.write( + JSON.stringify({ + systemMessage: `htmlify wrote a long-answer HTML artifact: ${filePath}`, + suppressOutput: false, + }) + ); } main().catch((error) => { 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/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.

+
+
+ + +
+ + + + diff --git a/index.js b/index.js index 579435d..328351a 100644 --- a/index.js +++ b/index.js @@ -1,1420 +1,45 @@ -const EXTENSION_VERSION = '2026-05-31a'; -const PRODUCT_NAME = 'htmlify'; - -const fs = require('fs/promises'); -const path = require('path'); -const os = require('os'); -const crypto = require('crypto'); -const { execFile, spawn } = require('child_process'); -const { promisify } = require('util'); - -const execFileAsync = promisify(execFile); -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 = ''; - -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; -} - -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'); -} - -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 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} -
    - -
    -
    - -`; -} - -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; -} - -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}`; -} - -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}`; -} - -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 }); - } -}; - -module.exports._internals = { - buildLocalHtmlDocument, - buildRichHtmlPrompt, - extractHtmlDocument, - formatInline, - getExportRoot, - parseArgs, - parseHtmlLastInput: parseHtmlCommandInput, - resolveOpenCommand, - hasSelectableUi, - renderMarkdownish, - resolveForcedExportMode, +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, - addCommentableAttributes, - buildAnnotationLayer, - buildCommentsPrompt, - injectAnnotationLayer, - validateCommentBundle, - writeHtmlArtifact, - writeRichHtmlArtifact, -}; + validateDeckDocument, + collectRichHtmlIssues, + collectDeckIssues, + detectProfile, +} = 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 = Object.assign(createExtension, { + _internals: { + buildLocalHtmlDocument, + buildRichHtmlPrompt, + extractHtmlDocument, + formatInline, + getExportRoot, + parseArgs, + parseHtmlLastInput: parseHtmlCommandInput, + resolveOpenCommand, + hasSelectableUi, + renderMarkdownish, + resolveForcedExportMode, + validateRichHtmlDocument, + validateDeckDocument, + collectRichHtmlIssues, + collectDeckIssues, + detectProfile, + addCommentableAttributes, + buildAnnotationLayer, + buildCommentsPrompt, + injectAnnotationLayer, + validateCommentBundle, + writeHtmlArtifact, + writeRichHtmlArtifact, + }, +}); diff --git a/package.json b/package.json index fc06f6a..074a377 100644 --- a/package.json +++ b/package.json @@ -2,35 +2,45 @@ "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", "bin": { "htmlify-answer": "bin/htmlify-answer.js" }, "scripts": { - "test": "node --test test/*.test.js" + "test": "node --test test/*.test.js", + "lint": "biome ci .", + "lint:fix": "biome check --write .", + "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", "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", @@ -57,5 +67,10 @@ "./index.js" ] }, - "packageManager": "pnpm@8.15.0+sha512.ea45517d5285d123eac02c3793505fa1fd6da90a2fc60d1e8d9e0c1e9292886ecfaff513f062b9d1cc8021bb8615033b1ac5bea3b2ee3fc165a6d7034bbe6b03" + "packageManager": "pnpm@10.34.3+sha512.f2c531b08829d7be7f03c90addc266615f9c5477463e4bf1d275cb263ce9ea57cf0d5599110d94649a9bb3fed9f0b5efcea74a7016b61532a24cb9ad87860c1d", + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@types/node": "^20.19.42", + "typescript": "^6.0.3" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b9f188..0ff2c78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,134 @@ -lockfileVersion: '6.0' +lockfileVersion: '9.0' settings: autoInstallPeers: true excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@biomejs/biome': + specifier: ^2.4.16 + version: 2.4.16 + '@types/node': + specifier: ^20.19.42 + version: 20.19.42 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@biomejs/biome@2.4.16': + resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.16': + resolution: {integrity: sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.16': + resolution: {integrity: sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.16': + resolution: {integrity: sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.16': + resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.16': + resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.16': + resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.16': + resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.16': + resolution: {integrity: sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@types/node@20.19.42': + resolution: {integrity: sha512-5L7SUaFC1RyDraj2yRhyBzHTobyXHmohD100CChNtyPyleoq37Mqab5Gn8XEKI04dfN/oqPdpHk38MgcQWHbZg==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@biomejs/biome@2.4.16': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.16 + '@biomejs/cli-darwin-x64': 2.4.16 + '@biomejs/cli-linux-arm64': 2.4.16 + '@biomejs/cli-linux-arm64-musl': 2.4.16 + '@biomejs/cli-linux-x64': 2.4.16 + '@biomejs/cli-linux-x64-musl': 2.4.16 + '@biomejs/cli-win32-arm64': 2.4.16 + '@biomejs/cli-win32-x64': 2.4.16 + + '@biomejs/cli-darwin-arm64@2.4.16': + optional: true + + '@biomejs/cli-darwin-x64@2.4.16': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.16': + optional: true + + '@biomejs/cli-linux-arm64@2.4.16': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.16': + optional: true + + '@biomejs/cli-linux-x64@2.4.16': + optional: true + + '@biomejs/cli-win32-arm64@2.4.16': + optional: true + + '@biomejs/cli-win32-x64@2.4.16': + optional: true + + '@types/node@20.19.42': + dependencies: + undici-types: 6.21.0 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} 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/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" + } + ] + } + } +} 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 69% rename from SKILL.md rename to skills/htmlify/SKILL.md index 4f10c45..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,9 +31,26 @@ 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. Validate before final response: doctype, standalone `<html>`/`<body>`, no missing local assets, expected sections, and no obvious layout overlap. +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 @@ -97,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/src/annotation.js b/src/annotation.js new file mode 100644 index 0000000..78dc3ad --- /dev/null +++ b/src/annotation.js @@ -0,0 +1,172 @@ +const { COMMENT_BUNDLE_VERSION, TRUSTED_ANNOTATION_MARKER } = require('./constants'); + +/** + * @param {any} html + * @returns {string} + */ +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}">`; + } + ); +} + +/** + * @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'); + return `${TRUSTED_ANNOTATION_MARKER} +<style> + :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-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 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> + <p>Select text in the export, then add a comment. Copy Markdown for the agent or download JSON.</p> + <textarea id="hla-comment-input" placeholder="Comment on the selected text"></textarea> + <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px"> + <button type="button" id="hla-save-comment">Save comment</button> + <button type="button" id="hla-copy-md">Copy Markdown</button> + <button type="button" id="hla-download-json">Download JSON</button> + </div> + <div class="hla-comment-list" id="hla-comment-list"></div> +</section> +<div class="hla-comment-bar"> + <button type="button" id="hla-open-comments">Comments</button> + <button type="button" id="hla-add-comment">Comment on selection</button> +</div> +<script> +(() => { + const meta = { version: ${COMMENT_BUNDLE_VERSION}, sourceId: ${JSON.stringify(sourceId)}, title: ${JSON.stringify(title)}, exportUrl: location.href }; + const key = "htmlify-comments:" + (meta.sourceId || location.pathname); + const panel = document.getElementById("hla-comment-panel"); + const input = document.getElementById("hla-comment-input"); + const list = document.getElementById("hla-comment-list"); + let pending = null; + let comments = []; + try { comments = JSON.parse(localStorage.getItem(key) || "[]"); } catch (_) { comments = []; } + function closestBlock(node) { + const el = node && (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement); + return el ? el.closest("[data-commentable], p, li, h1, h2, h3, h4, h5, h6, pre, table, blockquote, aside") : null; + } + function contextFor(block, selected) { + const text = (block ? block.textContent : document.body.textContent || "").replace(/\\s+/g, " ").trim(); + const needle = String(selected || "").replace(/\\s+/g, " ").trim(); + const at = needle ? text.indexOf(needle) : -1; + return { + prefix: at >= 0 ? text.slice(Math.max(0, at - 180), at) : text.slice(0, 180), + suffix: at >= 0 ? text.slice(at + needle.length, at + needle.length + 180) : "", + }; + } + function persist() { localStorage.setItem(key, JSON.stringify(comments)); render(); } + function bundle() { return { version: meta.version, sourceId: meta.sourceId, title: meta.title, exportUrl: meta.exportUrl, exportedAt: new Date().toISOString(), comments }; } + function markdown() { + const data = bundle(); + const lines = ["I reviewed the HTML export and left comments.", "", "Source: " + data.title, "Source ID: " + data.sourceId, ""]; + data.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 || ""), "", "Comment:", comment.comment, ""); + }); + return lines.join("\\n"); + } + function render() { + list.innerHTML = ""; + comments.forEach((comment, index) => { + const card = document.createElement("div"); + card.className = "hla-comment-card"; + const quote = document.createElement("blockquote"); + quote.textContent = comment.selectedText; + const body = document.createElement("div"); + body.textContent = comment.comment; + const jump = document.createElement("button"); + jump.type = "button"; + jump.textContent = "Jump"; + jump.onclick = () => { + const target = comment.blockId ? document.querySelector('[data-block-id="' + CSS.escape(comment.blockId) + '"]') : null; + if (target) { target.scrollIntoView({ behavior: "smooth", block: "center" }); target.classList.add("hla-comment-target"); setTimeout(() => target.classList.remove("hla-comment-target"), 1800); } + }; + const del = document.createElement("button"); + del.type = "button"; + del.textContent = "Delete"; + del.onclick = () => { comments.splice(index, 1); persist(); }; + card.append(quote, body, jump, del); + list.appendChild(card); + }); + } + document.getElementById("hla-open-comments").onclick = () => { panel.hidden = !panel.hidden; render(); }; + document.getElementById("hla-add-comment").onclick = () => { + const selection = getSelection(); + const selectedText = selection ? String(selection).trim() : ""; + if (!selectedText) { alert("Select text in the export first."); return; } + const block = closestBlock(selection.anchorNode) || closestBlock(selection.focusNode); + const ctx = contextFor(block, selectedText); + pending = { selectedText, blockId: block ? (block.dataset.blockId || "") : "", prefix: ctx.prefix, suffix: ctx.suffix }; + input.value = ""; + panel.hidden = false; + input.focus(); + }; + document.getElementById("hla-save-comment").onclick = () => { + if (!pending) { alert("Select text and click Comment on selection first."); return; } + const text = input.value.trim(); + if (!text) { alert("Write a comment first."); return; } + comments.push({ id: "cmt_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8), sourceId: meta.sourceId, blockId: pending.blockId, selectedText: pending.selectedText, prefix: pending.prefix, suffix: pending.suffix, comment: text, createdAt: new Date().toISOString() }); + pending = null; + input.value = ""; + persist(); + }; + document.getElementById("hla-copy-md").onclick = async () => { + const text = markdown(); + if (navigator.clipboard && navigator.clipboard.writeText) await navigator.clipboard.writeText(text); + else prompt("Copy comments for the agent:", text); + }; + document.getElementById("hla-download-json").onclick = () => { + const blob = new Blob([JSON.stringify(bundle(), null, 2)], { type: "application/json" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "html-comments-" + (meta.sourceId || "export").slice(0, 12) + ".json"; + a.click(); + URL.revokeObjectURL(a.href); + }; + render(); +})(); +</script>`; +} + +/** + * @param {any} html + * @param {{ sourceId?: string, title?: string }} [meta] + * @returns {string} + */ +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</body>`); + 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..24d0d6c --- /dev/null +++ b/src/artifacts.js @@ -0,0 +1,69 @@ +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'); + +/** + * @param {string} dir + */ +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; +} + +/** + * @param {{ title: string, bodyHtml: string, sourceText: string, mode: string }} input + * @returns {Promise<string>} + */ +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; +} + +/** + * @param {{ title: string, htmlText: string, sourceId?: string }} input + * @returns {Promise<string>} + */ +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..77daa5d --- /dev/null +++ b/src/comments.js @@ -0,0 +1,75 @@ +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) + 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((/** @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(); + 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 || ''), + }; + }), + }; +} + +/** + * @param {CommentBundle} bundle + * @returns {string} + */ +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..249b2b5 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,72 @@ +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; +// Interactive (app/deck) profiles allow inline scripts and form controls but still ban embeds. +const BLOCKED_EMBED_TAGS = /<\s*\/?\s*(?:iframe|object|embed|base)\b/i; +const SCRIPT_SRC_ATTR = /<\s*script\b[^>]*\ssrc\s*=/i; +const LINK_TAG = /<\s*link\b[^>]*>/gi; +const MAX_DECK_HTML_CHARS = 2 * 1024 * 1024; +const WARN_HTML_CHARS = 512 * 1024; +const SLIDE_SECTION = /<section\b[^>]*\bclass\s*=\s*(['"])[^'"]*\bslide\b[^'"]*\1[^>]*>/gi; +const NOTES_ASIDE = /<aside\b[^>]*\bclass\s*=\s*(['"])[^'"]*\bnotes\b[^'"]*\1[^>]*>/i; +const KEYDOWN_LISTENER = /(?:addEventListener\s*\(\s*['"]keydown['"]|\.onkeydown\s*=)/; +const OPEN_FAILURE_WINDOW_MS = 1000; + +const TRUSTED_ANNOTATION_MARKER = '<!-- htmlify trusted annotation layer -->'; + +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, + BLOCKED_EMBED_TAGS, + SCRIPT_SRC_ATTR, + LINK_TAG, + MAX_DECK_HTML_CHARS, + WARN_HTML_CHARS, + SLIDE_SECTION, + NOTES_ASIDE, + KEYDOWN_LISTENER, + OPEN_FAILURE_WINDOW_MS, + TRUSTED_ANNOTATION_MARKER, +}; diff --git a/src/document.js b/src/document.js new file mode 100644 index 0000000..4a0be16 --- /dev/null +++ b/src/document.js @@ -0,0 +1,356 @@ +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'; + 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); +} + +/** + * @param {any} text + * @returns {string} + */ +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); +} + +/** + * @param {any} text + * @returns {string} + */ +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 `<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 + * @returns {string} + */ +function buildLocalHtmlDocument(title, body, meta) { + const exportedAt = new Date(meta.exportedAt).toLocaleString(); + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <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)}
    +
    +
    +
    +
    + ${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..979204e --- /dev/null +++ b/src/extension/index.js @@ -0,0 +1,685 @@ +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); + +/** @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, + lastExport: null, + pendingRichExport: null, + lastPromptedSignature: null, + geminiAvailable: null, + 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') { + 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; + } + } + + /** @param {any} branch */ + 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; + } + } + } + + /** @param {ExtensionCtx | undefined} ctx */ + 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. + } + } + + /** + * @param {string} type + * @param {object} data + */ + 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. + } + } + + /** @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 { + const result = ctx.ui.notify(message, level || 'info'); + if (result && typeof result.then === 'function') { + result.catch(() => {}); + } + } catch (_) { + // Ignore UI failures. + } + } + + /** + * @param {ExtensionCtx | undefined} ctx + * @param {any} error + */ + 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; + } + + /** + * @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; + notify( + ctx, + `${mode === 'local' ? 'Opened local HTML export' : 'Opened designed HTML export'} in your default browser. [${PRODUCT_NAME} ${EXTENSION_VERSION}]`, + 'info' + ); + return true; + } + + /** + * @param {string} text + * @returns {SourceRecord} + */ + 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), + }, + }; + } + + /** + * @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({ + 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; + } + + /** + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + * @param {string} htmlText + */ + 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; + } + + /** + * @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') { + 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; + } + + /** + * @param {any} ui + * @param {string} summary + */ + 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; + } + } + + /** + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + */ + 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'; + } + + /** + * @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 (/** @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}]`, + '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.'); + } + + /** + * @param {SourceRecord} source + * @returns {Promise} + */ + 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; + } + + /** @param {ExtensionCtx | undefined} ctx */ + 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'; + } + } + + /** + * @param {string | null} choice + * @param {ExtensionCtx | undefined} ctx + * @param {SourceRecord} source + */ + 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'); + } + } + + /** + * @param {any} event + * @param {ExtensionCtx | undefined} ctx + */ + 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 (/** @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}]`, + '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; + } + + /** + * @param {any} event + * @param {ExtensionCtx | undefined} ctx + */ + 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. + } + + /** + * @param {any} args + * @param {ExtensionCtx | undefined} ctx + */ + 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'); + } + + /** @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.'); + 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); + } + + /** + * @param {any} args + * @param {ExtensionCtx | undefined} ctx + */ + 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. + } + } + + /** @type {(event: any, ctx: ExtensionCtx | undefined) => Promise} */ + 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 (/** @type {any} */ error) { + await notify( + ctx, + `${PRODUCT_NAME} extension error: ${error && error.message ? error.message : String(error)}`, + 'error' + ); + } + }); + } + + 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.', + 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 }); + + /** @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) => { + void importCommentsFromCommand(args, ctx).catch((error) => { + notifyCommandError(ctx, error); + }); + }, + }; + + 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) => { + 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..08a308a --- /dev/null +++ b/src/extension/messages.js @@ -0,0 +1,97 @@ +const { sha, countLines, countParagraphs } = require('../text'); + +/** + * @param {any} part + * @returns {string} + */ +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 ''; +} + +/** + * @param {any} candidate + * @returns {string | null} + */ +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; +} + +/** + * 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; + 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(), + }; +} + +/** + * @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; + return ( + source.length >= config.minChars || + countLines(source) >= config.minLines || + countParagraphs(source) >= config.minParagraphs + ); +} + +/** + * @param {any} text + * @returns {string | null} + */ +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..4753f40 --- /dev/null +++ b/src/extension/open.js @@ -0,0 +1,82 @@ +const fs = require('fs/promises'); +const path = require('path'); +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)) { + 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; +} + +/** + * @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; + const executable = await resolveOpenCommand(command); + 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; + 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..bd2ec8c --- /dev/null +++ b/src/extension/parse.js @@ -0,0 +1,60 @@ +/** + * @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((/** @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)) { + 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; +} + +/** + * @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'; + 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; +} + +/** + * @param {any} ctx + * @returns {boolean} + */ +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..23d78a5 --- /dev/null +++ b/src/extension/prompts.js @@ -0,0 +1,32 @@ +/** + * @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.', + '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/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 new file mode 100644 index 0000000..ccc3593 --- /dev/null +++ b/src/markdown.js @@ -0,0 +1,164 @@ +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() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((cell) => cell.trim()); +} + +/** + * @param {any} raw + * @returns {string} + */ +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; +} + +/** + * @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; + while (index < lines.length && predicate(lines[index], index)) { + collected.push(lines[index]); + index += 1; + } + return { collected, nextIndex: index }; +} + +/** + * @param {any} text + * @returns {string} + */ +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..b7efdc7 --- /dev/null +++ b/src/text.js @@ -0,0 +1,79 @@ +const crypto = require('crypto'); + +/** + * @param {any} input + * @returns {string} + */ +function sha(input) { + return crypto + .createHash('sha1') + .update(String(input || '')) + .digest('hex'); +} + +/** + * @param {any} value + * @returns {string} + */ +function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * @param {any} value + * @returns {string} + */ +function slugify(value) { + const normalized = String(value || 'export') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return normalized || 'export'; +} + +/** + * @param {any} text + * @returns {number} + */ +function countParagraphs(text) { + return String(text || '') + .split(/\n\s*\n/g) + .map((chunk) => chunk.trim()) + .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() + .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..60ea3cb --- /dev/null +++ b/src/validate.js @@ -0,0 +1,265 @@ +const fs = require('fs'); +const path = require('path'); + +const { + MAX_RICH_HTML_CHARS, + MAX_RICH_HTML_TAGS, + MAX_DECK_HTML_CHARS, + WARN_HTML_CHARS, + BLOCKED_RICH_TAGS, + BLOCKED_EMBED_TAGS, + BLOCKED_META_REFRESH, + EVENT_HANDLER_ATTR, + JAVASCRIPT_URL_ATTR, + SCRIPT_SRC_ATTR, + LINK_TAG, + EXTERNAL_ASSET_ATTR, + EXTERNAL_CSS_URL, + SLIDE_SECTION, + NOTES_ASIDE, + KEYDOWN_LISTENER, +} = require('./constants'); + +/** + * @typedef {{ code: string, message: string }} Issue + * @typedef {{ errors: Issue[], warnings: Issue[] }} IssueReport + * @typedef {{ + * allowInlineScript?: boolean, + * maxChars?: number, + * maxTags?: number, + * baseDir?: string, + * }} CollectOptions + */ + +/** + * @param {string} html + * @param {string | undefined} baseDir + * @param {Issue[]} warnings + */ +function checkLocalAssets(html, baseDir, warnings) { + if (!baseDir) return; + const refs = html.matchAll(/\s(?:src|href)\s*=\s*(['"])([^'"]+)\1/gi); + for (const ref of refs) { + const target = ref[2]; + if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(target)) continue; // absolute URL, data:, mailto:, anchor + const cleaned = target.split(/[?#]/)[0]; + if (!cleaned || cleaned.endsWith('/')) continue; + if (!fs.existsSync(path.resolve(baseDir, cleaned))) { + warnings.push({ code: 'missing-local-asset', message: `Referenced local asset does not exist: ${target}` }); + } + } +} + +/** + * Collect validation issues for a self-contained HTML document. + * + * The default options reproduce the historical rich-output rules (no scripts + * at all). `allowInlineScript: true` switches to the interactive (app/deck) + * profile: inline `' + ); + assert.ok(codes(report.errors).includes('no-slides')); +}); + +test('app profile allows inline scripts but rejects script src, external links, and handlers', () => { + const inline = collectRichHtmlIssues(fixture('rich-script.html'), { allowInlineScript: true }); + assert.deepEqual(codes(inline.errors), []); + + const srcScript = collectRichHtmlIssues( + '', + { allowInlineScript: true } + ); + assert.ok(codes(srcScript.errors).includes('external-script')); + + const externalLink = collectRichHtmlIssues( + '', + { allowInlineScript: true } + ); + assert.ok(codes(externalLink.errors).includes('external-link')); + + const dataLink = collectRichHtmlIssues( + '', + { allowInlineScript: true } + ); + assert.deepEqual(codes(dataLink.errors), []); + + const handler = collectRichHtmlIssues('', { + allowInlineScript: true, + }); + assert.ok(codes(handler.errors).includes('event-handler')); +}); + +test('rich profile still rejects any script (regression guard for the profile split)', () => { + const report = collectRichHtmlIssues(fixture('rich-script.html')); + assert.ok(codes(report.errors).includes('blocked-tag')); + const valid = collectRichHtmlIssues(fixture('rich-valid.html')); + assert.deepEqual(codes(valid.errors), []); +}); + +test('size limits: deck errors past 2 MiB and warns past 512 KiB', () => { + const filler = 'a'.repeat(600 * 1024); + const base = fixture('deck-valid.html').replace('', ``); + const warned = collectDeckIssues(base); + assert.ok(codes(warned.warnings).includes('large-file')); + assert.deepEqual(codes(warned.errors), []); + + const huge = fixture('deck-valid.html').replace('', ``); + const errored = collectDeckIssues(huge); + assert.ok(codes(errored.errors).includes('too-large')); +}); + +test('missing local assets surface as warnings with baseDir context', () => { + const html = fixture('deck-valid.html').replace( + '

    Fixture Talk

    ', + '

    x

    m' + ); + const report = collectDeckIssues(html, { baseDir: fixturesDir }); + assert.ok(codes(report.warnings).includes('missing-local-asset')); +}); + +test('detectProfile sniffs deck, app, and rich shapes', () => { + assert.equal(detectProfile(fixture('deck-valid.html')), 'deck'); + assert.equal(detectProfile(fixture('rich-script.html')), 'app'); + assert.equal(detectProfile(fixture('rich-valid.html')), 'rich'); +}); diff --git a/test/extension.test.js b/test/extension.test.js index eb71de6..3c929ed 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/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` + ); + } + } +}); 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); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ef35191 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "strict": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["index.js", "src/**/*.js", "bin/**/*.js", "hooks/**/*.js", "test/**/*.js"], + "exclude": ["node_modules", "test/fixtures"] +}