diff --git a/.agents/skills/update-lifecycle-docs/SKILL.md b/.agents/skills/update-lifecycle-docs/SKILL.md new file mode 100644 index 00000000..f78b3072 --- /dev/null +++ b/.agents/skills/update-lifecycle-docs/SKILL.md @@ -0,0 +1,189 @@ +--- +name: update-lifecycle-docs +description: Audit, update, correct, reorganize, or review Lifecycle's source-verified ASD-STE100 end-user MDX documentation, raw Markdown routes, and UI screenshots. Use for documentation gap analysis, upstream documentation impact, new or changed product behavior, configuration/schema/API/CLI synchronization, troubleshooting guides, navigation changes, raw Markdown projection, screenshot capture or refresh, and docs drift remediation in lifecycle-docs. +--- + +# Update Lifecycle Docs + +Follow the repository `AGENTS.md` throughout this workflow. + +## Classify the work + +For an upstream change, choose one: + +- **No docs needed:** Internal behavior has no user, API, configuration, + installation, or interface effect. +- **Docs needed:** A user-visible behavior, workflow, contract, default, + command, route, or support boundary changed. +- **Uncertain:** The authoritative source does not establish the user effect. + +Do not manufacture a change for the first case. For the third case, identify +the missing product decision or evidence. + +For a gap analysis, give every claim one disposition: + +- **Confirmed — docs:** The owning product contract is clear. +- **Confirmed — upstream:** The product contract is broken or contradictory. +- **Blocked — decision:** Implementation exists, but public policy is not + resolved. +- **Adjusted:** The gap is real, but its scope, evidence, or remedy changed. +- **Refuted:** Authoritative evidence contradicts the claim. + +Deduplicate reports before implementation. Do not endorse an upstream defect +as a documented workflow. + +## Update workflow + +1. **Choose the user and task.** + + - Name the reader. + - State the outcome that the page must help the reader reach. + +2. **Find the owning contract.** + + - Use the source routing in `AGENTS.md`. + - Resolve sources from the task context. Do not assume sibling directories, + local ports, or one contributor's workspace. + - Inspect the implementation, schema, OpenAPI artifact, CLI registration, + chart values, or live UI as applicable. + - Record the exact commit, PR head SHA, release, or immutable artifact for + each claim. + - Separate committed evidence from uncommitted behavior in a dirty + checkout. + - Add or update `audience`, `lastVerified`, and `verificationBaseline` + frontmatter. Register new source revisions in + `documentation-metadata.json`. + - If no owning source is available, mark the claim unverified. Do not use + memory or another docs page as product evidence. + - Treat support status as a product decision. Do not infer it from code + presence. + +3. **Assess the user journey.** + + - Read the target page, adjacent pages, navigation metadata, and links. + - Read `references/language-profile.md` before an end-user content change. + - Read `references/ste100-review.md` before page, navigation, or visible + site-chrome copy changes. + - Prefer an update to an existing page. + - Create a page only for a distinct user task or audience. + +4. **Author the page.** + + - Set `contentProfile: asd-ste100`. + - Apply ASD-STE100 Issue 9 to all end-user prose, frontmatter, headings, + tables, callouts, links, captions, navigation text, and image alt text. + - Do not create an ASD-STE100 waiver. + - Keep procedural sentences at 20 words or fewer. + - Keep descriptive sentences at 25 words or fewer. + - Lead with the outcome. + - Put prerequisites and product status before the procedure. + - Use the smallest safe sequence with tested examples. + - Add observable success criteria, recovery, troubleshooting, and the next + action when they help the task. + - Keep exact commands, identifiers, API routes, schema keys, UI labels, + statuses, and error messages faithful to the owning source. + - Keep the evidence trail out of end-user prose. Do not describe source + inspection, code-comment disagreements, revision history, or how an + implementation proves a claim. + - Do not publish cache tiers, datastore records, handler names, controller + names, or similar machinery unless a supported user task requires that + detail. + - Translate verified evidence into the supported condition, user-observable + effect, user action, and expected result. Put source evidence in task + scratch, review notes, metadata, or the handoff. + +5. **Decide whether a screenshot helps.** + + - Use a screenshot only when recognition, location, or UI density makes the + task materially easier. + - Read `references/screenshots.md` before capture. + - Prefer diagrams for concepts and selectable text for code, commands, + schemas, or payloads. + - Get explicit authorization before each external state change. Record the + exact target, prior state, created identifiers, and cleanup result in + private task scratch. + +6. **Keep navigation and generated outputs coherent.** + + - Update the nearest `_meta.ts` when you add, move, or rename a page. + - Run generated metadata steps and review ordering changes. + - Treat `public/llms.txt` as generated output. Change page metadata and + navigation, then run `bun run build:llms`. + - Read `references/raw-markdown.md` before you add an MDX construct or + change the build path. + - Treat `public/docs.md` and `public/docs/**/*.md` as generated output. + Run `bun run build:raw`. + - Add an explicit lossless transform and a golden test for each new MDX + construct. Do not discard unsupported content. + +7. **Verify content and rendering.** + + - Validate examples against the owning schema, CLI, API, or chart. + - Use `references/ste100-review.md` for all 53 rule checks on each changed + page. + - Supply the portable contract inputs in `README.md` and run + `bun run check:contracts --require-all` when schema, API, or CLI claims + change. A **SKIP** is not contract verification. + - Format changed files only. + - Run `bun run check:styles`. + - Run `bun run check:raw`. + - After the complete human review, run + `bun run update:style-baseline`. Review the page, navigation, and visible + site-chrome hash diff. + - Run `bun run verify`. + - Resolve the preview URL from the task, `DOCS_BASE_URL`, or development + server output. Inspect every changed route with an approved desktop + browser inspection tool. + - Inspect `src/theme.config.tsx` and check each enabled theme. + - Run `bun run check:screenshots` for screenshot changes. Manually inspect + the final pixels and live UI state. + - Complete a user-voice review of every changed sentence. Keep a sentence + only when it gives a prerequisite, condition, action, observable result, + recovery step, safety boundary, or necessary explanation for the reader. + Remove sentences that only show how the writer verified a claim. + - If the browser or runtime is unavailable, report visual verification as + incomplete. + +8. **Prepare the handoff.** + + - Summarize the reader task, authoritative revisions, changed routes, + validation, and remaining uncertainty. + - Keep private fixture identifiers in task scratch. + - Create a docs PR only when authorized. Do not merge without explicit + authorization. + +## Page shape + +Use this order when it helps the task: + +1. Outcome +2. Audience, prerequisites, and support status +3. Procedure +4. Verify the result +5. Recover or roll back +6. Troubleshoot +7. Next steps +8. Version and owner last verified + +Do not force headings that add no value. Do not omit safety, verification, or +recovery when the workflow changes state. + +## Safety and accuracy + +- Do not infer public support from code acceptance. +- Do not publish the reasoning used to verify a user-facing claim. +- Do not add `supportStatus` without a product or release decision. +- Do not describe Labs or admin-gated controls as universally available. +- Do not publish private values, credentials, unrestricted examples, or + destructive shortcuts. +- Do not silently reconcile conflicting contracts. +- Do not add placeholders or TODO-only pages to navigation. +- Do not record personal paths, private fixture identities, fixed local URLs, + or local credentials in tracked files. +- Do not edit generated files by hand. +- Do not copy or commit licensed ASD-STE100 rule text, dictionaries, PDFs, or + checker data. +- Do not treat an automated style check or matching baseline hash as complete + ASD-STE100 certification. +- Do not claim that a screenshot is private-data-safe or current from + automated checks alone. diff --git a/.agents/skills/update-lifecycle-docs/agents/openai.yaml b/.agents/skills/update-lifecycle-docs/agents/openai.yaml new file mode 100644 index 00000000..9d3bd7f8 --- /dev/null +++ b/.agents/skills/update-lifecycle-docs/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Update Lifecycle Docs" + short_description: "Maintain source-verified Lifecycle docs" + default_prompt: "Use $update-lifecycle-docs to audit or update Lifecycle documentation with source verification, ASD-STE100 review, raw Markdown parity, and evidence-backed screenshots." diff --git a/.agents/skills/update-lifecycle-docs/references/language-profile.md b/.agents/skills/update-lifecycle-docs/references/language-profile.md new file mode 100644 index 00000000..502a40c1 --- /dev/null +++ b/.agents/skills/update-lifecycle-docs/references/language-profile.md @@ -0,0 +1,86 @@ +# Lifecycle documentation language profile + +Read this reference before you add, remove, move, or change an end-user page. + +## Scope + +All end-user documentation under `src/pages/docs/**` must follow ASD-STE100 +Issue 9. This requirement includes every nested documentation route. + +Repository maintainer files, source comments, generated Markdown, and code +examples are not end-user prose. Keep them clear and portable. + +## ASD-STE100 profile + +Use the +[official ASD-STE100 Issue 9 standard](https://www.asd-ste100.org/assets/files/ASD-STE100_ISSUE9.pdf) +during the human editorial review. Do not copy or commit its +controlled-language dictionary, rule text, PDF, or licensed checker data. +Use `ste100-review.md` to track all 53 rule identifiers for every page. Its +short review questions do not replace the official standard. + +Apply these project controls: + +- Use American English. +- Use one term for one concept. +- Use the approved general vocabulary when it expresses the correct meaning. +- Treat exact Lifecycle product terms, UI labels, schema keys, API fields, + commands, paths, statuses, and other required domain terms as technical + terms. +- Define a new technical term when a reader cannot understand it from the + task context. +- Prefer active voice. +- Use simple verb groups. +- Write all necessary words. Do not use contractions. +- Do not use semicolons. +- Put a condition before its instruction. +- Write one instruction in each procedural sentence. +- Use the imperative for an instruction. +- Keep a procedural sentence at 20 words or fewer. +- Keep a descriptive sentence at 25 words or fewer. +- Keep one topic in each descriptive paragraph. +- Keep a descriptive paragraph at six sentences or fewer. +- Use a note for information. Do not put an instruction in a note. + +Apply the same profile to titles, descriptions, navigation text, headings, +paragraphs, callouts, tables, list text, link text, captions, and image alt +text. + +Do not change an exact code block, command, API route, identifier, schema key, +UI label, status, or error message only to satisfy a prose rule. Correct an +exact string only when the owning product source proves that the string is +wrong. + +Set this frontmatter value on every end-user page: + +```yaml +contentProfile: asd-ste100 +``` + +Do not create a page-level ASD-STE100 waiver. + +## Review and validation + +Run: + +```sh +bun run check:styles +``` + +The style baseline stores a hash, profile, and review date for every page. It +also covers maintained documentation navigation and visible site chrome. The +baseline makes later prose changes require another review. It preserves the +review date for an unchanged page. + +After the human review and all checks pass, update the baseline: + +```sh +bun run update:style-baseline +``` + +Review the baseline diff. Do not update it to hide an incomplete language +review. + +No automated tool can certify complete ASD-STE100 compliance. The author and +reviewer remain responsible for vocabulary, technical-term selection, +sentence meaning, and context-dependent rules. diff --git a/.agents/skills/update-lifecycle-docs/references/raw-markdown.md b/.agents/skills/update-lifecycle-docs/references/raw-markdown.md new file mode 100644 index 00000000..ae5ade02 --- /dev/null +++ b/.agents/skills/update-lifecycle-docs/references/raw-markdown.md @@ -0,0 +1,111 @@ +# Raw Markdown routes + +Read this reference when you add a page, change MDX component usage, or change +the build and deployment path. + +## Architecture decision + +This repository uses Nextra 3 with the Next.js Pages Router and +[static export](https://nextra.site/docs/guide/static-exports). Nextra 3 does +not provide raw Markdown endpoints. Nextra 4 requires the App Router, and its +copy-page feature copies MDX source rather than a clean Markdown rendering. + +[Next.js static export](https://nextjs.org/docs/pages/guides/static-exports) +does not support a runtime API route or rewrite for this purpose. A pre-build +projection into `public/` gives local development, static export, GitHub Pages, +and Nginx the same files. + +Re-evaluate this decision only during an intentional framework migration. +Require the replacement to support clean Markdown, exact `.md` paths, static +export, component conversion, and fail-closed handling. + +## Public contract + +Append `.md` to an HTML documentation route to get clean Markdown: + +| HTML route | Raw Markdown route | +| -------------------- | ----------------------- | +| `/docs` | `/docs.md` | +| `/docs/releases` | `/docs/releases.md` | +| `/docs/features/cli` | `/docs/features/cli.md` | + +The mapping follows the public route. It does not follow the `.mdx` file name +when that file is an `index.mdx`. + +## Source and generated files + +The `.mdx` page is the source. `scripts/generateRawMarkdown.ts` creates a clean +GitHub Flavored Markdown projection under `public/` before the Next.js static +export. + +Generated raw files have a sentinel and are ignored by Git: + +```txt +public/docs.md +public/docs/**/*.md +``` + +Do not edit or commit them. The generator refuses to replace or delete a +reserved Markdown file without its generated sentinel. + +Use: + +```sh +bun run build:raw +bun run check:raw +``` + +`build:prep`, `dev`, and `build` run the generator. If you edit an MDX file +while `next dev` is already running, run `bun run build:raw` again to refresh +its `.md` route. + +## Projection contract + +The projector keeps normal Markdown and converts the supported MDX vocabulary: + +- `Callout` becomes a labeled GitHub alert blockquote. +- `Steps` keeps its ordered content. +- `Tabs` becomes a sequence of labeled sections. +- `Cards` becomes a linked list. +- `Image` becomes a Markdown image. +- `Iframe` becomes a titled link. +- A static string inside `` becomes inline code. +- Layout-only `div` elements keep their content. +- `br` becomes a Markdown line break. +- Useful code-fence filenames become adjacent Markdown text. +- Imports are removed. + +The projector fails with the source file and line when it finds an unknown +component, dynamic expression, unsafe attribute, raw HTML, or unsupported +code-fence metadata. It must never discard unknown content silently. + +When you introduce a new MDX construct: + +1. Define its lossless Markdown representation. +2. Add the explicit projector transform. +3. Add a golden projection test. +4. Confirm that all documentation pages project. +5. Run the full build and inspect the emitted `.md` file under `out/`. + +## Index policy + +`public/llms.txt` lists the public pages and links to their raw Markdown +routes. Update frontmatter and navigation, then regenerate the index. Do not +edit `public/llms.txt` directly. + +## Deployment + +Next.js copies generated files from `public/` to `out/`. GitHub Pages serves +them from the static export. The repository Nginx configuration gives `.md` +files the `text/markdown; charset=utf-8` media type and returns 404 for a +missing `.md` path. Its HTML fallback checks `$uri.html` before `$uri`. This +order prevents a page asset directory, such as `/docs/guide/`, from hiding the +exported `/docs/guide.html` page. + +After a deployment-path or asset-path change, use the production server +configuration to confirm all four conditions: + +- Each documentation `.md` route returns `200` and readable UTF-8 Markdown. +- A missing `.md` route returns `404` instead of the HTML fallback. +- A page with a same-name asset directory still returns its exported HTML. +- The page assets return `200`. diff --git a/.agents/skills/update-lifecycle-docs/references/screenshots.md b/.agents/skills/update-lifecycle-docs/references/screenshots.md new file mode 100644 index 00000000..8662c5f9 --- /dev/null +++ b/.agents/skills/update-lifecycle-docs/references/screenshots.md @@ -0,0 +1,181 @@ +# Lifecycle documentation screenshots + +Use this reference only when adding, replacing, or reviewing UI screenshots. + +## Contents + +- [When a screenshot is worth maintaining](#when-a-screenshot-is-worth-maintaining) +- [Capture standard](#capture-standard) +- [Fixture workflow](#fixture-workflow) +- [Asset placement and accessibility](#asset-placement-and-accessibility) +- [Screenshot catalog](#screenshot-catalog) +- [Capture checklist](#capture-checklist) + +## When a Screenshot Is Worth Maintaining + +Use a screenshot when it materially helps a reader: + +- Recognize a page, dialog, or state +- Find an action in a dense interface +- Distinguish success, pending, and failure states +- Understand the relationship between an Environment, its Services, logs, or Agent activity + +Do not use a screenshot for: + +- Code, terminal commands, schemas, or API payloads +- Conceptual architecture that a diagram explains more clearly +- Decorative proof that a feature exists +- Rapidly changing Labs controls unless the page names the status and the catalog has a refresh owner + +Keep the smallest screenshot set that explains the journey. + +## Capture Standard + +- Resolve the running Lifecycle UI from the task context, runtime output, or + `LIFECYCLE_UI_URL`. Never assume a host, port, authentication provider, or + test user. +- Use an available desktop browser inspection tool and an authorized, + authenticated non-production session. Follow the owning UI repository's + instructions when they are available; do not invent credentials or bypass + authentication. +- Set the viewport to 1440×900 at device scale factor 1 and browser zoom 100%. +- Use the theme required by the page and keep a screenshot series consistent. + Record the chosen theme in the catalog rather than assuming one here. +- Enable Labs only through the supported task/runtime procedure. Do not publish + a local cookie or developer bypass as an end-user enablement step. +- Wait for loading, transitions, toasts, and live updates to settle. +- Prefer a component or application-region capture. Keep enough surrounding UI for orientation. +- Use viewport position or element capture instead of editing pixels afterward. +- Do not bake arrows, labels, or explanatory text into the bitmap; put explanation in prose and captions. +- Do not use generative image editing on product screenshots. +- Capture PNG for UI text. Optimize only when legibility and dimensions remain unchanged. +- Keep raw captures in task scratch; only reviewed images belong in `public/`. +- If the browser tool cannot persist a file, save the returned image bytes + exactly through a trusted task-local mechanism. Do not re-render or transform + the UI. +- Prefer neutral fixture values at the source. For an authorized, + non-production fixture whose incidental identity, hostname, UUID, or + timestamp cannot be controlled at the source or cropped without losing the + teaching point, substitute only those identity-bearing DOM text or link + values before capture. Record every substitution in task scratch, preserve + product labels, statuses, actions, controls, and messages exactly, and + re-inspect the final pixels and live state. Never use this exception for + private or production data. +- If no approved browser tool or authenticated runtime is available, stop the + capture and report visual verification as incomplete. Do not infer the + rendered state from source or disrupt another session's browser. + +Use neutral fixtures. Exclude or avoid: + +- Tokens, secrets, environment-variable values, and one-time credentials +- Personal names, avatars, emails, and private repository ownership +- Internal cluster identifiers or unrelated local resources +- Incidental timestamps, random identifiers, and hostnames when they do not teach the task +- Browser chrome, developer-tool overlays, unrelated tabs, and transient notifications + +Crop or choose a narrower UI region rather than blurring. If a user-facing identifier is necessary to explain the task, use a neutral fixture value. + +## Fixture Workflow + +Use only a disposable fixture repository explicitly authorized for the task. +Resolve its identity at runtime from `DOCS_SCREENSHOT_FIXTURE_REPOSITORY`, the +task context, or the fixture checkout. Never put a private repository or owner +in tracked instructions: + +```sh +gh repo view "$DOCS_SCREENSHOT_FIXTURE_REPOSITORY" \ + --json nameWithOwner,defaultBranchRef,viewerPermission +``` + +Before mutation: + +1. Confirm the user authorized every planned external mutation: branch push, + PR creation, label or comment changes, PR closure, branch deletion, and + resulting Lifecycle resource creation/cleanup. +2. Inspect and record the exact repository, default branch, current revision, + worktree status, existing worktrees, open PRs, and current `lifecycle.yaml`. +3. Never switch the user's existing checkout or reuse, close, or modify a pre-existing PR. +4. Resolve the fixture's default branch and remote, then create a unique branch + in an isolated temporary worktree from the fetched default-branch revision. +5. Use one minimal Service and a neutral PR title. +6. Never merge a fixture PR. +7. Record the branch, PR, Environment UUID, and cleanup status in task scratch. + +Build the smallest fixture from the current authoritative schema. Do not keep a +copy of a product manifest in this skill: it will drift independently from the +runtime contract. Validate the fixture with the current server-side validator +before pushing it. Use current configured control labels rather than assuming +their default names. + +For a failure screenshot, prefer a deterministic, reversible fault with one +changed input. Record the exact change and verify that the resulting user-facing +state is the state the guide explains. Do not infer a build-versus-deploy +failure from the fixture alone. + +Cleanup order: + +1. Verify the PR's head branch exactly matches the recorded fixture branch. +2. Close the PR without deleting the branch. +3. Wait for teardown and confirm the recorded Environment reached the expected + terminal cleanup state, such as `Torn down`, through the user-visible UI or + supported API. Lifecycle can retain terminal Environment history. Use + cluster inspection only when the task explicitly includes operator access. +4. Delete only the recorded remote branch. +5. Remove the temporary worktree. +6. Attempt to delete the local branch with `git branch -d`. A fixture branch + from an unmerged, closed PR may require `git branch -D`; before doing that, + re-verify its exact name and commit against the task record and confirm the + remote branch is gone and the Environment reached its terminal cleanup + state. +7. Stop if cleanup finds unexpected changes or a target mismatch. + +If any authorized cleanup cannot complete, leave unrelated state untouched and +report the exact remaining PR, branch, Environment, Site, or session identifier +and the last verified state in the private task handoff. Use only a sanitized +cleanup statement in a public PR or issue. + +## Asset Placement and Accessibility + +- Mirror the page route under `public/docs/`; for example, a screenshot for `/docs/troubleshooting/deploy-issues` belongs under `public/docs/troubleshooting/deploy-issues/`. +- After you add a page asset directory, test the parent HTML route through the + deployment server. The server must prefer the exported `.html` file over the + same-name directory. +- Use a descriptive, stable filename such as `service-deploy-failure.png`, not a UUID or date. +- Add explicit `width`, `height`, and `ratio` to ``. +- Write alt text that explains the information contributed by the image, not every visible control. +- Keep all operational instructions in nearby text so the page remains usable without the image. +- Add a caption when the state, scope, or support status is not obvious. + +## Screenshot Catalog + +Update this table whenever a tracked screenshot is added, refreshed, removed, +or found to be stale. `keep` means the final pixels and live state were manually +reviewed. `replace` and `remove` deliberately fail `bun run check:screenshots` +until the referenced debt is resolved. + +| Asset | Docs page | User point | UI route/state | Fixture | Viewport/theme | UI revision | Last verified | Review | +| ----------------------------------------------------------------- | ------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------- | ------------------------------------------ | ------------- | ------ | +| `/docs/getting-started/explore-environment/environment-ready.png` | `/docs/getting-started/explore-environment` | Recognize a ready Environment and Service | Environment details, selected Service Summary, `Deployed` / `Ready` | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | +| `/docs/getting-started/onboard-repository/repository-list.png` | `/docs/getting-started/onboard-repository` | Find and select a repository for onboarding | `/onboard`, installed but not-onboarded repository filtered | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | +| `/docs/features/lifecycle-ui/environment-list.png` | `/docs/features/lifecycle-ui` | Find, filter, and select an Environment | `/environments`, one `Torn down` Environment filtered | Authorized disposable fixture; incidental identity, hostname, UUID, and timestamp values generalized in DOM; cleanup confirmed | 1440×900 / light | `b1df0cd64b4680fb241364be6cab985a2dce1f4f` | 2026-07-24 | `keep` | + +The structural check cannot read meaning from rendered pixels. A `keep` status +is a human assertion that the final image contains no secret, personal, +private, local-only, stale, or misleading content; automation verifies only +references, intrinsic dimensions, catalog state, and detectable tracked-text +markers. + +## Capture Checklist + +- [ ] The screenshot teaches a point that text or a diagram cannot communicate as well. +- [ ] The owning UI behavior was verified in the running app. +- [ ] The fixture is neutral, minimal, and recorded. +- [ ] Loading, animation, and transient UI have settled. +- [ ] No secret, personal, private, or unrelated data is visible. +- [ ] The crop retains enough context for orientation. +- [ ] Alt text and adjacent instructions carry the same essential meaning. +- [ ] The asset path and catalog entry are updated. +- [ ] The rendered page was checked in every theme enabled by + `src/theme.config.tsx`. +- [ ] `bun run check:screenshots` passes. +- [ ] Temporary PR, branch, Environment, Site, or session state was cleaned up. diff --git a/.agents/skills/update-lifecycle-docs/references/ste100-review.md b/.agents/skills/update-lifecycle-docs/references/ste100-review.md new file mode 100644 index 00000000..c1fc04cb --- /dev/null +++ b/.agents/skills/update-lifecycle-docs/references/ste100-review.md @@ -0,0 +1,92 @@ +# ASD-STE100 review coverage + +Use this checklist for every canonical end-user page. It tracks all 53 writing +rules in ASD-STE100 Issue 9 without reproducing the standard. Read the +[official Issue 9 PDF](https://www.asd-ste100.org/assets/files/ASD-STE100_ISSUE9.pdf) +for the authoritative rule text, definitions, permitted forms, categories, +and examples. + +`check:styles` is a mechanical aid. In the table: + +- **Mechanical** means that the repository check covers an objective part of + the rule. +- **Human** means that an editor must review meaning and context. +- **Source** means that product source or approved terminology must establish + the technical term. + +No end-user page or rule has a waiver. + +## Complete rule matrix + +| Rule | Project review question | Coverage | +| ---- | ------------------------------------------------------------------------------------------------- | ----------------- | +| 1.1 | Is each general word approved, or is it a necessary technical term? | Human, Source | +| 1.2 | Does each approved word use its permitted part of speech? | Human | +| 1.3 | Does each approved word use its permitted meaning? | Human | +| 1.4 | Does each verb or adjective use a permitted form? | Human | +| 1.5 | Does each technical noun fit an Issue 9 technical-noun category? | Human, Source | +| 1.6 | Is each unapproved word used only as a valid technical noun? | Human, Source | +| 1.7 | Is every technical noun used as a noun, not as a verb? | Human | +| 1.8 | Does product or industry evidence approve each technical noun? | Human, Source | +| 1.9 | Is each selected technical noun short and easy to understand? | Human | +| 1.10 | Is technical terminology free of regional language, slang, and jargon? | Human | +| 1.11 | Does one technical noun identify one item consistently? | Human, Source | +| 1.12 | Does each technical verb fit an Issue 9 technical-verb category? | Human, Source | +| 1.13 | Is every technical verb used as a verb, not as a noun? | Human | +| 1.14 | Does prose use American English spelling? | Human | +| 2.1 | Does a normal multi-word noun contain no more than three words? | Human | +| 2.2 | Is an official technical noun longer than three words written in full? | Human, Source | +| 3.1 | Does each general verb use a dictionary-approved form? | Human | +| 3.2 | Does each verb use a form and tense permitted by Issue 9? | Human | +| 3.3 | Is each past participle used only in a permitted adjective role? | Human | +| 3.4 | Does prose avoid complex auxiliary verb groups? | Mechanical, Human | +| 3.5 | Does each `-ing` form have a permitted technical-noun or modifier role? | Human | +| 3.6 | Is the voice active unless Issue 9 permits passive voice in that context? | Human | +| 3.7 | Does each action use a verb instead of a noun phrase? | Human | +| 4.1 | Is each sentence short, direct, and clear? | Mechanical, Human | +| 4.2 | Does each sentence keep necessary words and avoid contractions? | Mechanical, Human | +| 4.3 | Does complex information use a vertical list? | Human | +| 4.4 | Do related sentences use clear, approved connections? | Human | +| 4.5 | Does prose include necessary articles or demonstrative adjectives? | Human | +| 5.1 | Does each procedural sentence contain no more than 20 words? | Mechanical | +| 5.2 | Does each procedural sentence contain one instruction, except for an allowed simultaneous action? | Mechanical, Human | +| 5.3 | Does each instruction use the imperative form? | Mechanical, Human | +| 5.4 | Does a necessary condition occur before its instruction? | Mechanical, Human | +| 5.5 | Does each note contain information only? | Human | +| 6.1 | Does descriptive text introduce information in a useful sequence? | Human | +| 6.2 | Do headings and key terms make the information structure clear? | Human | +| 6.3 | Does each descriptive sentence contain no more than 25 words? | Mechanical | +| 6.4 | Does each paragraph contain related information? | Human | +| 6.5 | Does each paragraph have one topic? | Human | +| 6.6 | Does each descriptive paragraph contain no more than six sentences? | Mechanical | +| 7.1 | Does each safety message identify the correct severity? | Human, Source | +| 7.2 | Does each safety instruction start with an unambiguous command or condition? | Human | +| 7.3 | Does each safety message state the risk or possible result? | Human, Source | +| 8.1 | Does prose avoid semicolons and use punctuation correctly? | Mechanical, Human | +| 8.2 | Do hyphens connect only directly related words? | Human | +| 8.3 | Does each parenthetical expression have a permitted purpose? | Human | +| 8.4 | Does a colon introduce vertical-list content clearly? | Human | +| 8.5 | Does sentence-length review count a parenthetical expression as Issue 9 specifies? | Mechanical, Human | +| 8.6 | Does sentence-length review count protected strings and symbols as Issue 9 specifies? | Mechanical, Human | +| 8.7 | Does sentence-length review count a hyphenated word as one word? | Mechanical | +| 9.1 | If direct wording is not possible, does a new sentence construction keep the meaning? | Human | +| 9.2 | Is each approved word correct for its selected meaning and context? | Human | +| 9.3 | Does prose avoid unapproved phrasal verbs? | Human | +| 9.4 | Are terminology and sentence patterns consistent across the page and site? | Human, Source | + +## Review record + +For each changed canonical page: + +1. Verify facts and technical terms against the owning source revision. +2. Review all rule rows against the title, description, navigation text, + headings, paragraphs, lists, tables, callouts, links, captions, and alt + text. +3. Run `bun run check:styles` and resolve every mechanical finding. +4. Review the rendered page for sentence purpose, topic boundaries, safety + meaning, and information sequence. +5. Run `bun run update:style-baseline` only after the complete review. + +The baseline stores a content hash, profile, and review date for every route. +It proves review coverage and detects later changes. It does not certify +compliance. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cf9884aa..27afe3d4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,39 @@ -## Fixes +## Reader and task -- Fixes # +- Audience: +- Task/outcome: -## Proposed Changes +## Source of truth -- Change +- Exact owning repository revisions, PR head SHAs, or immutable artifacts: +- Frontmatter verification baseline added or reused: +- Product or runtime verification: +- Contract checks (schema / OpenAPI / CLI; identify any **SKIP**): +- Unavailable or unresolved sources: ---- +## Documentation changes -> Read about referenced issues [here](https://help.github.com/articles/closing-issues-using-keywords/). Replace words with this Pull Request's context. +- Changed pages: +- ASD-STE100 human review: +- Navigation or redirects: +- Raw Markdown projection: +- External state: sanitized authorization and cleanup assertion, with only + public or non-sensitive references (keep private identifiers in task scratch): +- Screenshots, catalog status, and manual pixel/state review: + +## Verification + +- [ ] Formatted changed files +- [ ] Kept source-review evidence and non-actionable implementation details out of end-user prose +- [ ] Confirmed that each changed sentence helps the reader understand, complete, verify, or recover the task +- [ ] Reviewed all 53 rule rows in `.agents/skills/update-lifecycle-docs/references/ste100-review.md` +- [ ] `bun run check:styles` +- [ ] Updated the style baseline only after the complete human review +- [ ] `bun run check:raw` +- [ ] `bun run verify` +- [ ] `bun run check:contracts --require-all` when schema, API, or CLI claims changed +- [ ] Checked changed routes with an approved desktop browser inspection tool +- [ ] Checked every theme enabled by the current theme config +- [ ] Ran `bun run check:screenshots` when visuals changed +- [ ] Confirmed screenshots contain no secrets or personal/private data +- [ ] Confirmed tracked guidance has no contributor-specific paths, hosts, credentials, or fixture identities diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0f51199..2695a1bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,6 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest - - run: bun install - - run: bun run lint - - run: bun run test + bun-version: 1.2.8 + - run: bun install --frozen-lockfile + - run: bun run verify diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1f625ed..625b91d2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,10 +31,10 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.2.8 - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Deploy app run: bun run deploy @@ -46,4 +46,5 @@ jobs: uses: JamesIves/github-pages-deploy-action@v4 with: branch: gh-pages + clean: true folder: out diff --git a/.gitignore b/.gitignore index dbdca016..43d22450 100644 --- a/.gitignore +++ b/.gitignore @@ -141,6 +141,8 @@ bun.lockb # Generated content public/styles.css +public/docs.md +public/docs/**/*.md # Local build files .vercel diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8b68c100 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,197 @@ +# Lifecycle Docs Agent Guide + +Lifecycle Docs is the public, end-user documentation for Lifecycle. Keep it accurate, task-oriented, safe to follow, and easy to scan. + +## Load Order + +1. Read `PRODUCT.md` for audiences, voice, and accessibility goals. It is editorial direction, not product-behavior truth. +2. Read `README.md` for MDX and component mechanics. +3. Read the target page, its nearest `_meta.ts`, and adjacent pages before editing. +4. Read `DESIGN.md` and `DESIGN.json` only for component, layout, brand, or visual-system work. +5. Inspect the owning product source before asserting behavior. +6. Use `$update-lifecycle-docs` for documentation synchronization, new guides, troubleshooting, or UI screenshots. + +## Sources of Truth + +Resolve product sources from the task context instead of assuming a particular +workspace layout. Prefer, in order: + +1. A source checkout or path explicitly supplied for the task +2. A configured workspace mapping +3. The upstream commit or PR head SHA, schema, OpenAPI document, release + artifact, or published repository + +These optional environment variables provide a portable source map for scripts +and agent sessions: + +| Variable | Owning source | +| ---------------------------- | ------------------------------------------------- | +| `LIFECYCLE_SOURCE_DIR` | API, OpenAPI, `lifecycle.yaml`, and Agent runtime | +| `LIFECYCLE_UI_SOURCE_DIR` | UI routes, labels, states, and interactions | +| `LIFECYCLE_CLI_SOURCE_DIR` | CLI commands, flags, output, and exit behavior | +| `LIFECYCLE_HELM_SOURCE_DIR` | Installation defaults and Helm values | +| `LIFECYCLE_INFRA_SOURCE_DIR` | Demo/evaluation infrastructure | + +For automated cross-repository checks, provide +`DOCS_SCHEMA_VALIDATOR_COMMAND`, `DOCS_OPENAPI_SPEC_PATH`, and +`DOCS_CLI_COMMAND` as documented in `README.md`. Run +`bun run check:contracts --require-all` when all three reviewed artifacts are +available. A reported **SKIP** is not contract verification. + +Do not require those exact variables when another authoritative source is +already available. If an owning source cannot be resolved, require an upstream +PR, commit, contract artifact, or maintainer decision. Mark the claim +unverified instead of guessing. Never use an existing docs page as proof that +the same page is correct. + +Before editing, record the exact owning source revision for each claim. If a +checkout is dirty, distinguish committed evidence from uncommitted behavior. +When no checkout is available, use an immutable public commit, exact PR head +SHA, release, or contract URL. If none exists, stop at an explicit unverified +claim or decision request; do not fill the gap from memory. + +The public explanation, navigation, and task guidance belong in this +repository only after the owning product contract has been verified. + +## Authoring Rules + +- Read + `.agents/skills/update-lifecycle-docs/references/language-profile.md` + completely before any end-user content change. +- Name the intended reader and task before editing. +- Lead with the outcome, then prerequisites, steps, expected result, recovery, and next action. +- Explain user-observable behavior rather than internal implementation. +- Keep source comparisons, code-comment discrepancies, verification reasoning, + and implementation evidence in private review notes. Do not publish them as + end-user documentation. +- In the final user-voice review, keep each sentence only when it helps the + reader understand, complete, verify, or recover the documented task. +- Prefer canonical product language. Use **Environment** for the user concept; mention **Build** only when an API or CLI compatibility context requires it. +- Give every page useful `title`, `description`, `audience`, `lastVerified`, + and `verificationBaseline` frontmatter. Resolve allowed values and exact + source revisions through `documentation-metadata.json`. +- Prefer updating an existing page unless a distinct user task needs its own page. +- Put copyable code, commands, and payloads in text—not screenshots. +- Give every meaningful image descriptive alt text and adjacent instructions or a caption. +- Give every iframe a useful title. +- Clearly label Stable, Alpha/Beta, Labs, admin-gated, evaluation-only, and production-supported behavior. +- Add `supportStatus` only when a product or release decision establishes it; + the absence of the field never implies Stable. +- Use a product decision, release note, or visible product label as the source + for support status. Code presence alone does not prove public support; if the + status is unresolved, say so in the handoff instead of inventing a label. +- Never publish credentials, secret values, broad secret-decoding commands, direct database mutation as normal setup, or destructive diagnostic shortcuts. +- Do not expose private repository ownership, personal identities, access tokens, internal-only hostnames, or incidental local data in screenshots. +- Keep tracked agent guidance portable. Do not record contributor-specific + filesystem paths, fixed development hosts or ports, private fixture + repositories, local credentials, or one workspace's process-management + rules. +- Treat generated navigation and `public/llms.txt` as derivatives. Update their + canonical page/frontmatter/navigation sources, then run the generators and + review the diff; do not hand-edit generated output. +- Preserve unrelated changes and review generated `_meta.ts` output for accidental ordering churn. + +## ASD-STE100 Language Profile + +- Maintain one end-user documentation tree under `src/pages/docs/**`. +- Apply ASD-STE100 Issue 9 to every title, description, heading, + paragraph, callout, table, list, link label, caption, and image alt text. +- Apply the same profile to maintained navigation titles and visible + site-chrome copy. +- Use + `.agents/skills/update-lifecycle-docs/references/ste100-review.md` to review + all 53 rule identifiers for every changed page. +- Set `contentProfile: asd-ste100` on each page. +- Do not create a page-level ASD-STE100 exclusion. +- Run `bun run check:styles`. +- Update `documentation-style-baseline.json` only after the complete human + review. The baseline covers pages, documentation navigation, root Docs + navigation, and visible site chrome. A matching hash records review + coverage; it does not certify ASD-STE100 compliance. +- Do not copy or commit the ASD-STE100 PDF, dictionaries, licensed checker + data, or verbatim rule text. + +## Raw Markdown + +- Read + `.agents/skills/update-lifecycle-docs/references/raw-markdown.md` before you + add an MDX construct or change the build path. +- Every HTML documentation route must also work when `.md` is appended. +- `scripts/generateRawMarkdown.ts` is the fail-closed MDX-to-GFM projector. +- Treat `public/docs.md` and `public/docs/**/*.md` as untracked generated + files. Do not edit or commit them. +- Add an explicit projector transform and a golden test for each new MDX + construct. Never discard an unknown construct silently. +- Keep `public/llms.txt` canonical-only and link it to the raw `.md` routes. +- Run `bun run check:raw` and confirm the static build contains every expected + Markdown route. + +## UI and Screenshot Work + +- Resolve the UI URL from the task, runtime output, or `LIFECYCLE_UI_URL`; + never assume a host or port. +- Verify UI claims in a running desktop UI through an available browser + inspection tool; source inspection alone is not enough for visual behavior. +- Use a 1440×900 desktop viewport. Do not add mobile captures unless the task explicitly requires them. +- Enable Labs only through the supported task/runtime procedure, and label + Labs surfaces in the resulting documentation. +- Never create a fixture PR, push a fixture branch, or mutate another external + repository unless the user authorized that external state change. +- Before any authorized external mutation, record the exact target and + pre-existing state. Record every created PR, branch, Environment, Site, or + session and its cleanup result. Authorization to create state does not imply + permission to merge, reuse, or delete unrelated state. +- Capture only stable, task-relevant states. Prefer focused application regions over full browser windows. +- Use neutral, reversible fixture data and meaningful alt text. Do not use blur or generative editing to hide sensitive content. +- Read `.agents/skills/update-lifecycle-docs/references/screenshots.md` before creating or refreshing screenshots. + +## Verification + +Format only the files you changed: + +```sh +bunx prettier --check +``` + +Run the repository verification suite: + +```sh +bun run verify +``` + +The suite includes STE100 mechanical checks, style-review hashes, and raw +Markdown projection. + +When the task changes schema, API, or CLI claims, also supply the exact reviewed +contract inputs and run: + +```sh +bun run check:contracts --require-all +``` + +For screenshot changes, also inspect the focused output from +`bun run check:screenshots`. It verifies structural properties but cannot +replace manual review of rendered pixels and live UI state. + +When content or visuals change, inspect the rendered routes with an approved +desktop browser inspection tool. If no such tool or authenticated runtime is +available, report visual verification as incomplete; do not infer rendered +behavior from source or disrupt another session's browser. Derive the required +theme checks from `src/theme.config.tsx` instead of recording a theme +assumption in tracked guidance. + +For local rendering, run `bun run dev` and use the URL printed by the +development server. A caller may supply `DOCS_BASE_URL` when verification is +performed against an existing preview or deployment. + +In the PR summary, identify: + +- Intended reader and task +- Exact owning source revisions or immutable artifacts used for each claim +- Changed pages +- Runtime/browser verification +- A sanitized external-state authorization and cleanup assertion, when + applicable. Keep private fixture repositories, branches, PRs, users, and + other contributor-specific identifiers in task scratch; include only public + or non-sensitive references in the PR. +- Screenshot structural audit and manual pixel/state review, when applicable diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e02a1cf6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +# Lifecycle Docs + +Read and follow `AGENTS.md` before changing this repository. For +documentation updates, language-profile work, raw Markdown, or UI screenshots, +also read `.agents/skills/update-lifecycle-docs/SKILL.md`. diff --git a/Dockerfile b/Dockerfile index cd3031c7..abc4ea08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ # limitations under the License. # Stage 1: Build the Nextra app with secrets injection -FROM oven/bun:1.2.4 AS builder +FROM oven/bun:1.2.8 AS builder WORKDIR /app # Copy dependency files diff --git a/README.md b/README.md index 85ef4ffb..f65d8246 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,75 @@ Documentation for Lifecycle. +Agents and contributors must read `AGENTS.md` before making changes. For +documentation synchronization, gap analysis, or screenshot work, also follow +`.agents/skills/update-lifecycle-docs/SKILL.md`. + --- -## Making a post +## Adding documentation -Making a post can be done in a few steps. +1. Create the `.mdx` page under `src/pages/docs`. +2. Add the required task and verification frontmatter: -1. Create a new `.mdx` file in the `src/pages` directory. -2. Add frontmatter to the top of the file including at least `title`, and `description`. - It will look like so: ```md - --- - title: "My Post" - description: "This is a description of my post." - --- + --- + title: My task + description: Complete the task safely and verify the result. + audience: + - application-developer + lastVerified: "" + verificationBaseline: "" + contentProfile: asd-ste100 + --- + ``` + + Use audience and baseline values registered in + `documentation-metadata.json`. Add `supportStatus` only when that registry + contains a product-approved value; omission does not mean Stable. + +3. Read + `.agents/skills/update-lifecycle-docs/references/language-profile.md`. + Write the page to ASD-STE100 Issue 9. Use + `.agents/skills/update-lifecycle-docs/references/ste100-review.md` to cover + all 53 rule identifiers. +4. Add the page to the nearest `_meta.ts` in its intended public navigation + order. +5. Put any maintained image under `public/` in a directory that mirrors the + documentation route: + + ```txt + src/pages/docs/troubleshooting/.mdx + public/docs/troubleshooting// ``` -3. Write your content in markdown. -4. Add images to the `public` directory and reference them in your markdown. - Images should be placed in a subdirectory of `public` with the same name as the `.mdx` file. - ```txt - // example - src/pages/docs/troubleshooting/ - public/docs/troubleshooting/new-page> - ``` + +6. When adding a UI screenshot, follow the capture and catalog workflow in + `.agents/skills/update-lifecycle-docs/references/screenshots.md`. + +--- + +## External contract checks + +`bun run check:contracts` validates against upstream contracts when their +portable inputs are supplied: + +| Variable | Value | +| --- | --- | +| `DOCS_SCHEMA_VALIDATOR_COMMAND` | JSON argv array for the canonical validator; each extracted `filename="lifecycle.yaml"` file is appended | +| `DOCS_OPENAPI_SPEC_PATH` | Path to the reviewed OpenAPI JSON artifact | +| `DOCS_CLI_COMMAND` | JSON argv array for the reviewed `lfc` executable | + +The default command reports every unavailable input as **SKIP**. Use +`bun run check:contracts --require-all` in a cross-repository validation job +that supplies all three inputs. Commands are argv arrays, not shell strings, so +the workflow remains portable and does not evaluate contributor-specific shell +syntax. --- ## Components -Lifecycle Docs provides a few extra components [in addition to components provided by Nextra](https://nextra.site/docs/guide/built-ins). +Lifecycle Docs provides a few extra components [in addition to components provided by Nextra](https://nextra.site/docs/built-ins). View all the currently exported components [here](https://github.com/GoodRxOSS/lifecycle-docs/blob/main/src/components/index.tsx). - Components like Image & Iframe have been added to make the docs look more consistent visually. @@ -41,23 +81,25 @@ View all the currently exported components [here](https://github.com/GoodRxOSS/l The `` component is a wrapper around next/image that provides a few extra features to make it easier to look nice in the docs. ```mdx -import Image from '@lifecycle-docs/components'; +import { Image } from '@lifecycle-docs/components'; Alt text ``` -You can center the image by adding the `center` prop and some extra JSX. +`alt` is required. Describe the information the image contributes to the +surrounding instructions. + +You can constrain and center an image with normal layout classes: ```mdx
-
+
Additional Optional Services
@@ -79,14 +121,7 @@ import { Iframe } from '@lifecycle-docs/components'; ## Development -Ensure you're using the correct version of node - -```bash -# or, n i auto -npm install bun -g -``` - -Install the dependencies +Install the Bun version declared by `packageManager`, then install dependencies: ```bash bun install @@ -98,71 +133,69 @@ Run the development server bun run dev ``` -## Deployment +Open the URL printed by the development server. -This site is deployed to GitHub Pages using GitHub Actions. When changes are pushed to the `oss` branch, a GitHub Action workflow automatically builds the site and deploys it to the `gh-pages` branch. +Before opening a pull request, run: -### Automatic Deployment +```bash +bun run verify +``` -The deployment process is handled by a GitHub Actions workflow defined in `.github/workflows/deploy.yml`. This workflow: +Useful focused checks: -1. Runs when changes are pushed to the `oss` branch -2. Sets up Bun -3. Installs dependencies -4. Builds the static site -5. Deploys the built site to the `gh-pages` branch +```bash +bun run check:docs +bun run check:styles +bun run check:llms +bun run check:raw +bun run check:screenshots +``` -### GitHub Pages Configuration +`public/llms.txt` is generated from curated public navigation and page +frontmatter. Update the canonical page and `_meta.ts` files, then run +`bun run build:llms`; do not edit `public/llms.txt` directly. -To enable GitHub Pages for this repository: +## Raw Markdown -1. Go to the repository on GitHub -2. Click on "Settings" -3. Scroll down to the "GitHub Pages" section -4. Under "Source", select "Deploy from a branch" -5. Under "Branch", select "gh-pages" and "/ (root)" -6. Click "Save" +Every documentation route also has clean Markdown at the same path with `.md` +appended: -The site will be available at `https://uselifecycle.com/`. +| HTML | Markdown | +| --- | --- | +| `/docs` | `/docs.md` | +| `/docs/features/cli` | `/docs/features/cli.md` | -### Static Build for GitHub Pages +`bun run build:raw` projects MDX into untracked files under `public/` before +the static build. Do not edit or commit `public/docs.md` or +`public/docs/**/*.md`. -For GitHub Pages deployment, we use a simplified build process that doesn't require GitHub API access. This avoids the need for setting up GitHub secrets for the deployment workflow. +The projector supports the repository component vocabulary and fails when a +component cannot be represented without content loss. Read +`.agents/skills/update-lifecycle-docs/references/raw-markdown.md` before you +add a new MDX construct. -The deployment process: -1. Builds the site using the standard build process -2. Creates a `.nojekyll` file to prevent GitHub Pages from processing the site with Jekyll +`documentation-style-baseline.json` stores hashes for completed ASD-STE100 +page reviews. It also tracks documentation navigation, the visible root Docs +navigation, and visible site chrome. After +the human review and focused checks pass, run +`bun run update:style-baseline`. Do not update the baseline only to silence a +content change. -### Manual Deployment +The screenshot check validates references, intrinsic dimensions, catalog +coverage, review status, and detectable tracked-text markers. It cannot decide +whether rendered pixels show private data or whether the captured UI state is +current. Manually inspect every final image and record the review in +`.agents/skills/update-lifecycle-docs/references/screenshots.md`. -You can also build and deploy the site manually: +## Deployment -1. Build the site: - ```bash - bun run deploy - ``` +The canonical deployment path is the GitHub Actions workflow in +`.github/workflows/deploy.yml`. A push to `main` builds the static site and +publishes `out/` to the `gh-pages` branch. -2. The static site will be generated in the `out` directory with a `.nojekyll` file. - -3. To deploy manually, you can push the `out` directory to the `gh-pages` branch: - ```bash - # First time setup - git checkout --orphan gh-pages - git reset --hard - git commit --allow-empty -m "Initial gh-pages commit" - git push origin gh-pages - git checkout oss - - # For subsequent deployments - bun run deploy - git checkout gh-pages - git rm -rf . - cp -r out/* . - touch .nojekyll - git add . - git commit -m "Deploy to GitHub Pages" - git push origin gh-pages - git checkout oss - ``` +`bun run deploy` creates the local static output and `.nojekyll` marker; it does +not authorize or perform a production publication. Do not switch deployment +branches, push generated output, or change GitHub Pages settings unless a +maintainer explicitly authorizes that external state change. -However, it's recommended to let the GitHub Actions workflow handle the deployment automatically. +The public site is available at `https://uselifecycle.com/`. diff --git a/bun.lock b/bun.lock index 41829a4f..ce4c94b7 100644 --- a/bun.lock +++ b/bun.lock @@ -53,6 +53,7 @@ "eslint": "^9.27.0", "eslint-plugin-react": "^7.37.5", "fast-glob": "^3.3.3", + "github-slugger": "^2.0.0", "globals": "^16.2.0", "gray-matter": "^4.0.3", "husky": "^9.1.7", @@ -63,11 +64,16 @@ "postcss": "^8.5.3", "postcss-import": "^16.1.0", "prettier": "^3.5.3", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "rimraf": "^6.0.1", "tailwindcss": "^^3.4.15", "tailwindcss-animate": "^^1.0.7", "typescript": "^5.8.3", "typescript-eslint": "^8.33.0", + "unified": "^11.0.5", }, }, }, diff --git a/default.conf b/default.conf index d77e940e..7e9ffb00 100644 --- a/default.conf +++ b/default.conf @@ -4,13 +4,27 @@ server { root /usr/share/nginx/html; index index.html; + location = /docs/cm { + return 404; + } + location = /docs/cm.md { + return 404; + } + location ^~ /docs/cm/ { + return 404; + } + location ~ \.md$ { + types {} + default_type "text/markdown; charset=utf-8"; + try_files $uri =404; + } location / { - try_files $uri $uri.html /index.html; + try_files $uri.html $uri $uri/ /index.html; } location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|otf|svg|map)$ { expires 6M; access_log off; add_header Cache-Control "public"; } - error_page 404 /index.html; + error_page 404 /404.html; } diff --git a/documentation-metadata.json b/documentation-metadata.json new file mode 100644 index 00000000..1d46f1d7 --- /dev/null +++ b/documentation-metadata.json @@ -0,0 +1,48 @@ +{ + "schemaVersion": 1, + "maintenance": { + "owner": "@GoodRxOSS/lifecycle-core", + "reviewTrigger": "owning-contract-change" + }, + "audiences": [ + "evaluator", + "application-developer", + "platform-operator", + "administrator", + "agent-user", + "api-user" + ], + "supportStatuses": [], + "verificationBaselines": { + "2026-07-24-comprehensive-audit": { + "verifiedOn": "2026-07-24", + "sources": { + "lifecycle": "9956d3e70a89c81a23c6502d03e237750c3886a4", + "lifecycle-ui": "b1df0cd64b4680fb241364be6cab985a2dce1f4f", + "lifecycle-cli": "3f8600dca8c97a715c7ae01c3a3087ba934fa4e1", + "helm-charts": "4c9504c8a264cfb39e09cb8cc4adbac50188dde7", + "lifecycle-opentofu": "b14865912608096379c28ef648f7ed99538b5600" + } + }, + "2026-08-01-configuration-schema-fix": { + "verifiedOn": "2026-08-01", + "sources": { + "lifecycle": "9956d3e70a89c81a23c6502d03e237750c3886a4", + "lifecycle-ui": "b1df0cd64b4680fb241364be6cab985a2dce1f4f", + "lifecycle-cli": "483115e47542b5b210ff416d507ebceaf7dd4932", + "helm-charts": "4c9504c8a264cfb39e09cb8cc4adbac50188dde7", + "lifecycle-opentofu": "b14865912608096379c28ef648f7ed99538b5600" + } + }, + "2026-08-01-lifecycle-mcp-preparation": { + "verifiedOn": "2026-08-01", + "sources": { + "lifecycle": "73b6a27f36c030ed5874a57d9ebf03848657201f", + "lifecycle-ui": "03d25cb7940ced77118611674582f88e15f0f7e1", + "lifecycle-cli": "483115e47542b5b210ff416d507ebceaf7dd4932", + "helm-charts": "c0257d1f88b5b30533f490d12f6293fa465b906c", + "lifecycle-opentofu": "b14865912608096379c28ef648f7ed99538b5600" + } + } + } +} diff --git a/documentation-style-baseline.json b/documentation-style-baseline.json new file mode 100644 index 00000000..33f4c7d9 --- /dev/null +++ b/documentation-style-baseline.json @@ -0,0 +1,552 @@ +{ + "schemaVersion": 3, + "updatedOn": "2026-08-01", + "references": { + "canonical": "ASD-STE100 Issue 9", + "canonicalRules": [ + "1.1", + "1.2", + "1.3", + "1.4", + "1.5", + "1.6", + "1.7", + "1.8", + "1.9", + "1.10", + "1.11", + "1.12", + "1.13", + "1.14", + "2.1", + "2.2", + "3.1", + "3.2", + "3.3", + "3.4", + "3.5", + "3.6", + "3.7", + "4.1", + "4.2", + "4.3", + "4.4", + "4.5", + "5.1", + "5.2", + "5.3", + "5.4", + "5.5", + "6.1", + "6.2", + "6.3", + "6.4", + "6.5", + "6.6", + "7.1", + "7.2", + "7.3", + "8.1", + "8.2", + "8.3", + "8.4", + "8.5", + "8.6", + "8.7", + "9.1", + "9.2", + "9.3", + "9.4" + ] + }, + "pages": { + "/docs": { + "file": "src/pages/docs/index.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "803e08b3b479ebad947b70ef369232203221cedd75cd185c4d4e0ca02f93ee50" + }, + "/docs/api-authentication/api-keys": { + "file": "src/pages/docs/api-authentication/api-keys.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "abccff28a910388f751789b3cd1ba7aba1ef81fc98e31ba66379427332eb6257" + }, + "/docs/api-authentication/overview": { + "file": "src/pages/docs/api-authentication/overview.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "b03afe35c3cc89a3539636567be27a94c831d7a205e0752ac6db4bf67546815a" + }, + "/docs/api/overview": { + "file": "src/pages/docs/api/overview.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "7d157e87da781f5bd05a43ccfd2030763ab00050a958a9ca53c1c0008add6cd4" + }, + "/docs/features/agent-administration": { + "file": "src/pages/docs/features/agent-administration.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "22fdd43dff37ccad2c1a3c3b2a3d7b1f737a24de960c00bea0da3d0a8971343d" + }, + "/docs/features/agent-sessions": { + "file": "src/pages/docs/features/agent-sessions.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "c440c089b444d668b0026952590da606cdbbecd17790d8dfdb2346d7d2588035" + }, + "/docs/features/ai-agent": { + "file": "src/pages/docs/features/ai-agent.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "29e68ca32de6e3de8f1eb8c3877afd82c88024d99432e62af60bdc696c4b5283" + }, + "/docs/features/ai-agent-configuration": { + "file": "src/pages/docs/features/ai-agent-configuration.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "88ca72aa79051de8b75605615e2ed5496749b72eafbf0b91a25f9c5e621f5646" + }, + "/docs/features/api-environments": { + "file": "src/pages/docs/features/api-environments.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "12ea39615d56eeab2a02438c2addbe6ed863b3d6e0c6baf4caff672a5c3fa39a" + }, + "/docs/features/authentication": { + "file": "src/pages/docs/features/authentication.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "9b2447691c45022e4c7362631710ace90d38d06d4f9bcc6666b8971c6896a449" + }, + "/docs/features/auto-deployment": { + "file": "src/pages/docs/features/auto-deployment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "c2d16a04ef6fbb671ad70f475f2a8e16f67812d01b3fbea5425f2750328178df" + }, + "/docs/features/build-metadata-links": { + "file": "src/pages/docs/features/build-metadata-links.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "97c55fb5f65e6c7c39011bd46d1814da17e208abf13a8aaca2646d59324a8d09" + }, + "/docs/features/cli": { + "file": "src/pages/docs/features/cli.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "88a35c2ebf6bb6ac52874de2df6edd96b99dab582e6e3de3414226d12cbe5c7c" + }, + "/docs/features/cli-telemetry": { + "file": "src/pages/docs/features/cli-telemetry.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "c77fd2bd08a0f8cc751a87a80d8a5f8dc92918f1a43d08eef3043862e74eac37" + }, + "/docs/features/configurable-labels": { + "file": "src/pages/docs/features/configurable-labels.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "a0728eac4304db001755e92933695858dfb7873b1870f586dae003874ceab01c" + }, + "/docs/features/environment-ttl": { + "file": "src/pages/docs/features/environment-ttl.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "6979cbee45db7f8b518a0e05d0b461b9b67e0787b215a4519e400d18a3201ca4" + }, + "/docs/features/ignore-file-patterns": { + "file": "src/pages/docs/features/ignore-file-patterns.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "61394d94394663b1ae2e2be2dd52c0ef9be3a03905255a917bb35d96cf4bc9e5" + }, + "/docs/features/lifecycle-ui": { + "file": "src/pages/docs/features/lifecycle-ui.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "e9e8e26803ea904d4851dd46787a3ded142baeb721bff7a812654f7a9560ca30" + }, + "/docs/features/mcp-integration": { + "file": "src/pages/docs/features/mcp-integration.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "d5139af83f22fbb59fa44543df90e2975f05bd1093d4a0997f3c48fa1eece2b5" + }, + "/docs/features/mcp-server": { + "file": "src/pages/docs/features/mcp-server.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "49addadbc408af37d03f1f2e4f8b12e4ca0dcf03c6e4b7b48bb6a3fee963f582" + }, + "/docs/features/native-helm-deployment": { + "file": "src/pages/docs/features/native-helm-deployment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "a58acbf6c95124f944ec8c910d4d4f25b79d14dd3923bc353182190d143e81fb" + }, + "/docs/features/secrets": { + "file": "src/pages/docs/features/secrets.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "7cdaec4ca63d7e47fa7b734419ba30e323a25987fba155e81cd24834e5d6210b" + }, + "/docs/features/service-dependencies": { + "file": "src/pages/docs/features/service-dependencies.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "9f531b80fb779a91458a096c27abf6fb6a8ed6e75a20fe8873537ad231b35b3e" + }, + "/docs/features/sites": { + "file": "src/pages/docs/features/sites.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "598f40560777f7d3f2074137ba9b2a102ceb4f61946fc05bc74f3a85d64d6bca" + }, + "/docs/features/template-variables": { + "file": "src/pages/docs/features/template-variables.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "0a4e4dd70e223a78eb6c642dfee60e0c34d3dae8609078db60131f3c46dfc3aa" + }, + "/docs/features/webhooks": { + "file": "src/pages/docs/features/webhooks.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "fb218fe2659838ddabb475f1645e28048522715296a8718f5d37df7f9dc0466e" + }, + "/docs/features/workspace-backends": { + "file": "src/pages/docs/features/workspace-backends.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "860c0b9f77ec3fb83d6d403d6f3d4d05e94cc6114cb02cd4acf3f4d675dd1264" + }, + "/docs/getting-started/configure-environment": { + "file": "src/pages/docs/getting-started/configure-environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "afd9bcd8201163e9b1a93290309691d4c46f5f09b1567d309cb7b73b2116eea0" + }, + "/docs/getting-started/create-environment": { + "file": "src/pages/docs/getting-started/create-environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "993562c14fafa481eead12346d1337de93fafe819662e26f356eedc13a880e2b" + }, + "/docs/getting-started/delete-environment": { + "file": "src/pages/docs/getting-started/delete-environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "5637386b9ba1ad495e72df3ac476c7a62e5e95824a20fe6d983a5211da2f945d" + }, + "/docs/getting-started/explore-environment": { + "file": "src/pages/docs/getting-started/explore-environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "3f67a2a743c38aaeb18cb2275c1076c9df51769898e465cced19dd369e99a6f1" + }, + "/docs/getting-started/explore-static-environment": { + "file": "src/pages/docs/getting-started/explore-static-environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "4fe9e1b3aebcc5319cd28d1343217facd32715acfdfecc4819706419a8ce3522" + }, + "/docs/getting-started/onboard-repository": { + "file": "src/pages/docs/getting-started/onboard-repository.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "b961c2f7e702280b4cdc4c859dca6399da9462b9c91b5ce7a1b1d6e2cea44c67" + }, + "/docs/getting-started/terminology": { + "file": "src/pages/docs/getting-started/terminology.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "0abb535486483ce37b7532e4e8b814bc1bc411f33bb1719b0afbfe36e7c081b4" + }, + "/docs/operations/architecture": { + "file": "src/pages/docs/operations/architecture.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "95378ac1babc753221f891b23ac1b7da589d9fab2f43f9fd8cc976b3df14dfff" + }, + "/docs/operations/configuration": { + "file": "src/pages/docs/operations/configuration.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "dcf78161c9d44e74638153de0f0b074361ba9ac6a8878a41338d35716860d755" + }, + "/docs/operations/day-two": { + "file": "src/pages/docs/operations/day-two.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "6f82683c8823a309bf7ceabc9115c3f82da189341f66a774315d2c3d3c00fc1f" + }, + "/docs/operations/monitoring": { + "file": "src/pages/docs/operations/monitoring.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "8f28ff718b35b9c600da263b60057ebd8975ade6f222c52c9a14f6aee1dc8ebc" + }, + "/docs/operations/security": { + "file": "src/pages/docs/operations/security.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "892aafebdd33b0c39b51f5c8ae6817e067679e3797d02eaaceee949b6ce13365" + }, + "/docs/reference/statuses": { + "file": "src/pages/docs/reference/statuses.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "9f1e546c90f54816d6911a71bb2abd5c72334fe153e4a52849d6a30814abb753" + }, + "/docs/releases": { + "file": "src/pages/docs/releases/index.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "db4c73e5c8a52b1785a19cb695fcacae932cdbe68a66814750b29af5b0368261" + }, + "/docs/releases/compatibility": { + "file": "src/pages/docs/releases/compatibility.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "1901b9d61ca33af98a41c41382624e7c07607c6cbaffb112e71718c3e17bbb1f" + }, + "/docs/schema/aurora-restore": { + "file": "src/pages/docs/schema/aurora-restore.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "6229361f88865ef443fe5535b240b81cd6d9a2b54793db1e23f822576f13cfa9" + }, + "/docs/schema/codefresh": { + "file": "src/pages/docs/schema/codefresh.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "3fa10fd918e625b41feab7103a22d5cffba74ca71eb73b7fccf02a3a417d1f3b" + }, + "/docs/schema/configuration": { + "file": "src/pages/docs/schema/configuration.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "e5af418530f552fd083d0855844e7ed89f49a7c0335d4a4d0d9662d959eb30b8" + }, + "/docs/schema/docker": { + "file": "src/pages/docs/schema/docker.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "0cebead1b57cb6f87eb53de1bf15507376e535678bfab1922813682737009638" + }, + "/docs/schema/environment": { + "file": "src/pages/docs/schema/environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "2ae5e1523d0bb7b3892d572f5db34c609ac09cfd38610e425157cd1ec0681ace" + }, + "/docs/schema/external-http": { + "file": "src/pages/docs/schema/external-http.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "166fecf7f0f1a0a0e451f4451e17306cef615a1c01a124e553ce39c851e86065" + }, + "/docs/schema/github": { + "file": "src/pages/docs/schema/github.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "6d90c58a426518bc34d557468d91c80088ad64a6784c16eb692bd91c292fd6f3" + }, + "/docs/schema/helm": { + "file": "src/pages/docs/schema/helm.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "8e15742511246d8df0a91df004ccbd5b376fa9add9ec5bf1f8123cf0832e9a1d" + }, + "/docs/schema/overview": { + "file": "src/pages/docs/schema/overview.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "7e139fbe62aadacded3c7bed3374fe3d86c34c86770a53a824271c35d9dc5e3f" + }, + "/docs/schema/webhooks": { + "file": "src/pages/docs/schema/webhooks.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "15b53414913120374ca076b4396cb1fd2987a781ede7921341839973e3681a3f" + }, + "/docs/setup/configure-lifecycle": { + "file": "src/pages/docs/setup/configure-lifecycle.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "da07ffe41700f90967f179fb7b65ac7130d90d5650e777ffa3ff77809973285f" + }, + "/docs/setup/create-github-app": { + "file": "src/pages/docs/setup/create-github-app.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "638f90b9cc97e49aa17b08d4b14b8940690fd15ba30f9d7eacda75bbaf31deb3" + }, + "/docs/setup/install-lifecycle": { + "file": "src/pages/docs/setup/install-lifecycle.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "675a15ac05fef8f53b5cd2e177eb24b1fac1f745e5189ae6d8fc3133d9580a0c" + }, + "/docs/setup/prerequisites": { + "file": "src/pages/docs/setup/prerequisites.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "4281e28f958ec899b17ef6727607609e5653863b1cd403cedbf26e2d506f5bd0" + }, + "/docs/setup/setup-infra": { + "file": "src/pages/docs/setup/setup-infra.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "d8ffadca7fa1879acbb8767228028cf06f3c9626fa36ba21b6ac534e82f10682" + }, + "/docs/tips/telemetry": { + "file": "src/pages/docs/tips/telemetry.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "3db850a9cd8838f2cb9fd3581d897aff17e9b7cc02b3971bb50d1c524f3267a5" + }, + "/docs/tips/using-mission-control": { + "file": "src/pages/docs/tips/using-mission-control.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "bd2d05f591c2321894649ee20489379594ee668e07e78ed9e85f664ce72594b1" + }, + "/docs/troubleshooting": { + "file": "src/pages/docs/troubleshooting/index.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "121575542a6f0eeb000c5b3dace388b9b02ec2a34e745de1b6a83be068f9b397" + }, + "/docs/troubleshooting/access-and-api": { + "file": "src/pages/docs/troubleshooting/access-and-api.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "90266f91972e88f323196a84db38ffa4ca0b6562b40dcb501ddc40ac9b5a443a" + }, + "/docs/troubleshooting/build-issues": { + "file": "src/pages/docs/troubleshooting/build-issues.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "e0e56dd4687dc250a63ad88c978b1a55e95d5756d7501fc5175358e4da798453" + }, + "/docs/troubleshooting/deploy-issues": { + "file": "src/pages/docs/troubleshooting/deploy-issues.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "d5d101e74cab84ef57079865d027370e604f9afdd3135b6a0a893094c6534e91" + }, + "/docs/troubleshooting/github-app-webhooks": { + "file": "src/pages/docs/troubleshooting/github-app-webhooks.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "369cf931c1dedf0b7a5ee1915256f573320144db64f5c8dc0ffaba6b0cf7a49a" + }, + "/docs/troubleshooting/stuck-environment": { + "file": "src/pages/docs/troubleshooting/stuck-environment.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "a6fb2730cb2e2a790b3fa60b44262485a26288a56a5c487ed65db8d77e88bd55" + }, + "/docs/what-is-lifecycle": { + "file": "src/pages/docs/what-is-lifecycle.mdx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-25", + "sha256": "862c7c055e26b81214de6bc67851bdfc1b4e94851fab1a53b6d51771f6003b6f" + } + }, + "surfaces": { + "navigation:/": { + "file": "src/pages/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "938d828e4bfadebd36f42d46f1c7179e67e3c6392d5b547d35f61b75a8f32a47" + }, + "navigation:/docs": { + "file": "src/pages/docs/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "91cb82f6d854583230b2bd31f748bbcbbf27f6c89b62dbffd0a54b05f30a34e0" + }, + "navigation:/docs/api": { + "file": "src/pages/docs/api/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "5daaf80fab18bbc39c5e61cbb87c1e3982073f49762a013ab13dd1e920537096" + }, + "navigation:/docs/api-authentication": { + "file": "src/pages/docs/api-authentication/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "0cc155f6a10b2eed69130667b87d8d5c727c9405725a6a9bf823082434717601" + }, + "navigation:/docs/features": { + "file": "src/pages/docs/features/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-08-01", + "sha256": "c9f3976a1784098cd78e6ff983b41716c1b092a3e499e1c99026bcabcadeb6b8" + }, + "navigation:/docs/getting-started": { + "file": "src/pages/docs/getting-started/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "2c14dd06201f896050d9b9e31e6529cd3c0f015c1ea9755cb345e7e6b85367d7" + }, + "navigation:/docs/operations": { + "file": "src/pages/docs/operations/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "2d6d4446e4934f8d04034a668ead60fc3acaa021ff7c97a7a7fd35b9a99a9f80" + }, + "navigation:/docs/reference": { + "file": "src/pages/docs/reference/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "2f0e10969ad4580b91c720127824ee839722b6945b81387eb0d3567cecc28ea9" + }, + "navigation:/docs/releases": { + "file": "src/pages/docs/releases/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "b48afe0e55e7cc786c8ceacc908cc32889479778af3621f6591874d5346a0dd5" + }, + "navigation:/docs/schema": { + "file": "src/pages/docs/schema/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "bb9d40d6f76628dbf7b02b560f73b5b2a7796deed7db05e4976eba9303e3a11f" + }, + "navigation:/docs/setup": { + "file": "src/pages/docs/setup/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "75696ccc95304e52455775f63e2674af41345dc4710ed7fc396e7dd3314c6bd4" + }, + "navigation:/docs/tips": { + "file": "src/pages/docs/tips/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "be9cd78ce9140f3f813d18b60e7474801db6c7d6ee182569dea19a7c6755b209" + }, + "navigation:/docs/troubleshooting": { + "file": "src/pages/docs/troubleshooting/_meta.ts", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "2dd61f939bffd5c7e7a55e45b35afd24af071229e05e6fe06e3915ad0f6b9e97" + }, + "site-chrome:footer": { + "file": "src/components/site-footer/index.tsx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "6f7323a479f6c6b5e303f69d5e6d83caae0c7f4bd43232502ac0aba946f8e2c6" + }, + "site-chrome:theme": { + "file": "src/theme.config.tsx", + "profile": "asd-ste100", + "reviewedOn": "2026-07-24", + "sha256": "a5d21a5e718c568406303219afde508fc5f0cb172fc42b623bf8b27e14f6c4f8" + } + } +} diff --git a/package.json b/package.json index ed5c3d15..360a6b6b 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,17 @@ "build": "bun run build:prep && bun run build:styles && next build", "build:styles": "tailwindcss -i ./src/styles/globals.css -o public/styles.css", "build:meta": "bun run ./scripts/generateMeta.ts", - "build:tags": "bun run ./scripts/generateTagPages.ts", - "build:prep": "bun run clean && bun run build:tags && bun run build:meta", + "build:llms": "bun run ./scripts/generateLlms.ts", + "build:raw": "bun run ./scripts/generateRawMarkdown.ts", + "build:prep": "bun run clean && bun run build:meta && bun run build:llms && bun run build:raw", + "check:contracts": "bun run ./scripts/validateContracts.ts", + "check:docs": "bun run ./scripts/validateDocs.ts", + "check:llms": "bun run ./scripts/generateLlms.ts --check", + "check:raw": "bun run ./scripts/generateRawMarkdown.ts --check", + "check:screenshots": "bun run ./scripts/validateDocs.ts --screenshots-only", + "check:styles": "bun run ./scripts/validateSte100.ts", "clean": "rimraf src/pages/tags src/lib/data", - "dev": "bun run build:prep && next dev -p 3333", + "dev": "bun run build:prep && next dev", "deploy": "bun run build && touch out/.nojekyll", "start": "next start", "lint": "eslint . && next lint", @@ -22,7 +29,9 @@ "test": "bun test", "test:watch": "bun test --watch", "test:coverage": "bun test --coverage", - "update": "codependence --update" + "update:style-baseline": "bun run ./scripts/validateSte100.ts --update-baseline", + "update": "codependence --update", + "verify": "bun run lint && bun run typecheck && bun run check:docs && bun run check:styles && bun run check:contracts && bun run check:llms && bun run check:raw && bun run check:screenshots && bun run test && bun run build" }, "dependencies": { "@code-hike/mdx": "^0.9.0", @@ -75,6 +84,7 @@ "fast-glob": "^3.3.3", "globals": "^16.2.0", "gray-matter": "^4.0.3", + "github-slugger": "^2.0.0", "husky": "^9.1.7", "js-yaml": "^4.1.0", "lint-staged": "^16.1.0", @@ -84,10 +94,15 @@ "postcss-import": "^16.1.0", "prettier": "^3.5.3", "rimraf": "^6.0.1", + "remark-gfm": "^4.0.1", + "remark-mdx": "^3.1.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", "tailwindcss": "^^3.4.15", "tailwindcss-animate": "^^1.0.7", "typescript": "^5.8.3", - "typescript-eslint": "^8.33.0" + "typescript-eslint": "^8.33.0", + "unified": "^11.0.5" }, "lint-staged": { "*.{js,jsx,ts,tsx,md,mdx}": [ diff --git a/public/docs/features/lifecycle-ui/environment-list.png b/public/docs/features/lifecycle-ui/environment-list.png new file mode 100644 index 00000000..2e034242 Binary files /dev/null and b/public/docs/features/lifecycle-ui/environment-list.png differ diff --git a/public/docs/getting-started/explore-environment/environment-ready.png b/public/docs/getting-started/explore-environment/environment-ready.png new file mode 100644 index 00000000..41d3f515 Binary files /dev/null and b/public/docs/getting-started/explore-environment/environment-ready.png differ diff --git a/public/docs/getting-started/onboard-repository/repository-list.png b/public/docs/getting-started/onboard-repository/repository-list.png new file mode 100644 index 00000000..1ac7de8e Binary files /dev/null and b/public/docs/getting-started/onboard-repository/repository-list.png differ diff --git a/public/docs/operations/architecture/request-flow.svg b/public/docs/operations/architecture/request-flow.svg new file mode 100644 index 00000000..9a641fe7 --- /dev/null +++ b/public/docs/operations/architecture/request-flow.svg @@ -0,0 +1,108 @@ + + Lifecycle request-to-Environment flow + GitHub, user clients, and the identity provider send requests to Lifecycle web and API services. Redis sends queued work to Lifecycle workers. Web and worker services use Postgres. Workers use object storage and run builds, Kubernetes workloads, and Site delivery. Lifecycle returns status, logs, and URLs to clients. + + + + + + + + + + + + + + + + + + + + REQUESTS + LIFECYCLE CONTROL PLANE + WORK AND DELIVERY + + + + GitHub + Repository events + + + User clients + UI · CLI · API · MCP + + + Identity provider + Interactive sign-in + + + Web and API + Authenticate and accept work + + + Redis + Queued work + + + Workers + Build, deploy, and clean up + + + Postgres + Lifecycle data + + + Object storage + Logs and Site content + + + BuildKit or external CI + Build application images + + + Kubernetes + Run Environment workloads + + + Site gateway + Serve published Site content + + + + + + + + + + + + + + + + + + + + + + + Result + Environment status, logs, and URLs return to the UI, CLI, API, GitHub comments, and MCP clients. + + diff --git a/public/docs/simple-workflow.png b/public/docs/simple-workflow.png deleted file mode 100644 index 00a51175..00000000 Binary files a/public/docs/simple-workflow.png and /dev/null differ diff --git a/public/getting-started/pr-comment.png b/public/getting-started/pr-comment.png deleted file mode 100644 index 6175c3d6..00000000 Binary files a/public/getting-started/pr-comment.png and /dev/null differ diff --git a/public/getting-started/status-comment-building.png b/public/getting-started/status-comment-building.png deleted file mode 100644 index 096de48c..00000000 Binary files a/public/getting-started/status-comment-building.png and /dev/null differ diff --git a/public/getting-started/status-comment.png b/public/getting-started/status-comment.png deleted file mode 100644 index 15b6098b..00000000 Binary files a/public/getting-started/status-comment.png and /dev/null differ diff --git a/public/llms.txt b/public/llms.txt index 192d494a..9090c1df 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,850 +1,106 @@ # Lifecycle -> Lifecycle is an ephemeral environment generator that creates isolated dev environments from a `lifecycle.yaml` config file. This guide helps AI agents assist developers in configuring their repositories. +> End-user documentation for Lifecycle environments, configuration, automation, agents, and platform operations. -## Documentation +This index is generated from the human documentation. Follow the linked page for current prerequisites, procedures, support boundaries, and recovery guidance. -- [What is Lifecycle](https://uselifecycle.com/docs/what-is-lifecycle): Overview of Lifecycle and core concepts. -- [Terminology](https://uselifecycle.com/docs/getting-started/terminology): Key terms and definitions. -- [Create Environment](https://uselifecycle.com/docs/getting-started/create-environment): Tutorial for creating your first environment. -- [Configure Environment](https://uselifecycle.com/docs/getting-started/configure-environment): Tutorial for configuring services. +## Start here -## Schema Reference +- [What is Lifecycle?](https://uselifecycle.com/docs/what-is-lifecycle.md): Understand how Lifecycle creates connected, isolated application Environments for pull requests and API-driven workflows. _(audience: evaluator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ -- [Schema Overview](https://uselifecycle.com/docs/schema/overview): Complete lifecycle.yaml structure reference. -- [Environment Schema](https://uselifecycle.com/docs/schema/environment): Environment-level configuration options. -- [GitHub Service](https://uselifecycle.com/docs/schema/github): Build services from source code. -- [Docker Service](https://uselifecycle.com/docs/schema/docker): Deploy pre-built Docker images. -- [Helm Service](https://uselifecycle.com/docs/schema/helm): Deploy Helm charts. -- [Codefresh Service](https://uselifecycle.com/docs/schema/codefresh): External CI/CD pipeline integration. -- [Configuration Service](https://uselifecycle.com/docs/schema/configuration): Metadata-only services. -- [Webhooks Schema](https://uselifecycle.com/docs/schema/webhooks): Webhook configuration reference. +## Getting started -## Features - -- [Auto-Deployment](https://uselifecycle.com/docs/features/auto-deployment): Automatic deployment on PR updates. -- [Template Variables](https://uselifecycle.com/docs/features/template-variables): Dynamic references between services. -- [Service Dependencies](https://uselifecycle.com/docs/features/service-dependencies): Hard dependencies with `requires`. -- [Secrets](https://uselifecycle.com/docs/features/secrets): AWS/GCP secret manager integration. -- [Webhooks](https://uselifecycle.com/docs/features/webhooks): Trigger actions on deployment events. -- [Configurable Labels](https://uselifecycle.com/docs/features/configurable-labels): GitHub label configuration. -- [Environment TTL](https://uselifecycle.com/docs/features/environment-ttl): Automatic cleanup of inactive environments. -- [Native Helm Deployment](https://uselifecycle.com/docs/features/native-helm-deployment): Kubernetes job-based Helm deployments. - ---- - -## Getting Started - -**File location:** Create `lifecycle.yaml` at the root of your repository. - -**Trigger deployment:** Add `lifecycle-deploy!` label to a PR, or set `autoDeploy: true` in config. - -**Access environment:** After deployment, environment URLs appear in PR comments. - -**Minimum viable config:** -```yaml -version: "1.0.0" - -environment: - defaultServices: - - name: "api" - -services: - - name: "api" - github: - repository: "org/repo" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 -``` - ---- - -## Core Concepts - -| Term | Definition | -|------|------------| -| **Service** | A deployable unit (container, database, etc.) | -| **Environment** | A stack of services deployed together | -| **Build/UUID** | Unique identifier for an environment instance (e.g., `arm-model-060825`) | -| **defaultServices** | Services always deployed with every environment | -| **optionalServices** | Services available on-demand via UI or labels | -| **branchName** | Default branch for the repository (used for builds) | -| **defaultTag** | Docker image tag, typically matches the default branch name | - -Docs: [Terminology](https://uselifecycle.com/docs/getting-started/terminology) - ---- - -## Decision Tree for Agents - -When a user wants to add a service, ask these questions in order: - -### Step 1: Service Type -``` -Q: Is this service built from source code or using a pre-built image? - -→ "Built from source, deployed directly" → Use `github` type -→ "Pre-built image" → Use `docker` type -→ "Deploy via Helm chart" → Use `helm` type -``` - -### Step 2: Build Configuration - -For `github` type: -``` -Q: Where is your Dockerfile located? - Default: "Dockerfile" in repo root - -Q: What port does your application listen on? - Common: 3000, 8080, 80 - -Q: Does it need a health check endpoint? - Common: /health, /healthz, /ready -``` - -For `helm` type: -``` -Q: Is this a public chart (e.g. Bitnami), a local chart in the repo, or an OCI registry chart? - -Q: What is the chart name and (if public) the Helm repository URL? - -Q: Does it need a custom Docker build? (If yes, also collect Dockerfile location and ports) - -Q: What Helm values need to be set? (e.g. auth credentials, replica count) -``` - -### Step 3: Runtime Configuration -``` -Q: What environment variables does your service need? - → Add to `env` section - -Q: Does it need to connect to other services? - → Use template variables (always quote them): "{{{service_name_internalHostname}}}" - -Q: Should it be publicly accessible? - → Set `deployment.public: true` -``` - -### Step 4: Dependencies -``` -Q: Does this service depend on other services (databases, caches)? - → Add to `requires` array - → Or add dependency as separate service in defaultServices -``` - ---- - -## Service Types - -### GitHub Service (Build from Source) - -Use when: Building a Docker image from source code in a GitHub repository. - -Docs: [GitHub Schema](https://uselifecycle.com/docs/schema/github) - -**Required fields:** -- `github.repository` - GitHub org/repo (e.g., "myorg/api") -- `github.branchName` - Default branch (e.g., "main") -- `github.docker.defaultTag` - Image tag, typically same as default branch -- `github.docker.app.dockerfilePath` - Path to Dockerfile -- `github.docker.app.ports` - Array of exposed ports - -**Nesting rules (common source of errors):** -- `env` lives at `github.docker.app.env` — deep inside the `app` block -- `deployment` lives at `github.deployment` — sibling of `docker`, NOT inside `docker` - -This asymmetry is intentional but easy to get wrong. Use the annotated template below as the reference. - -**Template (with env, deployment, and health check):** -```yaml -services: - - name: "SERVICE_NAME" - github: # ← github level - repository: "ORG/REPO" - branchName: "main" - docker: # ← github.docker level - defaultTag: "main" - app: # ← github.docker.app level - dockerfilePath: "Dockerfile" - ports: - - PORT_NUMBER - env: # ← env goes HERE (github.docker.app.env) - KEY: "value" - deployment: # ← deployment goes HERE (github.deployment, sibling of docker) - public: true - readiness: - httpGet: - path: "/health" - port: PORT_NUMBER - initialDelaySeconds: 10 - periodSeconds: 5 -``` - -**Optional: build engine** — add `builder` under `docker` to select the build engine (default is `buildkit`): -```yaml - docker: - defaultTag: "main" - builder: - engine: "buildkit" # buildkit (default), kaniko, codefresh - app: - # ... -``` - -**With resource limits:** -```yaml -services: - - name: "api" - github: - repository: "myorg/api" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 - deployment: - public: true - resource: - cpu: - request: "100m" - limit: "500m" - memory: - request: "256Mi" - limit: "512Mi" -``` - -### Docker Service (Pre-built Images) - -Use when: Deploying existing Docker images (databases, Redis, third-party services). - -Docs: [Docker Schema](https://uselifecycle.com/docs/schema/docker) - -**Required fields:** -- `docker.dockerImage` - Image name (e.g., "postgres", "redis") -- `docker.defaultTag` - Image tag (e.g., "15-alpine", "7") -- `docker.ports` - Array of exposed ports - -**Important:** For docker services, `deployment` is nested **inside** `docker:`, not at the service level. - -**Template:** -```yaml -services: - - name: "SERVICE_NAME" - docker: - dockerImage: "IMAGE_NAME" - defaultTag: "TAG" - ports: - - PORT_NUMBER - env: - KEY: "value" - deployment: - public: false - readiness: - tcpSocketPort: PORT_NUMBER -``` - -**Common database examples:** - -PostgreSQL: -```yaml -services: - - name: "postgres" - docker: - dockerImage: "postgres" - defaultTag: "15-alpine" - ports: - - 5432 - env: - POSTGRES_USER: "app" - POSTGRES_PASSWORD: "password" - POSTGRES_DB: "appdb" -``` - -Redis: -```yaml -services: - - name: "redis" - docker: - dockerImage: "redis" - defaultTag: "7-alpine" - ports: - - 6379 - deployment: - readiness: - tcpSocketPort: 6379 -``` - -MySQL: -```yaml -services: - - name: "mysql" - docker: - dockerImage: "mysql" - defaultTag: "8.0" - ports: - - 3306 - env: - MYSQL_ROOT_PASSWORD: "password" - MYSQL_DATABASE: "appdb" -``` - -MongoDB: -```yaml -services: - - name: "mongo" - docker: - dockerImage: "mongo" - defaultTag: "6" - ports: - - 27017 - env: - MONGO_INITDB_ROOT_USERNAME: "admin" - MONGO_INITDB_ROOT_PASSWORD: "password" -``` - -Nginx (public with deployment): -```yaml -services: - - name: "nginx" - docker: - dockerImage: "nginx" - defaultTag: "latest" - ports: - - 80 - deployment: - public: true - resource: - cpu: - request: "10m" - memory: - request: "200Mi" - readiness: - tcpSocketPort: 80 -``` - -### Helm Service (Helm Charts) - -Use when: Deploying services via Helm charts — local charts, OCI registries, or public repositories like Bitnami. Supports an optional Docker build step. - -Docs: [Helm Schema](https://uselifecycle.com/docs/schema/helm), [Native Helm Deployment](https://uselifecycle.com/docs/features/native-helm-deployment) - -**Required fields:** -- `helm.chart.name` - Chart name, local path (`./charts/app`), OCI URL (`oci://...`), or public chart name -- `helm.chart.repoUrl` - Helm repository URL (required for public charts) - -**Recommended:** -- `helm.deploymentMethod: "native"` - Runs Helm as a Kubernetes job for real-time logs and better concurrency handling - -**Nesting rules:** -- `repository` and `branchName` go directly under `helm:` (only needed when using `docker:` for a build) -- `envMapping` goes directly under `helm:` (sibling of `chart:` and `docker:`) -- `docker.app.env` goes at `helm.docker.app.env` — same depth as in github services - -**Passing env vars to Helm services — two approaches:** - -**Approach 1: Via `chart.values`** (for public/Bitnami charts with no custom build) -Pass env as Helm values directly in `key=value` format. See the "Template: Public chart" example below. - -**Approach 2: Via `docker.app.env` + `envMapping`** (for local/org charts with a Docker build) -Define env vars in `docker.app.env`, then use `envMapping` to tell Lifecycle where to inject them in the chart's values. Without a service-level `envMapping`, env vars will only be passed if a chart-level default is configured in `global_config`; otherwise they are not injected. See the "Template: Local chart with Docker build" example below. - -**Template: Public chart (e.g. Bitnami PostgreSQL)** -```yaml -services: - - name: "SERVICE_NAME" - helm: - deploymentMethod: "native" - chart: - name: "postgresql" - repoUrl: "https://charts.bitnami.com/bitnami" - version: "12.9.0" - values: - - "auth.username=myuser" - - "auth.password=mypassword" - - "auth.database=mydb" -``` - -**Template: Local chart with Docker build and env vars** -```yaml -services: - - name: "SERVICE_NAME" - helm: - deploymentMethod: "native" - repository: "ORG/REPO" - branchName: "main" - chart: # ← helm.chart level - name: "./charts/app" - valueFiles: - - "./helm/values.yaml" - envMapping: # ← helm.envMapping level (sibling of chart and docker) - app: - format: "array" # array or map, depending on your chart's values.yaml - path: "env" # path in chart values where env vars are injected - docker: # ← helm.docker level - defaultTag: "main" - builder: - engine: "buildkit" - app: # ← helm.docker.app level - dockerfilePath: "Dockerfile" - ports: - - PORT_NUMBER - env: # ← env goes HERE (helm.docker.app.env) - KEY: "value" -``` - -**Common Bitnami chart examples:** - -Redis: -```yaml -services: - - name: "redis" - helm: - deploymentMethod: "native" - chart: - name: "redis" - repoUrl: "https://charts.bitnami.com/bitnami" - version: "18.4.0" - values: - - "architecture=standalone" -``` - ---- - -## Template Variables - -Connect services using template variables. **Always wrap template variables in quotes.** - -Docs: [Template Variables](https://uselifecycle.com/docs/features/template-variables) - -**Format:** `"{{{variable_name}}}"` +- [Terminology](https://uselifecycle.com/docs/getting-started/terminology.md): Canonical Lifecycle terms for Environments, Services, deployments, and automation. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Onboard a repository](https://uselifecycle.com/docs/getting-started/onboard-repository.md): Add a GitHub App installation repository to Lifecycle so pull-request and push events can create Environments. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Create your first Environment](https://uselifecycle.com/docs/getting-started/create-environment.md): Onboard a repository and create a pull-request Environment from a correct Lifecycle configuration. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Explore an Environment](https://uselifecycle.com/docs/getting-started/explore-environment.md): Make sure an Environment is ready, open a deployed Service, and use Lifecycle's pull request comments. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Configure an Environment](https://uselifecycle.com/docs/getting-started/configure-environment.md): Set Service selection, dependencies, builds, and deployments in lifecycle.yaml. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Delete an Environment](https://uselifecycle.com/docs/getting-started/delete-environment.md): Tear down a pull-request or API-created Environment safely from GitHub, the UI, the CLI, or the API. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Create a default static Environment](https://uselifecycle.com/docs/getting-started/explore-static-environment.md): Create and pin a long-lived dev-0 Environment with supported Lifecycle controls. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ -| Variable | Description | Example Value | -|----------|-------------|---------------| -| `{{{buildUUID}}}` | Environment identifier | `arm-model-060825` | -| `{{{namespace}}}` | Kubernetes namespace | `env-arm-model-060825` | -| `{{{SERVICE_internalHostname}}}` | Internal DNS for service | `api.env-xxx.svc.cluster.local` | -| `{{{SERVICE_publicUrl}}}` | External URL (if public) | `api-xxx.example.com` | - -**Service name to variable mapping:** -- Service name `postgres` → `"{{{postgres_internalHostname}}}"` -- Service name `users-service` → `"{{{users-service_internalHostname}}}"` -- Hyphens in service names are preserved in variable names - -**Example usage:** -```yaml -env: - DATABASE_URL: "postgresql://app:password@{{{postgres_internalHostname}}}:5432/appdb" - REDIS_URL: "redis://{{{redis_internalHostname}}}:6379" - API_URL: "https://{{{api_publicUrl}}}" -``` - ---- - -## Multi-Service Environments - -### Connecting Services - -Docs: [Service Dependencies](https://uselifecycle.com/docs/features/service-dependencies) - -**Example: API connecting to PostgreSQL and Redis** -```yaml -version: "1.0.0" - -environment: - defaultServices: - - name: "api" - - name: "postgres" - - name: "redis" - -services: - - name: "api" - github: - repository: "myorg/api" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 - env: - DATABASE_URL: "postgresql://app:password@{{{postgres_internalHostname}}}:5432/appdb" - REDIS_URL: "redis://{{{redis_internalHostname}}}:6379" - deployment: - public: true - - - name: "postgres" - docker: - dockerImage: "postgres" - defaultTag: "15-alpine" - ports: - - 5432 - env: - POSTGRES_USER: "app" - POSTGRES_PASSWORD: "password" - POSTGRES_DB: "appdb" - - - name: "redis" - docker: - dockerImage: "redis" - defaultTag: "7-alpine" - ports: - - 6379 -``` - -### Service Dependencies with `requires` - -Use `requires` when a service has hard dependencies that must be deployed together: - -```yaml -services: - - name: "api" - requires: - - name: "postgres" - github: - # ... config - - - name: "postgres" - docker: - # ... config -``` - -Note: `requires` only resolves 1 level deep. - -### Full Stack Example (Frontend + Backend + Database) - -```yaml -version: "1.0.0" - -environment: - autoDeploy: true - defaultServices: - - name: "frontend" - - name: "api" - - name: "postgres" - -services: - - name: "frontend" - github: - repository: "myorg/frontend" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 - env: - API_URL: "https://{{{api_publicUrl}}}" - deployment: - public: true - - - name: "api" - requires: - - name: "postgres" - github: - repository: "myorg/api" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 8080 - env: - DATABASE_URL: "postgresql://app:password@{{{postgres_internalHostname}}}:5432/appdb" - deployment: - public: true - readiness: - httpGet: - path: "/health" - port: 8080 - - - name: "postgres" - docker: - dockerImage: "postgres" - defaultTag: "15-alpine" - ports: - - 5432 - env: - POSTGRES_USER: "app" - POSTGRES_PASSWORD: "password" - POSTGRES_DB: "appdb" -``` - ---- - -## Environment Configuration - -Docs: [Environment Schema](https://uselifecycle.com/docs/schema/environment) - -### Auto-Deploy - -Automatically deploy on PR creation/updates: - -Docs: [Auto-Deployment](https://uselifecycle.com/docs/features/auto-deployment) - -```yaml -environment: - autoDeploy: true -``` - -### Optional Services - -Services that can be deployed on-demand (not by default): - -```yaml -environment: - defaultServices: - - name: "api" - optionalServices: - - name: "debug-tools" -``` - -### Cross-Repository Services - -Reference services from other repositories: - -```yaml -environment: - defaultServices: - - name: "api" - - name: "shared-service" - repository: "myorg/shared-infra" - branch: "main" -``` - ---- - -## Advanced Features - -### Cloud Secrets - -Reference secrets from AWS Secrets Manager or GCP Secret Manager. - -Docs: [Secrets](https://uselifecycle.com/docs/features/secrets) - -```yaml -env: - DB_PASSWORD: "{{aws:myapp/database:password}}" - API_KEY: "{{gcp:project/api-keys:main}}" -``` - -Format: `{{::}}` - -### Webhooks - -Trigger actions after deployment (e.g., run tests). - -Docs: [Webhooks](https://uselifecycle.com/docs/features/webhooks), [Webhooks Schema](https://uselifecycle.com/docs/schema/webhooks) - -```yaml -environment: - webhooks: - - name: "E2E Tests" - state: deployed - type: docker - docker: - image: "myorg/e2e-tests:latest" - command: ["npm", "run", "test"] - env: - TEST_URL: "https://{{{frontend_publicUrl}}}" -``` - -### Persistent Storage - -Add persistent volumes to services: - -```yaml -deployment: - serviceDisks: - - name: "data" - mountPath: "/var/lib/data" - storageSize: "5Gi" -``` - ---- - -## Validation Checklist - -Before finalizing a lifecycle.yaml, verify: - -- [ ] `version: "1.0.0"` is present -- [ ] File is named `lifecycle.yaml` and located at repository root -- [ ] Every service in `defaultServices` has a matching definition in `services` -- [ ] All `github` services have: `repository`, `branchName`, `docker.defaultTag`, `docker.app.dockerfilePath`, `docker.app.ports` -- [ ] All `docker` services have: `dockerImage`, `defaultTag`, `ports` -- [ ] All `helm` services have: `chart.name` and `chart.repoUrl` (for public charts); when using a `docker:` build step also verify `repository`, `branchName`, `docker.defaultTag`, `docker.app.dockerfilePath`, and `docker.app.ports` -- [ ] Template variables are wrapped in quotes (e.g., `"{{{postgres_internalHostname}}}"`) -- [ ] Template variables reference existing service names -- [ ] Public services have `deployment.public: true` -- [ ] Health check paths match actual endpoints in the application - ---- - -## Information to Gather from Users - -When helping a user configure Lifecycle, collect: - -### For each service: -1. **Service name** - Identifier used in config and template variables -2. **Service type** - Building from source or using pre-built image? -3. **Source details** (if building): - - GitHub repository (org/repo) - - Dockerfile location -4. **Image details** (if pre-built): - - Image name and tag -5. **Ports** - What ports does the service expose? -6. **Environment variables** - Required config values -7. **Dependencies** - What other services does it connect to? -8. **Public access** - Should it be externally accessible? -9. **Health check** - Endpoint path for readiness probe - -### For the environment: -1. **Auto-deploy** - Should it deploy automatically on PR updates? -2. **Default vs optional** - Which services are always deployed vs on-demand? - ---- - -## Common Patterns - -### Pattern: API with Database -```yaml -environment: - defaultServices: - - name: "api" - - name: "db" - -services: - - name: "api" - github: - repository: "org/api" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 8080 - env: - DATABASE_URL: "postgresql://user:pass@{{{db_internalHostname}}}:5432/app" - deployment: - public: true - - - name: "db" - docker: - dockerImage: "postgres" - defaultTag: "15-alpine" - ports: - - 5432 - env: - POSTGRES_USER: "user" - POSTGRES_PASSWORD: "pass" - POSTGRES_DB: "app" -``` - -### Pattern: Microservices with Shared Database -```yaml -environment: - defaultServices: - - name: "gateway" - - name: "users-service" - - name: "orders-service" - - name: "postgres" - -services: - - name: "gateway" - github: - repository: "org/gateway" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 8080 - env: - USERS_URL: "http://{{{users-service_internalHostname}}}:3000" - ORDERS_URL: "http://{{{orders-service_internalHostname}}}:3000" - deployment: - public: true - - - name: "users-service" - github: - repository: "org/users-service" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 - env: - DATABASE_URL: "postgresql://user:pass@{{{postgres_internalHostname}}}:5432/users" - - - name: "orders-service" - github: - repository: "org/orders-service" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 - env: - DATABASE_URL: "postgresql://user:pass@{{{postgres_internalHostname}}}:5432/orders" - - - name: "postgres" - docker: - dockerImage: "postgres" - defaultTag: "15-alpine" - ports: - - 5432 - env: - POSTGRES_USER: "user" - POSTGRES_PASSWORD: "pass" -``` - -### Pattern: Frontend + BFF (Backend for Frontend) -```yaml -environment: - defaultServices: - - name: "web" - - name: "bff" - -services: - - name: "web" - github: - repository: "org/web" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 3000 - env: - API_URL: "https://{{{bff_publicUrl}}}" - deployment: - public: true +## Features - - name: "bff" - github: - repository: "org/bff" - branchName: "main" - docker: - defaultTag: "main" - app: - dockerfilePath: "Dockerfile" - ports: - - 8080 - deployment: - public: true -``` +- [Authentication](https://uselifecycle.com/docs/features/authentication.md): Sign in to Lifecycle, link GitHub, sign out, and verify access controls. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Auto-deploy and labels](https://uselifecycle.com/docs/features/auto-deployment.md): Automatically deploy pull request Environments and control them with configurable GitHub labels. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Ignore File Patterns](https://uselifecycle.com/docs/features/ignore-file-patterns.md): Skip push redeploys for documentation, metadata, and other non-runtime changes. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Configurable Labels](https://uselifecycle.com/docs/features/configurable-labels.md): Use deployment-specific GitHub labels to control Environments, cleanup, and status comments. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Template Variables](https://uselifecycle.com/docs/features/template-variables.md): Use Environment, Service, and configuration values in Lifecycle Service settings. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Build metadata links](https://uselifecycle.com/docs/features/build-metadata-links.md): Add administrator-managed links to Environment details using build environment variables as templates. _(audience: application-developer, platform-operator, administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Webhooks](https://uselifecycle.com/docs/features/webhooks.md): Run Codefresh pipelines or Kubernetes Jobs after an Environment deploys, fails, or is torn down. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Service Dependencies](https://uselifecycle.com/docs/features/service-dependencies.md): Understand Service dependencies, their effects, and configuration. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Native Helm deployment](https://uselifecycle.com/docs/features/native-helm-deployment.md): Deploy Services with Helm directly in Kubernetes without an external CI/CD deployment pipeline. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Cloud secrets](https://uselifecycle.com/docs/features/secrets.md): Reference External Secrets Operator values from Lifecycle Services and native Helm deployments. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Environment expiration and cleanup](https://uselifecycle.com/docs/features/environment-ttl.md): Understand how Lifecycle cleans up pull-request environments and expires API-created environments. _(audience: application-developer, platform-operator, administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [API-created Environments](https://uselifecycle.com/docs/features/api-environments.md): Create branch-based Lifecycle Environments without a pull request. Then, track, extend, redeploy, or tear them down. _(audience: api-user, application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Sites](https://uselifecycle.com/docs/features/sites.md): Upload and manage static HTML sites through Lifecycle's UI, CLI, or authenticated v2 API. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Lifecycle Agent](https://uselifecycle.com/docs/features/ai-agent.md): Investigate a Lifecycle Environment, examine evidence, and do approved recovery tasks from the Environment details page. _(audience: agent-user; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Configure Lifecycle Agent](https://uselifecycle.com/docs/features/ai-agent-configuration.md): Configure models, instructions, tools, approvals, and repository overrides for Lifecycle Agent from Settings. _(audience: administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Agent Sessions](https://uselifecycle.com/docs/features/agent-sessions.md): Start, examine, and continue repository work with Lifecycle Agent in an isolated workspace. _(audience: agent-user; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Agent administration](https://uselifecycle.com/docs/features/agent-administration.md): Administer Lifecycle Agent availability, models, instructions, permissions, tools, workspaces, and session audit. _(audience: administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Agent workspace backends](https://uselifecycle.com/docs/features/workspace-backends.md): Compare and safely activate the runtime backend used for new Lifecycle Agent workspaces. _(audience: administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Connect external MCP servers](https://uselifecycle.com/docs/features/mcp-integration.md): Add administrator-approved Model Context Protocol servers and complete authentication for each Lifecycle Agent user. _(audience: agent-user, administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Lifecycle MCP](https://uselifecycle.com/docs/features/mcp-server.md): Enable Lifecycle MCP, connect an OAuth client, and use Lifecycle tools with existing user permissions. _(audience: agent-user, administrator, platform-operator; last verified: 2026-08-01; baseline: 2026-08-01-lifecycle-mcp-preparation)_ +- [CLI (lfc)](https://uselifecycle.com/docs/features/cli.md): Install and use the Lifecycle CLI to examine Environments, manage Services, stream logs, and validate configuration. _(audience: application-developer, platform-operator; last verified: 2026-08-01; baseline: 2026-08-01-configuration-schema-fix)_ +- [Lifecycle CLI telemetry](https://uselifecycle.com/docs/features/cli-telemetry.md): Understand the pseudonymous command-usage event sent by lfc, its destination, and how to opt out. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Use the Lifecycle UI](https://uselifecycle.com/docs/features/lifecycle-ui.md): Use the web UI to find Environments, examine Services and logs, run actions, and examine webhooks. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## API authentication + +- [API authentication](https://uselifecycle.com/docs/api-authentication/overview.md): Select a supported authentication method for Lifecycle v2 API requests and understand typical authorization failures. _(audience: api-user, administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [API keys](https://uselifecycle.com/docs/api-authentication/api-keys.md): Create, scope, use, rotate, and revoke personal or service API keys for authenticated Lifecycle v2 requests. _(audience: api-user, administrator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## HTTP API + +- [Lifecycle API overview](https://uselifecycle.com/docs/api/overview.md): Use the authenticated Lifecycle v2 API, read response envelopes, and make safe integrations. _(audience: api-user; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Lifecycle schema + +- [Schema overview](https://uselifecycle.com/docs/schema/overview.md): Understand the lifecycle.yaml configuration file and its structure. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Environment configuration](https://uselifecycle.com/docs/schema/environment.md): Configure deployment behavior, Service groups, and automation for ephemeral Environments. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Webhook configuration](https://uselifecycle.com/docs/schema/webhooks.md): Configure webhooks that start automated actions after Environment events. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [GitHub Service](https://uselifecycle.com/docs/schema/github.md): Build and deploy Services from GitHub repositories with Docker. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Docker Service](https://uselifecycle.com/docs/schema/docker.md): Deploy pre-built Docker images for databases, caches, and other infrastructure components. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Helm Service](https://uselifecycle.com/docs/schema/helm.md): Deploy Services with local, OCI, or public Helm charts. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Codefresh Service](https://uselifecycle.com/docs/schema/codefresh.md): Trigger external Codefresh pipelines for deployment and teardown. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Aurora Restore Service](https://uselifecycle.com/docs/schema/aurora-restore.md): Make an AWS Aurora point-in-time copy for an ephemeral Environment. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Configuration Service](https://uselifecycle.com/docs/schema/configuration.md): Deploy configuration-only Services for feature flags and shared configuration. _(audience: application-developer; last verified: 2026-08-01; baseline: 2026-08-01-configuration-schema-fix)_ +- [External HTTP Service](https://uselifecycle.com/docs/schema/external-http.md): Use an existing hosted HTTP dependency. Lifecycle does not build or deploy it. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Reference + +- [Environment and Service statuses](https://uselifecycle.com/docs/reference/statuses.md): Interpret Lifecycle Environment, build, deployment, and Service states. Select the correct response. _(audience: application-developer, api-user, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Setup and installation + +- [Starter infrastructure prerequisites](https://uselifecycle.com/docs/setup/prerequisites.md): Prepare cloud, DNS, domain, and command-line access for the starter OpenTofu evaluation path. _(audience: evaluator, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Set up an evaluation cluster](https://uselifecycle.com/docs/setup/setup-infra.md): Provision starter GKE or EKS infrastructure, DNS, dependencies, and Lifecycle for evaluation. _(audience: evaluator, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Install Lifecycle](https://uselifecycle.com/docs/setup/install-lifecycle.md): Select the OpenTofu-managed or standalone Helm installation path. Make sure that the API, UI, identity, and cluster are healthy. _(audience: evaluator, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Create the GitHub App](https://uselifecycle.com/docs/setup/create-github-app.md): Create and connect a private GitHub App for Lifecycle. _(audience: platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Optional configuration](https://uselifecycle.com/docs/setup/configure-lifecycle.md): Apply optional installation-wide settings safely after Lifecycle is running. _(audience: platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Operations + +- [Lifecycle architecture](https://uselifecycle.com/docs/operations/architecture.md): Understand how requests become Lifecycle Environments and plan dependencies, security controls, availability, and recovery. _(audience: evaluator, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Runtime configuration surfaces](https://uselifecycle.com/docs/operations/configuration.md): Select a supported UI, API, Helm, or repository configuration surface and verify each change. _(audience: platform-operator, administrator; last verified: 2026-08-01; baseline: 2026-08-01-lifecycle-mcp-preparation)_ +- [Security boundaries](https://uselifecycle.com/docs/operations/security.md): Understand Lifecycle authentication, network, Kubernetes, secret, and Agent boundaries before other networks can reach a deployment. _(audience: platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Monitor Lifecycle](https://uselifecycle.com/docs/operations/monitoring.md): Interpret health endpoints, verify end-to-end operation, and collect safe diagnostic data for stuck work. _(audience: platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Day-two operations](https://uselifecycle.com/docs/operations/day-two.md): Plan Lifecycle upgrades, backups, rollback decisions, recovery validation, and uninstall. _(audience: platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Releases and compatibility + +- [Releases](https://uselifecycle.com/docs/releases.md): Find your installed Lifecycle versions and the release information for an upgrade. _(audience: platform-operator, application-developer; last verified: 2026-08-01; baseline: 2026-08-01-lifecycle-mcp-preparation)_ +- [Compatibility and deprecation policy](https://uselifecycle.com/docs/releases/compatibility.md): Select compatible Lifecycle components and prepare a safe upgrade or rollback. _(audience: platform-operator, application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Troubleshooting + +- [Troubleshooting](https://uselifecycle.com/docs/troubleshooting.md): Start from a Lifecycle symptom and open the guide for the failed phase with safe evidence and recovery steps. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Troubleshoot a stuck Environment](https://uselifecycle.com/docs/troubleshooting/stuck-environment.md): Find the last Lifecycle phase that changed and distinguish queue, build, deployment, readiness, and teardown delays. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Troubleshoot a failed build](https://uselifecycle.com/docs/troubleshooting/build-issues.md): Identify the failed Service build, examine the correct job logs, correct the source or build configuration, and check a redeploy. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Troubleshoot a failed deployment](https://uselifecycle.com/docs/troubleshooting/deploy-issues.md): Find the failed Service, examine the related logs, correct the cause, and redeploy an Environment. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Missing PR comment](https://uselifecycle.com/docs/troubleshooting/github-app-webhooks.md): Diagnose failed GitHub App webhook deliveries without exposing credentials. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Troubleshoot access and API errors](https://uselifecycle.com/docs/troubleshooting/access-and-api.md): Diagnose Lifecycle sign-in, GitHub linking, API key, scope, repository, and feature-policy failures while credentials stay secret. _(audience: api-user, application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ + +## Tips + +- [Use the Mission Control comment](https://uselifecycle.com/docs/tips/using-mission-control.md): Select Services, change sources, set Environment overrides, and request actions from Lifecycle's editable pull-request comment. _(audience: application-developer; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ +- [Datadog application telemetry](https://uselifecycle.com/docs/tips/telemetry.md): Add standard Datadog labels so Datadog can correlate application telemetry by Environment and Service. _(audience: application-developer, platform-operator; last verified: 2026-07-24; baseline: 2026-07-24-comprehensive-audit)_ diff --git a/public/setup/app-installed.png b/public/setup/app-installed.png deleted file mode 100644 index 0742993a..00000000 Binary files a/public/setup/app-installed.png and /dev/null differ diff --git a/public/setup/cert-ready.png b/public/setup/cert-ready.png deleted file mode 100644 index 0c401cfe..00000000 Binary files a/public/setup/cert-ready.png and /dev/null differ diff --git a/public/setup/github-app-installed.png b/public/setup/github-app-installed.png deleted file mode 100644 index c4d90994..00000000 Binary files a/public/setup/github-app-installed.png and /dev/null differ diff --git a/public/setup/github-app-setup.png b/public/setup/github-app-setup.png deleted file mode 100644 index 6f0b3209..00000000 Binary files a/public/setup/github-app-setup.png and /dev/null differ diff --git a/scripts/docsMetadata.ts b/scripts/docsMetadata.ts new file mode 100644 index 00000000..8320e93f --- /dev/null +++ b/scripts/docsMetadata.ts @@ -0,0 +1,236 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * 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. + */ + +import fs from "node:fs"; +import path from "node:path"; + +export const DOCUMENTATION_METADATA = "documentation-metadata.json"; + +export type VerificationBaseline = { + sources: Record; + verifiedOn: string; +}; + +export type DocumentationMetadata = { + audiences: Set; + baselines: Map; + maintenance: { + owner: string; + reviewTrigger: string; + }; + supportStatuses: Set; +}; + +export type ResolvedPageMetadata = { + audience: string[]; + lastVerified: string; + supportStatus: string | null; + verificationBaseline: string; +}; + +export type PageMetadataResolution = { + issues: string[]; + metadata: ResolvedPageMetadata | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isIsoDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const date = new Date(`${value}T00:00:00.000Z`); + return ( + !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value + ); +} + +function stringRegistry( + value: unknown, + field: string, + { allowEmpty = false }: { allowEmpty?: boolean } = {}, +): string[] { + if ( + !Array.isArray(value) || + (!allowEmpty && value.length === 0) || + value.some((item) => typeof item !== "string" || item.trim() === "") + ) { + throw new Error( + `${field} requires ${allowEmpty ? "an" : "a non-empty"} array of strings`, + ); + } + const values = value as string[]; + if (new Set(values).size !== values.length) { + throw new Error(`${field} must not contain duplicate values`); + } + return values; +} + +export async function loadDocumentationMetadata( + rootDir: string, +): Promise { + const file = path.join(rootDir, DOCUMENTATION_METADATA); + let parsed: unknown; + try { + parsed = JSON.parse(await fs.promises.readFile(file, "utf8")); + } catch (error) { + throw new Error( + `documentation metadata is missing or invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + if (!isRecord(parsed) || parsed.schemaVersion !== 1) { + throw new Error("documentation metadata requires schemaVersion 1"); + } + + const maintenance = parsed.maintenance; + if ( + !isRecord(maintenance) || + typeof maintenance.owner !== "string" || + maintenance.owner.trim() === "" || + typeof maintenance.reviewTrigger !== "string" || + maintenance.reviewTrigger.trim() === "" + ) { + throw new Error( + "maintenance requires a non-empty owner and event-driven reviewTrigger", + ); + } + + const audiences = stringRegistry(parsed.audiences, "audiences"); + const supportStatuses = stringRegistry( + parsed.supportStatuses, + "supportStatuses", + { allowEmpty: true }, + ); + const baselines = new Map(); + if (!isRecord(parsed.verificationBaselines)) { + throw new Error("verificationBaselines requires a keyed object"); + } + + for (const [id, rawBaseline] of Object.entries( + parsed.verificationBaselines, + )) { + if ( + !id.trim() || + !isRecord(rawBaseline) || + typeof rawBaseline.verifiedOn !== "string" || + !isIsoDate(rawBaseline.verifiedOn) || + !isRecord(rawBaseline.sources) || + Object.keys(rawBaseline.sources).length === 0 || + Object.entries(rawBaseline.sources).some( + ([source, revision]) => + !source.trim() || + typeof revision !== "string" || + !/^[0-9a-f]{40}$/i.test(revision), + ) + ) { + throw new Error( + `verification baseline ${id || "(empty)"} requires verifiedOn YYYY-MM-DD and one or more named full commit revisions`, + ); + } + baselines.set(id, { + sources: rawBaseline.sources as Record, + verifiedOn: rawBaseline.verifiedOn, + }); + } + if (baselines.size === 0) { + throw new Error("verificationBaselines requires at least one baseline"); + } + + return { + audiences: new Set(audiences), + baselines, + maintenance: { + owner: maintenance.owner.trim(), + reviewTrigger: maintenance.reviewTrigger.trim(), + }, + supportStatuses: new Set(supportStatuses), + }; +} + +export function resolvePageMetadata( + data: Record, + registry: DocumentationMetadata, + today = new Date().toISOString().slice(0, 10), +): PageMetadataResolution { + const issues: string[] = []; + const rawAudience = data.audience; + const audience = Array.isArray(rawAudience) + ? rawAudience.filter( + (value): value is string => + typeof value === "string" && value.trim() !== "", + ) + : []; + if ( + audience.length === 0 || + audience.length !== rawAudience?.length || + new Set(audience).size !== audience.length || + audience.some((value) => !registry.audiences.has(value)) + ) { + issues.push( + "frontmatter requires a non-empty, unique audience array using documentation-metadata.json values", + ); + } + + const lastVerified = + typeof data.lastVerified === "string" ? data.lastVerified : ""; + if (!isIsoDate(lastVerified)) { + issues.push("frontmatter requires lastVerified in YYYY-MM-DD format"); + } else if (lastVerified > today) { + issues.push("lastVerified cannot be in the future"); + } + + const verificationBaseline = + typeof data.verificationBaseline === "string" + ? data.verificationBaseline + : ""; + const baseline = registry.baselines.get(verificationBaseline); + if (!baseline) { + issues.push( + "frontmatter requires a verificationBaseline defined in documentation-metadata.json", + ); + } else if (isIsoDate(lastVerified) && lastVerified < baseline.verifiedOn) { + issues.push( + `lastVerified cannot predate verification baseline ${verificationBaseline} (${baseline.verifiedOn})`, + ); + } + + const supportStatus = + typeof data.supportStatus === "string" ? data.supportStatus : null; + if ( + data.supportStatus !== undefined && + (!supportStatus || !registry.supportStatuses.has(supportStatus)) + ) { + issues.push( + "supportStatus must use an established value from documentation-metadata.json", + ); + } + + return { + issues, + metadata: + issues.length === 0 + ? { + audience, + lastVerified, + supportStatus, + verificationBaseline, + } + : null, + }; +} diff --git a/scripts/generateLlms.ts b/scripts/generateLlms.ts new file mode 100644 index 00000000..43f67c51 --- /dev/null +++ b/scripts/generateLlms.ts @@ -0,0 +1,401 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * 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. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import fg from "fast-glob"; +import matter from "gray-matter"; +import { + loadDocumentationMetadata, + resolvePageMetadata, + type DocumentationMetadata, +} from "./docsMetadata"; +import { docsRouteToRawOutputRelativePath } from "./generateRawMarkdown"; + +export type PublicDocsPage = { + audience: string[]; + lastVerified: string; + route: string; + sectionTitle: string; + supportStatus: string | null; + title: string; + description: string; + verificationBaseline: string; +}; + +export type DocsGenerationOptions = { + rootDir?: string; + publicOrigin?: string; +}; + +type MetaEntry = { + display?: unknown; + href?: unknown; + title?: unknown; + type?: unknown; +}; + +const START_SECTION = "Start here"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function pageRoute(rootDir: string, file: string): string { + const relative = path + .relative(path.join(rootDir, "src/pages"), file) + .replace(/\\/g, "/") + .replace(/\.mdx$/, ""); + return `/${relative.replace(/\/index$/, "")}`; +} + +function fallbackTitle(slug: string): string { + return slug + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function nonEmptyString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function normalizeDescription(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function normalizeOrigin(value: string): string { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error(`Docs public URL must use http or https: ${value}`); + } + return url.toString().replace(/\/$/, ""); +} + +async function resolvePublicOrigin( + rootDir: string, + explicitOrigin?: string, +): Promise { + if (explicitOrigin) return normalizeOrigin(explicitOrigin); + if (process.env.DOCS_PUBLIC_URL) { + return normalizeOrigin(process.env.DOCS_PUBLIC_URL); + } + const cname = ( + await fs.promises.readFile(path.join(rootDir, "public/CNAME"), "utf8") + ).trim(); + if (!cname) throw new Error("public/CNAME must contain the docs hostname"); + return normalizeOrigin(`https://${cname}`); +} + +async function loadMeta(directory: string): Promise> { + const metaPath = path.join(directory, "_meta.ts"); + let stat: fs.Stats; + try { + stat = await fs.promises.stat(metaPath); + } catch { + throw new Error( + `${metaPath} is required to define curated public navigation`, + ); + } + + const url = pathToFileURL(metaPath); + url.searchParams.set("revision", `${stat.mtimeMs}-${stat.size}`); + const imported: unknown = await import(url.href); + const exported = isRecord(imported) ? imported.default : null; + if (!isRecord(exported)) { + throw new Error(`${metaPath} must default-export a navigation object`); + } + return exported; +} + +async function directoryContainsPage(directory: string): Promise { + const matches = await fg("**/*.mdx", { + cwd: directory, + onlyFiles: true, + }); + return matches.length > 0; +} + +function metaEntry(value: unknown): MetaEntry { + if (typeof value === "string") return { title: value }; + return isRecord(value) ? value : {}; +} + +function isSeparator(entry: MetaEntry): boolean { + return entry.type === "separator"; +} + +function isHidden(entry: MetaEntry): boolean { + return entry.display === "hidden"; +} + +function isExternalOnly(entry: MetaEntry): boolean { + const href = nonEmptyString(entry.href); + return Boolean(href && !href.startsWith("/docs")); +} + +async function readPage( + rootDir: string, + file: string, + sectionTitle: string, + navigationTitle: string | null, + registry: DocumentationMetadata, +): Promise { + const source = await fs.promises.readFile(file, "utf8"); + const { data } = matter(source); + const title = nonEmptyString(data.title); + const description = nonEmptyString(data.description); + const relative = path.relative(rootDir, file); + + if (!title || !description) { + const missing = [ + !title ? "title" : null, + !description ? "description" : null, + ] + .filter(Boolean) + .join(" and "); + throw new Error( + `${relative} requires non-empty ${missing} frontmatter before llms.txt can be generated`, + ); + } + const resolution = resolvePageMetadata(data, registry); + if (!resolution.metadata) { + throw new Error( + `${relative} has invalid documentation metadata: ${resolution.issues.join("; ")}`, + ); + } + const { audience, lastVerified, supportStatus, verificationBaseline } = + resolution.metadata; + + return { + audience, + lastVerified, + route: pageRoute(rootDir, file), + sectionTitle, + supportStatus, + title: navigationTitle || title, + description: normalizeDescription(description), + verificationBaseline, + }; +} + +async function collectDirectoryPages({ + rootDir, + directory, + sectionTitle, + visible, + isDocsRoot, + registry, +}: { + rootDir: string; + directory: string; + sectionTitle: string; + visible: boolean; + isDocsRoot: boolean; + registry: DocumentationMetadata; +}): Promise { + const navigation = await loadMeta(directory); + const diskEntries = await fs.promises.readdir(directory, { + withFileTypes: true, + }); + const pageSlugs = new Set( + diskEntries + .filter((entry) => entry.isFile() && entry.name.endsWith(".mdx")) + .map((entry) => entry.name.replace(/\.mdx$/, "")), + ); + const directorySlugs = new Set(); + + for (const entry of diskEntries) { + if ( + entry.isDirectory() && + (await directoryContainsPage(path.join(directory, entry.name))) + ) { + directorySlugs.add(entry.name); + } + } + + const declaredTargets = new Set(); + const pages: PublicDocsPage[] = []; + + for (const [slug, rawEntry] of Object.entries(navigation)) { + const entry = metaEntry(rawEntry); + if (isSeparator(entry) || isExternalOnly(entry)) continue; + if (isDocsRoot && slug === "cm") { + throw new Error( + `${path.relative(rootDir, path.join(directory, "_meta.ts"))} declares the retired /docs/cm target`, + ); + } + + const pageExists = pageSlugs.has(slug); + const directoryExists = directorySlugs.has(slug); + if (pageExists && directoryExists) { + throw new Error( + `${path.relative(rootDir, directory)} navigation target "${slug}" is ambiguous`, + ); + } + if (!pageExists && !directoryExists) { + throw new Error( + `${path.relative(rootDir, path.join(directory, "_meta.ts"))} declares missing target "${slug}"`, + ); + } + + declaredTargets.add(slug); + const navigationTitle = nonEmptyString(entry.title) || fallbackTitle(slug); + const entryVisible = visible && !isHidden(entry); + + if (pageExists) { + const page = await readPage( + rootDir, + path.join(directory, `${slug}.mdx`), + sectionTitle, + navigationTitle, + registry, + ); + if (entryVisible) pages.push(page); + continue; + } + + const nestedSection = isDocsRoot ? navigationTitle : sectionTitle; + pages.push( + ...(await collectDirectoryPages({ + rootDir, + directory: path.join(directory, slug), + sectionTitle: nestedSection, + visible: entryVisible, + isDocsRoot: false, + registry, + })), + ); + } + + const undeclared = [...pageSlugs, ...directorySlugs] + .filter((slug) => !declaredTargets.has(slug)) + .sort(); + if (undeclared.length > 0) { + throw new Error( + `${path.relative(rootDir, path.join(directory, "_meta.ts"))} omits public target(s): ${undeclared.join(", ")}`, + ); + } + + return pages; +} + +export async function collectPublicDocsPages({ + rootDir = process.cwd(), +}: Pick = {}): Promise { + const resolvedRoot = path.resolve(rootDir); + const docsRoot = path.join(resolvedRoot, "src/pages/docs"); + const registry = await loadDocumentationMetadata(resolvedRoot); + const pages = await collectDirectoryPages({ + rootDir: resolvedRoot, + directory: docsRoot, + sectionTitle: START_SECTION, + visible: true, + isDocsRoot: true, + registry, + }); + const routes = new Set(); + for (const page of pages) { + if (routes.has(page.route)) { + throw new Error( + `Curated navigation contains duplicate route ${page.route}`, + ); + } + routes.add(page.route); + } + return pages; +} + +export function renderLlms( + pages: PublicDocsPage[], + publicOrigin: string, +): string { + const lines = [ + "# Lifecycle", + "", + "> End-user documentation for Lifecycle environments, configuration, automation, agents, and platform operations.", + "", + "This index is generated from the human documentation. Follow the linked page for current prerequisites, procedures, support boundaries, and recovery guidance.", + "", + ]; + + let currentSection: string | null = null; + for (const page of pages) { + if (page.sectionTitle !== currentSection) { + if (currentSection !== null) lines.push(""); + currentSection = page.sectionTitle; + lines.push(`## ${page.sectionTitle}`, ""); + } + const metadata = [ + `audience: ${page.audience.join(", ")}`, + `last verified: ${page.lastVerified}`, + `baseline: ${page.verificationBaseline}`, + page.supportStatus ? `status: ${page.supportStatus}` : null, + ] + .filter(Boolean) + .join("; "); + const rawMarkdownPath = `/${docsRouteToRawOutputRelativePath(page.route)}`; + lines.push( + `- [${page.title}](${publicOrigin}${rawMarkdownPath}): ${page.description} _(${metadata})_`, + ); + } + + return `${lines.join("\n").trim()}\n`; +} + +export async function generateLlms({ + rootDir = process.cwd(), + publicOrigin, +}: DocsGenerationOptions = {}): Promise { + const resolvedRoot = path.resolve(rootDir); + const pages = await collectPublicDocsPages({ rootDir: resolvedRoot }); + const origin = await resolvePublicOrigin(resolvedRoot, publicOrigin); + return renderLlms(pages, origin); +} + +async function runCli() { + const rootDir = process.cwd(); + const output = await generateLlms({ rootDir }); + const outputPath = path.join(rootDir, "public/llms.txt"); + if (process.argv.includes("--check")) { + let current: string | null = null; + try { + current = await fs.promises.readFile(outputPath, "utf8"); + } catch { + current = null; + } + if (current !== output) { + throw new Error( + "public/llms.txt is stale or missing. Run `bun run build:llms` and commit the result.", + ); + } + console.log("public/llms.txt matches the documentation page tree."); + return; + } + + await fs.promises.writeFile(outputPath, output); + console.log("Generated public/llms.txt from curated public navigation."); +} + +if (import.meta.main) { + try { + await runCli(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/generateRawMarkdown.ts b/scripts/generateRawMarkdown.ts new file mode 100644 index 00000000..54bec1eb --- /dev/null +++ b/scripts/generateRawMarkdown.ts @@ -0,0 +1,1346 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * 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. + */ + +import fs from "node:fs"; +import path from "node:path"; +import fg from "fast-glob"; +import matter from "gray-matter"; +import remarkGfm from "remark-gfm"; +import remarkMdx from "remark-mdx"; +import remarkParse from "remark-parse"; +import remarkStringify from "remark-stringify"; +import { unified } from "unified"; + +export const GENERATED_RAW_MARKDOWN_SENTINEL = + ""; + +export type RawMarkdownRoute = { + outputRelativePath: string; + route: string; + sourceRelativePath: string; +}; + +export type RawMarkdownPage = RawMarkdownRoute & { + markdown: string; + sourcePath: string; +}; + +export type RawMarkdownGenerationOptions = { + check?: boolean; + rootDir?: string; +}; + +export type RawMarkdownGenerationResult = { + outdatedCount: number; + projectedCount: number; + removedCount: number; + staleCount: number; + writtenCount: number; +}; + +type Position = { + start?: { + column?: number; + line?: number; + }; +}; + +type AstNode = { + [key: string]: unknown; + attributes?: AstNode[]; + children?: AstNode[]; + data?: { + estree?: AstNode; + }; + meta?: string | null; + name?: string | null; + position?: Position; + type: string; + value?: unknown; +}; + +type TransformContext = { + lineOffset: number; + sourcePath: string; +}; + +const STANDARD_PHRASE_NODES = new Set([ + "break", + "delete", + "emphasis", + "footnoteReference", + "image", + "imageReference", + "inlineCode", + "link", + "linkReference", + "strong", + "text", +]); + +const DECORATIVE_CALLOUT_EMOJI_LITERALS = new Set(["⚠️"]); +const DECORATIVE_CALLOUT_ICON_NAMES = new Set(["Info"]); + +function normalizedSourceRelativePath(sourceRelativePath: string): string { + const normalized = sourceRelativePath.replace(/\\/g, "/"); + const segments = normalized.split("/"); + if ( + !normalized || + path.posix.isAbsolute(normalized) || + !normalized.endsWith(".mdx") || + segments.some((segment) => !segment || segment === "." || segment === "..") + ) { + throw new Error( + `Documentation source path must be a safe relative .mdx path: ${sourceRelativePath}`, + ); + } + return normalized; +} + +export function sourceRelativePathToDocsRoute( + sourceRelativePath: string, +): string { + const normalized = normalizedSourceRelativePath(sourceRelativePath); + let slug = normalized.slice(0, -".mdx".length); + if (slug === "index") slug = ""; + else if (slug.endsWith("/index")) slug = slug.slice(0, -"/index".length); + return slug ? `/docs/${slug}` : "/docs"; +} + +export function docsRouteToRawOutputRelativePath(route: string): string { + if ( + route !== "/docs" && + (!route.startsWith("/docs/") || + route.endsWith("/") || + route.split("/").some((segment) => segment === "." || segment === "..")) + ) { + throw new Error(`Invalid documentation route: ${route}`); + } + return route === "/docs" ? "docs.md" : `${route.slice(1)}.md`; +} + +export function docsRouteToHtmlOutputRelativePath(route: string): string { + docsRouteToRawOutputRelativePath(route); + return route === "/docs" ? "docs.html" : `${route.slice(1)}.html`; +} + +export function sourceRelativePathToRawOutputRelativePath( + sourceRelativePath: string, +): string { + return docsRouteToRawOutputRelativePath( + sourceRelativePathToDocsRoute(sourceRelativePath), + ); +} + +export function rawMarkdownRouteForSource( + sourceRelativePath: string, +): RawMarkdownRoute { + const normalized = normalizedSourceRelativePath(sourceRelativePath); + const route = sourceRelativePathToDocsRoute(normalized); + return { + outputRelativePath: docsRouteToRawOutputRelativePath(route), + route, + sourceRelativePath: normalized, + }; +} + +export function validateRawRouteCollisions(routes: RawMarkdownRoute[]): void { + const byRoute = new Map(); + const byOutput = new Map(); + for (const item of routes) { + const priorRoute = byRoute.get(item.route); + if (priorRoute) { + throw new Error( + `Raw Markdown route collision: ${priorRoute} and ${item.sourceRelativePath} both map to ${item.route}`, + ); + } + byRoute.set(item.route, item.sourceRelativePath); + + const priorOutput = byOutput.get(item.outputRelativePath); + if (priorOutput) { + throw new Error( + `Raw Markdown output collision: ${priorOutput} and ${item.sourceRelativePath} both map to ${item.outputRelativePath}`, + ); + } + byOutput.set(item.outputRelativePath, item.sourceRelativePath); + } +} + +export function validateExportedFileBijection( + routes: RawMarkdownRoute[], +): void { + const htmlFiles = new Map(); + const rawFiles = new Map(); + + for (const item of routes) { + const expectedRaw = docsRouteToRawOutputRelativePath(item.route); + const html = docsRouteToHtmlOutputRelativePath(item.route); + if (item.outputRelativePath !== expectedRaw) { + throw new Error( + `Exported documentation file mismatch: ${item.sourceRelativePath} maps to ${html}, so its raw counterpart must be ${expectedRaw}, not ${item.outputRelativePath}`, + ); + } + + const priorHtml = htmlFiles.get(html); + if (priorHtml) { + throw new Error( + `Exported HTML file collision: ${priorHtml} and ${item.sourceRelativePath} both map to ${html}`, + ); + } + htmlFiles.set(html, item.sourceRelativePath); + + const priorRaw = rawFiles.get(expectedRaw); + if (priorRaw) { + throw new Error( + `Exported raw Markdown file collision: ${priorRaw} and ${item.sourceRelativePath} both map to ${expectedRaw}`, + ); + } + rawFiles.set(expectedRaw, item.sourceRelativePath); + } + + if (htmlFiles.size !== rawFiles.size || htmlFiles.size !== routes.length) { + throw new Error( + `Exported documentation file mismatch: found ${htmlFiles.size} HTML file(s), ${rawFiles.size} raw Markdown file(s), and ${routes.length} route(s)`, + ); + } +} + +export function validateDocumentationSourcePaths( + sourceRelativePaths: string[], +): number { + const normalized = sourceRelativePaths.map(normalizedSourceRelativePath); + const retired = normalized.filter( + (item) => item === "cm.mdx" || item.startsWith("cm/"), + ); + if (retired.length > 0) { + throw new Error( + `The retired /docs/cm route namespace cannot contain source pages: ${retired.sort().join(", ")}`, + ); + } + return normalized.length; +} + +function location(node: AstNode, context: TransformContext): string { + const line = node.position?.start?.line; + const column = node.position?.start?.column; + if (line === undefined) return context.sourcePath; + return `${context.sourcePath}:${line + context.lineOffset}:${column ?? 1}`; +} + +function unsupported( + node: AstNode, + context: TransformContext, + detail: string, +): never { + const name = node.name ? ` <${node.name}>` : ""; + throw new Error( + `${location(node, context)}: ${detail} [${node.type}${name}]`, + ); +} + +function cloneScalarFields( + node: AstNode, + fields: string[], +): Record { + const cloned: Record = { type: node.type }; + for (const field of fields) { + if (node[field] !== undefined && node[field] !== null) { + cloned[field] = node[field]; + } + } + return cloned; +} + +function validateAttributes( + node: AstNode, + allowed: Set, + context: TransformContext, +): void { + for (const attribute of node.attributes ?? []) { + if ( + attribute.type !== "mdxJsxAttribute" || + typeof attribute.name !== "string" + ) { + unsupported( + attribute, + context, + "JSX spread attributes are not supported", + ); + } + if (!allowed.has(attribute.name)) { + unsupported( + attribute, + context, + `Unsupported attribute "${attribute.name}" on <${node.name}>`, + ); + } + } +} + +function findAttribute(node: AstNode, name: string): AstNode | null { + return ( + node.attributes?.find( + (attribute) => + attribute.type === "mdxJsxAttribute" && attribute.name === name, + ) ?? null + ); +} + +function requiredLiteralAttribute( + node: AstNode, + name: string, + context: TransformContext, +): string { + const attribute = findAttribute(node, name); + if (!attribute || typeof attribute.value !== "string") { + unsupported( + attribute ?? node, + context, + `<${node.name}> requires a literal "${name}" attribute`, + ); + } + if (!attribute.value.trim()) { + unsupported( + attribute, + context, + `<${node.name}> requires a non-empty "${name}" attribute`, + ); + } + return attribute.value; +} + +function optionalLiteralAttribute( + node: AstNode, + name: string, + context: TransformContext, +): string | null { + const attribute = findAttribute(node, name); + if (!attribute) return null; + if (typeof attribute.value !== "string") { + unsupported( + attribute, + context, + `<${node.name}> "${name}" must be a literal string`, + ); + } + return attribute.value; +} + +function expressionFromEstree(node: AstNode): AstNode | null { + const program = node.data?.estree; + if (!program || program.type !== "Program" || !Array.isArray(program.body)) { + return null; + } + if (program.body.length !== 1) return null; + const statement = program.body[0]; + if ( + !statement || + statement.type !== "ExpressionStatement" || + typeof statement.expression !== "object" || + statement.expression === null + ) { + return null; + } + return statement.expression as AstNode; +} + +function tabsLabels(node: AstNode, context: TransformContext): string[] { + const attribute = findAttribute(node, "items"); + if ( + !attribute || + typeof attribute.value !== "object" || + attribute.value === null + ) { + unsupported( + attribute ?? node, + context, + " requires an items string-array expression", + ); + } + const expression = expressionFromEstree(attribute.value as AstNode); + if ( + !expression || + expression.type !== "ArrayExpression" || + !Array.isArray(expression.elements) + ) { + unsupported( + attribute, + context, + " items must be a static array of string literals", + ); + } + const labels: string[] = []; + for (const element of expression.elements) { + if ( + typeof element !== "object" || + element === null || + (element as AstNode).type !== "Literal" || + typeof (element as AstNode).value !== "string" + ) { + unsupported( + attribute, + context, + " items must contain only string literals", + ); + } + labels.push((element as AstNode).value as string); + } + return labels; +} + +function literalStringExpression( + node: AstNode, + context: TransformContext, +): string { + const expression = expressionFromEstree(node); + if ( + !expression || + expression.type !== "Literal" || + typeof expression.value !== "string" + ) { + unsupported( + node, + context, + "Only a static string expression is supported inside ", + ); + } + return expression.value; +} + +function isReviewedCalloutIconElement(expression: AstNode): boolean { + if (expression.type !== "JSXElement") return false; + const opening = expression.openingElement; + if (typeof opening !== "object" || opening === null) return false; + const openingElement = opening as Record; + const name = openingElement.name; + if (typeof name !== "object" || name === null) return false; + const identifier = name as Record; + + return ( + openingElement.type === "JSXOpeningElement" && + openingElement.selfClosing === true && + Array.isArray(openingElement.attributes) && + openingElement.attributes.length === 0 && + identifier.type === "JSXIdentifier" && + typeof identifier.name === "string" && + DECORATIVE_CALLOUT_ICON_NAMES.has(identifier.name) && + expression.closingElement === null && + Array.isArray(expression.children) && + expression.children.length === 0 + ); +} + +function validateCalloutDecoration( + node: AstNode, + context: TransformContext, +): void { + const emoji = findAttribute(node, "emoji"); + if (emoji) { + const expression = + typeof emoji.value === "object" && emoji.value !== null + ? expressionFromEstree(emoji.value as AstNode) + : null; + const reviewedEmoji = + (typeof emoji.value === "string" && + DECORATIVE_CALLOUT_EMOJI_LITERALS.has(emoji.value)) || + (expression !== null && isReviewedCalloutIconElement(expression)); + if (!reviewedEmoji) { + unsupported( + emoji, + context, + ' "emoji" must be a reviewed static decoration: emoji="⚠️" or emoji={}', + ); + } + } + + const icon = findAttribute(node, "icon"); + if (icon) { + const expression = + typeof icon.value === "object" && icon.value !== null + ? expressionFromEstree(icon.value as AstNode) + : null; + if ( + !expression || + expression.type !== "Identifier" || + typeof expression.name !== "string" || + !DECORATIVE_CALLOUT_ICON_NAMES.has(expression.name) + ) { + unsupported( + icon, + context, + ' "icon" must be the reviewed static decoration icon={Info}', + ); + } + } +} + +function transformPhrasingChildren( + children: AstNode[] | undefined, + context: TransformContext, +): AstNode[] { + return (children ?? []).flatMap((child) => + transformPhrasingNode(child, context), + ); +} + +function transformPhrasingNode( + node: AstNode, + context: TransformContext, +): AstNode[] { + if (node.type === "mdxTextExpression") { + unsupported( + node, + context, + "Standalone MDX expressions are not supported; only static strings inside can be projected", + ); + } + + if (node.type === "mdxJsxTextElement") { + if (node.name === "br") { + validateAttributes(node, new Set(), context); + if ((node.children?.length ?? 0) > 0) { + unsupported(node, context, "
cannot have children"); + } + return [{ type: "break" }]; + } + + if (node.name === "code") { + validateAttributes(node, new Set(), context); + let value = ""; + for (const child of node.children ?? []) { + if (child.type === "text" && typeof child.value === "string") { + value += child.value; + } else if (child.type === "mdxTextExpression") { + value += literalStringExpression(child, context); + } else { + unsupported( + child, + context, + " can contain only text or static string expressions", + ); + } + } + return [{ type: "inlineCode", value }]; + } + + if (node.name === "div") { + validateAttributes(node, new Set(["className"]), context); + return transformPhrasingChildren(node.children, context); + } + + unsupported(node, context, "Unsupported inline JSX element"); + } + + if (!STANDARD_PHRASE_NODES.has(node.type)) { + unsupported(node, context, "Unsupported Markdown phrasing node"); + } + + switch (node.type) { + case "text": + case "inlineCode": + if (typeof node.value !== "string") { + unsupported(node, context, "Text node does not contain a string"); + } + return [{ type: node.type, value: node.value }]; + case "break": + return [{ type: "break" }]; + case "image": + return [cloneScalarFields(node, ["url", "title", "alt"]) as AstNode]; + case "imageReference": + return [ + cloneScalarFields(node, [ + "identifier", + "label", + "referenceType", + "alt", + ]) as AstNode, + ]; + case "footnoteReference": + return [cloneScalarFields(node, ["identifier", "label"]) as AstNode]; + case "link": + case "linkReference": { + const fields = + node.type === "link" + ? ["url", "title"] + : ["identifier", "label", "referenceType"]; + return [ + { + ...cloneScalarFields(node, fields), + children: transformPhrasingChildren(node.children, context), + } as AstNode, + ]; + } + case "delete": + case "emphasis": + case "strong": + return [ + { + type: node.type, + children: transformPhrasingChildren(node.children, context), + }, + ]; + default: + return unsupported(node, context, "Unsupported Markdown phrasing node"); + } +} + +function filenameParagraph(filename: string): AstNode { + return { + type: "paragraph", + children: [ + { + type: "strong", + children: [{ type: "text", value: "File:" }], + }, + { type: "text", value: " " }, + { type: "inlineCode", value: filename }, + ], + }; +} + +function codeFilename(node: AstNode, context: TransformContext): string | null { + const meta = node.meta?.trim(); + if (!meta) return null; + + const explicit = /(?:^|\s)filename=(?:"([^"]+)"|'([^']+)'|([^\s]+))/.exec( + meta, + ); + const filename = explicit + ? (explicit[1] ?? explicit[2] ?? explicit[3]) + : null; + let remaining = explicit + ? `${meta.slice(0, explicit.index)} ${meta.slice( + explicit.index + explicit[0].length, + )}` + : meta; + remaining = remaining.replace(/\{[^{}]*\}/g, " ").trim(); + + if (filename) { + if (remaining) { + unsupported( + node, + context, + `Unsupported code-fence metadata after filename: ${remaining}`, + ); + } + return filename; + } + + if (remaining && !/\s/.test(remaining) && !remaining.includes("=")) { + return remaining; + } + if (!remaining) return null; + unsupported(node, context, `Unsupported code-fence metadata: ${remaining}`); +} + +function transformCode(node: AstNode, context: TransformContext): AstNode[] { + if (typeof node.value !== "string") { + unsupported(node, context, "Code block does not contain a string"); + } + const code: AstNode = { + type: "code", + value: node.value, + }; + if (typeof node.lang === "string" && node.lang) code.lang = node.lang; + const filename = codeFilename(node, context); + return filename ? [filenameParagraph(filename), code] : [code]; +} + +function transformCallout(node: AstNode, context: TransformContext): AstNode[] { + validateAttributes(node, new Set(["emoji", "icon", "type"]), context); + validateCalloutDecoration(node, context); + const type = optionalLiteralAttribute(node, "type", context) ?? "info"; + const labels: Record = { + caution: "CAUTION", + danger: "CAUTION", + error: "CAUTION", + important: "IMPORTANT", + info: "NOTE", + note: "NOTE", + tip: "TIP", + warning: "WARNING", + }; + const label = labels[type.toLowerCase()]; + if (!label) { + unsupported(node, context, `Unsupported Callout type "${type}"`); + } + const children = transformBlockChildren(node.children, context); + const marker: AstNode = { type: "html", value: `[!${label}]\n` }; + if (children[0]?.type === "paragraph") { + children[0] = { + ...children[0], + children: [marker, ...(children[0].children ?? [])], + }; + } else { + children.unshift({ + type: "paragraph", + children: [{ type: "html", value: `[!${label}]` }], + }); + } + return [{ type: "blockquote", children }]; +} + +function transformTabs(node: AstNode, context: TransformContext): AstNode[] { + validateAttributes(node, new Set(["items"]), context); + const labels = tabsLabels(node, context); + const tabs = node.children ?? []; + if (tabs.length !== labels.length) { + unsupported( + node, + context, + ` has ${labels.length} label(s) but ${tabs.length} tab panel(s)`, + ); + } + + return tabs.flatMap((tab, index) => { + if ( + !["mdxJsxFlowElement", "mdxJsxTextElement"].includes(tab.type) || + tab.name !== "Tabs.Tab" + ) { + unsupported(tab, context, " can contain only elements"); + } + validateAttributes(tab, new Set(), context); + return [ + { + type: "paragraph", + children: [ + { + type: "strong", + children: [{ type: "text", value: labels[index] }], + }, + ], + }, + ...transformBlockChildren(tab.children, context), + ] as AstNode[]; + }); +} + +function transformCard(node: AstNode, context: TransformContext): AstNode { + validateAttributes(node, new Set(["href", "title"]), context); + const title = requiredLiteralAttribute(node, "title", context); + const href = requiredLiteralAttribute(node, "href", context); + const description = transformBlockChildren(node.children, context); + const link: AstNode = { + type: "link", + url: href, + children: [{ type: "text", value: title }], + }; + + if (description[0]?.type === "paragraph") { + const [first, ...rest] = description; + return { + type: "listItem", + children: [ + { + type: "paragraph", + children: [ + link, + { type: "text", value: " — " }, + ...(first.children ?? []), + ], + }, + ...rest, + ], + }; + } + + return { + type: "listItem", + children: [{ type: "paragraph", children: [link] }, ...description], + }; +} + +function transformCards(node: AstNode, context: TransformContext): AstNode[] { + validateAttributes(node, new Set(), context); + const items = (node.children ?? []).map((card) => { + if ( + !["mdxJsxFlowElement", "mdxJsxTextElement"].includes(card.type) || + card.name !== "Cards.Card" + ) { + unsupported( + card, + context, + " can contain only elements", + ); + } + return transformCard(card, context); + }); + return [{ type: "list", ordered: false, spread: false, children: items }]; +} + +function transformFlowJsx(node: AstNode, context: TransformContext): AstNode[] { + switch (node.name) { + case "Callout": + return transformCallout(node, context); + case "Steps": + validateAttributes(node, new Set(), context); + return transformBlockChildren(node.children, context); + case "Tabs": + return transformTabs(node, context); + case "Cards": + return transformCards(node, context); + case "Image": { + validateAttributes( + node, + new Set([ + "alt", + "className", + "height", + "priority", + "ratio", + "src", + "title", + "width", + ]), + context, + ); + const src = requiredLiteralAttribute(node, "src", context); + const alt = requiredLiteralAttribute(node, "alt", context); + const title = optionalLiteralAttribute(node, "title", context); + return [ + { + type: "paragraph", + children: [ + { + type: "image", + url: src, + alt, + ...(title ? { title } : {}), + }, + ], + }, + ]; + } + case "Iframe": { + validateAttributes(node, new Set(["src", "title"]), context); + const src = requiredLiteralAttribute(node, "src", context); + const title = requiredLiteralAttribute(node, "title", context); + return [ + { + type: "paragraph", + children: [ + { + type: "link", + url: src, + children: [{ type: "text", value: title }], + }, + ], + }, + ]; + } + case "div": + validateAttributes(node, new Set(["className"]), context); + return transformBlockChildren(node.children, context); + case "br": + case "code": + return [ + { + type: "paragraph", + children: transformPhrasingNode( + { ...node, type: "mdxJsxTextElement" }, + context, + ), + }, + ]; + case "Cards.Card": + return unsupported( + node, + context, + " must be a direct child of ", + ); + case "Tabs.Tab": + return unsupported( + node, + context, + " must be a direct child of ", + ); + default: + return unsupported(node, context, "Unsupported flow JSX element"); + } +} + +function transformBlockChildren( + children: AstNode[] | undefined, + context: TransformContext, +): AstNode[] { + return (children ?? []).flatMap((child) => + transformBlockNode(child, context), + ); +} + +function transformBlockNode( + node: AstNode, + context: TransformContext, +): AstNode[] { + switch (node.type) { + case "mdxjsEsm": { + const program = node.data?.estree; + const body = + program?.type === "Program" && Array.isArray(program.body) + ? program.body + : null; + if ( + !body || + body.some( + (statement) => + typeof statement !== "object" || + statement === null || + (statement as AstNode).type !== "ImportDeclaration", + ) + ) { + unsupported( + node, + context, + "Only import declarations can be removed from raw Markdown", + ); + } + return []; + } + case "mdxFlowExpression": + case "mdxTextExpression": + return unsupported( + node, + context, + "Standalone MDX expressions are not supported", + ); + case "mdxJsxFlowElement": + return transformFlowJsx(node, context); + case "mdxJsxTextElement": + return [ + { + type: "paragraph", + children: transformPhrasingNode(node, context), + }, + ]; + case "paragraph": + return [ + { + type: "paragraph", + children: transformPhrasingChildren(node.children, context), + }, + ]; + case "heading": + if (typeof node.depth !== "number" || node.depth < 1 || node.depth > 6) { + unsupported(node, context, "Heading depth must be between 1 and 6"); + } + return [ + { + type: "heading", + depth: node.depth, + children: transformPhrasingChildren(node.children, context), + }, + ]; + case "blockquote": + return [ + { + type: "blockquote", + children: transformBlockChildren(node.children, context), + }, + ]; + case "list": + return [ + { + ...cloneScalarFields(node, ["ordered", "start", "spread"]), + children: (node.children ?? []).flatMap((child) => { + if (child.type !== "listItem") { + unsupported(child, context, "A list can contain only list items"); + } + return transformBlockNode(child, context); + }), + } as AstNode, + ]; + case "listItem": + return [ + { + ...cloneScalarFields(node, ["checked", "spread"]), + children: transformBlockChildren(node.children, context), + } as AstNode, + ]; + case "code": + return transformCode(node, context); + case "thematicBreak": + return [{ type: "thematicBreak" }]; + case "definition": + return [ + cloneScalarFields(node, [ + "identifier", + "label", + "title", + "url", + ]) as AstNode, + ]; + case "footnoteDefinition": + return [ + { + ...cloneScalarFields(node, ["identifier", "label"]), + children: transformBlockChildren(node.children, context), + } as AstNode, + ]; + case "table": + return [ + { + ...cloneScalarFields(node, ["align"]), + children: (node.children ?? []).flatMap((child) => + transformBlockNode(child, context), + ), + } as AstNode, + ]; + case "tableRow": + return [ + { + type: "tableRow", + children: (node.children ?? []).flatMap((child) => + transformBlockNode(child, context), + ), + }, + ]; + case "tableCell": + return [ + { + type: "tableCell", + children: transformPhrasingChildren(node.children, context), + }, + ]; + case "html": + return unsupported( + node, + context, + "Raw HTML is not supported in projected Markdown", + ); + default: + if (STANDARD_PHRASE_NODES.has(node.type)) { + return [ + { + type: "paragraph", + children: transformPhrasingNode(node, context), + }, + ]; + } + return unsupported(node, context, "Unsupported Markdown block node"); + } +} + +function normalizeDescription(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +export function projectMdxToMarkdown( + source: string, + sourcePath = "", +): string { + const parsed = matter(source); + const title = + typeof parsed.data.title === "string" ? parsed.data.title.trim() : ""; + const description = + typeof parsed.data.description === "string" + ? normalizeDescription(parsed.data.description) + : ""; + if (!title || !description) { + const missing = [ + !title ? "title" : null, + !description ? "description" : null, + ] + .filter(Boolean) + .join(" and "); + throw new Error( + `${sourcePath}: raw Markdown projection requires non-empty ${missing} frontmatter`, + ); + } + + const bodyStart = source.indexOf(parsed.content); + const lineOffset = + bodyStart < 0 ? 0 : source.slice(0, bodyStart).split(/\r?\n/).length - 1; + const context: TransformContext = { lineOffset, sourcePath }; + let parsedTree: AstNode; + try { + parsedTree = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkMdx) + .parse(parsed.content) as AstNode; + } catch (error) { + throw new Error( + `${sourcePath}: cannot parse MDX for raw Markdown: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const body = transformBlockChildren(parsedTree.children, context); + const tree: AstNode = { + type: "root", + children: [ + { + type: "heading", + depth: 1, + children: [{ type: "text", value: title }], + }, + { + type: "paragraph", + children: [{ type: "text", value: description }], + }, + ...body, + ], + }; + const markdown = unified() + .use(remarkGfm) + .use(remarkStringify, { + bullet: "-", + fences: true, + listItemIndent: "one", + rule: "-", + }) + .stringify(tree as never) + .trim(); + return `${GENERATED_RAW_MARKDOWN_SENTINEL}\n\n${markdown}\n`; +} + +export async function collectRawMarkdownPages( + rootDir = process.cwd(), +): Promise<{ + pages: RawMarkdownPage[]; +}> { + const resolvedRoot = path.resolve(rootDir); + const docsRoot = path.join(resolvedRoot, "src/pages/docs"); + const sourceRelativePaths = ( + await fg("**/*.mdx", { + cwd: docsRoot, + onlyFiles: true, + }) + ) + .map((item) => item.replace(/\\/g, "/")) + .sort(); + if (sourceRelativePaths.length === 0) { + throw new Error(`${docsRoot} does not contain any .mdx pages`); + } + + validateDocumentationSourcePaths(sourceRelativePaths); + const routes = sourceRelativePaths.map(rawMarkdownRouteForSource); + validateRawRouteCollisions(routes); + validateExportedFileBijection(routes); + const pages = await Promise.all( + routes.map(async (route): Promise => { + const sourcePath = path.join(docsRoot, route.sourceRelativePath); + const source = await fs.promises.readFile(sourcePath, "utf8"); + return { + ...route, + markdown: projectMdxToMarkdown( + source, + path.relative(resolvedRoot, sourcePath).replace(/\\/g, "/"), + ), + sourcePath, + }; + }), + ); + return { pages }; +} + +function isMissingFile(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ENOENT" + ); +} + +async function readIfPresent(file: string): Promise { + try { + return await fs.promises.readFile(file, "utf8"); + } catch (error) { + if (isMissingFile(error)) return null; + throw error; + } +} + +function generatedFile(contents: string): boolean { + return contents.startsWith(`${GENERATED_RAW_MARKDOWN_SENTINEL}\n`); +} + +function reservedRawMarkdownPath(relative: string): boolean { + const normalized = relative.replace(/\\/g, "/"); + return normalized === "docs.md" || normalized.startsWith("docs/"); +} + +function outputPath(publicRoot: string, relative: string): string { + const resolvedPublicRoot = path.resolve(publicRoot); + const resolved = path.resolve(resolvedPublicRoot, relative); + if (!resolved.startsWith(`${resolvedPublicRoot}${path.sep}`)) { + throw new Error(`Raw Markdown output escapes public/: ${relative}`); + } + return resolved; +} + +async function removeEmptyParents( + directory: string, + publicRoot: string, +): Promise { + let current = path.resolve(directory); + const stop = path.resolve(publicRoot); + while (current !== stop && current.startsWith(`${stop}${path.sep}`)) { + try { + await fs.promises.rmdir(current); + } catch (error) { + if ( + isMissingFile(error) || + (typeof error === "object" && + error !== null && + "code" in error && + ["ENOTEMPTY", "EEXIST"].includes(String(error.code))) + ) { + return; + } + throw error; + } + current = path.dirname(current); + } +} + +export async function generateRawMarkdown({ + check = false, + rootDir = process.cwd(), +}: RawMarkdownGenerationOptions = {}): Promise { + const resolvedRoot = path.resolve(rootDir); + const publicRoot = path.join(resolvedRoot, "public"); + const { pages } = await collectRawMarkdownPages(resolvedRoot); + const expected = new Map(); + const existing = new Map(); + + for (const page of pages) { + const target = outputPath(publicRoot, page.outputRelativePath); + expected.set(target, page); + const contents = await readIfPresent(target); + if (contents !== null && !generatedFile(contents)) { + throw new Error( + `Refusing to overwrite non-generated raw Markdown file: ${path.relative( + resolvedRoot, + target, + )}`, + ); + } + existing.set(target, contents); + } + + const publicMarkdownFiles = await fg("**/*.md", { + cwd: publicRoot, + onlyFiles: true, + followSymbolicLinks: false, + }); + const outdated = [...expected].filter( + ([target, page]) => + existing.get(target) !== null && existing.get(target) !== page.markdown, + ); + const stale: string[] = []; + for (const relative of publicMarkdownFiles.sort()) { + const target = outputPath(publicRoot, relative); + if (expected.has(target)) continue; + const contents = await fs.promises.readFile(target, "utf8"); + if (generatedFile(contents)) { + stale.push(target); + } else if (reservedRawMarkdownPath(relative)) { + throw new Error( + `Refusing unmanaged file in the reserved raw Markdown namespace: ${path.relative( + resolvedRoot, + target, + )}`, + ); + } + } + + if (check) { + return { + outdatedCount: outdated.length, + projectedCount: pages.length, + removedCount: 0, + staleCount: stale.length, + writtenCount: 0, + }; + } + + let writtenCount = 0; + for (const [target, page] of expected) { + if (existing.get(target) === page.markdown) continue; + await fs.promises.mkdir(path.dirname(target), { recursive: true }); + await fs.promises.writeFile(target, page.markdown); + writtenCount += 1; + } + + for (const target of stale) { + const contents = await fs.promises.readFile(target, "utf8"); + if (!generatedFile(contents)) { + throw new Error( + `Refusing to delete raw Markdown file after its generated sentinel changed: ${path.relative( + resolvedRoot, + target, + )}`, + ); + } + await fs.promises.unlink(target); + await removeEmptyParents(path.dirname(target), publicRoot); + } + + return { + outdatedCount: outdated.length, + projectedCount: pages.length, + removedCount: stale.length, + staleCount: stale.length, + writtenCount, + }; +} + +export async function runRawMarkdownCli( + args = process.argv.slice(2), + rootDir = process.cwd(), +): Promise { + const unexpected = args.filter((arg) => arg !== "--check"); + if (unexpected.length > 0) { + throw new Error( + `Unknown raw Markdown generator argument(s): ${unexpected.join(", ")}`, + ); + } + const check = args.includes("--check"); + const result = await generateRawMarkdown({ check, rootDir }); + if (check) { + const issues = [ + result.outdatedCount > 0 + ? `${result.outdatedCount} outdated generated raw Markdown file(s) differ from their MDX source` + : null, + result.staleCount > 0 + ? `${result.staleCount} stale generated raw Markdown file(s) remain` + : null, + ].filter((issue): issue is string => issue !== null); + if (issues.length > 0) { + throw new Error(`${issues.join("; ")}. Run \`bun run build:raw\`.`); + } + console.log( + `Projected ${result.projectedCount} raw Markdown routes; generated files are not required in --check mode.`, + ); + } else { + console.log( + `Generated ${result.projectedCount} raw Markdown routes; wrote ${result.writtenCount} and removed ${result.removedCount} stale file(s).`, + ); + } + return result; +} + +if (import.meta.main) { + try { + await runRawMarkdownCli(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/scripts/generateTagPages.ts b/scripts/generateTagPages.ts deleted file mode 100755 index db41380f..00000000 --- a/scripts/generateTagPages.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * 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. - */ - -// scripts/generate-tag-pages.js -import { writeFile, readFile, readdir, mkdir, stat } from "node:fs/promises"; -import { join } from "node:path"; -import matter from "gray-matter"; - -import { Command } from "commander"; - -const program = new Command(); - -async function processDirectory(directory, tags, data) { - const fileNames = await readdir(directory); - - for (const fileName of fileNames) { - const filePath = join(directory, fileName); - const foundState = await stat(filePath); - - if (foundState.isDirectory()) { - await processDirectory(filePath, tags, data); - } else if (foundState.isFile() && fileName.endsWith(".mdx")) { - const specificDir = "pages"; - const startIndex = filePath.indexOf(specificDir) + specificDir.length; - const updatedPath = filePath.substring(startIndex); - const fileContents = await readFile(filePath, "utf8"); - const matterResult = matter(fileContents); - matterResult.data.tags?.forEach((tag) => tags.add(tag)); - - const lowerCaseTags = - matterResult.data.tags?.map((tag) => tag.toLowerCase()) || []; - - data.push({ - name: fileName.replace(".mdx", ""), - title: matterResult.data.title, - description: matterResult.data.description, - tags: lowerCaseTags, - path: updatedPath.replace(".mdx", ""), - }); - } - } -} - -export async function generateTagPages({ - dir = "src/pages", - out = "src/pages/tag", - template = "scripts/tagtemplatepage.mdx", - dataDir = "src/lib/data", - isDebugging = false, -} = {}) { - const contentDirectory = join(process.cwd(), dir); - const outputDirectory = join(process.cwd(), out); - const templatePath = join(process.cwd(), template); - const dataDirectory = join(process.cwd(), dataDir); - const templateContent = await readFile(templatePath, "utf8"); - const fileNames = await readdir(contentDirectory); - - if (isDebugging) console.log({ fileNames }); - const tags = new Set(); - const data = [] as unknown as { - name: string; - title: string; - description: string; - tags: string[]; - }[]; - - await processDirectory(contentDirectory, tags, data); - - if (isDebugging) console.log({ tags, data }); - - for (const tag of tags) { - const lowerCaseTag = (tag as string).toLowerCase(); - const tagPageContent = templateContent.replace( - /\[tag\]/g, - lowerCaseTag as string, - ); - await mkdir(outputDirectory, { recursive: true }); - const tagPagePath = join(outputDirectory, `${lowerCaseTag}.mdx`); - await writeFile(tagPagePath, tagPageContent, "utf8"); - - const tagDataDir = join(dataDirectory, lowerCaseTag as string); - await mkdir(tagDataDir, { recursive: true }); - - const tagDataPath = join(tagDataDir, "tag.data.ts"); - const tagData = data.filter((item) => - item.tags?.includes(lowerCaseTag as string), - ); - const dataContent = `export default ${JSON.stringify(tagData, null, 2)};`; - await writeFile(tagDataPath, dataContent, "utf8"); - if (isDebugging) - console.log( - `wrote "${lowerCaseTag}.mdx" and "/src/lib/data/${lowerCaseTag}" with:`, - { - tagData, - }, - ); - } -} - -program - .option("-d, --dir ", "directory of pages", "./src/pages") - .option("-o, --out ", "output directory", "./src/pages/tags") - .option( - "-t, --template ", - "path to template file", - "scripts/tagtemplatepage.mdx", - ) - .option( - "--dataDir ", - "output directory for data files", - "./src/lib/data", - ) - .option("--debug", "enable debug logs", false); - -program.parse(process.argv); - -const { dir, out, debug, template, dataDir } = program.opts(); - -(async () => { - await generateTagPages({ - dir: dir, - out: out, - template: template, - isDebugging: debug, - dataDir: dataDir, - }); - console.log("Meta files generated successfully!"); -})(); diff --git a/scripts/navigationMetadata.ts b/scripts/navigationMetadata.ts new file mode 100644 index 00000000..82c768b9 --- /dev/null +++ b/scripts/navigationMetadata.ts @@ -0,0 +1,135 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * 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. + */ + +import fs from "node:fs"; +import ts from "typescript"; + +export type NavigationSource = { + navigation: Record; + source: string; +}; + +function propertyName( + name: ts.PropertyName, + sourceFile: ts.SourceFile, +): string { + if ( + ts.isIdentifier(name) || + ts.isStringLiteral(name) || + ts.isNumericLiteral(name) + ) { + return name.text; + } + throw new Error( + `${sourceFile.fileName}:${sourceFile.getLineAndCharacterOfPosition(name.getStart()).line + 1} navigation keys must be static strings`, + ); +} + +function literalValue( + expression: ts.Expression, + sourceFile: ts.SourceFile, +): unknown { + if ( + ts.isAsExpression(expression) || + ts.isSatisfiesExpression(expression) || + ts.isParenthesizedExpression(expression) + ) { + return literalValue(expression.expression, sourceFile); + } + if ( + ts.isStringLiteral(expression) || + ts.isNoSubstitutionTemplateLiteral(expression) + ) { + return expression.text; + } + if (ts.isNumericLiteral(expression)) return Number(expression.text); + if (expression.kind === ts.SyntaxKind.TrueKeyword) return true; + if (expression.kind === ts.SyntaxKind.FalseKeyword) return false; + if (expression.kind === ts.SyntaxKind.NullKeyword) return null; + if ( + ts.isPrefixUnaryExpression(expression) && + expression.operator === ts.SyntaxKind.MinusToken && + ts.isNumericLiteral(expression.operand) + ) { + return -Number(expression.operand.text); + } + if (ts.isArrayLiteralExpression(expression)) { + return expression.elements.map((element) => + literalValue(element, sourceFile), + ); + } + if (ts.isObjectLiteralExpression(expression)) { + const value: Record = {}; + for (const property of expression.properties) { + if (!ts.isPropertyAssignment(property)) { + throw new Error( + `${sourceFile.fileName}:${sourceFile.getLineAndCharacterOfPosition(property.getStart()).line + 1} navigation metadata must use static property assignments`, + ); + } + value[propertyName(property.name, sourceFile)] = literalValue( + property.initializer, + sourceFile, + ); + } + return value; + } + throw new Error( + `${sourceFile.fileName}:${sourceFile.getLineAndCharacterOfPosition(expression.getStart()).line + 1} navigation metadata must use literal values`, + ); +} + +export async function readNavigationMetadata( + file: string, +): Promise { + const source = await fs.promises.readFile(file, "utf8"); + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + const parseError = sourceFile.parseDiagnostics.find( + (diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error, + ); + if (parseError) { + const position = sourceFile.getLineAndCharacterOfPosition( + parseError.start ?? 0, + ); + throw new Error( + `${file}:${position.line + 1} ${ts.flattenDiagnosticMessageText(parseError.messageText, " ")}`, + ); + } + const assignment = sourceFile.statements.find( + (statement): statement is ts.ExportAssignment => + ts.isExportAssignment(statement) && !statement.isExportEquals, + ); + if (!assignment) { + throw new Error(`${file} must default-export a navigation object`); + } + const navigation = literalValue(assignment.expression, sourceFile); + if ( + typeof navigation !== "object" || + navigation === null || + Array.isArray(navigation) + ) { + throw new Error(`${file} must default-export a navigation object`); + } + return { + navigation: navigation as Record, + source, + }; +} diff --git a/scripts/tagtemplatepage.mdx b/scripts/tagtemplatepage.mdx deleted file mode 100644 index f64bb5d6..00000000 --- a/scripts/tagtemplatepage.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: [tag] -description: 'Lifecycle "[tag]" docs' ---- - -import { LifecycleDocsCards as Cards } from "@lifecycle-docs/components"; -import meta from "@/lib/data/[tag]/tag.data"; - - diff --git a/scripts/validateContracts.ts b/scripts/validateContracts.ts new file mode 100644 index 00000000..2e0eb500 --- /dev/null +++ b/scripts/validateContracts.ts @@ -0,0 +1,604 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * 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. + */ + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import fg from "fast-glob"; + +export type ContractIssue = { + file: string; + message: string; +}; + +export type ContractCheck = { + detail: string; + name: "schema" | "openapi" | "cli"; + status: "fail" | "pass" | "skip"; +}; + +export type ContractInputs = { + cliCommand?: string[]; + openApiSpecPath?: string; + schemaValidatorCommand?: string[]; +}; + +export type ContractValidationOptions = { + inputs?: ContractInputs; + requireAll?: boolean; + rootDir?: string; +}; + +export type ContractValidationResult = { + checks: ContractCheck[]; + issues: ContractIssue[]; +}; + +type SourceFile = { + file: string; + source: string; +}; + +type CommandResult = { + code: number; + output: string; +}; + +type CliInvocation = { + file: string; + line: number; + source: string; + tokens: string[]; +}; + +const INPUT_NAMES = { + schemaValidatorCommand: "DOCS_SCHEMA_VALIDATOR_COMMAND", + openApiSpecPath: "DOCS_OPENAPI_SPEC_PATH", + cliCommand: "DOCS_CLI_COMMAND", +} as const; + +const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", +]; +const GLOBAL_OPTIONS_WITH_VALUES = new Set([ + "--api-url", + "--profile", + "--config-dir", +]); + +function lineAt(source: string, index: number): number { + return source.slice(0, index).split("\n").length; +} + +function parseArgv( + name: string, + value: string | undefined, +): string[] | undefined { + if (value === undefined || value.trim() === "") return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`${name} must be a JSON argv array`); + } + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + parsed.some((item) => typeof item !== "string" || item.trim() === "") + ) { + throw new Error(`${name} must be a non-empty JSON array of strings`); + } + return parsed as string[]; +} + +export function contractInputsFromEnvironment( + env: Record = process.env, +): ContractInputs { + return { + schemaValidatorCommand: parseArgv( + INPUT_NAMES.schemaValidatorCommand, + env[INPUT_NAMES.schemaValidatorCommand], + ), + openApiSpecPath: env[INPUT_NAMES.openApiSpecPath]?.trim() || undefined, + cliCommand: parseArgv(INPUT_NAMES.cliCommand, env[INPUT_NAMES.cliCommand]), + }; +} + +async function runCommand( + argv: string[], + extra: string[] = [], +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(argv[0], [...argv.slice(1), ...extra], { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => chunks.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => chunks.push(chunk)); + child.once("error", reject); + child.once("close", (code) => { + resolve({ + code: code ?? 1, + output: Buffer.concat(chunks).toString("utf8").trim(), + }); + }); + }); +} + +async function readSources(rootDir: string): Promise { + const files = ( + await fg("src/pages/docs/**/*.mdx", { + absolute: true, + cwd: rootDir, + }) + ).sort(); + return await Promise.all( + files.map(async (file) => ({ + file, + source: await fs.promises.readFile(file, "utf8"), + })), + ); +} + +async function validateSchemaExamples( + rootDir: string, + sources: SourceFile[], + command: string[], +): Promise<{ count: number; issues: ContractIssue[] }> { + const examples: Array<{ + content: string; + file: string; + line: number; + }> = []; + for (const { file, source } of sources) { + for (const match of source.matchAll(/```ya?ml([^\n]*)\n([\s\S]*?)```/g)) { + if (!/\bfilename=["']lifecycle\.ya?ml["']/.test(match[1])) continue; + examples.push({ + content: match[2], + file, + line: lineAt(source, match.index ?? 0), + }); + } + } + + const temporaryRoot = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "lifecycle-docs-contracts-"), + ); + const issues: ContractIssue[] = []; + try { + for (const [index, example] of examples.entries()) { + const temporaryFile = path.join(temporaryRoot, `example-${index}.yaml`); + await fs.promises.writeFile(temporaryFile, example.content); + const result = await runCommand(command, [temporaryFile]); + if (result.code !== 0) { + issues.push({ + file: `${path.relative(rootDir, example.file)}:${example.line}`, + message: `canonical lifecycle.yaml validation failed${ + result.output ? `: ${result.output.replace(/\s+/g, " ")}` : "" + }`, + }); + } + } + } finally { + await fs.promises.rm(temporaryRoot, { force: true, recursive: true }); + } + return { count: examples.length, issues }; +} + +function normalizeApiPath(value: string): string { + return value + .replace(/[`'",.;:)\]]+$/g, "") + .split(/[?#]/, 1)[0] + .replace(/<[^>]+>|\{[^}]+\}/g, "{}") + .replace(/\/$/, ""); +} + +function collectApiClaims( + rootDir: string, + sources: SourceFile[], +): Array<{ + file: string; + line: number; + method: string; + normalized: string; + path: string; +}> { + const claims: Array<{ + file: string; + line: number; + method: string; + normalized: string; + path: string; + }> = []; + for (const { file, source } of sources) { + for (const [index, line] of source.split(/\r?\n/).entries()) { + for (const pathMatch of line.matchAll(/\/api\/v2(?:\/[^\s`"')|,]+)+/g)) { + const prefix = line.slice(0, pathMatch.index); + const methods = HTTP_METHODS.filter((method) => + new RegExp(`\\b${method}\\b`).test(prefix), + ); + for (const method of methods) { + claims.push({ + file: path.relative(rootDir, file), + line: index + 1, + method, + normalized: normalizeApiPath(pathMatch[0]), + path: pathMatch[0], + }); + } + } + } + } + return claims; +} + +async function validateOpenApiClaims( + rootDir: string, + sources: SourceFile[], + specPath: string, +): Promise<{ count: number; issues: ContractIssue[] }> { + const resolvedPath = path.resolve(rootDir, specPath); + const spec: unknown = JSON.parse( + await fs.promises.readFile(resolvedPath, "utf8"), + ); + if ( + typeof spec !== "object" || + spec === null || + !("paths" in spec) || + typeof spec.paths !== "object" || + spec.paths === null + ) { + throw new Error(`${specPath} does not contain an OpenAPI paths object`); + } + + const operations = new Map>(); + for (const [apiPath, pathItem] of Object.entries( + spec.paths as Record, + )) { + if (typeof pathItem !== "object" || pathItem === null) continue; + operations.set( + normalizeApiPath(apiPath), + new Set(Object.keys(pathItem).map((method) => method.toUpperCase())), + ); + } + + const claims = collectApiClaims(rootDir, sources); + const issues: ContractIssue[] = []; + for (const claim of claims) { + if (!operations.get(claim.normalized)?.has(claim.method)) { + issues.push({ + file: `${claim.file}:${claim.line}`, + message: `${claim.method} ${claim.path} is not present in the supplied OpenAPI contract`, + }); + } + } + return { count: claims.length, issues }; +} + +function shellTokens(command: string): string[] { + const tokens: string[] = []; + for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|([^\s]+)/g)) { + tokens.push(match[1] ?? match[2] ?? match[3]); + } + return tokens; +} + +function collectCliInvocations( + rootDir: string, + sources: SourceFile[], +): CliInvocation[] { + const invocations: CliInvocation[] = []; + for (const { file, source } of sources) { + for (const fence of source.matchAll( + /```(?:sh|bash|shell)[^\n]*\n([\s\S]*?)```/g, + )) { + const content = fence[1].replace(/\\\r?\n[ \t]*/g, " "); + const fenceLine = lineAt(source, fence.index ?? 0); + for (const [offset, line] of content.split(/\r?\n/).entries()) { + const lfcIndex = line.search(/\blfc(?:\s|$)/); + if (lfcIndex < 0) continue; + const invocation = line + .slice(lfcIndex) + .replace(/\s+#.*$/, "") + .trim(); + invocations.push({ + file: path.relative(rootDir, file), + line: fenceLine + offset + 1, + source: invocation, + tokens: shellTokens(invocation), + }); + } + } + } + return invocations; +} + +function helpCommands(output: string): Set { + const commands = new Set(); + const lines = output.split(/\r?\n/); + let inCommands = false; + for (const line of lines) { + if (/^\s*Commands:\s*$/.test(line)) { + inCommands = true; + continue; + } + if (!inCommands) continue; + if (/^\S/.test(line) || /^\s*(?:Options|Arguments):\s*$/.test(line)) break; + const command = line.match(/^\s{2,}([a-z][a-z0-9-]*)\b/)?.[1]; + if (command) commands.add(command); + } + return commands; +} + +function optionName(token: string): string | null { + const cleaned = token.replace(/^[[(]+|[\]),;]+$/g, "").split("=", 1)[0]; + return /^--?[a-z][a-z0-9-]*$/i.test(cleaned) ? cleaned : null; +} + +async function validateCliInvocations( + rootDir: string, + sources: SourceFile[], + command: string[], +): Promise<{ count: number; issues: ContractIssue[] }> { + const invocations = collectCliInvocations(rootDir, sources); + const issues: ContractIssue[] = []; + const helpCache = new Map(); + async function help(commandPath: string[]): Promise { + const key = commandPath.join(" "); + const cached = helpCache.get(key); + if (cached) return cached; + const result = await runCommand(command, [...commandPath, "--help"]); + helpCache.set(key, result); + return result; + } + + const rootHelp = await help([]); + if (rootHelp.code !== 0) { + throw new Error( + `CLI root --help failed${rootHelp.output ? `: ${rootHelp.output}` : ""}`, + ); + } + const rootCommands = helpCommands(rootHelp.output); + + for (const invocation of invocations) { + const raw = invocation.tokens.slice(1); + const helpOutputs = [rootHelp.output]; + const commandSetsByToken = new Map>(); + let topIndex = -1; + for (let index = 0; index < raw.length; index += 1) { + const token = raw[index]; + if (GLOBAL_OPTIONS_WITH_VALUES.has(token)) { + index += 1; + continue; + } + if (token.startsWith("-")) continue; + if (rootCommands.has(token)) { + topIndex = index; + break; + } + } + + const commandPath: string[] = []; + if (topIndex < 0) { + const hasUnexpectedPositional = raw.some((token, index) => { + if (index > 0 && GLOBAL_OPTIONS_WITH_VALUES.has(raw[index - 1])) { + return false; + } + return token !== "|" && !token.startsWith("-") && !/^[[(<]/.test(token); + }); + if (hasUnexpectedPositional) { + issues.push({ + file: `${invocation.file}:${invocation.line}`, + message: `cannot resolve a documented CLI command from: ${invocation.source}`, + }); + } + } else { + commandPath.push(raw[topIndex]); + commandSetsByToken.set(topIndex, rootCommands); + let commandTokenIndex = topIndex; + + while (commandPath.length > 0) { + const commandHelp = await help(commandPath); + if (commandHelp.code !== 0) { + issues.push({ + file: `${invocation.file}:${invocation.line}`, + message: `CLI command is unavailable: lfc ${commandPath.join(" ")}`, + }); + break; + } + helpOutputs.push(commandHelp.output); + + const subcommands = helpCommands(commandHelp.output); + if (subcommands.size === 0) break; + + const nextIndex = commandTokenIndex + 1; + const nextToken = raw[nextIndex]; + if (!nextToken) break; + const nextCommand = nextToken.replace(/^[[(]+|[\]),;]+$/g, ""); + if (subcommands.has(nextCommand)) { + commandPath.push(nextCommand); + commandSetsByToken.set(nextIndex, subcommands); + commandTokenIndex = nextIndex; + continue; + } + + if ( + nextToken === "|" || + nextToken.startsWith("-") || + /^[[(<]/.test(nextToken) + ) { + break; + } + issues.push({ + file: `${invocation.file}:${invocation.line}`, + message: `CLI subcommand is unavailable: lfc ${[ + ...commandPath, + nextCommand, + ].join(" ")}`, + }); + break; + } + } + + const availableHelp = helpOutputs.join("\n"); + for (const token of raw) { + const option = optionName(token); + if ( + option && + option !== "--help" && + !new RegExp( + `(^|[\\s,])${option.replace(/-/g, "\\-")}(?=[\\s,=<\\[])`, + "m", + ).test(availableHelp) + ) { + issues.push({ + file: `${invocation.file}:${invocation.line}`, + message: `${option} is not present in help for ${ + commandPath.length > 0 ? `lfc ${commandPath.join(" ")}` : "lfc" + }`, + }); + } + } + + for (let index = 1; index < raw.length - 1; index += 1) { + if (raw[index] !== "|") continue; + const siblingCommands = commandSetsByToken.get(index - 1); + if (!siblingCommands) continue; + const alternative = raw[index + 1]?.replace(/^[[(]+|[\]),;]+$/g, ""); + if (alternative && !siblingCommands.has(alternative)) { + issues.push({ + file: `${invocation.file}:${invocation.line}`, + message: `CLI subcommand alternative is unavailable: ${alternative}`, + }); + } + } + } + return { count: invocations.length, issues }; +} + +export async function validateContracts({ + rootDir = process.cwd(), + inputs = contractInputsFromEnvironment(), + requireAll = false, +}: ContractValidationOptions = {}): Promise { + const resolvedRoot = path.resolve(rootDir); + const sources = await readSources(resolvedRoot); + const issues: ContractIssue[] = []; + const checks: ContractCheck[] = []; + + for (const [key, environmentName] of Object.entries(INPUT_NAMES)) { + if (requireAll && !inputs[key as keyof ContractInputs]) { + issues.push({ + file: "contract inputs", + message: `${environmentName} is required by --require-all`, + }); + } + } + + if (inputs.schemaValidatorCommand) { + const result = await validateSchemaExamples( + resolvedRoot, + sources, + inputs.schemaValidatorCommand, + ); + issues.push(...result.issues); + checks.push({ + name: "schema", + status: result.issues.length > 0 ? "fail" : "pass", + detail: `${result.count} named lifecycle.yaml example(s) checked`, + }); + } else { + checks.push({ + name: "schema", + status: "skip", + detail: `${INPUT_NAMES.schemaValidatorCommand} not supplied`, + }); + } + + if (inputs.openApiSpecPath) { + const result = await validateOpenApiClaims( + resolvedRoot, + sources, + inputs.openApiSpecPath, + ); + issues.push(...result.issues); + checks.push({ + name: "openapi", + status: result.issues.length > 0 ? "fail" : "pass", + detail: `${result.count} explicit API operation claim(s) checked`, + }); + } else { + checks.push({ + name: "openapi", + status: "skip", + detail: `${INPUT_NAMES.openApiSpecPath} not supplied`, + }); + } + + if (inputs.cliCommand) { + const result = await validateCliInvocations( + resolvedRoot, + sources, + inputs.cliCommand, + ); + issues.push(...result.issues); + checks.push({ + name: "cli", + status: result.issues.length > 0 ? "fail" : "pass", + detail: `${result.count} CLI invocation(s) checked`, + }); + } else { + checks.push({ + name: "cli", + status: "skip", + detail: `${INPUT_NAMES.cliCommand} not supplied`, + }); + } + + return { checks, issues }; +} + +async function runCli() { + let result: ContractValidationResult; + try { + result = await validateContracts({ + requireAll: process.argv.includes("--require-all"), + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + for (const check of result.checks) { + console.log(`${check.status.toUpperCase()} ${check.name}: ${check.detail}`); + } + for (const issue of result.issues) { + console.error(`${issue.file} — ${issue.message}`); + } + if (result.issues.length > 0) process.exit(1); +} + +if (import.meta.main) { + await runCli(); +} diff --git a/scripts/validateDocs.ts b/scripts/validateDocs.ts new file mode 100644 index 00000000..c8ba549e --- /dev/null +++ b/scripts/validateDocs.ts @@ -0,0 +1,1199 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * 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. + */ + +import fs from "node:fs"; +import path from "node:path"; +import fg from "fast-glob"; +import GithubSlugger from "github-slugger"; +import matter from "gray-matter"; +import yaml from "js-yaml"; +import { + DOCUMENTATION_METADATA, + loadDocumentationMetadata, + resolvePageMetadata, + type DocumentationMetadata, +} from "./docsMetadata"; + +export type ValidationIssue = { + file: string; + message: string; +}; + +export type ValidationOptions = { + rootDir?: string; +}; + +type ValidationContext = { + publicRoot: string; + rootDir: string; +}; + +type ScreenshotReference = { + asset: string; + declaredHeight: number | null; + declaredWidth: number | null; + file: string; + index: number; + source: string; +}; + +type ScreenshotCatalogEntry = { + asset: string; + fields: string[]; + line: number; + status: string; +}; + +type ImageDimensions = { + height: number; + width: number; +}; + +const SCREENSHOT_CATALOG = + ".agents/skills/update-lifecycle-docs/references/screenshots.md"; +const RAW_MARKDOWN_SERVER_CONFIG = "default.conf"; +const RASTER_EXTENSIONS = new Set([".gif", ".jpeg", ".jpg", ".png", ".webp"]); +const NON_SCREENSHOT_RASTERS = new Set(["/logo.png"]); +const SCREENSHOT_STATUSES = new Set(["keep", "remove", "replace"]); +const PORTABLE_GUIDANCE_FILES = [ + "AGENTS.md", + "CLAUDE.md", + "README.md", + ".github/PULL_REQUEST_TEMPLATE.md", + ".agents/skills/update-lifecycle-docs/SKILL.md", + ".agents/skills/update-lifecycle-docs/references/language-profile.md", + ".agents/skills/update-lifecycle-docs/references/raw-markdown.md", + ".agents/skills/update-lifecycle-docs/references/screenshots.md", + ".agents/skills/update-lifecycle-docs/references/ste100-review.md", + ".agents/skills/update-lifecycle-docs/agents/openai.yaml", + "package.json", +]; +const PORTABILITY_PATTERNS = [ + { + pattern: /\/Users\/[^/\s"'`]+/g, + message: "tracked guidance contains a contributor-specific macOS path", + }, + { + pattern: /\/home\/[^/\s"'`]+/g, + message: "tracked guidance contains a contributor-specific home path", + }, + { + pattern: /[A-Za-z]:\\Users\\[^\\\s"'`]+/g, + message: "tracked guidance contains a contributor-specific Windows path", + }, + { + pattern: + /https?:\/\/(?:localhost|127(?:\.\d{1,3}){3})(?::\d{2,5})?(?:[/?#][^\s"'`]*)?/gi, + message: "tracked guidance contains a fixed local development URL", + }, + { + pattern: /\b[^\s"'`]*\.nip\.io\b/gi, + message: "tracked guidance contains a local-only nip.io hostname", + }, + { + pattern: /(? { + const resolvedRoot = path.resolve(rootDir); + const configPath = path.join(resolvedRoot, RAW_MARKDOWN_SERVER_CONFIG); + let source: string; + try { + source = await fs.promises.readFile(configPath, "utf8"); + } catch (error) { + return [ + { + file: RAW_MARKDOWN_SERVER_CONFIG, + message: `raw Markdown server configuration is unavailable: ${ + error instanceof Error ? error.message.split("\n")[0] : String(error) + }`, + }, + ]; + } + + const location = source.match( + /location\s+~\s+\\\.md\$\s*\{([\s\S]*?)^\s*\}/m, + )?.[1]; + if (!location) { + return [ + { + file: RAW_MARKDOWN_SERVER_CONFIG, + message: "raw Markdown requires a dedicated .md location", + }, + ]; + } + + const issues: ValidationIssue[] = []; + if (!/\btypes\s*\{\s*\}/.test(location)) { + issues.push({ + file: RAW_MARKDOWN_SERVER_CONFIG, + message: + "raw Markdown must clear inherited MIME mappings before setting its UTF-8 media type", + }); + } + if ( + !/\bdefault_type\s+["']text\/markdown;\s*charset=utf-8["']\s*;/i.test( + location, + ) + ) { + issues.push({ + file: RAW_MARKDOWN_SERVER_CONFIG, + message: + 'raw Markdown must use Content-Type "text/markdown; charset=utf-8"', + }); + } + if (!/\btry_files\s+\$uri\s+=404\s*;/.test(location)) { + issues.push({ + file: RAW_MARKDOWN_SERVER_CONFIG, + message: + "raw Markdown must return 404 for a missing file instead of the HTML fallback", + }); + } + const retiredRouteLocations = [ + /location\s*=\s*\/docs\/cm\s*\{\s*return\s+404\s*;\s*\}/, + /location\s*=\s*\/docs\/cm\.md\s*\{\s*return\s+404\s*;\s*\}/, + /location\s+\^~\s+\/docs\/cm\/\s*\{\s*return\s+404\s*;\s*\}/, + ]; + if (retiredRouteLocations.some((pattern) => !pattern.test(source))) { + issues.push({ + file: RAW_MARKDOWN_SERVER_CONFIG, + message: "retired /docs/cm routes must return 404", + }); + } + if (!/\berror_page\s+404\s+\/404\.html\s*;/.test(source)) { + issues.push({ + file: RAW_MARKDOWN_SERVER_CONFIG, + message: "404 responses must use the exported 404 page", + }); + } + const htmlLocation = source.match(/location\s+\/\s*\{([\s\S]*?)^\s*\}/m)?.[1]; + if ( + !htmlLocation || + !/\btry_files\s+\$uri\.html\s+\$uri(?:\s+\$uri\/)?\s+\/index\.html\s*;/.test( + htmlLocation, + ) + ) { + issues.push({ + file: RAW_MARKDOWN_SERVER_CONFIG, + message: + "HTML routes must prefer the exported .html file before a same-name asset directory", + }); + } + return issues; +} + +function routeFor(rootDir: string, file: string): string { + const relative = path + .relative(path.join(rootDir, "src/pages"), file) + .replace(/\\/g, "/") + .replace(/\.mdx$/, ""); + return `/${relative.replace(/\/index$/, "")}`; +} + +function stripDestination(destination: string): string { + return destination.split(/[?#]/, 1)[0].replace(/\/$/, "") || "/"; +} + +function headingText(markdown: string): string { + return markdown + .replace(/\s+\{#[^}]+\}\s*$/, "") + .replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/[`*_~]/g, "") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim(); +} + +function headingFragments(source: string): Set { + const fragments = new Set(); + const slugger = new GithubSlugger(); + const withoutFences = source.replace( + /^(?: {0,3})(`{3,}|~{3,})[^\n]*\n[\s\S]*?^(?: {0,3})\1[ \t]*$/gm, + "", + ); + + for (const match of withoutFences.matchAll( + /^(?: {0,3})#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/gm, + )) { + const explicit = match[1].match(/\s+\{#([^}]+)\}\s*$/)?.[1]; + if (explicit) fragments.add(explicit); + const text = headingText(match[1]); + if (text) fragments.add(slugger.slug(text)); + } + + for (const match of withoutFences.matchAll( + /<(?:a|[hH][1-6])\b[^>]*\bid=["']([^"']+)["'][^>]*>/g, + )) { + fragments.add(match[1]); + } + + return fragments; +} + +function sourceLine(source: string, index: number): number { + return source.slice(0, index).split("\n").length; +} + +function addIssue( + issues: ValidationIssue[], + ctx: ValidationContext, + file: string, + source: string, + index: number, + message: string, +) { + issues.push({ + file: `${path.relative(ctx.rootDir, file)}:${sourceLine(source, index)}`, + message, + }); +} + +function validateFrontmatter( + issues: ValidationIssue[], + ctx: ValidationContext, + file: string, + data: Record, + metadata: DocumentationMetadata | null, +) { + for (const key of ["title", "description"]) { + if (typeof data[key] !== "string" || data[key].trim() === "") { + issues.push({ + file: path.relative(ctx.rootDir, file), + message: `frontmatter requires a non-empty ${key}`, + }); + } + } + + if (metadata) { + for (const message of resolvePageMetadata(data, metadata).issues) { + issues.push({ file: path.relative(ctx.rootDir, file), message }); + } + } +} + +function validateInternalLinks( + issues: ValidationIssue[], + ctx: ValidationContext, + file: string, + source: string, + routes: Set, + fragmentsByRoute: Map>, +) { + const destinations = [ + ...source.matchAll(/\]\(((?:\/[^)\s]+|#[^)\s]+))(?:\s+["'][^"']*["'])?\)/g), + ...source.matchAll(/\bhref=["']((?:\/[^"']+|#[^"']+))["']/g), + ]; + + for (const match of destinations) { + const destination = match[1]; + if (!destination.startsWith("/docs") && !destination.startsWith("#")) { + continue; + } + if (destination.includes(".mdx")) { + addIssue( + issues, + ctx, + file, + source, + match.index ?? 0, + `internal link must use a public route, not an .mdx path: ${destination}`, + ); + continue; + } + + const route = destination.startsWith("#") + ? routeFor(ctx.rootDir, file) + : stripDestination(destination); + if (!routes.has(route)) { + addIssue( + issues, + ctx, + file, + source, + match.index ?? 0, + `internal link targets a missing docs route: ${destination}`, + ); + continue; + } + + const encodedFragment = destination.includes("#") + ? destination.slice(destination.indexOf("#") + 1) + : ""; + if (!encodedFragment) continue; + + let fragment = encodedFragment; + try { + fragment = decodeURIComponent(encodedFragment); + } catch { + addIssue( + issues, + ctx, + file, + source, + match.index ?? 0, + `internal link contains an invalid encoded fragment: ${destination}`, + ); + continue; + } + + if (!fragmentsByRoute.get(route)?.has(fragment)) { + addIssue( + issues, + ctx, + file, + source, + match.index ?? 0, + `internal link targets a missing docs fragment: ${destination}`, + ); + } + } +} + +function localAssetPath( + ctx: ValidationContext, + sourcePath: string, +): string | null { + if (!sourcePath.startsWith("/")) return null; + const normalized = path.posix.normalize(sourcePath.split(/[?#]/, 1)[0]); + const resolved = path.resolve(ctx.publicRoot, normalized.replace(/^\/+/, "")); + const publicPrefix = `${path.resolve(ctx.publicRoot)}${path.sep}`; + if (!resolved.startsWith(publicPrefix)) return null; + return resolved; +} + +function validateMedia( + issues: ValidationIssue[], + ctx: ValidationContext, + file: string, + source: string, +) { + for (const match of source.matchAll(//g)) { + const component = match[0]; + const index = match.index ?? 0; + for (const prop of ["src", "alt", "width", "height"]) { + if (!new RegExp(`\\b${prop}=`).test(component)) { + addIssue(issues, ctx, file, source, index, ` requires ${prop}`); + } + } + + const src = component.match(/\bsrc=["']([^"']+)["']/)?.[1]; + if (src?.startsWith("/")) { + const asset = localAssetPath(ctx, src); + if (!asset || !fs.existsSync(asset)) { + addIssue( + issues, + ctx, + file, + source, + index, + ` asset does not exist: ${src}`, + ); + } + } + } + + for (const match of source.matchAll(//g)) { + if (!/\btitle=/.test(match[0])) { + addIssue( + issues, + ctx, + file, + source, + match.index ?? 0, + "