From 0105977f5954306505a3ce3a27b5a6357389971b Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Thu, 22 Oct 2026 03:49:52 +0200 Subject: [PATCH 1/6] Implement a cli&web wizard to create and configure purestack sites with ease. --- .github/workflows/ci.yml | 45 +- .gitignore | 2 - CONTRIBUTING.md | 1 - README.md | 87 ++- README_WRITING_CHECKLIST.md | 300 --------- REGOR_REACTIVITY.md | 384 ----------- biome.json | 15 +- docs/site-studio.md | 80 +++ docs/template-scaffolding.md | 111 +++ frontend/IMPLEMENTATION.md | 2 - frontend/README.md | 37 +- frontend/docs/component-documentation-plan.md | 24 +- frontend/legal-content.md | 1 - .../guides/getting-started/purestack-cli.mdx | 47 ++ package.json | 17 +- packages/purestack/README.md | 56 +- packages/purestack/package.json | 11 +- packages/purestack/src/cli.ts | 30 +- packages/purestack/src/cli/application.ts | 83 +++ packages/purestack/src/cli/arguments.ts | 118 ++++ packages/purestack/src/cli/creation.test.ts | 141 ++++ packages/purestack/src/cli/creation.ts | 107 +++ packages/purestack/src/cli/invocation.test.ts | 39 ++ packages/purestack/src/cli/invocation.ts | 10 + .../src/cli/terminal/commands.test.ts | 72 ++ .../purestack/src/cli/terminal/commands.ts | 18 + .../src/cli/terminal/creation-view.ts | 136 ++++ .../purestack/src/cli/terminal/layout.test.ts | 19 + packages/purestack/src/cli/terminal/layout.ts | 79 +++ packages/purestack/src/cli/wizard/commands.ts | 51 ++ .../src/cli/wizard/dashboard.test.ts | 175 +++++ .../purestack/src/cli/wizard/model.test.ts | 209 ++++++ .../purestack/src/cli/wizard/options.test.ts | 46 ++ .../src/cli/wizard/preview-config.test.ts | 83 +++ .../src/cli/wizard/preview-config.ts | 79 +++ .../src/cli/wizard/preview-process.ts | 40 ++ packages/purestack/src/cli/wizard/preview.ts | 98 +++ packages/purestack/src/cli/wizard/run.ts | 112 +++ .../purestack/src/cli/wizard/session.test.ts | 288 ++++++++ packages/purestack/src/cli/wizard/session.ts | 334 +++++++++ packages/purestack/src/init/create-site.ts | 155 +++++ packages/purestack/src/init/init.test.ts | 635 ++++++++++++++++++ .../src/init/package-manager.test.ts | 231 +++++++ .../purestack/src/init/package-manager.ts | 154 +++++ packages/purestack/src/init/process.test.ts | 52 ++ packages/purestack/src/init/process.ts | 87 +++ packages/purestack/src/init/tasks.ts | 4 + packages/purestack/src/init/template-store.ts | 107 +++ .../purestack/src/init/template-ui.test.ts | 240 +++++++ packages/purestack/src/init/template-ui.ts | 294 ++++++++ packages/purestack/src/init/template.ts | 385 +++++++++++ packages/purestack/src/init/version.test.ts | 51 ++ packages/purestack/src/init/version.ts | 22 + packages/purestack/wizard/footer.mdx | 23 + packages/purestack/wizard/header.mdx | 1 + packages/purestack/wizard/index.mdx | 23 + .../modules/components/creation-panel.ts | 22 + .../modules/components/creation-progress.ts | 49 ++ .../modules/components/creation-result.ts | 32 + .../wizard/modules/components/dashboard.ts | 57 ++ .../wizard/modules/components/index.ts | 48 ++ .../wizard/modules/components/navigation.ts | 29 + .../wizard/modules/components/next-steps.ts | 18 + .../modules/components/option-category.ts | 21 + .../wizard/modules/components/option-field.ts | 39 ++ .../wizard/modules/components/options-step.ts | 25 + .../wizard/modules/components/review-step.ts | 27 + .../wizard/modules/components/setup-step.ts | 30 + .../wizard/modules/components/site-step.ts | 26 + .../wizard/modules/components/step-actions.ts | 22 + .../wizard/modules/components/step.ts | 20 + .../modules/components/template-card.ts | 27 + .../modules/components/templates-step.ts | 28 + .../purestack/wizard/modules/connection.ts | 93 +++ packages/purestack/wizard/modules/model.ts | 274 ++++++++ .../wizard/modules/purestack.config.ts | 4 + .../wizard/modules/shared/protocol.ts | 53 ++ packages/purestack/wizard/modules/skin.ts | 124 ++++ .../wizard/modules/styles/creation.ts | 62 ++ .../wizard/modules/styles/dashboard.ts | 34 + .../purestack/wizard/modules/styles/footer.ts | 34 + .../purestack/wizard/modules/styles/index.ts | 17 + .../purestack/wizard/modules/styles/shell.ts | 53 ++ packages/purestack/wizard/modules/wizard.ts | 7 + .../purestack/wizard/modules/wizardPlugin.ts | 13 + packages/purestack/wizard/siteConfig.json | 24 + .../standard/doughnutChart/doughnutChart.ts | 2 +- .../src/standard/form/formStyle.ts | 71 +- packages/ts-css/README.md | 48 +- packages/ts-html/README.md | 25 +- packages/ts-images/README.md | 44 +- .../ts-minidom/src/createDom.globals.test.ts | 50 ++ packages/ts-minidom/src/createDom.ts | 32 +- packages/ts-page-scripts/README.md | 10 +- packages/ts-render/README.md | 12 +- packages/ts-ssg-vscode/README.md | 22 - packages/ts-ssg/NAVIGATION.md | 17 +- packages/ts-ssg/README.md | 130 ++-- packages/ts-ssg/ROADMAP.md | 53 -- packages/ts-ssg/sample-content-i18n/README.md | 15 +- .../de/docs/{index.md => index.mdx} | 6 +- .../de/docs/{links.md => links.mdx} | 17 +- .../de/docs/{routing.md => routing.mdx} | 13 +- .../ts-ssg/sample-content-i18n/de/index.mdx | 6 +- .../en/docs/{index.md => index.mdx} | 4 +- .../en/docs/{links.md => links.mdx} | 17 +- .../en/docs/{routing.md => routing.mdx} | 13 +- .../ts-ssg/sample-content-i18n/en/index.mdx | 6 +- .../ts-ssg/sample-content/guide/content.mdx | 2 +- .../sample-content/guide/getting-started.mdx | 2 +- .../journal/{index.md => index.mdx} | 0 .../{weekly-review.md => weekly-review.mdx} | 12 +- .../src/build/incremental/incremental.md | 7 - packages/ts-ssg/src/build/logger.test.ts | 23 +- packages/ts-ssg/src/build/logger.ts | 14 +- packages/ts-ssg/src/build/manifest.md | 7 - packages/ts-ssg/src/cli-runner.ts | 8 +- .../ts-ssg/src/dev/server-embedded.test.ts | 199 ++++++ packages/ts-ssg/src/dev/server.ts | 149 ++-- packages/ts-ssg/src/dev/websocket-frames.ts | 128 ++++ packages/ts-ssg/src/dev/websocket.test.ts | 177 +++++ packages/ts-ssg/src/dev/websocket.ts | 222 ++++++ packages/ts-ssg/src/index.ts | 13 +- packages/ts-ssg/src/publish-ideas.md | 29 - packages/ts-style/README.md | 6 +- packages/ts-util-node/README.md | 14 +- scripts/bump-version.ts | 8 +- scripts/bundle.ts | 6 +- scripts/check-eol.ts | 1 - scripts/cleanup.ts | 24 +- scripts/fixtures/toolchain-worker.ts | 126 ++++ scripts/pack-templates.ts | 45 ++ scripts/publish-new-packages.ts | 9 +- scripts/publish-package.ts | 22 +- scripts/tsconfig.json | 7 +- scripts/verify-templates.ts | 139 ++++ scripts/verify-toolchain.ts | 73 ++ scripts/workspaces.ts | 23 + sites/.gitignore | 2 + sites/README.md | 74 ++ skills/purestack-development/SKILL.md | 248 +++++++ skills/purestack-development/SKILL_shorter.md | 53 ++ templates/README.md | 26 + templates/starter/LICENSE | 21 + templates/starter/README.md | 12 + templates/starter/package.json | 24 + templates/starter/template.json | 6 + templates/starter/template.ui.json | 189 ++++++ templates/starter/template/.gitignore | 9 + templates/starter/template/.yarnrc.yml | 1 + templates/starter/template/AGENTS.md | 42 ++ templates/starter/template/README.md | 45 ++ templates/starter/template/biome.json | 54 ++ templates/starter/template/content/about.mdx | 14 + templates/starter/template/content/hello.ts | 8 + templates/starter/template/content/index.mdx | 25 + .../template/content/purestack.config.ts | 3 + .../starter/template/content/siteConfig.json | 14 + templates/starter/template/package.json | 27 + .../starter/template/tests/hello.test.ts | 27 + templates/starter/template/vitest.config.ts | 9 + tsconfig.json | 2 +- yarn.lock | 150 +++-- 163 files changed, 9284 insertions(+), 1272 deletions(-) delete mode 100644 README_WRITING_CHECKLIST.md delete mode 100644 REGOR_REACTIVITY.md create mode 100644 docs/site-studio.md create mode 100644 docs/template-scaffolding.md create mode 100644 packages/purestack/src/cli/application.ts create mode 100644 packages/purestack/src/cli/arguments.ts create mode 100644 packages/purestack/src/cli/creation.test.ts create mode 100644 packages/purestack/src/cli/creation.ts create mode 100644 packages/purestack/src/cli/invocation.test.ts create mode 100644 packages/purestack/src/cli/invocation.ts create mode 100644 packages/purestack/src/cli/terminal/commands.test.ts create mode 100644 packages/purestack/src/cli/terminal/commands.ts create mode 100644 packages/purestack/src/cli/terminal/creation-view.ts create mode 100644 packages/purestack/src/cli/terminal/layout.test.ts create mode 100644 packages/purestack/src/cli/terminal/layout.ts create mode 100644 packages/purestack/src/cli/wizard/commands.ts create mode 100644 packages/purestack/src/cli/wizard/dashboard.test.ts create mode 100644 packages/purestack/src/cli/wizard/model.test.ts create mode 100644 packages/purestack/src/cli/wizard/options.test.ts create mode 100644 packages/purestack/src/cli/wizard/preview-config.test.ts create mode 100644 packages/purestack/src/cli/wizard/preview-config.ts create mode 100644 packages/purestack/src/cli/wizard/preview-process.ts create mode 100644 packages/purestack/src/cli/wizard/preview.ts create mode 100644 packages/purestack/src/cli/wizard/run.ts create mode 100644 packages/purestack/src/cli/wizard/session.test.ts create mode 100644 packages/purestack/src/cli/wizard/session.ts create mode 100644 packages/purestack/src/init/create-site.ts create mode 100644 packages/purestack/src/init/init.test.ts create mode 100644 packages/purestack/src/init/package-manager.test.ts create mode 100644 packages/purestack/src/init/package-manager.ts create mode 100644 packages/purestack/src/init/process.test.ts create mode 100644 packages/purestack/src/init/process.ts create mode 100644 packages/purestack/src/init/tasks.ts create mode 100644 packages/purestack/src/init/template-store.ts create mode 100644 packages/purestack/src/init/template-ui.test.ts create mode 100644 packages/purestack/src/init/template-ui.ts create mode 100644 packages/purestack/src/init/template.ts create mode 100644 packages/purestack/src/init/version.test.ts create mode 100644 packages/purestack/src/init/version.ts create mode 100644 packages/purestack/wizard/footer.mdx create mode 100644 packages/purestack/wizard/header.mdx create mode 100644 packages/purestack/wizard/index.mdx create mode 100644 packages/purestack/wizard/modules/components/creation-panel.ts create mode 100644 packages/purestack/wizard/modules/components/creation-progress.ts create mode 100644 packages/purestack/wizard/modules/components/creation-result.ts create mode 100644 packages/purestack/wizard/modules/components/dashboard.ts create mode 100644 packages/purestack/wizard/modules/components/index.ts create mode 100644 packages/purestack/wizard/modules/components/navigation.ts create mode 100644 packages/purestack/wizard/modules/components/next-steps.ts create mode 100644 packages/purestack/wizard/modules/components/option-category.ts create mode 100644 packages/purestack/wizard/modules/components/option-field.ts create mode 100644 packages/purestack/wizard/modules/components/options-step.ts create mode 100644 packages/purestack/wizard/modules/components/review-step.ts create mode 100644 packages/purestack/wizard/modules/components/setup-step.ts create mode 100644 packages/purestack/wizard/modules/components/site-step.ts create mode 100644 packages/purestack/wizard/modules/components/step-actions.ts create mode 100644 packages/purestack/wizard/modules/components/step.ts create mode 100644 packages/purestack/wizard/modules/components/template-card.ts create mode 100644 packages/purestack/wizard/modules/components/templates-step.ts create mode 100644 packages/purestack/wizard/modules/connection.ts create mode 100644 packages/purestack/wizard/modules/model.ts create mode 100644 packages/purestack/wizard/modules/purestack.config.ts create mode 100644 packages/purestack/wizard/modules/shared/protocol.ts create mode 100644 packages/purestack/wizard/modules/skin.ts create mode 100644 packages/purestack/wizard/modules/styles/creation.ts create mode 100644 packages/purestack/wizard/modules/styles/dashboard.ts create mode 100644 packages/purestack/wizard/modules/styles/footer.ts create mode 100644 packages/purestack/wizard/modules/styles/index.ts create mode 100644 packages/purestack/wizard/modules/styles/shell.ts create mode 100644 packages/purestack/wizard/modules/wizard.ts create mode 100644 packages/purestack/wizard/modules/wizardPlugin.ts create mode 100644 packages/purestack/wizard/siteConfig.json create mode 100644 packages/ts-minidom/src/createDom.globals.test.ts delete mode 100644 packages/ts-ssg/ROADMAP.md rename packages/ts-ssg/sample-content-i18n/de/docs/{index.md => index.mdx} (69%) rename packages/ts-ssg/sample-content-i18n/de/docs/{links.md => links.mdx} (51%) rename packages/ts-ssg/sample-content-i18n/de/docs/{routing.md => routing.mdx} (52%) rename packages/ts-ssg/sample-content-i18n/en/docs/{index.md => index.mdx} (68%) rename packages/ts-ssg/sample-content-i18n/en/docs/{links.md => links.mdx} (50%) rename packages/ts-ssg/sample-content-i18n/en/docs/{routing.md => routing.mdx} (51%) rename packages/ts-ssg/sample-content/journal/{index.md => index.mdx} (100%) rename packages/ts-ssg/sample-content/journal/{weekly-review.md => weekly-review.mdx} (82%) create mode 100644 packages/ts-ssg/src/dev/server-embedded.test.ts create mode 100644 packages/ts-ssg/src/dev/websocket-frames.ts create mode 100644 packages/ts-ssg/src/dev/websocket.test.ts create mode 100644 packages/ts-ssg/src/dev/websocket.ts delete mode 100644 packages/ts-ssg/src/publish-ideas.md create mode 100644 scripts/fixtures/toolchain-worker.ts create mode 100644 scripts/pack-templates.ts create mode 100644 scripts/verify-templates.ts create mode 100644 scripts/verify-toolchain.ts create mode 100644 scripts/workspaces.ts create mode 100644 sites/.gitignore create mode 100644 sites/README.md create mode 100644 skills/purestack-development/SKILL.md create mode 100644 skills/purestack-development/SKILL_shorter.md create mode 100644 templates/README.md create mode 100644 templates/starter/LICENSE create mode 100644 templates/starter/README.md create mode 100644 templates/starter/package.json create mode 100644 templates/starter/template.json create mode 100644 templates/starter/template.ui.json create mode 100644 templates/starter/template/.gitignore create mode 100644 templates/starter/template/.yarnrc.yml create mode 100644 templates/starter/template/AGENTS.md create mode 100644 templates/starter/template/README.md create mode 100644 templates/starter/template/biome.json create mode 100644 templates/starter/template/content/about.mdx create mode 100644 templates/starter/template/content/hello.ts create mode 100644 templates/starter/template/content/index.mdx create mode 100644 templates/starter/template/content/purestack.config.ts create mode 100644 templates/starter/template/content/siteConfig.json create mode 100644 templates/starter/template/package.json create mode 100644 templates/starter/template/tests/hello.test.ts create mode 100644 templates/starter/template/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8b85554..97150902 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,38 @@ concurrency: cancel-in-progress: true jobs: + toolchain: + name: Fresh Corepack and Yarn (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '26' + package-manager-cache: false + + - name: Prepare repository tools + run: | + npm install --global corepack@latest + corepack enable + yarn install --immutable + + - name: Test package manager setup + run: yarn vitest run packages/purestack/src/init/package-manager.test.ts + + - name: Verify fresh installation and reuse with isolated tools + run: yarn verify-toolchain + checks: name: Checks (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -31,11 +63,14 @@ jobs: - name: Setup Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '24' + node-version: '26' package-manager-cache: false - - name: Enable Corepack - run: corepack enable + # Node 25 and later no longer bundle Corepack. + - name: Install and enable Corepack + run: | + npm install --global corepack@latest + corepack enable - name: Install dependencies run: yarn install --immutable @@ -78,6 +113,6 @@ jobs: if: runner.os == 'Linux' run: yarn bundle - - name: Pack distributable packages + - name: Pack packages and verify standalone templates if: runner.os == 'Linux' - run: yarn package + run: yarn verify-templates diff --git a/.gitignore b/.gitignore index 3f5eb39e..6f0919e1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,6 @@ node_modules dist *.tgz tsconfig.bundle.generated.json -PureGate -purestack-setup *.vsix .codex/* /.tmp* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e08d5316..1d306c82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,6 @@ page scripts change, run `yarn embed` manually; do not edit generated files unde After pushing these files and obtaining the first successful CI run, create an active branch ruleset targeting `main` in **Settings → Rules → Rulesets**: - - Require a pull request before merging and resolve all review conversations. - Require `Checks (ubuntu-latest)` and `Checks (windows-latest)` to pass. - Require branches to be up to date before merging. diff --git a/README.md b/README.md index ac1b2d91..95f189ed 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,25 @@ for the markup and browser app model. ## Get your first page running +Create a complete project, install its dependencies, and start the preview: + +```sh +npx purestack@latest init my-site --install --yes +cd my-site +yarn dev +``` + +The `starter` template creates a small site. You can select it directly with +`--template starter`, initialize Git with `--git`, or +run `init` alone to configure and create a site in the local PureStack wizard. +Studio shows progress, starts the live site, and stays open until you finish. +Use `--yes` to create a site directly without opening Studio. +Installation is opt-in with `--install`. +Each project includes `README.md` and +`AGENTS.md` to guide customization, plus build and publish scripts. + +To assemble a site manually instead, follow the steps below. + ### 1. Create a project ```sh @@ -126,25 +145,25 @@ has a complete example. ## Pick your next step -| I want to… | Start here | -| :--- | :--- | -| Configure a site | [Site configuration](https://purestack.studio/guides/getting-started/site-config/) | -| Build a richer page | [Component catalog](https://purestack.studio/components/) | -| Add browser behavior | [PageScript and RegorApp examples](packages/purestack/README.md) | -| Understand the CLI | [CLI guide](https://purestack.studio/guides/getting-started/purestack-cli/) | -| Explore a complete project | [Sample content](packages/ts-ssg/sample-content) · [Website source](frontend) | -| Work on PureStack itself | [Contribution guide](CONTRIBUTING.md) | +| I want to… | Start here | +| :------------------------- | :--------------------------------------------------------------------------------- | +| Configure a site | [Site configuration](https://purestack.studio/guides/getting-started/site-config/) | +| Build a richer page | [Component catalog](https://purestack.studio/components/) | +| Add browser behavior | [PageScript and RegorApp examples](packages/purestack/README.md) | +| Understand the CLI | [CLI guide](https://purestack.studio/guides/getting-started/purestack-cli/) | +| Explore a complete project | [Sample content](packages/ts-ssg/sample-content) · [Website source](frontend) | +| Work on PureStack itself | [Contribution guide](CONTRIBUTING.md) |
How PureStack fits together -| Source | What PureStack does | -| --- | --- | -| `siteConfig.json` | Defines output paths, site identity, themes, navigation, and optional features. | -| `.md`, `.mdx`, `.rmdx` | Turn content files into routes and HTML pages. | -| Regor components | Render UI into pages at build time. | -| `PageScript` and `RegorApp` | Bundle TypeScript and add browser behavior where requested. | -| Static assets | Copies files from the content directory into the site output. | +| Source | What PureStack does | +| --------------------------- | ------------------------------------------------------------------------------- | +| `siteConfig.json` | Defines output paths, site identity, themes, navigation, and optional features. | +| `.md`, `.mdx`, `.rmdx` | Turn content files into routes and HTML pages. | +| Regor components | Render UI into pages at build time. | +| `PageScript` and `RegorApp` | Bundle TypeScript and add browser behavior where requested. | +| Static assets | Copies files from the content directory into the site output. | The [CLI guide](https://purestack.studio/guides/getting-started/purestack-cli/) covers the commands and options. [Site configuration](https://purestack.studio/guides/getting-started/site-config/) @@ -154,24 +173,28 @@ covers navigation, themes, search, sitemap, localization, and output paths. ## Repository map +Use [sites/](sites/README.md) to test the workspace CLI directly. Its shared +instructions are repository files; generated site directories are Git-ignored. + The workspace contains the CLI, site generator, UI components, typed HTML/CSS builders, browser scripts, icon providers, and a private VS Code extension.
Explore the packages and source directories -| Path | Purpose | -| --- | --- | -| [`packages/purestack`](packages/purestack) | Public `purestack` package, CLI, and TypeScript API. | -| [`packages/ts-ssg`](packages/ts-ssg) | Content discovery, rendering, routing, builds, and development server. | -| [`packages/ts-components`](packages/ts-components) | Built-in UI components and component metadata. | -| [`packages/ts-style`](packages/ts-style) | Themes, skins, typography, and style generation. | -| [`packages/ts-css`](packages/ts-css) and [`packages/ts-html`](packages/ts-html) | Typed CSS and HTML builders. | -| [`packages/ts-page-scripts`](packages/ts-page-scripts) | Browser-side behavior used by site components. | -| [`packages/ts-svg-icons`](packages/ts-svg-icons) | SVG icon providers and lookup. | -| [`packages/ts-ssg-vscode`](packages/ts-ssg-vscode) | Private VS Code extension for PureStack content and components. | -| [`frontend`](frontend) | Source for purestack.studio, built with PureStack. | -| [`packages/ts-ssg/sample-content`](packages/ts-ssg/sample-content) | A larger example site with content, components, and browser scripts. | +| Path | Purpose | +| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| [`packages/purestack`](packages/purestack) | Public `purestack` package, CLI, and TypeScript API. | +| [`packages/ts-ssg`](packages/ts-ssg) | Content discovery, rendering, routing, builds, and development server. | +| [`packages/ts-components`](packages/ts-components) | Built-in UI components and component metadata. | +| [`packages/ts-style`](packages/ts-style) | Themes, skins, typography, and style generation. | +| [`packages/ts-css`](packages/ts-css) and [`packages/ts-html`](packages/ts-html) | Typed CSS and HTML builders. | +| [`packages/ts-page-scripts`](packages/ts-page-scripts) | Browser-side behavior used by site components. | +| [`packages/ts-svg-icons`](packages/ts-svg-icons) | SVG icon providers and lookup. | +| [`packages/ts-ssg-vscode`](packages/ts-ssg-vscode) | Private VS Code extension for PureStack content and components. | +| [`templates/`](templates) | Official npm template packages, sharing the PureStack product version. | +| [`frontend`](frontend) | Source for purestack.studio, built with PureStack. | +| [`packages/ts-ssg/sample-content`](packages/ts-ssg/sample-content) | A larger example site with content, components, and browser scripts. | The remaining workspace packages provide shared types, utilities, rendering, and DOM support. @@ -197,11 +220,11 @@ run `yarn frontend`; its development server opens at . See the [frontend README](frontend/README.md) for the site-specific workflow. -| Command | Purpose | -| :--- | :--- | -| `yarn lint:ci` | Check source without modifying files. | -| `yarn test:ci` | Run the test suite once. | -| `yarn bundle` | Build distributable packages. | +| Command | Purpose | +| :------------- | :----------------------------------------------- | +| `yarn lint:ci` | Check source without modifying files. | +| `yarn test:ci` | Run the test suite once. | +| `yarn bundle` | Build distributable packages. | | `yarn package` | Pack non-private workspaces into local tarballs. | --- diff --git a/README_WRITING_CHECKLIST.md b/README_WRITING_CHECKLIST.md deleted file mode 100644 index f434f985..00000000 --- a/README_WRITING_CHECKLIST.md +++ /dev/null @@ -1,300 +0,0 @@ -# PureStack README Writing Checklist - -This checklist is the writing machinery for the root `README.md`. - -The current docs are not authoritative. Treat existing Markdown files as hints -only. The root README must be built from facts visible in code, package -manifests, source exports, tests, schemas, samples, and scripts. - -## Goal - -Create a README that is inspiring, accurate, and robust: - -- explains PureStack as a TypeScript-native frontend ecosystem for the AI age -- makes the sustained AI generation thesis clear -- describes what this repository contains -- maps the packages without hallucinating features -- shows only syntax and commands that are verified in this repo -- avoids coupling PureStack to independent projects - -## Non-Negotiables - -- Do not use `FEATURES.md`, package README files, or old docs as factual - authority. -- Do not claim a capability unless it is verified in source, tests, schema, or - package metadata. -- Do not present future plans as current features. -- Do not position PureStack as a TypeScript backend/server ideology. -- Do not couple PureStack to independent downstream projects. -- Do not imply an open-source/pro split before the product strategy is decided. -- Do not include install or publish commands unless they are true for the public - release state. -- Do not use icon names in examples unless they are verified in - `packages/ts-svg-icons`. - -## Authoritative Evidence Sources - -Use these as truth sources, in this order: - -1. `package.json` at the repo root -2. `packages/*/package.json` -3. `packages/*/src/index.ts` -4. package source files under `packages/*/src` -5. tests under `packages/**/*.test.ts` -6. schemas under `packages/**/schema` -7. real sample content under `packages/ts-ssg/sample-content` -8. build and generation scripts under `scripts` -9. current root tooling configuration such as `vite.config.ts`, `tsconfig.json`, - and `biome.json` - -Existing docs may be used only to find places to verify in code. - -## Evidence Ledger - -Every meaningful README claim should fit this table before it is written. - -| Claim | Evidence | Confidence | README wording | -| --- | --- | --- | --- | -| PureStack has Yarn workspaces under `packages/*` | root `package.json` workspaces | high | "This repository contains the PureStack package workspace." | -| `@purestack/ts-ssg` exports `buildSite` and `startDevServer` | `packages/ts-ssg/src/index.ts` | high | "`ts-ssg` provides build and dev-server APIs." | -| `.mdx` and `.rmdx` are supported content extensions | `contentExtensions.ts` and related tests | pending | pending | - -Rules: - -- `Confidence` must be `high` for anything in the final README. -- `pending` claims stay out of the README. -- If wording requires two facts, list both evidence sources. - -## Phase 1: Inventory The Public Surface - -Checklist: - -- Read root `package.json`. -- List workspace package names from `packages/*/package.json`. -- For each package, capture: - - package name - - private/public status - - module/types/bin fields - - export map - - files included in package - - dependencies that define its role - - scripts that reveal how it is built or tested -- Read each `packages/*/src/index.ts`. -- Record only exported APIs, types, and registration functions visible from - package entry points. -- Mark internal packages or private tools clearly. - -Output: - -- a verified package map -- a list of public entry points -- a list of packages that should not be marketed as public if still private - -## Phase 2: Verify Core Capabilities - -For each capability, find direct code/test evidence. - -### SSG Pipeline - -Verify before claiming: - -- build API -- dev server API -- CLI bin -- content discovery -- `.md`, `.mdx`, `.rmdx` support -- frontmatter parsing -- Markdown and Regor MDX compilation -- static assets -- script bundling -- template tag transform -- navigation generation -- sitemap/robots/Pagefind/consent/GA4 only if source confirms current support -- i18n only if source confirms current support -- sample content shape - -### Regor MDX And Components - -Verify before claiming: - -- Regor component syntax in MDX -- component registration path -- built-in component initialization -- app/script embedding through real samples -- `.mdx` as primary extension and `.rmdx` as explicit Regor MDX alternative - -### Components And Styles - -Verify before claiming: - -- exported component definition functions -- exported style registration functions -- `regorComponents` package metadata -- source condition in `@purestack/ts-components` -- included `src/**/*.ts` package files, excluding tests -- semantic tones and theme APIs from `ts-style` -- component names only from actual exports or package metadata - -### Typed HTML/CSS Builders - -Verify before claiming: - -- `@purestack/ts-html` exported builders and node types -- `@purestack/ts-css` exported style helpers -- tests that demonstrate serialization, escaping, selectors, or CSS variable - behavior - -### Runtime Scripts - -Verify before claiming: - -- exported page script builders -- runtime globals -- generated embed files exist, but do not edit them -- any script named in README exists in `src/index.ts` - -### Icons - -Verify before claiming: - -- exported icon providers -- `getSvgIcon` behavior -- direct icon exports -- icon names used in README examples exist in generated source - -### VS Code Extension - -Verify before claiming: - -- extension package metadata -- contributed languages and file extensions -- syntax grammars -- activation events -- commands -- source features such as formatting, diagnostics, linked editing, component - metadata, and navigation only if implemented in source - -## Phase 3: Verify Examples - -Every example in the README must come from a working pattern. - -Checklist: - -- Prefer examples adapted from `packages/ts-ssg/sample-content`. -- Use `.mdx` as the primary docs/example extension. -- Mention `.rmdx` as the explicit Regor MDX alternative. -- If showing `RegorApp`, verify the pattern in sample content or code. -- If showing TypeScript component/app code, verify imports exist. -- If showing component tags, verify the component exists in `regorComponents` or - package exports. -- If showing config, verify fields in `siteConfig.schema.json` or config types. -- Keep examples small enough to be readable. -- Do not invent quick-start commands that have not been verified. - -## Phase 4: README Claim Gates - -Before a section is accepted, answer these questions. - -### Hero - -- Does it match the organization profile hero, replacing `PureStack Studio` with - `PureStack`? -- Does it say frontend/product source, not web stack? -- Does it avoid unsupported business model claims? - -### AI Thesis - -- Does it distinguish one-shot generation from sustained product evolution? -- Does it say PureStack gives AI a better source world, not perfect magic? -- Does it explain why coherent TypeScript helps generation, inspection, - refactoring, and maintenance? - -### Repository Contents - -- Does every package in the map exist? -- Is every one-line package description backed by `package.json` and - `src/index.ts`? -- Are private or not-yet-public pieces labeled carefully? - -### Feature Summary - -- Is it compact? -- Are all claims verified? -- Does it avoid duplicating `FEATURES.md`? -- Does it avoid naming independent downstream projects? - -### Commands - -- Do commands exist in root or package `package.json`? -- Do commands avoid running generated embed scripts as a reader prerequisite? -- Do TypeScript build and type-check commands use the stable TypeScript 7 - `tsc` executable, except where a package script explicitly uses another tool? - -### Status - -- Does status say built and production-used without overclaiming public - readiness? -- Does it leave room for public release shaping? -- Does it avoid "stable", "1.0-ready", or "drop-in replacement" unless - explicitly decided? - -## Phase 5: Recommended README Outline - -Use this outline unless a better code-derived structure appears. - -1. Centered hero matching the organization profile -2. "Why PureStack Exists" - - AI can one-shot in any framework - - PureStack targets sustained AI generation and maintenance - - coherent typed source is the architectural bet -3. "What This Repository Contains" - - one paragraph - - compact package table -4. "How The Pieces Fit" - - Markdown/MDX for prose/content - - Regor MDX for component-rich content - - TypeScript for components, styles, scripts, and product behavior - - one SSG/build pipeline -5. "A Tiny Shape Of PureStack" - - verified `.mdx` page snippet - - verified TypeScript app/component snippet if compact enough -6. "Package Map" - - package - - role - - evidence-backed one-liner -7. "Current Status" - - built - - production-used - - public release preparation -8. "Development" - - verified commands only -9. "License" - - verify from root and package manifests - -## Phase 6: Final Review - -Run this before accepting the README. - -- No claim depends only on docs. -- No downstream project coupling. -- No TypeScript-server ideology. -- No invented examples. -- No unverified commands. -- No package feature described from memory. -- No feature inventory dump. -- No competitor dunking. -- No AI hype without architectural explanation. -- The README is skimmable in two minutes. -- The README still feels alive and worth entering. - -## Working Notes For The Next Step - -When writing the README, first build the evidence ledger from source. Then draft -the README from the ledger, not from memory. - -Good README language should be: - -- precise enough for engineers -- confident enough for the vision -- short enough to be read -- concrete enough to be trusted diff --git a/REGOR_REACTIVITY.md b/REGOR_REACTIVITY.md deleted file mode 100644 index 6638f112..00000000 --- a/REGOR_REACTIVITY.md +++ /dev/null @@ -1,384 +0,0 @@ -# Regor Reactivity - -This document describes the baseline reactivity model used in PureStack code. -It starts with the common path: how to use `ref`, `sref`, and -`flatten` correctly without losing type safety. - -## Reactive Factories - -`ref` and `sref` are factories. They create reactive containers. - -A reactive container is called like a function: - -```ts -const count = ref(0) - -count() // read -count(1) // write -``` - -The same read/write shape applies to both `ref` and `sref`. - -## `ref` - -`ref` creates deep reactive content. - -For primitive values, `ref` is straightforward: - -```ts -const title = ref('Hello') - -title() -title('Welcome') -``` - -For objects and arrays, `ref` changes the object shape. It wraps the value into -Regor reactive content. - -```ts -interface User { - name: string - email: string -} - -const plainUser: User = { - name: 'Ali', - email: 'admin@example.com', -} - -const user = ref(plainUser) - -plainUser !== user() -``` - -The value returned by `user()` is no longer the original plain object. It is the -Regor reactive version of that object. - -Nested fields follow Regor's smart reactive contract: - -```ts -user().name() -user().name('Cem') -user().email() -``` - -So the important rule is: - -```ts -ref(object) // wraps/mutates the object shape into reactive content -``` - -Use `ref` when nested fields should participate in Regor reactivity. - -## `flatten` - -`flatten` is the reverse operation for `ref` content. - -It converts Regor reactive content back into plain JavaScript content: - -```ts -const plain = flatten(user) -``` - -Use `flatten` when you need to send reactive content to APIs, compare plain -values, serialize state, or pass data to code that expects normal JavaScript -objects. - -## `unref` - -`unref` reads either a reactive value or a plain value. - -Use it when an API accepts both: - -```ts -import { unref, type RefOrValue } from 'regor' - -function readTitle(title: RefOrValue) { - return unref(title) -} -``` - -If `title` is a `ref`, `unref(title)` returns `title()`. - -If `title` is already a plain string, `unref(title)` returns the string. - -This makes `unref` useful for component props: - -```ts -interface ButtonProps { - label?: RefOrValue - disabled?: RefOrValue -} - -const label = unref(props.label) || 'Submit' -const disabled = unref(props.disabled) === true -``` - -Use `unref` for one-level read normalization. Use `flatten` when you need to -convert reactive object content back into plain JavaScript content. - -## `sref` - -`sref` creates a shallow/simple reactive container. - -The container is reactive, but the stored object remains ordinary JavaScript -content. - -```ts -interface User { - name: string - email: string -} - -const plainUser: User = { - name: 'Ali', - email: 'admin@example.com', -} - -const user = sref(plainUser) - -plainUser === user() -``` - -Fields are normal properties: - -```ts -user().name -user().email -``` - -To update `sref` object content, replace the value: - -```ts -user({ - ...user(), - name: 'Ali', -}) -``` - -So the matching rule is: - -```ts -sref(object) // keeps the object as normal JavaScript content -``` - -Use `sref` when you want a reactive container but want the content itself to stay -plain. - -## Choosing `ref` or `sref` - -Use `ref` when you want deep reactive fields: - -```ts -const user = ref({ - name: 'Cem', - email: 'admin@example.com', -}) - -user().name('Ali') -``` - -Use `sref` when you want normal object fields and replacement-style updates: - -```ts -const user = sref({ - name: 'Ali', - email: 'admin@example.com', -}) - -user({ - ...user(), - name: 'Cem', -}) -``` - -The short version: - -```ts -ref(object) // deep reactive content, object shape changes -sref(object) // reactive container, object shape stays plain -``` - -## Type Safety - -Use known types with `ref` and `sref`. - -Prefer: - -```ts -const user = ref(initialUser) -const draft = sref(initialDraft) -``` - -Avoid: - -```ts -const user = ref(value) -const data = ref(value) -``` - -Regor's smart contract is type-driven. If the type is `any` or `unknown`, -TypeScript cannot describe the generated reactive shape well enough to protect -the code. - -Good type information is especially important with `ref`, because nested object -fields become reactive according to the declared type. - -## Delayed Initialization - -`ref` and `sref` support delayed initialization: - -```ts -const user = ref() -const draft = sref() -``` - -This creates a reactive container without providing an initial runtime value. -The generated TypeScript type still behaves like `User` or `MailDraft`. - -This shape is useful for component contexts where initialization is delayed -until a known lifecycle point, such as `mounted`, a fetch result, or another -setup step. - -Use delayed initialization carefully. Runtime safety depends on assigning the -value before anything reads it. - -Prefer an initial value when possible: - -```ts -const user = ref({ - name: '', - email: '', -}) -``` - -For nullable state, model nullability explicitly: - -```ts -const user = ref(null) -``` - -Do not use `undefined` to model nullable state: - -```ts -const user = ref() -``` - -`undefined` collapses out of the generated ref contract, so this breaks type -safety. Use `null` when missing state is part of the model. - -## `computed` - -`computed` creates derived reactive state. - -Use it when a value can be calculated from other reactive values: - -```ts -const firstName = ref('Ali') -const lastName = ref('Cem') - -const fullName = computed(() => `${firstName()} ${lastName()}`) - -fullName() -``` - -`computed` should describe a value. It should not be used to perform side -effects. - -Good: - -```ts -const messageCountText = computed(() => `${messages().length} message(s)`) -``` - -Avoid: - -```ts -const value = computed(() => { - localStorage.setItem('count', String(count())) - return count() -}) -``` - -If code needs to perform a side effect when reactive state changes, use -`observe` or `watchEffect`. - -## `observe` - -`observe` runs a side effect when a specific reactive source changes. - -Use it when the dependency is explicit: - -```ts -observe(selectedId, (id) => { - scrollToItem(id) -}) -``` - -`observe` is a good fit when one known source drives the effect. - -```ts -observe(items, () => { - refreshVirtualList() -}) -``` - -Use `observe` for side effects and synchronization. Do not use it for values -that can be expressed as `computed`. - -## `watchEffect` - -`watchEffect` runs a reactive side effect. - -Use it when the effect should re-run whenever any reactive value read inside the -effect changes: - -```ts -watchEffect(() => { - document.title = title() -}) -``` - -`watchEffect` is for synchronization with the outside world: DOM APIs, browser -state, storage, imperative widgets, timers, or other side-effectful code. - -It is also useful when an effect depends on multiple reactive values: - -```ts -watchEffect(() => { - updateChart({ - items: items(), - selectedId: selectedId(), - theme: theme(), - }) -}) -``` - -Keep `watchEffect` focused. It should synchronize something; it should not hide -ordinary data derivation that belongs in `computed`. - -## Effect Cleanup in Components - -Inside component creation, `observe` and `watchEffect` are scoped to the -component. - -That means component code should not manually stop or dispose them: - -```ts -class ExampleComponent { - constructor() { - observe(this.items, () => { - this.refresh() - }) - - watchEffect(() => { - this.syncTitle(this.title()) - }) - } -} -``` - -Regor disposes these scoped effects when the component is disposed. - -Manual cleanup is only for effects created outside a component scope or in a -custom lifetime that Regor does not own. diff --git a/biome.json b/biome.json index c5187dea..1771c3b7 100644 --- a/biome.json +++ b/biome.json @@ -1,14 +1,15 @@ { - "$schema": "https://biomejs.dev/schemas/latest/schema.json", + "$schema": "https://biomejs.dev/schemas/2.6.2/schema.json", "files": { "includes": [ "**", "!frontend/out", + "!templates/starter/template", + "!sites/*", "!**/.*", "!**/dist", "!**/node_modules", - "!**/.yarn", - "!**/.puregate" + "!**/.yarn" ], "ignoreUnknown": true }, @@ -21,7 +22,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "correctness": { "noUnusedImports": "error", "noUnusedVariables": "error" @@ -35,6 +36,12 @@ } }, "overrides": [ + { + "includes": ["**/SKILL.md"], + "formatter": { + "enabled": false + } + }, { "includes": [ "packages/ts-css/src/**", diff --git a/docs/site-studio.md b/docs/site-studio.md new file mode 100644 index 00000000..5bd1e044 --- /dev/null +++ b/docs/site-studio.md @@ -0,0 +1,80 @@ +# Site Studio wizard + +Run `yarn purestack init` from `sites/` to serve the wizard locally. The CLI calls +PureStack's existing `startDevServer` with `packages/purestack/wizard` as its +content directory. Open the complete printed URL, including its session token. +Studio binds to `127.0.0.1` on an available port by default. Override the wizard +listener with `yarn purestack init --host 0.0.0.0 --port 4700`; `--port 0` selects +an available port. These flags affect Studio; generated-site previews keep their +own loopback listener and automatically assigned port. Studio and preview servers +log warnings and errors while creation progress remains visible. + +Studio builds the complete wizard before printing its URL, then serves the +generated site with file watching and live reload disabled. Its WebSocket +connection remains available for configuration, creation progress, and shutdown. + +The wizard is ordinary PureStack content: +- `wizard/index.mdx`: static introduction and native `RegorApp` mount. +- `wizard/header.mdx` and `footer.mdx`: shared native `TopBar`, `ThemeToggle`, + and `SiteFooter`. +- `wizard/siteConfig.json`: site settings. +- `wizard/modules/purestack.config.ts`: plugin configuration selected by the CLI. +- `wizard/modules/wizardPlugin.ts`: generates a branded light/dark pair through the + standard skin generator and registers fluent TypeScript styles. +- `wizard/modules/skin.ts`: refines both returned palettes for surfaces, readable + text, borders, typography, radii, shadows, and accent interaction states. +- `wizard/modules/styles/`: custom shell and dashboard rules using semantic palettes; + ordinary layout and spacing use native components and generated utilities. +- `wizard/modules/model.ts`: shared reactive configuration, validation, and navigation. +- `wizard/modules/connection.ts`: native browser WebSocket transport and reconnection. +- `wizard/modules/shared/protocol.ts`: typed commands, snapshots, progress, and results. +- `wizard/modules/components/dashboard.ts`: dashboard layout composing the navigation + and individual site, template, options, setup, and review steps. +- `wizard/modules/components/`: focused, typed Regor components, including reusable + step layouts, Back/Next actions, and template cards built from native primitives. +- `wizard/modules/wizard.ts`: mounts the browser app with explicit component registration. +- `src/cli/wizard/run.ts`: starts PureStack and prints the setup URL. +- `src/cli/wizard/session.ts`: validates commands, owns creation and progress, + retains state across reconnects, and rejects duplicate creation requests. +- `src/cli/wizard/preview.ts` and `preview-process.ts`: launch an isolated child + process using the same CLI and PureStack server, with IPC readiness and cleanup. + +The Regor app connects to a WebSocket endpoint on the existing PureStack HTTP +listener. The session validates the request origin, host, and a random token +before upgrading. Browser commands configure templates, create a project, retry +the live server, or finish the session. Server snapshots carry authoritative +state and task progress. Reconnecting observes the same operation without +resubmitting creation. No third-party WebSocket library is used. + +After creation, **Browse live site** opens the generated project on a separate +loopback port. A child process isolates its theme and build registries from the +wizard; source edits are watched and reloaded by PureStack. A failed preview can +be retried without regenerating the project. The wizard remains open until +**Finish Studio** or Ctrl+C, which closes both servers and cleans their temporary +build directories. Generated project files are preserved. The terminal prints +copyable commands for continuing independently. + +The live site uses the generated project's `dev` script (`purestack serve ...`), +including its content directory, host, port, and watch/reload options. For example, +`--port 5000` opens the live site on port 5000. Studio keeps its own listener and +stores preview build output in a temporary directory. + +## Retained generation services + +Direct creation continues to work: + +```sh +yarn purestack init my-site --template starter --yes +yarn purestack init my-site --template starter --set palette=mint --install --yes +``` + +Template fetching, archive validation, compatibility checks, typed settings, +project generation, optional installation/Git setup, and terminal progress and +completion output remain independent of the wizard. See +[Project creation](template-scaffolding.md) for the package contract. + +Template categories, nested categories, fields, choices, defaults, and configuration +bindings are defined by `template.ui.json` and rendered with native PureStack +controls. CLI arguments prefill the wizard, including custom template sources and +`--set` values. The live site is started after creation; template-specific optional +previews and custom template setup code remain separate future capabilities. diff --git a/docs/template-scaffolding.md b/docs/template-scaffolding.md new file mode 100644 index 00000000..ded04b63 --- /dev/null +++ b/docs/template-scaffolding.md @@ -0,0 +1,111 @@ +# Project creation + +`purestack init --yes` creates a standalone project from a versioned npm +template package. The CLI owns downloading, validation, substitution, +installation, and optional Git initialization. Templates contain files, not setup +code. Generated files belong to the user; templates are not project dependencies. + +## First release + +See [Site Studio](site-studio.md) for the browser wizard's PureStack content, +template-defined categories, configuration bindings, and extension points. +- Official alias: `starter`. +- `init` serves the wizard content with PureStack; `--yes` creates directly. +- Install dependencies with the template's package manager only when `--install` is supplied or selected. The starter pins Yarn; templates without a `packageManager` field use npm. +- For Yarn projects, installation also sets up missing Corepack and enables Yarn + through npm's global prefix, then prepares the project's pinned Yarn version. + These stages appear in creation progress; failed setup preserves the project. +- Git initialization is opt-in with `--git`; never create an initial commit. +- Accept explicit npm package versions and local `.tgz` files or package folders. +- Require an absent or empty destination. Stage and validate before writing. +- Check compatibility against the running CLI and pin that exact PureStack version. +- Preserve generated files if installation or Git initialization fails, with retry + instructions and a failing exit status. + +## Package contract (version 1) + +The starter template workspace lives in `templates/starter/`; future templates +can use `templates//`. Framework packages live in `packages/`. +Each template remains an npm package named `@purestack/template-` and uses +the shared PureStack product version. + +The outer `package.json` declares `purestackTemplate: true`, a name, a version, +and a `files` list containing `template.json`, `template/`, `README.md`, and +`LICENSE`, plus `template.ui.json` when it supplies interactive options. Official +template packages share the product version with all other PureStack packages. +There are no runtime exports. + +`template.json` contains `formatVersion: 1`, a description, and a `purestack` +minimum version, such as `"1.1.19"`. The CLI must meet that minimum and have the +same major version. Versions use plain `major.minor.patch`; ranges, prereleases, +and build metadata are not supported. `template/` contains a complete standalone +project, including `package.json`, `README.md`, `AGENTS.md`, and +`content/siteConfig.json`. +Project dependencies must use registry versions, ranges, tags, or `npm:` aliases. +Workspace references, local paths, Git sources, and tarball URLs are rejected. + +The generated package name, `purestack` dependency, site title, and logo brand +are customized. Template-defined option bindings can configure appearance, +features, publishing, and plugin settings. Unspecified values use the template's +declared defaults. The `purestack` dependency uses the literal +`{{purestackVersion}}` in the source template. Other `@purestack/*` dependencies +can use the same token to share the CLI's product version, including test helpers. +Keep `.gitignore` and `.yarnrc.yml` under their real names and explicitly include +`template/.gitignore` and `template/.yarnrc.yml` in the package's `files` list. +`yarn package` and `yarn publish-package` use npm to pack templates, preserving +these files. The CLI copies source and binary files without renaming them. + +The project's optional `packageManager` field pins a published Yarn or npm +version using plain `major.minor.patch`, such as `yarn@4.18.1`. +CLI installation, retry instructions, and next-step commands follow +this choice. The starter uses Yarn with the `node-modules` linker, Vitest app +tests outside `content/`, and a standalone `biome.json`. +For Yarn projects without a template-supplied lockfile, the CLI creates an empty +`yarn.lock` before installation to establish a separate project inside parent +workspaces. Existing template lockfiles are preserved. + +Printed next-step commands target Command Prompt (`cmd.exe`) on Windows and a +POSIX shell on Linux/macOS, with platform-specific literal path quoting. + +Archives must use npm's `package/` prefix. Absolute paths, traversal, Windows +special paths, links, duplicate paths, and non-file/non-directory entries are +rejected before extraction. Local folders go through `npm pack --ignore-scripts` +so testing exercises the files that will actually be published. + +Generated projects must provide `dev`, `build`, and `publish` scripts. Installation +lifecycle hooks and project `.npmrc` files are rejected. Template acquisition and +extraction never execute template setup code. + +## Releases + +`yarn bump-version` updates all workspace packages, including official templates, +to the same product version. Release templates with the rest of PureStack. +Update its `purestack` minimum version when it needs newer APIs or when a new +framework major version is supported. An incompatible template is rejected before +any project files are written; select an older template or a compatible CLI. + +Use `yarn publish-package template-starter --dry-run` to inspect a single release. +The same command without `--dry-run` publishes it. Templates also participate +in `yarn publish-new-packages`; data packages are packed without runtime bundling. +Publish the CLI and templates before advertising the new `npx` commands. + +## Verification + +For manual testing in this repository, run the workspace CLI directly and create +projects under `sites/`. Its README and ignore rules are shared repository files; +generated site directories are Git-ignored. See [the sites guide](../sites/README.md) +for direct CLI commands using local templates. + +Focused tests cover parsing, archive validation, compatibility, customization, +and preservation of existing files. `yarn verify-templates` packs the current +workspace packages, creates projects outside the monorepo from their packed +templates, installs the packed framework dependencies using npm overrides or +Yarn resolutions, runs available code-quality checks and app tests, and builds +and publishes each site. It does not publish anything to npm or upload a website. +CI runs this after bundling distributable packages. To verify only the starter +after packing, run `yarn tsx scripts/verify-templates.ts starter`. + +`yarn verify-toolchain` checks automatic Corepack installation, Yarn enablement, +the project-pinned Yarn version, dependency installation, and reuse on a second +run. It isolates Node, PATH, npm's global prefix, and tool caches in a temporary +directory. CI runs this check on Windows, Linux, and macOS. diff --git a/frontend/IMPLEMENTATION.md b/frontend/IMPLEMENTATION.md index 5f70339d..5b4c090a 100644 --- a/frontend/IMPLEMENTATION.md +++ b/frontend/IMPLEMENTATION.md @@ -28,7 +28,6 @@ Browser pages register their components and mount them with direct `createApp` calls. There is no shared example contract, lookup table, or mounting helper. Browser verification found existing shared-framework accessibility issues: - - The default doc template emits no `lang` for a site without locale routing. - Navigation and TOC header opacity gives insufficient text contrast in the studio skin. @@ -61,7 +60,6 @@ borders, generous spacing, and an interactive source/preview workbench. This is an implementation-oriented architecture review of the package boundaries, core rendering/build paths, and relevant component APIs. It is not an exhaustive line-by-line audit of every source file or generated asset. - - The public `purestack` package exports the SSG API and CLI. Current build input is `{ siteConfig, options, publish }`; older README examples using top-level `contentDir` are not the implementation's current API. diff --git a/frontend/README.md b/frontend/README.md index b5579ab5..978475b2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -35,24 +35,24 @@ See [Private legal content](legal-content.md) for paths and setup. ## Source map -| File | Purpose | -| --- | --- | -| `studioPlugin.ts` | The Studio plugin: skin, components, templates, styles, and generated previews | -| `purestack.studio/purestack.config.ts` | Adds the Studio plugin, so the CLI builds and serves the site | -| `demoStyle.ts` | Typed CSS for the real Style API workbench preview | -| `purestack.studio/index.mdx` | Landing page, source examples, and static component previews | -| `purestack.studio/header.mdx`, `footer.mdx` | Shared navigation and footer | -| `purestack.studio/studio.ts` | Keyboard enhancement for native Tabs, clipboard feedback, mobile menu, and live Regor counter | -| `components/studioComponents.ts` | Explicit registration of the Studio components | -| `components/studio*.ts` | One file per component, with its exported interface, named template, and typed definition | -| `theme/studioSkin.ts` | Dark studio skin using the standard semantic palette contract | -| `theme/studioSkinLight.ts` | Light studio palette with warm white surfaces, green accents, and ten semantic tones | -| `theme/studioSkinShared.ts` | Shared typography and corner radii for both studio modes | -| `theme/studioStyles.ts` | Registers the page's typed style extensions in PureStack's generated theme stylesheets | -| `theme/*Styles.ts` | Layout, typography, responsive rules, and component composition spacing | -| `purestack.studio/siteConfig.json` | SEO, social preview, assets, and output paths | -| `designs/social-preview.svg` | Editable source for the 1200×630 social preview PNG | -| `purestack.studio/_designs/_newspaper.mdx` | Earlier design reference | +| File | Purpose | +| ------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `studioPlugin.ts` | The Studio plugin: skin, components, templates, styles, and generated previews | +| `purestack.studio/purestack.config.ts` | Adds the Studio plugin, so the CLI builds and serves the site | +| `demoStyle.ts` | Typed CSS for the real Style API workbench preview | +| `purestack.studio/index.mdx` | Landing page, source examples, and static component previews | +| `purestack.studio/header.mdx`, `footer.mdx` | Shared navigation and footer | +| `purestack.studio/studio.ts` | Keyboard enhancement for native Tabs, clipboard feedback, mobile menu, and live Regor counter | +| `components/studioComponents.ts` | Explicit registration of the Studio components | +| `components/studio*.ts` | One file per component, with its exported interface, named template, and typed definition | +| `theme/studioSkin.ts` | Dark studio skin using the standard semantic palette contract | +| `theme/studioSkinLight.ts` | Light studio palette with warm white surfaces, green accents, and ten semantic tones | +| `theme/studioSkinShared.ts` | Shared typography and corner radii for both studio modes | +| `theme/studioStyles.ts` | Registers the page's typed style extensions in PureStack's generated theme stylesheets | +| `theme/*Styles.ts` | Layout, typography, responsive rules, and component composition spacing | +| `purestack.studio/siteConfig.json` | SEO, social preview, assets, and output paths | +| `designs/social-preview.svg` | Editable source for the 1200×630 social preview PNG | +| `purestack.studio/_designs/_newspaper.mdx` | Earlier design reference | ## Components and theme @@ -110,7 +110,6 @@ sample site. GitHub links target `PureStackStudio/PureStack` and the `main` bran The component catalog is organized into seven category folders, each with an `index.mdx` overview and a matching group in the generated navigation: - - `actions/`: buttons, grouped actions, badges, alerts and notifications. - `layout/`: layout primitives, content surfaces, icons, tabs and dialogs. - `forms/`: forms, fields, autocomplete, rich text and files. diff --git a/frontend/docs/component-documentation-plan.md b/frontend/docs/component-documentation-plan.md index ef03591a..87a032f7 100644 --- a/frontend/docs/component-documentation-plan.md +++ b/frontend/docs/component-documentation-plan.md @@ -12,18 +12,18 @@ ## Delivery sequence -| Area | Individual guides | Examples and verification | -| --- | --- | --- | -| Layout and content | Panel, Flex, Grid, SectionHeader, AlertBox, ExpandablePanel, Icon, IconFrame | Responsive composition, semantic containers, slots, disclosure keyboard behavior, meaningful icon labels | -| Landing sections | LandingBand, LandingSection, FeatureCard, MetricStrip, MetricItem, CodeShowcase, ComparisonTable, ComparisonColumn, ComparisonFeature, CtaSection | Complete sections, responsive tracks, slot composition, full-bleed geometry, valid action destinations | -| Pricing | PricingTable, PricingPlan, PricingFeature | Complete plan comparison, emphasis, feature lists and real documentation destinations | -| Forms | AppForm, FormInputField, FormSelectField, FormCheck, FormMeta, FormAssistLink, FormSubmit, FormDivider, FormStatus, ContactForm | Editable values, validation, submission without network side effects, disabled states, form serialization and status feedback | -| Advanced input | AutoCompleteInput, AutoCompleteOptionRow, MultiAutoCompleteInput, MultiAutoCompleteOptionRow, Composer, DropFiles | Keyboard selection, custom rows, loading/empty states, token creation, editing/source mode, file acceptance/removal | -| Charts | BarChart, LineChart, DoughnutChart | Reactive data, presentation controls, empty data, accessible values, series/segment contracts | -| Large data | VirtualList, VariableVirtualList, VirtualTable, VariableVirtualTable | Real scrolling, typed row components, fixed versus measured heights, table headers, empty states | -| Interaction helpers | TabPane, ModalTrigger, ToastHost | Parent composition, keyboard interactions, actual dialogs, timed and persistent notifications | -| Site integration | SiteLogo, SiteFooter, TopBar, NavMenu, NavList, PageLinks, PageToc, SearchBox, ThemeToggle, SignIn, Consent | Real site context, scoped previews where global runtime requires isolation, configuration and runtime prerequisites | -| Browser entry points | RegorApp, PageScript | Working local entry points, automatic relative source resolution, module loading and lifecycle boundaries | +| Area | Individual guides | Examples and verification | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| Layout and content | Panel, Flex, Grid, SectionHeader, AlertBox, ExpandablePanel, Icon, IconFrame | Responsive composition, semantic containers, slots, disclosure keyboard behavior, meaningful icon labels | +| Landing sections | LandingBand, LandingSection, FeatureCard, MetricStrip, MetricItem, CodeShowcase, ComparisonTable, ComparisonColumn, ComparisonFeature, CtaSection | Complete sections, responsive tracks, slot composition, full-bleed geometry, valid action destinations | +| Pricing | PricingTable, PricingPlan, PricingFeature | Complete plan comparison, emphasis, feature lists and real documentation destinations | +| Forms | AppForm, FormInputField, FormSelectField, FormCheck, FormMeta, FormAssistLink, FormSubmit, FormDivider, FormStatus, ContactForm | Editable values, validation, submission without network side effects, disabled states, form serialization and status feedback | +| Advanced input | AutoCompleteInput, AutoCompleteOptionRow, MultiAutoCompleteInput, MultiAutoCompleteOptionRow, Composer, DropFiles | Keyboard selection, custom rows, loading/empty states, token creation, editing/source mode, file acceptance/removal | +| Charts | BarChart, LineChart, DoughnutChart | Reactive data, presentation controls, empty data, accessible values, series/segment contracts | +| Large data | VirtualList, VariableVirtualList, VirtualTable, VariableVirtualTable | Real scrolling, typed row components, fixed versus measured heights, table headers, empty states | +| Interaction helpers | TabPane, ModalTrigger, ToastHost | Parent composition, keyboard interactions, actual dialogs, timed and persistent notifications | +| Site integration | SiteLogo, SiteFooter, TopBar, NavMenu, NavList, PageLinks, PageToc, SearchBox, ThemeToggle, SignIn, Consent | Real site context, scoped previews where global runtime requires isolation, configuration and runtime prerequisites | +| Browser entry points | RegorApp, PageScript | Working local entry points, automatic relative source resolution, module loading and lifecycle boundaries | The existing Btn, BtnLink, BtnGroup, BtnGroupDropDown, Badge, Tabs and Modal guides remain the reference standard. NavItem and ModalStore are internal registered implementation components; document their contracts with NavList and Modal rather than presenting them as independent public markup APIs. diff --git a/frontend/legal-content.md b/frontend/legal-content.md index 60931404..6b0e0daa 100644 --- a/frontend/legal-content.md +++ b/frontend/legal-content.md @@ -1,7 +1,6 @@ # Private legal content The public page wrappers import the complete legal text from these local files: - - `frontend/purestack.studio/legal/_private/imprint.mdx` - `frontend/purestack.studio/legal/_private/privacy.mdx` diff --git a/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx b/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx index 7dcd8ea2..053c6fee 100644 --- a/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx +++ b/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx @@ -19,6 +19,53 @@ Install `purestack` in your project with `yarn add purestack`. The commands belo ## Build a first site +Create a standalone project from an official template: + +```sh +npx purestack@latest init my-site --install --yes +cd my-site +yarn dev +``` + +The `starter` template creates a small site with a home page, about page, and +browser TypeScript example. Run `npx purestack@latest init` to open Site Studio. +The CLI serves ordinary wizard content through PureStack's existing server and +prints its local URL. Configure site details, templates, template-defined +settings, and project setup, then create the site with animated progress. +Browse the live site while Studio stays open; source edits reload automatically. +Choose **Finish Studio** to stop both local servers. Use `--yes` to create a +site directly without opening Studio. + +```sh +npx purestack@latest init my-site --template starter --git --yes +npx purestack@latest init my-site --template starter --yes +``` + +`--git` initializes a repository without creating a commit. If the project is +already inside a repository, the CLI keeps using that repository. `--install` +installs dependencies with the template's package manager (Yarn for starter). +Both actions are opt-in. `--yes` skips all prompts +and uses defaults for omitted settings; the default template is starter. + +Ctrl+C stops Studio and its live site. Direct creation with `--yes` requires a directory. + +For scripted customization, use `--set palette=mint --set search=false`. +The template's `template.ui.json` defines its categories, fields, choices, +defaults, and configuration bindings. Use `true` or `false` for toggle values. +Unspecified values use the template's declared defaults. + +Use `--title "My Documentation"` to set the site title and logo brand. Template +versions can be selected explicitly with `--template starter@1.1.19`. Full npm +package names and explicit local package-folder or `.tgz` paths are supported +too. Run `npx purestack@latest init --help` for the complete option list. + +The destination must be absent or empty. Templates become editable project +source and are not installed as project dependencies. Each project includes +`README.md` and `AGENTS.md` with customization guidance. Run `yarn build` for +development output and `yarn publish` for a local release artifact. + +### Set up a content directory manually + Create this folder beside your project's `package.json`: ```text diff --git a/package.json b/package.json index c518a076..1721bce0 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "type": "module", "sideEffects": false, "workspaces": [ - "packages/*" + "packages/*", + "templates/*" ], "dependencyConsistency": { "exceptions": { @@ -23,19 +24,19 @@ "url": "https://github.com/PureStackStudio/PureStack/issues" }, "devDependencies": { - "@biomejs/biome": "^2.5.14", - "@types/node": "^25.6.2", + "@biomejs/biome": "^2.6.2", + "@types/node": "^26.6.5", "esbuild": "^0.28.0", "tsdown": "0.23.0", "tsx": "^4.23.15", "typescript": "7.0.2", "typescript-parser": "npm:typescript@6.0.3", - "vite": "^8.3.3", + "vite": "^8.3.5", "vitest": "^5.0.3" }, "files": [], "scripts": { - "purestack": "yarn tsx packages/purestack/src/cli.ts", + "purestack": "tsx packages/purestack/src/cli.ts", "dev": "yarn tsx --watch packages/ts-ssg/src/cli.ts serve --content packages/ts-ssg/sample-content", "frontend": "yarn tsx --watch packages/purestack/src/cli.ts serve --content frontend/purestack.studio --port 4700", "frontend:build": "yarn purestack build --content frontend/purestack.studio", @@ -46,7 +47,7 @@ "gen-icons": "yarn tsx scripts/generate-icons.ts", "build": "echo 'Building...' && yarn workspaces foreach --no-private --all --verbose --topological -- run build", "lint": "yarn workspaces foreach --no-private --all --verbose --topological -- run lint", - "lint:ci": "yarn workspaces foreach --no-private --all --verbose --topological -- exec biome check ./src --formatter-enabled=false", + "lint:ci": "yarn workspaces foreach --no-private --all --verbose --topological --exclude '@purestack/template-*' -- exec biome check ./src --formatter-enabled=false", "check-eol": "yarn tsx scripts/check-eol.ts", "check-dependencies": "yarn tsx scripts/check-dependencies.ts", "bump-version": "yarn tsx --no-warnings scripts/bump-version.ts", @@ -56,7 +57,9 @@ "publish-new-packages": "yarn tsx --no-warnings scripts/publish-new-packages.ts", "test": "vitest", "test:ci": "vitest run", - "package": "yarn workspaces foreach --no-private --all --parallel pack", + "package": "yarn workspaces foreach --no-private --all --parallel --exclude '@purestack/template-*' pack && tsx scripts/pack-templates.ts", + "verify-templates": "yarn package && tsx --no-warnings scripts/verify-templates.ts", + "verify-toolchain": "tsx scripts/verify-toolchain.ts", "pack-vscode-ext": "yarn --cwd packages/ts-ssg-vscode build && yarn --cwd packages/ts-ssg-vscode package", "longest": "yarn tsx ./scripts/longest-functions.ts", "format": "biome format --write", diff --git a/packages/purestack/README.md b/packages/purestack/README.md index 7b8d316f..206089bf 100644 --- a/packages/purestack/README.md +++ b/packages/purestack/README.md @@ -10,6 +10,54 @@ a page needs it. ## Build your first site +Create a complete project from a template: + +```sh +npx purestack@latest init my-site --install --yes +cd my-site +yarn dev +``` + +The `starter` template creates a small site with a home page, about page, and +browser TypeScript example. Run `npx purestack@latest init` to serve the wizard's +PureStack content and print its local URL. Configure site details, choose a +template and its settings, and create your project with animated progress. +Use **Browse live site** to open the running project. Studio stays open, and +source edits reload the live site. **Finish Studio** stops both local servers. +Use `--yes` to create projects directly without opening Studio. + +Studio defaults to `127.0.0.1` and an automatically assigned port. Use +`init --host 127.0.0.1 --port 4700` to choose its listener. Server logging starts +at warning level; creation progress and next steps remain visible. +The wizard is built completely at startup and served with file watching and +live reload disabled. + +`--install` installs dependencies with the template's package manager (Yarn for +starter), and `--git` initializes a repository. +Both actions are opt-in. `--yes` uses defaults for omitted answers without prompts: + +```sh +npx purestack@latest init my-site --template starter --git --yes +npx purestack@latest init my-site --template starter --yes +``` + +You can use an explicit template release, such as `--template starter@1.1.19`, +a full npm package name, or a local package folder or `.tgz` path. Run +`npx purestack@latest init --help` for all options, including `--title`. +The destination must be absent or empty. Template files become your project's +source, and `AGENTS.md` explains customization and validation. + +Generated starter projects provide `yarn dev`, `yarn build`, and `yarn publish`. +The latter prepares `dist/publish` for deployment without uploading anything. + +Use `--set palette=mint --set search=false` to configure template-defined options +without opening menus. Categories, fields, choices, defaults, and configuration +bindings come from the template's `template.ui.json`. Unspecified values use its +declared defaults. Creation output uses colored Unicode frames and honors +`NO_COLOR`. The browser wizard uses PureStack's existing development server. + +### Create a site manually + Create a Node project and install PureStack: ```sh @@ -95,10 +143,10 @@ components, as the `Panel` example above does. Run these commands from the project root: -| Command | Result | -| --- | --- | -| `npx purestack serve --content ./content` | Build, serve, watch, and reload locally. Default port: `4173`. | -| `npx purestack build --content ./content` | Write the static site to `outDir` (`dist/site` in the example). | +| Command | Result | +| ------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `npx purestack serve --content ./content` | Build, serve, watch, and reload locally. Default port: `4173`. | +| `npx purestack build --content ./content` | Write the static site to `outDir` (`dist/site` in the example). | | `npx purestack publish --content ./content` | Clean and build a minified release artifact in `publishDir` (`dist/publish` in the example). | `publish` prepares files for a static host; it does not upload them. Use diff --git a/packages/purestack/package.json b/packages/purestack/package.json index a3780c2d..ea177537 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -31,6 +31,7 @@ "dist/*.mjs.map", "dist/*.d.mts", "dist/*.d.mts.map", + "wizard/**/*", "README.md", "LICENSE" ], @@ -40,6 +41,14 @@ "test": "vitest" }, "dependencies": { - "@purestack/ts-ssg": "workspace:^" + "@purestack/ts-components": "workspace:^", + "@purestack/ts-ssg": "workspace:^", + "@purestack/ts-style": "workspace:^", + "@purestack/ts-svg-icons": "workspace:^", + "regor": "^1.7.6", + "tar": "^7.5.22" + }, + "devDependencies": { + "@purestack/ts-minidom": "workspace:^" } } diff --git a/packages/purestack/src/cli.ts b/packages/purestack/src/cli.ts index 46ca161f..24d7aeb3 100644 --- a/packages/purestack/src/cli.ts +++ b/packages/purestack/src/cli.ts @@ -1,21 +1,31 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { runApplication } from './cli/application' +import { invocationDirectory } from './cli/invocation' async function main() { - const args = process.argv.slice(2) - if (args[0] === '--version' || args[0] === '-v') { - const { version } = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), - ) - console.log(version) + if (process.send && process.argv[2] === '__studio-preview') { + const { runPreviewProcess } = await import('./cli/wizard/preview-process') + await runPreviewProcess(process.argv[3], process.argv[4]) return } - - const { runCli } = await import('@purestack/ts-ssg') - await runCli(args) + process.chdir(invocationDirectory(process.cwd(), process.env)) + const { version } = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), + ) + const status = await runApplication(process.argv.slice(2), { + version, + cwd: process.cwd(), + packageRoot: fileURLToPath(new URL('..', import.meta.url)).replace( + /[\\/]$/, + '', + ), + }) + if (status) process.exitCode = status } main().catch((error) => { - console.error(error) + console.error(error instanceof Error ? error.message : error) process.exitCode = 1 }) diff --git a/packages/purestack/src/cli/application.ts b/packages/purestack/src/cli/application.ts new file mode 100644 index 00000000..e6927b90 --- /dev/null +++ b/packages/purestack/src/cli/application.ts @@ -0,0 +1,83 @@ +import path from 'node:path' +import { type TemplateEnvironment, TemplateStore } from '../init/template-store' +import { resolveTemplateValues } from '../init/template-ui' +import { + initHelp, + parseInitArguments, + parseTemplateOverrides, +} from './arguments' +import { createSiteWithProgress } from './creation' +import { runWizard } from './wizard/run' + +const help = `PureStack + + purestack init Open Site Studio + purestack init --yes Create directly with defaults + purestack serve --content Preview with live reload + purestack build --content Build static files + purestack publish --content Prepare a local release artifact + purestack --version Print the installed version + +Use purestack init --help to explore creation options.` + +export async function runApplication( + args: string[], + environment: TemplateEnvironment, +): Promise { + try { + if (['--version', '-v'].includes(args[0])) { + console.log(environment.version) + return 0 + } + if (!args.length || ['--help', '-h', 'help'].includes(args[0])) { + console.log(args[0] === 'help' && args[1] === 'init' ? initHelp : help) + return 0 + } + if (args[0] !== 'init') { + if (!['serve', 'build', 'publish'].includes(args[0])) + throw new Error(`Unknown command: ${args[0]}. Run purestack --help.`) + const { runCli } = await import('@purestack/ts-ssg') + await runCli(args) + return 0 + } + const input = parseInitArguments(args.slice(1), environment.cwd) + if (input.help) { + console.log(initHelp) + return 0 + } + if (!input.yes) return await runWizard(environment, input) + if (!input.directory) + throw new Error( + 'A directory is required with --yes. Use purestack init my-site --yes.', + ) + const store = new TemplateStore(environment) + try { + const template = await store.load(input.template) + const values = resolveTemplateValues( + template.ui, + parseTemplateOverrides(template.ui, input.sets), + ) + return await createSiteWithProgress( + { + plan: { + directory: input.directory, + template: input.template, + title: + input.title ?? + path.basename(path.resolve(environment.cwd, input.directory)), + values, + install: input.install, + git: input.git, + }, + templateRoot: template.root, + }, + environment, + ) + } finally { + await store.dispose() + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + return error instanceof Error && error.name === 'AbortError' ? 130 : 1 + } +} diff --git a/packages/purestack/src/cli/arguments.ts b/packages/purestack/src/cli/arguments.ts new file mode 100644 index 00000000..750cfcd2 --- /dev/null +++ b/packages/purestack/src/cli/arguments.ts @@ -0,0 +1,118 @@ +import { parseArgs } from 'node:util' +import { resolveTemplateSpec } from '../init/template' +import { + type OptionValue, + type TemplateUi, + templateFields, + validateField, +} from '../init/template-ui' + +export interface InitArguments { + directory?: string + template: string + title?: string + host?: string + port?: number + install: boolean + git: boolean + yes: boolean + sets: string[] + help: boolean +} +export const initHelp = `Usage: purestack init [directory] [options] + +Start the local PureStack wizard and open the URL printed in your terminal. +Configure your project, create it, and browse the live site in Studio. +Studio stays open until you finish. Use --yes for direct creation. + + -t, --template Official alias, npm package, local folder, or .tgz + --title Site title and logo brand + --set <id=value> A template-defined option; repeat for more options + --host <address> Studio bind address (default: 127.0.0.1) + --port <number> Studio port (default: automatic; 0 selects a free port) + --install Install dependencies with the template's package manager + --git Initialize or reuse a Git repository + -y, --yes Create directly without opening Site Studio + -h, --help Show help + +Examples: + purestack init + purestack init --host 127.0.0.1 --port 4700 + purestack init my-site --template starter --yes + purestack init my-site --template starter --set palette=mint --install --yes + +Installation and Git initialization are opt-in. Relative paths use your current +folder. In this checkout, unversioned official aliases use local templates.` + +export function parseInitArguments(args: string[], cwd: string): InitArguments { + const { values, positionals } = parseArgs({ + args, + allowPositionals: true, + options: { + template: { type: 'string', short: 't', default: 'starter' }, + title: { type: 'string' }, + host: { type: 'string' }, + port: { type: 'string' }, + set: { type: 'string', multiple: true, default: [] }, + install: { type: 'boolean', default: false }, + git: { type: 'boolean', default: false }, + yes: { type: 'boolean', short: 'y', default: false }, + help: { type: 'boolean', short: 'h', default: false }, + }, + }) + if (positionals.length > 1) + throw new Error('Expected a single project directory.') + if (values.title !== undefined && !values.title.trim()) + throw new Error('Site title cannot be empty.') + if (values.host !== undefined && !values.host.trim()) + throw new Error('Host cannot be empty.') + let port: number | undefined + if (values.port !== undefined) { + port = Number(values.port) + if (!/^\d+$/.test(values.port) || port > 65535) + throw new Error('Port must be an integer between 0 and 65535.') + } + resolveTemplateSpec(values.template, cwd) + return { + directory: positionals[0], + template: values.template, + title: values.title, + host: values.host?.trim(), + port, + sets: values.set, + install: values.install, + git: values.git, + yes: values.yes, + help: values.help, + } +} + +export function parseTemplateOverrides(ui: TemplateUi, inputs: string[]) { + const result: Record<string, OptionValue> = {} + const fields = templateFields(ui) + for (const input of inputs) { + const equals = input.indexOf('=') + if (equals < 1) throw new Error('Use --set option-id=value.') + const id = input.slice(0, equals) + const field = fields.find((candidate) => candidate.id === id) + if (!field) throw new Error(`Unknown template option: ${id}`) + if (Object.hasOwn(result, id)) + throw new Error(`Template option was specified twice: ${id}`) + const raw = input.slice(equals + 1) + const parsed = + field.type === 'toggle' + ? raw === 'true' + ? true + : raw === 'false' + ? false + : raw + : field.type === 'select' + ? (field.choices?.find((choice) => String(choice.value) === raw) + ?.value ?? raw) + : raw + const invalid = validateField(field, parsed) + if (invalid) throw new Error(`${field.label}: ${invalid}`) + result[id] = parsed + } + return result +} diff --git a/packages/purestack/src/cli/creation.test.ts b/packages/purestack/src/cli/creation.test.ts new file mode 100644 index 00000000..ea2657f9 --- /dev/null +++ b/packages/purestack/src/cli/creation.test.ts @@ -0,0 +1,141 @@ +import path from 'node:path' +import { stripVTControlCharacters } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CreateSiteResult } from '../init/create-site' +import { nextCommands, progressTasks } from './creation' +import { CreationView } from './terminal/creation-view' +import { width } from './terminal/layout' + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +describe('creation output', () => { + it('prints progress and failures without terminal escapes when redirected', async () => { + let text = '' + const task = progressTasks({ write: (chunk) => (text += chunk) }) + expect(await task('Create project files', async () => 42)).toBe(42) + await expect( + task('Install dependencies', async () => { + throw new Error('Installation failed') + }), + ).rejects.toThrow('Installation failed') + expect(text).toContain('→ Create project files\n') + expect(text).toContain('✓ Create project files\n') + expect(text).toContain('✗ Install dependencies\n') + expect(text).not.toContain(String.fromCharCode(27)) + }) + + it('animates slow terminal tasks and stops the animation on completion', async () => { + vi.useFakeTimers() + vi.stubEnv('TERM', 'xterm-256color') + try { + let text = '' + let finish!: () => void + const task = progressTasks({ + isTTY: true, + columns: 80, + write: (chunk) => (text += chunk), + }) + const running = task( + 'Install dependencies', + () => new Promise<void>((resolve) => (finish = resolve)), + ) + const initial = text + await vi.advanceTimersByTimeAsync(180) + expect(text.length).toBeGreaterThan(initial.length) + finish() + await running + expect(stripVTControlCharacters(text)).toContain( + '✓ Install dependencies\n', + ) + const completed = text + await vi.advanceTimersByTimeAsync(180) + expect(text).toBe(completed) + } finally { + vi.unstubAllEnvs() + } + }) + + it('uses colored frames while leaving complete commands on their own lines', () => { + vi.stubEnv('NO_COLOR', undefined) + vi.stubEnv('FORCE_COLOR', '1') + vi.stubEnv('TERM', 'xterm-256color') + const view = new CreationView({ isTTY: true, columns: 80 }) + const commands = ['cd "my site"', 'npm install', 'npm run dev'] + const text = view.complete( + { + directory: path.resolve('sites/my site'), + template: '@purestack/template-starter@1.0.0', + version: '1.1.19', + packageManager: 'npm', + installed: false, + git: 'skipped', + failures: [], + }, + commands, + ) + expect(text).toContain(`${String.fromCharCode(27)}[1;38;2;119;224;169m`) + const plain = stripVTControlCharacters(text) + expect(plain).toContain('╭──') + expect(plain).toContain('✓ YOUR PURESTACK SITE IS READY') + expect(plain).toContain('┌─ NEXT STEPS') + for (const command of commands) + expect(plain.split('\n')).toContain(` ${command}`) + }) + + it('wraps frame contents to fit narrow terminals and honors NO_COLOR', () => { + vi.stubEnv('NO_COLOR', '') + vi.stubEnv('TERM', 'xterm-256color') + const view = new CreationView({ isTTY: true, columns: 32 }) + const text = view.start( + 'A long website title with 中文 characters', + path.resolve('sites/a-very-long-directory-name'), + '1.1.19', + ) + expect(text).not.toContain(String.fromCharCode(27)) + for (const line of text.split('\n')) expect(width(line)).toBeLessThan(32) + expect(text).toContain('╰──') + const redirected = new CreationView({ isTTY: false }) + vi.stubEnv('NO_COLOR', undefined) + expect(redirected.progress('Create files', '✓', 'success')).not.toContain( + String.fromCharCode(27), + ) + }) + + it('gives commands relative to the caller and includes only necessary setup', () => { + const cwd = path.resolve('sites') + const result: CreateSiteResult = { + directory: path.join(cwd, 'my-site'), + template: 'starter@1.0.0', + version: '1.1.19', + packageManager: 'npm', + installed: false, + git: 'skipped', + failures: [], + } + expect(nextCommands(result, cwd)).toEqual([ + 'cd my-site', + 'npm install', + 'npm run dev', + ]) + expect( + nextCommands( + { ...result, directory: path.join(cwd, 'my site'), installed: true }, + cwd, + ), + ).toEqual([ + process.platform === 'win32' ? 'cd "my site"' : "cd 'my site'", + 'npm run dev', + ]) + expect(nextCommands({ ...result, git: 'failed' }, cwd)).toContain( + 'git init', + ) + expect(nextCommands({ ...result, packageManager: 'yarn' }, cwd)).toEqual([ + 'cd my-site', + 'yarn install', + 'yarn dev', + ]) + }) +}) diff --git a/packages/purestack/src/cli/creation.ts b/packages/purestack/src/cli/creation.ts new file mode 100644 index 00000000..d712b182 --- /dev/null +++ b/packages/purestack/src/cli/creation.ts @@ -0,0 +1,107 @@ +import path from 'node:path' +import { + type CreateSitePlan, + type CreateSiteResult, + createSite, +} from '../init/create-site' +import type { TaskRunner } from '../init/tasks' +import type { TemplateEnvironment } from '../init/template-store' +import { changeDirectoryCommand } from './terminal/commands' +import { type CreationOutput, CreationView } from './terminal/creation-view' +import { safeText } from './terminal/layout' + +export interface SiteCreationRequest { + plan: CreateSitePlan + templateRoot: string +} + +/** Direct creation with progress and copyable commands in terminal scrollback. */ +export async function createSiteWithProgress( + request: SiteCreationRequest, + environment: TemplateEnvironment, + output: CreationOutput = process.stdout, +) { + const abort = new AbortController() + const interrupt = () => abort.abort() + process.on('SIGINT', interrupt) + process.on('SIGTERM', interrupt) + try { + const view = new CreationView(output) + output.write( + view.start( + request.plan.title, + path.resolve(environment.cwd, request.plan.directory), + environment.version, + ), + ) + const result = await createSite( + request.plan, + environment.version, + environment.cwd, + progressTasks(output), + { templateRoot: request.templateRoot, signal: abort.signal }, + ) + output.write(view.complete(result, nextCommands(result, environment.cwd))) + return result.failures.length ? 1 : 0 + } catch (error) { + if (abort.signal.aborted) { + const cancelled = new Error( + 'Cancelled. Any project files already written have been preserved.', + ) + cancelled.name = 'AbortError' + throw cancelled + } + throw error + } finally { + process.removeListener('SIGINT', interrupt) + process.removeListener('SIGTERM', interrupt) + } +} + +export function progressTasks(output: CreationOutput): TaskRunner { + const view = new CreationView(output) + return async (label, action) => { + const frames = ['◜', '◠', '◝', '◞', '◡', '◟'] + let frame = 0 + const interactive = output.isTTY && process.env.TERM !== 'dumb' + const ansiEscape = String.fromCharCode(27) + const redraw = () => { + output.write( + `\r${ansiEscape}[2K${view.progress(label, frames[frame++ % frames.length], 'accent')}`, + ) + } + if (interactive) redraw() + else output.write(` → ${safeText(label)}\n`) + const timer = interactive ? setInterval(redraw, 90) : undefined + const finish = (symbol: string) => { + if (timer) clearInterval(timer) + output.write( + interactive + ? `\r${ansiEscape}[2K${view.progress(label, symbol, symbol === '✓' ? 'success' : 'error')}\n` + : ` ${symbol} ${safeText(label)}\n`, + ) + } + try { + const result = await action() + finish('✓') + return result + } catch (error) { + finish('✗') + throw error + } + } +} + +export function nextCommands(result: CreateSiteResult, cwd: string): string[] { + const relative = path.relative(cwd, result.directory) || '.' + const directory = relative.replaceAll(path.sep, '/') + return [ + changeDirectoryCommand( + directory, + process.platform === 'win32' ? 'windows' : 'posix', + ), + ...(!result.installed ? [`${result.packageManager} install`] : []), + ...(result.git === 'failed' ? ['git init'] : []), + result.packageManager === 'yarn' ? 'yarn dev' : 'npm run dev', + ] +} diff --git a/packages/purestack/src/cli/invocation.test.ts b/packages/purestack/src/cli/invocation.test.ts new file mode 100644 index 00000000..802bad01 --- /dev/null +++ b/packages/purestack/src/cli/invocation.test.ts @@ -0,0 +1,39 @@ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { invocationDirectory } from './invocation' + +describe('CLI invocation directory', () => { + const root = path.resolve('repository') + const sites = path.join(root, 'sites') + + it('uses the original caller directory when the purestack package script changes cwd', () => { + expect( + invocationDirectory(root, { + INIT_CWD: sites, + npm_lifecycle_event: 'purestack', + }), + ).toBe(sites) + }) + + it('keeps direct tsx and installed CLI invocations in their actual working directory', () => { + expect(invocationDirectory(sites, {})).toBe(sites) + expect( + invocationDirectory(sites, { + INIT_CWD: root, + npm_lifecycle_event: 'tsx', + }), + ).toBe(sites) + }) + + it('ignores a relative or missing launcher directory', () => { + expect( + invocationDirectory(sites, { + INIT_CWD: 'elsewhere', + npm_lifecycle_event: 'purestack', + }), + ).toBe(sites) + expect( + invocationDirectory(sites, { npm_lifecycle_event: 'purestack' }), + ).toBe(sites) + }) +}) diff --git a/packages/purestack/src/cli/invocation.ts b/packages/purestack/src/cli/invocation.ts new file mode 100644 index 00000000..ce5033d0 --- /dev/null +++ b/packages/purestack/src/cli/invocation.ts @@ -0,0 +1,10 @@ +import path from 'node:path' + +/** Package scripts run at the package root; INIT_CWD retains the caller's folder. */ +export function invocationDirectory(cwd: string, env: NodeJS.ProcessEnv) { + return env.npm_lifecycle_event === 'purestack' && + env.INIT_CWD && + path.isAbsolute(env.INIT_CWD) + ? path.resolve(env.INIT_CWD) + : cwd +} diff --git a/packages/purestack/src/cli/terminal/commands.test.ts b/packages/purestack/src/cli/terminal/commands.test.ts new file mode 100644 index 00000000..ef32ed0f --- /dev/null +++ b/packages/purestack/src/cli/terminal/commands.test.ts @@ -0,0 +1,72 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { changeDirectoryCommand } from './commands' + +let temporary: string +beforeAll(async () => { + temporary = await mkdtemp(path.join(tmpdir(), 'purestack-shell-')) +}) +afterAll(async () => { + expect(path.dirname(path.resolve(temporary))).toBe(path.resolve(tmpdir())) + expect(path.basename(temporary)).toMatch(/^purestack-shell-/) + await rm(temporary, { recursive: true, force: true }) +}) + +describe('copyable directory commands', () => { + it('uses cmd drive switching and POSIX literal quoting', () => { + expect(changeDirectoryCommand('my-site', 'windows')).toBe('cd my-site') + expect(changeDirectoryCommand('my$site', 'windows')).toBe('cd "my$site"') + expect(changeDirectoryCommand('D:/sites/my-site', 'windows')).toBe( + 'cd /d "D:/sites/my-site"', + ) + expect(changeDirectoryCommand('my$site', 'posix')).toBe("cd 'my$site'") + expect(changeDirectoryCommand('my`site', 'posix')).toBe("cd 'my`site'") + expect(changeDirectoryCommand('-site', 'posix')).toBe('cd ./-site') + }) + + it.each([ + 'my$site', + 'my`site', + "team's site", + 'space & (more)^', + 'my%PURESTACK_QUOTE_TEST%site', + 'my!site', + '-site', + ])( + 'changes to the literal directory %s in the platform shell', + async (name) => { + const target = path.join(temporary, name) + await mkdir(target) + const windows = process.platform === 'win32' + const command = changeDirectoryCommand( + name, + windows ? 'windows' : 'posix', + ) + const result = windows + ? spawnSync('cmd.exe', ['/d', '/v:off', '/c', `${command} && cd`], { + cwd: temporary, + encoding: 'utf8', + windowsVerbatimArguments: true, + env: { + ...process.env, + PURESTACK_QUOTE_TEST: 'unexpected expansion', + }, + }) + : spawnSync('/bin/sh', ['-c', `${command} && pwd -P`], { + cwd: temporary, + encoding: 'utf8', + env: { ...process.env, site: 'unexpected expansion' }, + }) + expect(result.error).toBeUndefined() + expect(result.stderr).toBe('') + expect(result.status).toBe(0) + const expected = await realpath(target) + expect( + windows ? result.stdout.trim().toLowerCase() : result.stdout.trim(), + ).toBe(windows ? expected.toLowerCase() : expected) + }, + ) +}) diff --git a/packages/purestack/src/cli/terminal/commands.ts b/packages/purestack/src/cli/terminal/commands.ts new file mode 100644 index 00000000..d31de660 --- /dev/null +++ b/packages/purestack/src/cli/terminal/commands.ts @@ -0,0 +1,18 @@ +export type Shell = 'windows' | 'posix' + +/** Quote paths for a Windows prompt (cmd quoting rules) or a POSIX shell. */ +export function changeDirectoryCommand(directory: string, shell: Shell) { + const target = + shell === 'posix' && directory.startsWith('-') + ? `./${directory}` + : directory + const quoted = /^[\w./-]+$/.test(target) + ? target + : shell === 'windows' + ? // cmd expands percent variables inside quotes; escape each percent outside them. + `"${target.replaceAll('%', '"^%"')}"` + : `'${target.replaceAll("'", "'\\''")}'` + // Only cmd needs /d, and only across drives; PowerShell rejects it. + const switchDrive = shell === 'windows' && /^[a-z]:/i.test(target) + return `${switchDrive ? 'cd /d' : 'cd'} ${quoted}` +} diff --git a/packages/purestack/src/cli/terminal/creation-view.ts b/packages/purestack/src/cli/terminal/creation-view.ts new file mode 100644 index 00000000..07e9fae7 --- /dev/null +++ b/packages/purestack/src/cli/terminal/creation-view.ts @@ -0,0 +1,136 @@ +import type { CreateSiteResult } from '../../init/create-site' +import { clip, pad, safeText, wrap } from './layout' + +export interface CreationOutput { + write(text: string): unknown + isTTY?: boolean + columns?: number +} + +type Tone = 'accent' | 'success' | 'warning' | 'error' | 'muted' | 'command' +const colors: Record<Tone, string> = { + accent: '1;38;2;111;214;228', + success: '1;38;2;119;224;169', + warning: '1;38;2;245;200;112', + error: '1;38;2;255;135;150', + muted: '38;2;133;155;180', + command: '1;38;2;193;166;255', +} +const ansiEscape = String.fromCharCode(27) + +/** Scrollback output shares the Studio palette, without framing shell commands. */ +export class CreationView { + private readonly columns: number + private readonly color: boolean + + constructor(output: Pick<CreationOutput, 'columns' | 'isTTY'>) { + this.columns = Math.max(6, Math.min(88, (output.columns ?? 80) - 1)) + this.color = Boolean( + output.isTTY && + process.env.NO_COLOR === undefined && + process.env.FORCE_COLOR !== '0' && + process.env.TERM !== 'dumb', + ) + } + + private paint(text: string, tone: Tone) { + return this.color + ? `${ansiEscape}[${colors[tone]}m${text}${ansiEscape}[0m` + : text + } + + private frame(heading: string, lines: string[], tone: Tone) { + const inner = this.columns - 4 + const edge = (text: string) => this.paint(text, 'muted') + const row = (text: string, headingRow = false) => + edge('│ ') + + (headingRow ? this.paint(pad(text, inner), tone) : pad(text, inner)) + + edge(' │') + return [ + edge(`╭${'─'.repeat(this.columns - 2)}╮`), + ...wrap(heading, inner).map((text) => row(text, true)), + edge(`├${'─'.repeat(this.columns - 2)}┤`), + ...lines.flatMap((line) => + (line ? wrap(line, inner) : ['']).map((text) => row(text)), + ), + edge(`╰${'─'.repeat(this.columns - 2)}╯`), + ].join('\n') + } + + start(title: string, directory: string, version: string) { + return `\n${this.frame( + '◈ PURESTACK · CREATE SITE', + [title, '', `Directory ${directory}`, `PureStack ${version}`], + 'accent', + )}\n\n` + } + + wizard(url: string, cwd: string) { + return [ + '', + this.frame( + '◈ PURESTACK STUDIO WIZARD', + [ + 'Open the link below to configure your new site.', + `Working folder ${cwd}`, + '', + 'Configure your project in the browser.', + 'Press Ctrl+C here to stop the wizard.', + ], + 'accent', + ), + '', + this.paint(` ${url}`, 'accent'), + '', + ].join('\n') + } + + progress(label: string, symbol: string, tone: Tone) { + return this.paint( + clip(` ${symbol} ${safeText(label)}`, this.columns), + tone, + ) + } + + complete(result: CreateSiteResult, commands: string[]) { + const tone = result.failures.length ? 'warning' : 'success' + const summary = this.frame( + result.failures.length + ? '◇ SITE CREATED · SETUP NEEDS ATTENTION' + : '✓ YOUR PURESTACK SITE IS READY', + [ + `Directory ${result.directory}`, + `Template ${result.template}`, + `PureStack ${result.version}`, + ], + tone, + ) + const warnings = result.failures.length + ? `\n\n${this.frame( + '◇ FINISH SETUP', + result.failures.flatMap((failure) => failure.split('\n')), + 'warning', + )}` + : '' + const label = clip(' ┌─ NEXT STEPS ', this.columns - 1) + const section = this.paint( + `${label}${'─'.repeat(Math.max(0, this.columns - label.length - 1))}┐`, + 'accent', + ) + return [ + `\n${summary}${warnings}\n`, + section, + this.paint(' Copy and run these commands in order:', 'muted'), + '', + ...commands.map((command) => this.paint(` ${command}`, 'command')), + '', + this.paint(` └${'─'.repeat(Math.max(0, this.columns - 4))}┘`, 'accent'), + '', + this.paint( + ' ↳ README.md and AGENTS.md guide your next changes.', + 'muted', + ), + '', + ].join('\n') + } +} diff --git a/packages/purestack/src/cli/terminal/layout.test.ts b/packages/purestack/src/cli/terminal/layout.test.ts new file mode 100644 index 00000000..f0d73e6f --- /dev/null +++ b/packages/purestack/src/cli/terminal/layout.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { clip, graphemes, pad, safeText, width } from './layout' + +describe('terminal text layout', () => { + it('measures display cells and edits whole graphemes', () => { + expect(width('中文')).toBe(4) + expect(width('e\u0301')).toBe(1) + expect(graphemes('👩🏽‍💻')).toHaveLength(1) + expect(width(pad('中文', 8))).toBe(8) + expect(width(clip('中文abc', 5))).toBeLessThanOrEqual(5) + }) + it('removes terminal control characters from untrusted labels', () => { + const esc = String.fromCharCode(27) + expect( + safeText(`${esc}[31mLabel\nOther${String.fromCharCode(7)}`), + ).not.toContain(esc) + expect(safeText('a\nb')).toBe('a b') + }) +}) diff --git a/packages/purestack/src/cli/terminal/layout.ts b/packages/purestack/src/cli/terminal/layout.ts new file mode 100644 index 00000000..bb6380d5 --- /dev/null +++ b/packages/purestack/src/cli/terminal/layout.ts @@ -0,0 +1,79 @@ +const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) +export function graphemes(value: string) { + return Array.from(segmenter.segment(value), (part) => part.segment) +} +export function safeText(value: string) { + return Array.from(value.replace(/[\r\n\t]/g, ' ')) + .filter((character) => { + const code = character.codePointAt(0) as number + return ( + code >= 32 && + !(code >= 127 && code <= 159) && + !(code >= 0x202a && code <= 0x202e) && + !(code >= 0x2066 && code <= 0x2069) + ) + }) + .join('') +} +function cellWidth(character: string) { + const code = character.codePointAt(0) as number + if (/^\p{Mark}+$/u.test(character)) return 0 + return code >= 0x1100 && + (code <= 0x115f || + (code >= 0x2e80 && code <= 0xa4cf) || + (code >= 0xac00 && code <= 0xd7a3) || + (code >= 0xf900 && code <= 0xfaff) || + (code >= 0xfe10 && code <= 0xfe6f) || + (code >= 0xff01 && code <= 0xff60) || + (code >= 0x1f300 && code <= 0x1faff) || + code >= 0x20000) + ? 2 + : 1 +} +export function width(value: string) { + return graphemes(safeText(value)).reduce( + (sum, character) => sum + cellWidth(character), + 0, + ) +} +export function clip(value: string, columns: number) { + if (columns <= 0) return '' + const clean = safeText(value) + if (width(clean) <= columns) return clean + let result = '' + let used = 0 + for (const character of graphemes(clean)) { + if (used + cellWidth(character) > columns - 1) break + result += character + used += cellWidth(character) + } + return `${result}…` +} +export function pad(value: string, columns: number) { + const result = clip(value, columns) + return result + ' '.repeat(Math.max(0, columns - width(result))) +} +export function wrap(value: string, columns: number): string[] { + const words = safeText(value).split(/\s+/) + const lines: string[] = [] + let line = '' + for (const word of words) { + if (width(`${line} ${word}`.trim()) > columns && line) { + lines.push(line) + line = '' + } + if (width(word) > columns) { + let fragment = '' + for (const character of graphemes(word)) { + if (width(fragment + character) > columns) { + lines.push(fragment) + fragment = '' + } + fragment += character + } + line = fragment + } else line = line ? `${line} ${word}` : word + } + if (line) lines.push(line) + return lines +} diff --git a/packages/purestack/src/cli/wizard/commands.ts b/packages/purestack/src/cli/wizard/commands.ts new file mode 100644 index 00000000..5bbeb072 --- /dev/null +++ b/packages/purestack/src/cli/wizard/commands.ts @@ -0,0 +1,51 @@ +import type { + StudioCommand, + StudioConfiguration, +} from '../../../wizard/modules/shared/protocol' + +function object(value: unknown): asserts value is Record<string, unknown> { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Expected a Studio message object.') +} +function text(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !value.trim() || + value.length > 2000 || + [...value].some((character) => character.charCodeAt(0) < 32) + ) + throw new Error('Enter a valid title, folder, or template source.') +} + +export function parseStudioCommand(message: string): StudioCommand { + const input: unknown = JSON.parse(message) + object(input) + if (input.type === 'finish' || input.type === 'preview') + return { type: input.type } + if (input.type === 'template') { + text(input.source) + return { type: 'template', source: input.source } + } + if (input.type !== 'create') throw new Error('Unknown Studio command.') + const config = input.configuration + object(config) + text(config.title) + text(config.directory) + text(config.template) + if (typeof config.install !== 'boolean' || typeof config.git !== 'boolean') + throw new Error('Invalid installation or Git choice.') + object(config.values) + if ( + Object.keys(config.values).length > 250 || + Object.values(config.values).some( + (value) => + typeof value !== 'boolean' && + (typeof value !== 'string' || value.length > 2000), + ) + ) + throw new Error('Invalid template options.') + return { + type: 'create', + configuration: config as unknown as StudioConfiguration, + } +} diff --git a/packages/purestack/src/cli/wizard/dashboard.test.ts b/packages/purestack/src/cli/wizard/dashboard.test.ts new file mode 100644 index 00000000..fc9d1634 --- /dev/null +++ b/packages/purestack/src/cli/wizard/dashboard.test.ts @@ -0,0 +1,175 @@ +import { runInDom } from '@purestack/ts-minidom' +import { createApp, html } from 'regor' +import { describe, expect, it } from 'vitest' +import { defineWizardComponents } from '../../../wizard/modules/components' +import { + defineWizardDashboardComponent, + type WizardDashboard, +} from '../../../wizard/modules/components/dashboard' + +function click(document: Document, label: string) { + const button = Array.from(document.querySelectorAll('button')).find((entry) => + entry.textContent?.includes(label), + ) + expect(button, `Missing button: ${label}`).toBeTruthy() + button?.dispatchEvent(new Event('click', { bubbles: true })) +} + +function edit(document: Document, id: string, value: string) { + const input = document.querySelector(`#${id}`) as HTMLInputElement + input.value = value + input.dispatchEvent(new Event('input', { bubbles: true })) +} + +describe('PureStack wizard Regor dashboard', () => { + it('switches to creation and keeps progress, connection state, and form values reactive', () => + runInDom('<div id="wizard-app"></div>', (document) => { + const dashboard = defineWizardDashboardComponent() + const context = dashboard.context + const contexts: WizardDashboard[] = [] + dashboard.context = (head) => { + const instance = context(head) + contexts.push(instance) + return instance + } + const app = createApp( + { + components: { + ...defineWizardComponents(), + wizardDashboard: dashboard, + }, + }, + { selector: '#wizard-app', template: html`<WizardDashboard/>` }, + ) + try { + const { model } = contexts[0] + edit(document, 'wizard-title', 'Preserved title') + model.status('creating') + model.tasks([{ label: 'Creating files', state: 'running' }]) + expect(document.querySelector('#wizard-title')).toBeNull() + expect(document.querySelector('.wizard-panel')?.textContent).toContain( + 'Creating files', + ) + expect( + Array.from( + document.querySelectorAll('.wizard-navigation button'), + ).every((button) => button.hasAttribute('disabled')), + ).toBe(true) + model.tasks([{ label: 'Creating files', state: 'done' }]) + model.status('failed') + expect(document.querySelector('#wizard-title')).toBeTruthy() + expect(model.title()).toBe('Preserved title') + model.result({ + directory: '/sites/my-site', + template: '@purestack/template-starter@1.1.19', + version: '1.1.19', + packageManager: 'yarn', + installed: false, + git: 'skipped', + failures: [], + commands: ['yarn dev'], + }) + model.status('ready') + const finish = () => + Array.from(document.querySelectorAll('button')).find((button) => + button.textContent?.includes('Finish Studio'), + ) + expect(finish()).toBeTruthy() + expect(finish()?.hasAttribute('disabled')).toBe(true) + model.connection('connected') + expect(finish()?.hasAttribute('disabled')).toBe(false) + model.connection('reconnecting') + expect(finish()?.hasAttribute('disabled')).toBe(true) + } finally { + app.unbind() + } + })) + + it('keeps native form values and selections through navigation and review', () => + runInDom('<div id="wizard-app"></div>', (document) => { + const app = createApp( + { components: defineWizardComponents() }, + { selector: '#wizard-app', template: html`<WizardDashboard/>` }, + ) + try { + edit(document, 'wizard-title', 'My Documentation') + edit(document, 'wizard-directory', 'my-docs') + click(document, 'Choose a template') + expect(document.querySelectorAll('.wizard-template-card')).toHaveLength( + 1, + ) + document + .querySelectorAll('.wizard-template-card button')[0] + .dispatchEvent(new Event('click', { bubbles: true })) + expect( + document + .querySelectorAll('.wizard-template-card button')[0] + .getAttribute('aria-pressed'), + ).toBe('true') + click(document, 'Template options →') + expect( + document.querySelector('[aria-current="step"]')?.textContent, + ).toContain('Template options') + click(document, 'Project setup →') + for (const id of ['wizard-install', 'wizard-git']) { + const checkbox = document.querySelector(`#${id}`) as HTMLInputElement + checkbox.checked = true + checkbox.dispatchEvent(new Event('change', { bubbles: true })) + } + click(document, 'Review configuration') + const summary = document.querySelector('.wizard-review')?.textContent + expect(summary).toContain('My Documentation') + expect(summary).toContain('my-docs') + expect( + document.querySelectorAll('.wizard-review dd')[2].textContent, + ).toBe('Starter') + expect(summary).toContain('Install dependencies') + expect(summary).toContain('Initialize or reuse') + click(document, 'Back') + for (const id of ['wizard-install', 'wizard-git']) { + expect( + (document.querySelector(`#${id}`) as HTMLInputElement).checked, + ).toBe(true) + } + click(document, 'Back') + click(document, 'Back') + expect( + document + .querySelectorAll('.wizard-template-card button')[0] + .getAttribute('aria-pressed'), + ).toBe('true') + click(document, 'Back') + expect( + (document.querySelector('#wizard-title') as HTMLInputElement).value, + ).toBe('My Documentation') + expect( + (document.querySelector('#wizard-directory') as HTMLInputElement) + .value, + ).toBe('my-docs') + } finally { + app.unbind() + } + })) + + it.each(['wizard-title', 'wizard-directory'])( + 'keeps review closed when %s is empty', + (id) => + runInDom('<div id="wizard-app"></div>', (document) => { + const app = createApp( + { components: defineWizardComponents() }, + { selector: '#wizard-app', template: html`<WizardDashboard/>` }, + ) + try { + edit(document, id, '') + click(document, 'Review') + expect(document.querySelector('.wizard-review')).toBeNull() + expect( + document.querySelector('.wizard-panel [role="status"]') + ?.textContent, + ).toContain('Enter a site title and project folder') + } finally { + app.unbind() + } + }), + ) +}) diff --git a/packages/purestack/src/cli/wizard/model.test.ts b/packages/purestack/src/cli/wizard/model.test.ts new file mode 100644 index 00000000..caecef0c --- /dev/null +++ b/packages/purestack/src/cli/wizard/model.test.ts @@ -0,0 +1,209 @@ +import { createDom } from '@purestack/ts-minidom' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StudioConnectionState } from '../../../wizard/modules/connection' +import { WizardModel } from '../../../wizard/modules/model' +import type { + StudioEvent, + StudioSnapshot, +} from '../../../wizard/modules/shared/protocol' + +const transport = vi.hoisted(() => ({ + receive: (_event: StudioEvent) => {}, + state: (_state: StudioConnectionState) => {}, + send: vi.fn(() => true), +})) + +vi.mock('../../../wizard/modules/connection', () => ({ + StudioConnection: class { + constructor( + receive: (event: StudioEvent) => void, + state: (state: StudioConnectionState) => void, + ) { + transport.receive = receive + transport.state = state + } + connect = vi.fn() + send = transport.send + close = vi.fn() + }, +})) + +function snapshot(source = 'starter'): StudioSnapshot { + return { + cwd: '/sites', + version: '1.1.19', + configuration: { + title: 'My site', + directory: 'my-site', + template: source, + install: false, + git: false, + values: { heading: 'Original heading' }, + }, + templates: [ + { + id: source, + title: source === 'starter' ? 'Starter' : 'Custom', + description: '', + marker: 'S', + }, + ], + template: { + source, + name: '@purestack/template-starter', + version: '1.1.19', + description: '', + ui: { + version: 1, + categories: [ + { + id: 'content', + label: 'Content', + description: '', + categories: [], + fields: [ + { + id: 'heading', + label: 'Heading', + description: '', + type: 'text', + default: 'Default heading', + bindings: [], + }, + ], + }, + ], + }, + }, + status: 'idle', + tasks: [], + } +} + +let restoreDom = () => {} +beforeEach(() => { + restoreDom = createDom('') + transport.send.mockReset().mockReturnValue(true) + vi.stubGlobal('location', new URL('http://localhost/#token=test')) +}) + +afterEach(() => { + vi.unstubAllGlobals() + restoreDom() +}) + +function connectedModel() { + const model = new WizardModel() + model.connect() + transport.state('connected') + transport.receive({ type: 'snapshot', snapshot: snapshot() }) + return model +} + +function receive(source: string, loadingTemplate?: string) { + transport.receive({ + type: 'snapshot', + snapshot: { ...snapshot(source), loadingTemplate }, + }) +} + +describe('Studio template selection', () => { + it('keeps the confirmed template and edited settings usable after a failed load', () => { + const model = connectedModel() + model.categories()[0].fields[0].value('Edited heading') + model.chooseTemplate('@missing/template') + expect(model.templateLoading()).toBe(true) + expect(model.canCreate()).toBe(false) + expect(model.template()).toBe('starter') + transport.receive({ type: 'error', message: 'Template package not found.' }) + expect(model.error()).toBe('Template package not found.') + expect(model.templateLoading()).toBe(false) + expect(model.template()).toBe('starter') + expect( + model.summary().find((entry) => entry.label === 'Template')?.value, + ).toBe('Starter') + expect(model.canCreate()).toBe(true) + model.create() + expect(transport.send).toHaveBeenLastCalledWith({ + type: 'create', + configuration: { + ...snapshot().configuration, + values: { heading: 'Edited heading' }, + }, + }) + }) + + it('changes the active template only when its snapshot is confirmed', () => { + const model = connectedModel() + model.chooseTemplate('@company/template') + expect(model.template()).toBe('starter') + expect(transport.send).toHaveBeenLastCalledWith({ + type: 'template', + source: '@company/template', + }) + receive('starter', '@company/template') + expect(model.templateLoading()).toBe(true) + expect(model.template()).toBe('starter') + receive('@company/template') + expect(model.template()).toBe('@company/template') + expect(model.templateLoading()).toBe(false) + expect( + model.summary().find((entry) => entry.label === 'Template')?.value, + ).toBe('Custom') + model.create() + expect(transport.send).toHaveBeenLastCalledWith({ + type: 'create', + configuration: snapshot('@company/template').configuration, + }) + }) + + it('preserves the confirmed template when a selection cannot be sent', () => { + const model = connectedModel() + transport.send.mockReturnValueOnce(false) + model.chooseTemplate('@company/template') + expect(model.templateLoading()).toBe(false) + expect(model.template()).toBe('starter') + expect(model.error()).toContain('reconnecting') + }) + + it('keeps loading and edited settings through unrelated snapshots', () => { + const model = connectedModel() + model.categories()[0].fields[0].value('Edited heading') + model.chooseTemplate('@company/template') + // Sent before the server received the request, e.g. by another tab. + receive('starter') + expect(model.templateLoading()).toBe(true) + expect(model.canCreate()).toBe(false) + receive('starter', '@company/template') + receive('starter', '@company/template') + expect(model.templateLoading()).toBe(true) + expect(model.categories()[0].fields[0].value()).toBe('Edited heading') + receive('@company/template') + expect(model.templateLoading()).toBe(false) + expect(model.template()).toBe('@company/template') + expect(model.categories()[0].fields[0].value()).toBe('Original heading') + }) + + it('follows the server when a newer selection replaces the request', () => { + const model = connectedModel() + model.chooseTemplate('@company/template') + receive('starter', '@company/template') + receive('starter', '@other/template') + expect(model.templateLoading()).toBe(true) + receive('@other/template') + expect(model.templateLoading()).toBe(false) + expect(model.template()).toBe('@other/template') + expect(model.canCreate()).toBe(true) + }) + + it('stops waiting for an unacknowledged request when the connection drops', () => { + const model = connectedModel() + model.chooseTemplate('@company/template') + transport.state('reconnecting') + transport.state('connected') + receive('starter') + expect(model.templateLoading()).toBe(false) + expect(model.template()).toBe('starter') + expect(model.canCreate()).toBe(true) + }) +}) diff --git a/packages/purestack/src/cli/wizard/options.test.ts b/packages/purestack/src/cli/wizard/options.test.ts new file mode 100644 index 00000000..fb94a740 --- /dev/null +++ b/packages/purestack/src/cli/wizard/options.test.ts @@ -0,0 +1,46 @@ +import { runInDom } from '@purestack/ts-minidom' +import { createApp, html, sref } from 'regor' +import { describe, expect, it } from 'vitest' +import { defineWizardComponents } from '../../../wizard/modules/components' +import type { WizardOption } from '../../../wizard/modules/model' +import type { OptionValue } from '../../../wizard/modules/shared/protocol' + +describe('template option controls', () => { + it('renders select choices and writes the selected value back to the shared option', () => + runInDom('<div id="wizard-app"></div>', (document) => { + const entry: WizardOption = { + field: { + id: 'palette', + label: 'Palette', + description: 'Colors', + type: 'select', + default: 'blue', + choices: [ + { label: 'Ocean', value: 'blue' }, + { label: 'Ember', value: 'orange' }, + ], + bindings: [], + }, + value: sref<OptionValue>('blue'), + } + const app = createApp( + { components: defineWizardComponents(), entry }, + { + selector: '#wizard-app', + template: html`<WizardOptionField :option="entry"/>`, + }, + ) + try { + const choices = document.querySelectorAll('option') + expect( + Array.from(choices).map((choice) => choice.textContent?.trim()), + ).toEqual(['Ocean', 'Ember']) + const select = document.querySelector('select') as HTMLSelectElement + select.selectedIndex = 1 + select.dispatchEvent(new Event('change', { bubbles: true })) + expect(entry.value()).toBe('orange') + } finally { + app.unbind() + } + })) +}) diff --git a/packages/purestack/src/cli/wizard/preview-config.test.ts b/packages/purestack/src/cli/wizard/preview-config.test.ts new file mode 100644 index 00000000..ae34f8e9 --- /dev/null +++ b/packages/purestack/src/cli/wizard/preview-config.test.ts @@ -0,0 +1,83 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readPreviewConfig } from './preview-config' + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(path.join(tmpdir(), 'purestack-preview-config-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function site(dev: string, content = 'content') { + await mkdir(path.join(directory, content), { recursive: true }) + await writeFile(path.join(directory, content, 'siteConfig.json'), '{}') + await writeFile( + path.join(directory, content, 'purestack.config.ts'), + 'export default {}', + ) + await writeFile( + path.join(directory, 'package.json'), + JSON.stringify({ scripts: { dev } }), + ) + return path.join(directory, 'studio-output') +} + +describe('Studio live site configuration', () => { + it('uses the dev script port and content instead of an automatic preview port', async () => { + const outDir = await site('purestack serve --content ./content --port 5000') + const config = await readPreviewConfig(directory, outDir) + expect(config.port).toBe(5000) + expect(config.configFile).toBe( + path.join(directory, 'content/purestack.config.ts'), + ) + expect(config.build?.siteConfig).toMatchObject({ + rootDir: directory, + contentDir: path.join(directory, 'content'), + outDir, + }) + expect(config.handleSignals).toBe(false) + expect(config.failOnInitialBuildError).toBe(true) + }) + + it('honors quoted content paths, host, equals syntax, and serve flags', async () => { + const outDir = await site( + 'purestack serve --content="./my content" --host=127.0.0.1 --port=5100 --no-watch --no-reload --full-render', + 'my content', + ) + expect(await readPreviewConfig(directory, outDir)).toMatchObject({ + host: '127.0.0.1', + port: 5100, + watch: false, + liveReload: false, + fullRender: true, + build: { siteConfig: { contentDir: path.join(directory, 'my content') } }, + }) + }) + + it('keeps an explicitly requested automatic port', async () => { + const outDir = await site('purestack serve --content ./content --port 0') + expect((await readPreviewConfig(directory, outDir)).port).toBe(0) + }) + + it('leaves omitted ports to the normal PureStack server default', async () => { + const outDir = await site('purestack serve --content ./content') + expect((await readPreviewConfig(directory, outDir)).port).toBeUndefined() + }) + + it.each([ + 'another-server --port 5000', + 'purestack build --content ./content', + 'purestack serve --content ./content && echo done', + 'purestack serve --content "./content', + 'purestack serve --content ./content --unknown', + ])('reports unsupported dev commands: %s', async (command) => { + const outDir = await site(command) + await expect(readPreviewConfig(directory, outDir)).rejects.toThrow() + }) +}) diff --git a/packages/purestack/src/cli/wizard/preview-config.ts b/packages/purestack/src/cli/wizard/preview-config.ts new file mode 100644 index 00000000..8393cc7b --- /dev/null +++ b/packages/purestack/src/cli/wizard/preview-config.ts @@ -0,0 +1,79 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { type DevServerInput, parseCliArgs } from '@purestack/ts-ssg' + +/** Reuse the site's dev command while Studio owns output and process lifetime. */ +export async function readPreviewConfig( + directory: string, + outDir: string, +): Promise<DevServerInput> { + const manifest: unknown = JSON.parse( + await readFile(path.join(directory, 'package.json'), 'utf8'), + ) + if ( + !manifest || + typeof manifest !== 'object' || + !('scripts' in manifest) || + !manifest.scripts || + typeof manifest.scripts !== 'object' || + !('dev' in manifest.scripts) || + typeof manifest.scripts.dev !== 'string' + ) + throw new Error( + 'The generated project must provide a dev script for live preview.', + ) + + const [executable, ...args] = splitDevCommand(manifest.scripts.dev) + if (executable !== 'purestack' || args[0] !== 'serve') + throw new Error( + 'Live preview requires a dev script starting with purestack serve.', + ) + const cli = parseCliArgs(args, directory) + return { + ...cli.input, + logLevel: 'WARN', + handleSignals: false, + failOnInitialBuildError: true, + configFile: cli.configFile, + build: { + ...cli.input.build, + siteConfig: { + ...cli.input.build?.siteConfig, + rootDir: directory, + outDir, + }, + options: { ...cli.input.build?.options, cache: false }, + }, + } +} + +/** Read literal arguments only; do not execute shell expressions from scripts. */ +function splitDevCommand(command: string): string[] { + const args: string[] = [] + let value = '' + let quote = '' + let started = false + for (const character of command) { + if (quote) { + if (character === quote) quote = '' + else value += character + } else if (character === '"' || character === "'") { + quote = character + started = true + } else if (/\s/.test(character)) { + if (started) args.push(value) + value = '' + started = false + } else { + if (/[&|;<>`$]/.test(character)) + throw new Error( + 'Live preview requires a literal purestack serve command without shell expressions.', + ) + value += character + started = true + } + } + if (quote) throw new Error('The dev script contains an unclosed quote.') + if (started) args.push(value) + return args +} diff --git a/packages/purestack/src/cli/wizard/preview-process.ts b/packages/purestack/src/cli/wizard/preview-process.ts new file mode 100644 index 00000000..45521b1e --- /dev/null +++ b/packages/purestack/src/cli/wizard/preview-process.ts @@ -0,0 +1,40 @@ +import { type DevServerHandle, startDevServer } from '@purestack/ts-ssg' +import { readPreviewConfig } from './preview-config' + +/** Private IPC worker, launched only by Studio through this package's own CLI. */ +export async function runPreviewProcess(directory: string, outDir: string) { + let server: DevServerHandle | undefined + const done = Promise.withResolvers<void>() + const stop = () => done.resolve() + const message = (input: unknown) => { + if ( + input && + typeof input === 'object' && + 'type' in input && + input.type === 'stop' + ) + stop() + } + process.on('message', message) + process.once('disconnect', stop) + process.once('SIGTERM', stop) + process.once('SIGINT', stop) + try { + server = await startDevServer(await readPreviewConfig(directory, outDir)) + const response = await fetch(server.url, { + signal: AbortSignal.timeout(30000), + }) + await response.text() + if (!response.ok) throw new Error(`Site returned HTTP ${response.status}.`) + if (process.connected) process.send?.({ type: 'ready', url: server.url }) + else stop() + await done.promise + } finally { + process.off('message', message) + process.off('disconnect', stop) + process.off('SIGTERM', stop) + process.off('SIGINT', stop) + await server?.close() + if (process.connected) process.disconnect?.() + } +} diff --git a/packages/purestack/src/cli/wizard/preview.ts b/packages/purestack/src/cli/wizard/preview.ts new file mode 100644 index 00000000..759c399d --- /dev/null +++ b/packages/purestack/src/cli/wizard/preview.ts @@ -0,0 +1,98 @@ +import { fork } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export interface SitePreview { + url: string + close(): Promise<void> +} + +/** Use the same CLI in a separate process: site theme/build registries stay isolated. */ +export function startSitePreview( + directory: string, + outDir: string, + packageRoot: string, + signal: AbortSignal, + stopped: () => void, +): Promise<SitePreview> { + signal.throwIfAborted() + const moduleFile = fileURLToPath(import.meta.url) + const cli = moduleFile.endsWith('.ts') + ? path.join(packageRoot, 'src/cli.ts') + : moduleFile + const environment = { ...process.env } + delete environment.INIT_CWD + delete environment.npm_lifecycle_event + const child = fork(cli, ['__studio-preview', directory, outDir], { + cwd: directory, + env: environment, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }) + let diagnostics = '' + let closing = false + let ready = false + const exited = Promise.withResolvers<void>() + const capture = (chunk: Buffer) => { + diagnostics = (diagnostics + chunk.toString()).slice(-8000) + } + child.stdout?.on('data', capture) + child.stderr?.on('data', capture) + const close = async () => { + if (closing) return exited.promise + closing = true + if (child.connected) child.send({ type: 'stop' }, () => {}) + const timer = setTimeout(() => child.kill(), 2000) + timer.unref() + await exited.promise + clearTimeout(timer) + } + const abort = () => { + void close() + } + signal.addEventListener('abort', abort, { once: true }) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject( + new Error( + 'Live site startup timed out. Check its configuration and dependencies.', + ), + ) + void close() + }, 45000) + child.once('error', (error) => { + clearTimeout(timer) + reject(error) + exited.resolve() + }) + child.once('exit', (code) => { + clearTimeout(timer) + signal.removeEventListener('abort', abort) + exited.resolve() + if (!ready) + reject( + new Error(`Could not start the live site (${code}). ${diagnostics}`), + ) + else if (!closing) stopped() + }) + child.on('message', (message) => { + if ( + !message || + typeof message !== 'object' || + !('type' in message) || + message.type !== 'ready' || + !('url' in message) || + typeof message.url !== 'string' || + ready + ) + return + clearTimeout(timer) + ready = true + if (signal.aborted) { + void close() + reject(signal.reason) + return + } + resolve({ url: message.url, close }) + }) + }) +} diff --git a/packages/purestack/src/cli/wizard/run.ts b/packages/purestack/src/cli/wizard/run.ts new file mode 100644 index 00000000..f66a1a6a --- /dev/null +++ b/packages/purestack/src/cli/wizard/run.ts @@ -0,0 +1,112 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { type DevServerHandle, startDevServer } from '@purestack/ts-ssg' +import { STUDIO_SOCKET_PATH } from '../../../wizard/modules/shared/protocol' +import { + type TemplateEnvironment, + TemplateStore, +} from '../../init/template-store' +import type { InitArguments } from '../arguments' +import { CreationView } from '../terminal/creation-view' +import { startSitePreview } from './preview' +import { StudioSession } from './session' + +/** Serve ordinary wizard content through PureStack's existing server. */ +export async function runWizard( + environment: TemplateEnvironment, + input: InitArguments, +) { + const temporary = await mkdtemp(path.join(tmpdir(), 'purestack-wizard-')) + let server: DevServerHandle | undefined + const done = Promise.withResolvers<number>() + const interrupt = () => { + done.resolve(130) + void session.dispose() + } + const token = randomBytes(32).toString('hex') + let origin = '' + let previewNumber = 0 + const session = new StudioSession(environment, { + store: new TemplateStore(environment), + output: process.stdout, + finish: () => done.resolve(0), + preview: (directory, signal, stopped) => + startSitePreview( + directory, + path.join(temporary, `preview-${++previewNumber}`), + environment.packageRoot, + signal, + stopped, + ), + }) + process.on('SIGINT', interrupt) + process.on('SIGTERM', interrupt) + try { + await session.initialize(input) + server = await startDevServer({ + host: input.host ?? '127.0.0.1', + port: input.port ?? 0, + logLevel: 'WARN', + watch: false, + liveReload: false, + fullRender: true, + handleSignals: false, + failOnInitialBuildError: true, + webSockets: [ + { + path: STUDIO_SOCKET_PATH, + authorize: (request) => { + const wildcard = input.host === '0.0.0.0' || input.host === '::' + const expectedOrigin = wildcard + ? `http://${request.headers.host}` + : origin + const supplied = + new URL(request.url ?? '/', 'http://localhost').searchParams.get( + 'token', + ) ?? '' + return Boolean( + origin && + request.headers.origin === expectedOrigin && + (wildcard || request.headers.host === new URL(origin).host) && + /^[a-f0-9]{64}$/.test(supplied) && + timingSafeEqual(Buffer.from(supplied), Buffer.from(token)), + ) + }, + connect: (peer) => session.connect(peer), + }, + ], + configFile: path.join( + environment.packageRoot, + 'wizard/modules/purestack.config.ts', + ), + build: { + siteConfig: { + rootDir: environment.packageRoot, + contentDir: path.join(environment.packageRoot, 'wizard'), + outDir: path.join(temporary, 'site'), + }, + options: { cache: false }, + }, + }) + origin = new URL(server.url).origin + if (process.stdout.isTTY) + process.stdout.write( + `${String.fromCharCode(27)}]0;PURESTACK STUDIO WIZARD${String.fromCharCode(7)}`, + ) + process.stdout.write( + new CreationView(process.stdout).wizard( + `${server.url}#token=${token}`, + environment.cwd, + ), + ) + return await done.promise + } finally { + process.removeListener('SIGINT', interrupt) + process.removeListener('SIGTERM', interrupt) + await session.dispose() + await server?.close() + await rm(temporary, { recursive: true, force: true }) + } +} diff --git a/packages/purestack/src/cli/wizard/session.test.ts b/packages/purestack/src/cli/wizard/session.test.ts new file mode 100644 index 00000000..81163496 --- /dev/null +++ b/packages/purestack/src/cli/wizard/session.test.ts @@ -0,0 +1,288 @@ +import { + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import type { + DevWebSocketConnection, + DevWebSocketMessage, +} from '@purestack/ts-ssg' +import { describe, expect, it } from 'vitest' +import type { + StudioCommand, + StudioEvent, +} from '../../../wizard/modules/shared/protocol' +import { createSite } from '../../init/create-site' +import { readTemplateUi } from '../../init/template-ui' +import { parseInitArguments } from '../arguments' +import { StudioSession } from './session' + +class BrowserPeer implements DevWebSocketConnection { + open = true + readonly events: StudioEvent[] = [] + private readonly listeners = new Set<(message: DevWebSocketMessage) => void>() + private readonly closed = new Set<() => void>() + send(message: DevWebSocketMessage) { + this.events.push(JSON.parse(String(message))) + return true + } + request(command: StudioCommand) { + for (const listener of this.listeners) listener(JSON.stringify(command)) + } + onMessage(listener: (message: DevWebSocketMessage) => void) { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + onClose(listener: () => void) { + this.closed.add(listener) + return () => { + this.closed.delete(listener) + } + } + close() { + this.open = false + for (const listener of this.closed) listener() + } +} + +async function fixture(previewFails = false) { + const cwd = await mkdtemp(path.join(tmpdir(), 'purestack-studio-test-')) + const root = path.resolve('templates/starter') + const template = { + root, + source: 'starter', + name: '@purestack/template-starter', + version: '1.0.0', + description: 'Starter', + ui: await readTemplateUi(root), + } + let creates = 0 + let previews = 0 + let stops = 0 + let finishes = 0 + const gate = Promise.withResolvers<void>() + const session = new StudioSession( + { cwd, packageRoot: path.resolve('packages/purestack'), version: '1.1.19' }, + { + store: { load: async () => template, dispose: async () => {} }, + output: { write: () => {} }, + create: async (...args) => { + creates++ + await gate.promise + return createSite(...args) + }, + preview: async () => { + previews++ + if (previewFails && previews === 1) + throw new Error('Missing a site dependency.') + return { + url: 'http://127.0.0.1:45678/', + close: async () => { + stops++ + }, + } + }, + finish: () => { + finishes++ + }, + }, + ) + await session.initialize( + parseInitArguments( + ['my-site', '--title', 'My Studio Site', '--set', 'palette=orange'], + cwd, + ), + ) + const peer = new BrowserPeer() + session.connect(peer) + return { + cwd, + session, + peer, + gate, + counts: () => ({ creates, previews, stops, finishes }), + close: async () => { + gate.resolve() + await session.dispose() + await rm(cwd, { recursive: true, force: true }) + }, + } +} + +describe('Studio session', () => { + it('creates once, streams tasks, survives reconnects and keeps the live site open until finishing', async () => { + const test = await fixture() + try { + const configuration = structuredClone(test.session.snapshot.configuration) + test.peer.request({ type: 'create', configuration }) + test.peer.request({ type: 'create', configuration }) + await expect.poll(() => test.counts().creates).toBe(1) + expect(test.session.snapshot.status).toBe('creating') + test.peer.close() + const reconnected = new BrowserPeer() + test.session.connect(reconnected) + expect(reconnected.events[0].type).toBe('snapshot') + test.gate.resolve() + await expect.poll(() => test.session.snapshot.status).toBe('ready') + const directory = path.join(test.cwd, 'my-site') + const config = JSON.parse( + await readFile(path.join(directory, 'content/siteConfig.json'), 'utf8'), + ) + expect(config.siteTitle).toBe('My Studio Site') + expect(config.style.theme.presets).toEqual(['orange']) + expect(await readdir(directory)).toContain('AGENTS.md') + expect( + test.session.snapshot.tasks.every((task) => task.state === 'done'), + ).toBe(true) + expect(test.session.snapshot.result?.previewUrl).toBe( + 'http://127.0.0.1:45678/', + ) + expect(test.counts()).toEqual({ + creates: 1, + previews: 1, + stops: 0, + finishes: 0, + }) + reconnected.request({ type: 'finish' }) + await expect.poll(() => test.counts().finishes).toBe(1) + await test.session.dispose() + expect(test.counts().stops).toBe(1) + } finally { + await test.close() + } + }) + + it('preserves existing files and allows correcting the destination after failure', async () => { + const test = await fixture() + test.gate.resolve() + try { + await mkdir(path.join(test.cwd, 'my-site')) + await writeFile(path.join(test.cwd, 'my-site/keep.txt'), 'keep me') + test.peer.request({ + type: 'create', + configuration: structuredClone(test.session.snapshot.configuration), + }) + await expect.poll(() => test.session.snapshot.status).toBe('failed') + expect(test.session.snapshot.error).toContain('empty directory') + expect( + await readFile(path.join(test.cwd, 'my-site/keep.txt'), 'utf8'), + ).toBe('keep me') + expect(test.counts().creates).toBe(0) + test.peer.request({ + type: 'create', + configuration: { + ...test.session.snapshot.configuration, + directory: 'another-site', + }, + }) + await expect.poll(() => test.session.snapshot.status).toBe('ready') + expect(test.session.snapshot.result?.directory).toBe( + path.join(test.cwd, 'another-site'), + ) + } finally { + await test.close() + } + }) + + it('retains the generated project when preview fails and retries without creating again', async () => { + const test = await fixture(true) + test.gate.resolve() + try { + test.peer.request({ + type: 'create', + configuration: structuredClone(test.session.snapshot.configuration), + }) + await expect.poll(() => test.session.snapshot.status).toBe('ready') + expect(test.session.snapshot.result?.previewError).toContain( + 'Missing a site dependency', + ) + test.peer.request({ type: 'preview' }) + await expect + .poll(() => test.session.snapshot.result?.previewUrl) + .toBe('http://127.0.0.1:45678/') + expect(test.counts().creates).toBe(1) + expect(test.counts().previews).toBe(2) + } finally { + await test.close() + } + }) +}) + +describe('Studio template selection', () => { + it('reports only the latest selection and drops outcomes it replaced', async () => { + const root = path.resolve('templates/starter') + const ui = await readTemplateUi(root) + const loads = new Map<string, PromiseWithResolvers<void>>() + const session = new StudioSession( + { + cwd: tmpdir(), + packageRoot: path.resolve('packages/purestack'), + version: '1.1.19', + }, + { + store: { + load: async (source) => { + if (source !== 'starter') { + const load = Promise.withResolvers<void>() + loads.set(source, load) + await load.promise + } + return { + root, + source, + name: source, + version: '1.0.0', + description: source, + ui, + } + }, + dispose: async () => {}, + }, + output: { write: () => {} }, + preview: async () => ({ + url: 'http://127.0.0.1/', + close: async () => {}, + }), + finish: () => {}, + }, + ) + await session.initialize(parseInitArguments([], tmpdir())) + const peer = new BrowserPeer() + session.connect(peer) + const errors = () => peer.events.filter((event) => event.type === 'error') + try { + peer.request({ type: 'template', source: 'first' }) + peer.request({ type: 'template', source: 'second' }) + await expect.poll(() => loads.size).toBe(2) + expect(session.snapshot.loadingTemplate).toBe('second') + loads.get('first')?.reject(new Error('first failed')) + loads.get('second')?.resolve() + await expect.poll(() => session.snapshot.template.source).toBe('second') + expect(session.snapshot.loadingTemplate).toBeUndefined() + expect(errors()).toEqual([]) + + peer.request({ type: 'template', source: 'third' }) + await expect.poll(() => loads.has('third')).toBe(true) + loads.get('third')?.reject(new Error('third failed')) + await expect.poll(() => errors().length).toBe(1) + // The cleared loading state is published before the failure reaches the tab. + const [cleared, failure] = peer.events.slice(-2) + expect(failure).toEqual({ type: 'error', message: 'third failed' }) + expect(cleared).toMatchObject({ + type: 'snapshot', + snapshot: { template: { source: 'second' } }, + }) + expect(cleared).not.toHaveProperty('snapshot.loadingTemplate') + expect(errors()).toHaveLength(1) + } finally { + await session.dispose() + } + }) +}) diff --git a/packages/purestack/src/cli/wizard/session.ts b/packages/purestack/src/cli/wizard/session.ts new file mode 100644 index 00000000..9ec8a47f --- /dev/null +++ b/packages/purestack/src/cli/wizard/session.ts @@ -0,0 +1,334 @@ +import path from 'node:path' +import type { DevWebSocketConnection } from '@purestack/ts-ssg' +import type { + StudioEvent, + StudioSnapshot, + StudioTemplate, +} from '../../../wizard/modules/shared/protocol' +import { type CreateSiteResult, createSite } from '../../init/create-site' +import type { TaskRunner } from '../../init/tasks' +import { assertEmptyDestination } from '../../init/template' +import { + type LoadedTemplate, + officialTemplates, + type TemplateEnvironment, + type TemplateSourceStore, +} from '../../init/template-store' +import { resolveTemplateValues } from '../../init/template-ui' +import { type InitArguments, parseTemplateOverrides } from '../arguments' +import { nextCommands, progressTasks } from '../creation' +import { type CreationOutput, CreationView } from '../terminal/creation-view' +import { parseStudioCommand } from './commands' +import type { SitePreview } from './preview' + +export interface StudioServices { + store: TemplateSourceStore + create?: typeof createSite + preview( + directory: string, + signal: AbortSignal, + stopped: () => void, + ): Promise<SitePreview> + output: CreationOutput + finish(): void +} + +function describe(template: LoadedTemplate): StudioTemplate { + const { source, name, version, description, ui } = template + return { source, name, version, description, ui } +} + +/** Owns a single project creation; browser reconnects observe the same operation. */ +export class StudioSession { + private readonly peers = new Set<DevWebSocketConnection>() + private readonly abort = new AbortController() + private readonly pending = new Set<Promise<unknown>>() + private selection = 0 + private live?: SitePreview + private previewStarting = false + private disposed = false + private closing?: Promise<void> + declare readonly snapshot: StudioSnapshot + + constructor( + private readonly environment: TemplateEnvironment, + private readonly services: StudioServices, + ) {} + + async initialize(input: InitArguments) { + const template = await this.services.store.load(input.template) + const directory = input.directory ?? 'my-site' + const configuration = { + directory, + title: + input.title ?? + (input.directory + ? path.basename(path.resolve(this.environment.cwd, directory)) + : 'My Site'), + template: input.template, + install: input.install, + git: input.git, + values: resolveTemplateValues( + template.ui, + parseTemplateOverrides(template.ui, input.sets), + ), + } + const templates = officialTemplates.map((entry) => ({ + id: entry.id, + title: entry.label, + description: entry.description, + marker: entry.label[0], + })) + if (!templates.some((entry) => entry.id === input.template)) + templates.push({ + id: input.template, + title: template.name, + description: template.description, + marker: template.name[0], + }) + Object.assign(this, { + snapshot: { + cwd: this.environment.cwd, + version: this.environment.version, + configuration, + template: describe(template), + templates, + status: 'idle', + tasks: [], + } satisfies StudioSnapshot, + }) + } + + connect(peer: DevWebSocketConnection) { + if (this.disposed || this.peers.size >= 8) { + peer.close(1008, 'Session unavailable.') + return + } + this.peers.add(peer) + this.send(peer, { type: 'snapshot', snapshot: this.snapshot }) + const unsubscribe = peer.onMessage((message) => { + if (typeof message !== 'string') { + peer.close(1003, 'Text messages required.') + return + } + const task = this.handle(message).catch((error) => { + this.send(peer, { + type: 'error', + message: error instanceof Error ? error.message : String(error), + }) + }) + this.pending.add(task) + void task.finally(() => this.pending.delete(task)) + }) + peer.onClose(() => { + unsubscribe() + this.peers.delete(peer) + }) + } + + private async handle(message: string) { + if (this.disposed || this.snapshot.status === 'closing') return + const command = parseStudioCommand(message) + if (this.snapshot.status === 'creating' || this.previewStarting) + throw new Error('Wait for the current operation to finish.') + if (command.type === 'finish') { + this.snapshot.status = 'closing' + this.publish() + setImmediate(() => this.services.finish()) + } else if (command.type === 'preview') { + if (!this.snapshot.result) throw new Error('Create your site first.') + await this.startPreview() + } else if (command.type === 'template') { + if (this.snapshot.result) + throw new Error('This project is already created.') + const selection = ++this.selection + this.snapshot.loadingTemplate = command.source + this.publish() + // A newer selection owns the loading state, so older outcomes are dropped. + const current = () => selection === this.selection && !this.disposed + let template: LoadedTemplate + try { + template = await this.services.store.load(command.source) + } catch (error) { + if (!current()) return + this.snapshot.loadingTemplate = undefined + this.publish() + throw error + } + if (!current()) return + this.snapshot.loadingTemplate = undefined + this.snapshot.template = describe(template) + this.snapshot.configuration.template = command.source + this.snapshot.configuration.values = resolveTemplateValues(template.ui) + if (!this.snapshot.templates.some((entry) => entry.id === command.source)) + this.snapshot.templates.push({ + id: command.source, + title: template.name, + description: template.description, + marker: template.name[0], + }) + this.publish() + } else { + if (this.snapshot.result) + throw new Error( + 'This project is already created. Browse the live site or finish Studio.', + ) + if (command.configuration.template !== this.snapshot.template.source) + throw new Error('Wait for the selected template to load.') + const plan = command.configuration + plan.title = plan.title.trim() + plan.values = resolveTemplateValues( + this.snapshot.template.ui, + plan.values, + ) + // Lock before any async work so repeated clicks cannot create twice. + ++this.selection + this.snapshot.loadingTemplate = undefined + this.snapshot.status = 'creating' + this.snapshot.error = undefined + this.snapshot.configuration = plan + this.snapshot.tasks = [ + 'Prepare selected template', + 'Validate project and compatibility', + 'Create project files', + ...(plan.install ? ['Install dependencies'] : []), + ...(plan.git ? ['Set up Git'] : []), + 'Start live site', + ].map((label) => ({ label, state: 'pending' })) + this.publish() + try { + await assertEmptyDestination( + path.resolve(this.environment.cwd, plan.directory), + ) + const template = await this.services.store.load(plan.template) + const output = this.services.output + output.write( + new CreationView(output).start( + plan.title, + path.resolve(this.environment.cwd, plan.directory), + this.environment.version, + ), + ) + const result = await (this.services.create ?? createSite)( + plan, + this.environment.version, + this.environment.cwd, + this.tasks(), + { templateRoot: template.root, signal: this.abort.signal }, + ) + this.snapshot.result = { + ...result, + commands: nextCommands(result, this.environment.cwd), + } + await this.startPreview() + this.snapshot.status = 'ready' + this.printResult(result) + } catch (error) { + this.snapshot.status = this.snapshot.result ? 'ready' : 'failed' + this.snapshot.error = + error instanceof Error ? error.message : String(error) + } + this.publish() + } + } + + private tasks(): TaskRunner { + const terminal = progressTasks(this.services.output) + return async (label, action) => { + let task = this.snapshot.tasks.find((entry) => entry.label === label) + if (!task) { + task = { label, state: 'pending' } + const next = this.snapshot.tasks.findIndex( + (entry) => entry.state === 'pending', + ) + if (next === -1) this.snapshot.tasks.push(task) + else this.snapshot.tasks.splice(next, 0, task) + } + task.state = 'running' + this.publish() + try { + const value = await terminal(label, action) + task.state = 'done' + return value + } catch (error) { + task.state = 'failed' + throw error + } finally { + this.publish() + } + } + } + + private async startPreview() { + const result = this.snapshot.result + if (!result || this.previewStarting) return + this.previewStarting = true + result.previewError = undefined + this.publish() + try { + await this.tasks()('Start live site', async () => { + await this.live?.close() + this.abort.signal.throwIfAborted() + this.live = await this.services.preview( + result.directory, + this.abort.signal, + () => { + result.previewUrl = undefined + result.previewError = + 'The live server stopped. You can start it again.' + this.publish() + }, + ) + result.previewUrl = this.live.url + }) + } catch (error) { + result.previewUrl = undefined + result.previewError = + error instanceof Error ? error.message : String(error) + } finally { + this.previewStarting = false + this.publish() + } + } + + private printResult(result: CreateSiteResult) { + const output = this.services.output + output.write( + new CreationView(output).complete( + result, + nextCommands(result, this.environment.cwd), + ), + ) + if (this.snapshot.result?.previewUrl) + output.write( + `\n Live site: ${this.snapshot.result.previewUrl}\n Studio remains open. Finish in the browser or press Ctrl+C here.\n`, + ) + } + + private send(peer: DevWebSocketConnection, event: StudioEvent) { + if (peer.open) peer.send(JSON.stringify(event)) + } + + private publish() { + if (this.disposed) return + for (const peer of this.peers) + this.send(peer, { type: 'snapshot', snapshot: this.snapshot }) + } + + dispose() { + this.closing ??= this.shutdown() + return this.closing + } + + private async shutdown() { + if (this.snapshot) { + this.snapshot.status = 'closing' + this.publish() + } + this.disposed = true + this.abort.abort() + await Promise.allSettled(this.pending) + await this.live?.close() + await this.services.store.dispose() + } +} diff --git a/packages/purestack/src/init/create-site.ts b/packages/purestack/src/init/create-site.ts new file mode 100644 index 00000000..979ae1f5 --- /dev/null +++ b/packages/purestack/src/init/create-site.ts @@ -0,0 +1,155 @@ +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + type PackageManager, + preparePackageManager, + runPackageManager, +} from './package-manager' +import { runProcess } from './process' +import type { TaskRunner } from './tasks' +import { + assertEmptyDestination, + fetchTemplate, + prepareProject, + unpackTemplate, + writeProject, +} from './template' +import { + applyTemplateValues, + type OptionValue, + readTemplateUi, +} from './template-ui' + +export interface CreateSitePlan { + directory: string + template: string + title: string + install: boolean + git: boolean + values: Record<string, OptionValue> +} +export interface CreateSiteResult { + directory: string + template: string + version: string + packageManager: PackageManager + installed: boolean + git: 'skipped' | 'existing' | 'created' | 'failed' + failures: string[] +} +/** Filesystem/process service: no prompts, terminal detection, or rendering. */ +export async function createSite( + plan: CreateSitePlan, + version: string, + cwd: string, + task: TaskRunner = (_label, action) => action(), + options: { templateRoot?: string; signal?: AbortSignal } = {}, +): Promise<CreateSiteResult> { + const perform: TaskRunner = async (label, action) => { + options.signal?.throwIfAborted() + const result = await task(label, action) + options.signal?.throwIfAborted() + return result + } + const directory = path.resolve(cwd, plan.directory) + await assertEmptyDestination(directory) + const temporary = await mkdtemp(path.join(tmpdir(), 'purestack-init-')) + let template: string + let packageManager: PackageManager + try { + const unpacked = path.join(temporary, 'unpacked') + const templateRoot = options.templateRoot + if (templateRoot) + await perform('Prepare selected template', () => + cp(templateRoot, unpacked, { recursive: true }), + ) + else { + const archive = await perform('Fetch template', () => + fetchTemplate(plan.template, temporary, cwd, options.signal), + ) + await perform('Validate template archive', () => + unpackTemplate(archive, unpacked), + ) + } + const project = await perform( + 'Validate project and compatibility', + async () => { + return prepareProject(unpacked, directory, version, plan.title) + }, + ) + await perform('Create project files', async () => { + await applyTemplateValues( + project.source, + await readTemplateUi(unpacked), + plan.values, + ) + await writeProject(project.source, project.files, directory) + }) + template = `${project.name}@${project.version}` + packageManager = project.packageManager + } finally { + await rm(temporary, { recursive: true, force: true }) + } + const result: CreateSiteResult = { + directory, + template, + version, + packageManager, + installed: false, + git: 'skipped', + failures: [], + } + if (plan.install) { + try { + await preparePackageManager( + packageManager, + directory, + perform, + options.signal, + ) + await perform('Install dependencies', () => + runPackageManager(packageManager, ['install'], directory, { + capture: true, + signal: options.signal, + }), + ) + result.installed = true + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error + result.failures.push( + `Dependency installation failed: ${error instanceof Error ? error.message : String(error)}\nRetry ${packageManager} install in the project directory.`, + ) + } + } + if (plan.git) { + try { + await perform('Set up Git', async () => { + const git = (args: string[]) => + runProcess('git', args, directory, { + capture: true, + signal: options.signal, + }) + const parent = await git(['rev-parse', '--show-toplevel']).catch( + (error) => { + if (error instanceof Error && error.name === 'AbortError') + throw error + return undefined + }, + ) + if (parent) result.git = 'existing' + else { + await git(['init']) + result.git = 'created' + } + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') throw error + result.git = 'failed' + result.failures.push( + `Git initialization failed: ${error instanceof Error ? error.message : String(error)}\nRetry git init in the project directory.`, + ) + } + } + return result +} diff --git a/packages/purestack/src/init/init.test.ts b/packages/purestack/src/init/init.test.ts new file mode 100644 index 00000000..015f5b79 --- /dev/null +++ b/packages/purestack/src/init/init.test.ts @@ -0,0 +1,635 @@ +import { + cp, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { gzipSync } from 'node:zlib' +import { c } from 'tar' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type CreateSitePlan, createSite } from './create-site' +import * as managers from './package-manager' +import * as processes from './process' +import { + assertEmptyDestination, + fetchTemplate, + inspectTemplate, + prepareProject, + projectName, + resolveTemplateSpec, + unpackTemplate, + writeProject, +} from './template' + +const repository = path.resolve(import.meta.dirname, '../../../..') +const cliVersion = JSON.parse( + await readFile( + path.join(repository, 'packages/purestack/package.json'), + 'utf8', + ), +).version as string +let temporary: string + +function plan( + directory: string, + template: string, + changes: Partial<CreateSitePlan> = {}, +): CreateSitePlan { + return { + directory, + template, + title: 'Test site', + install: false, + git: false, + values: {}, + ...changes, + } +} + +beforeEach(async () => { + temporary = await mkdtemp(path.join(tmpdir(), 'purestack-init-test-')) + vi.spyOn(managers, 'preparePackageManager').mockResolvedValue(undefined) +}) +afterEach(async () => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + await rm(temporary, { recursive: true, force: true }) +}) + +async function fixture(template = 'starter') { + const unpacked = path.join(temporary, 'fixture') + await cp(path.join(repository, 'templates', template), unpacked, { + recursive: true, + }) + return unpacked +} + +async function archiveFixture() { + const source = await fixture() + const archive = path.join(temporary, 'template.tgz') + await c({ file: archive, gzip: true, cwd: source, prefix: 'package' }, [ + 'package.json', + 'template.json', + 'template.ui.json', + 'template', + ]) + return archive +} + +// Build raw entries so the writer cannot sanitize the hostile paths under test. +async function rawArchive(entries: { name: string; type?: string }[]) { + const blocks = entries.map(({ name, type = '0' }) => { + const header = Buffer.alloc(512) + header.write(name, 0, 100) + header.write('0000644\0', 100) + header.write('0000000\0', 108) + header.write('0000000\0', 116) + header.write('00000000000\0', 124) + header.write('00000000000\0', 136) + header.fill(' ', 148, 156) + header.write(type, 156) + if (type === '1' || type === '2') header.write('../../outside', 157) + header.write('ustar\0', 257) + header.write('00', 263) + header.write( + `${header + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, '0')}\0 `, + 148, + ) + return header + }) + const archive = path.join(temporary, 'hostile.tgz') + await writeFile( + archive, + gzipSync(Buffer.concat([...blocks, Buffer.alloc(1024)])), + ) + return archive +} + +describe('init options', () => { + it('resolves aliases, explicit package versions, and local paths', () => { + expect(resolveTemplateSpec('starter', temporary)).toBe( + '@purestack/template-starter@latest', + ) + expect(resolveTemplateSpec('starter@1.2.3', temporary)).toBe( + '@purestack/template-starter@1.2.3', + ) + expect(resolveTemplateSpec('@company/template@1.0.0', temporary)).toBe( + '@company/template@1.0.0', + ) + expect(resolveTemplateSpec('./template.tgz', temporary)).toBe( + path.join(temporary, 'template.tgz'), + ) + expect(() => + resolveTemplateSpec('git+https://example.com/template.git', temporary), + ).toThrow() + expect(() => + resolveTemplateSpec('--registry=https://example.com', temporary), + ).toThrow() + }) + + it('normalizes a directory into a valid project name', () => { + expect(projectName(path.join(temporary, 'My Docs'))).toBe('my-docs') + expect(() => projectName(path.join(temporary, '...'))).toThrow() + }) +}) + +describe('template extraction', () => { + it.each([ + '/outside', + 'package/../../outside', + 'package/a/../outside', + 'package/a\\..\\outside', + 'C:/outside', + 'package/file:stream', + 'package/NUL.txt', + 'package/file.', + 'package/file ', + 'package//file', + 'package/file?', + ])('rejects unsafe archive entry %s before writing', async (name) => { + const archive = await rawArchive([{ name }]) + const output = path.join(temporary, 'output') + await expect(unpackTemplate(archive, output)).rejects.toThrow(/Unsafe/) + await expect(lstat(output)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it.each(['1', '2', '3', '6'])( + 'rejects links and special entries of type %s', + async (type) => { + const archive = await rawArchive([{ name: 'package/link', type }]) + await expect( + unpackTemplate(archive, path.join(temporary, 'output')), + ).rejects.toThrow(/cannot contain/) + }, + ) + + it('rejects duplicate paths across case-sensitive and insensitive filesystems', async () => { + const archive = await rawArchive([ + { name: 'package/README.md' }, + { name: 'package/readme.md' }, + ]) + await expect( + unpackTemplate(archive, path.join(temporary, 'output')), + ).rejects.toThrow(/Duplicate/) + }) +}) + +describe('project generation', () => { + it('creates a standalone starter project and preserves binary assets', async () => { + const unpacked = await fixture() + const bytes = Buffer.from([0, 255, 128, 10, 13]) + await writeFile(path.join(unpacked, 'template/content/image.bin'), bytes) + const destination = path.join(temporary, 'My Docs') + const prepared = await prepareProject( + unpacked, + destination, + cliVersion, + 'Docs "&" <Guide>', + ) + await writeProject(prepared.source, prepared.files, destination) + const project = JSON.parse( + await readFile(path.join(destination, 'package.json'), 'utf8'), + ) + expect(project).toMatchObject({ + name: 'my-docs', + private: true, + dependencies: { purestack: cliVersion }, + }) + expect(prepared.packageManager).toBe('yarn') + expect(await readFile(path.join(destination, 'yarn.lock'), 'utf8')).toBe('') + expect(project.packageManager).toBe('yarn@4.18.1') + expect(project.devDependencies['@purestack/ts-minidom']).toBe(cliVersion) + expect( + await readFile(path.join(destination, '.yarnrc.yml'), 'utf8'), + ).toContain('nodeLinker: node-modules') + expect(Object.keys(project.dependencies)).toEqual(['purestack']) + expect( + JSON.parse( + await readFile( + path.join(destination, 'content/siteConfig.json'), + 'utf8', + ), + ), + ).toMatchObject({ + siteTitle: 'Docs "&" <Guide>', + logo: { brand: 'Docs "&" <Guide>' }, + }) + expect(await readFile(path.join(destination, 'content/image.bin'))).toEqual( + bytes, + ) + expect( + await readFile(path.join(destination, '.gitignore'), 'utf8'), + ).toContain('node_modules/') + expect( + await readFile(path.join(destination, 'AGENTS.md'), 'utf8'), + ).toContain('yarn build') + }) + + it('rejects incompatible templates without creating a destination', async () => { + const unpacked = await fixture() + const destination = path.join(temporary, 'project') + await expect( + prepareProject(unpacked, destination, '0.0.1'), + ).rejects.toThrow(/requires PureStack/) + await expect(lstat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('checks a template minimum numerically and rejects the next major', async () => { + const unpacked = await fixture() + const manifestFile = path.join(unpacked, 'template.json') + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) + await writeFile( + manifestFile, + JSON.stringify({ ...manifest, purestack: '1.2.9' }), + ) + await expect(inspectTemplate(unpacked, '1.10.0')).resolves.toBeDefined() + await expect(inspectTemplate(unpacked, '1.2.8')).rejects.toThrow( + /requires PureStack/, + ) + await expect(inspectTemplate(unpacked, '2.0.0')).rejects.toThrow( + /same major/, + ) + }) + + it('rejects template ranges before creating a destination', async () => { + const unpacked = await fixture() + const manifestFile = path.join(unpacked, 'template.json') + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) + await writeFile( + manifestFile, + JSON.stringify({ ...manifest, purestack: `^${cliVersion}` }), + ) + const destination = path.join(temporary, 'project') + await expect( + prepareProject(unpacked, destination, cliVersion), + ).rejects.toThrow(/minimum version/) + await expect(lstat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('creates an independent Yarn project inside a parent workspace', async () => { + await writeFile( + path.join(temporary, 'package.json'), + JSON.stringify({ + name: 'parent-workspace', + private: true, + packageManager: 'yarn@4.18.1', + workspaces: ['packages/*'], + }), + ) + const parentLock = '# Parent workspace lockfile\n' + await writeFile(path.join(temporary, 'yarn.lock'), parentLock) + const unpacked = await fixture() + const directory = path.join(temporary, 'sites/nested-site') + await createSite( + plan(directory, 'starter'), + cliVersion, + temporary, + undefined, + { templateRoot: unpacked }, + ) + const output = await managers.runYarn( + ['workspaces', 'list', '--json'], + directory, + { capture: true }, + ) + expect(JSON.parse(output)).toEqual({ location: '.', name: 'nested-site' }) + expect(await readFile(path.join(temporary, 'yarn.lock'), 'utf8')).toBe( + parentLock, + ) + }) + + it('preserves a lockfile supplied by the template', async () => { + const unpacked = await fixture() + const lockfile = '# Template lockfile\n' + await writeFile(path.join(unpacked, 'template/yarn.lock'), lockfile) + const directory = path.join(temporary, 'project') + const prepared = await prepareProject(unpacked, directory, cliVersion) + await writeProject(prepared.source, prepared.files, directory) + expect(await readFile(path.join(directory, 'yarn.lock'), 'utf8')).toBe( + lockfile, + ) + }) + + it.each([ + 'pnpm@10.0.0', + 'yarn@latest', + 'yarn@4.18.1;echo unexpected', + 'yarn@4.18.1+sha512.abcdef', + 'yarn@4.18.1\n', + 42, + ])( + 'rejects an unsupported or unpinned package manager: %s', + async (packageManager) => { + const unpacked = await fixture() + const packageFile = path.join(unpacked, 'template/package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + await writeFile( + packageFile, + JSON.stringify({ ...project, packageManager }), + ) + await expect( + prepareProject(unpacked, path.join(temporary, 'project'), cliVersion), + ).rejects.toThrow(/packageManager must pin/) + }, + ) + + it('rejects unsupported manifest versions and missing guidance', async () => { + const unpacked = await fixture() + const destination = path.join(temporary, 'project') + const manifestFile = path.join(unpacked, 'template.json') + const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) + await writeFile( + manifestFile, + JSON.stringify({ ...manifest, formatVersion: 2 }), + ) + await expect( + prepareProject(unpacked, destination, cliVersion), + ).rejects.toThrow(/unsupported/) + await writeFile(manifestFile, JSON.stringify(manifest)) + await rm(path.join(unpacked, 'template/AGENTS.md')) + await expect( + prepareProject(unpacked, destination, cliVersion), + ).rejects.toThrow(/missing AGENTS.md/) + }) + + it.each([ + 'workspace:^', + 'file:../../packages/example', + 'link:../example', + 'portal:../example', + 'github:owner/repository', + 'git+https://github.com/owner/repository.git', + 'git@github.com:owner/repository.git', + 'owner/repository', + 'https://example.com/package.tgz', + '//example.com/package.tgz', + './package.tgz', + 'package.tgz', + 'package.tar.gz', + 'npm:example@https://example.com/package.tgz', + 'npm:example@file:../example', + 'C:\\packages\\example', + '.', + '..', + ' package.tgz ', + '', + '{{unknown}}', + ])('rejects nonportable dependency %s', async (version) => { + const unpacked = await fixture() + const packageFile = path.join(unpacked, 'template/package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + project.dependencies.example = version + await writeFile(packageFile, JSON.stringify(project)) + await expect( + prepareProject(unpacked, path.join(temporary, 'project'), cliVersion), + ).rejects.toThrow(/published dependency/) + }) + + it.each([ + '1.2.3', + '^1.2.3', + '~1.2.3', + '>=1 <2', + '*', + 'latest', + '1.2.3-beta.1', + 'npm:other@^1.2.3', + 'npm:@company/other@latest', + 'npm:other', + ])('accepts registry dependency selector %s', async (version) => { + const unpacked = await fixture() + const packageFile = path.join(unpacked, 'template/package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + project.dependencies.example = version + await writeFile(packageFile, JSON.stringify(project)) + await prepareProject(unpacked, path.join(temporary, 'project'), cliVersion) + expect( + JSON.parse(await readFile(packageFile, 'utf8')).dependencies.example, + ).toBe(version) + }) + + it('rejects template install hooks', async () => { + const unpacked = await fixture() + const packageFile = path.join(unpacked, 'template/package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + project.scripts.postinstall = 'echo surprise' + await writeFile(packageFile, JSON.stringify(project)) + await expect( + prepareProject(unpacked, path.join(temporary, 'project'), cliVersion), + ).rejects.toThrow(/installation hook/) + }) + + it('preserves existing files and rejects a destination that changed after validation', async () => { + const unpacked = await fixture() + const destination = path.join(temporary, 'project') + const prepared = await prepareProject(unpacked, destination, cliVersion) + await mkdir(destination) + await assertEmptyDestination(destination) + await writeFile(path.join(destination, 'keep.txt'), 'keep me') + await expect( + writeProject(prepared.source, prepared.files, destination), + ).rejects.toThrow(/empty directory/) + expect(await readdir(destination)).toEqual(['keep.txt']) + expect(await readFile(path.join(destination, 'keep.txt'), 'utf8')).toBe( + 'keep me', + ) + }) + + it.each([undefined, new AbortController().signal])( + 'fetches local folders with disabled lifecycle scripts and optional cancellation (%s)', + async (signal) => { + const pack = vi + .spyOn(processes, 'runNpm') + .mockResolvedValue(JSON.stringify([{ filename: 'template.tgz' }])) + expect( + await fetchTemplate('./template', temporary, repository, signal), + ).toBe(path.join(temporary, 'template.tgz')) + expect(pack.mock.calls[0]).toEqual([ + [ + 'pack', + path.join(repository, 'template'), + '--ignore-scripts', + '--json', + '--pack-destination', + temporary, + ], + repository, + { capture: true, signal }, + ]) + expect(pack.mock.calls[0][2]?.signal).toBe(signal) + }, + ) + + it('preserves the project and gives retry instructions when installation fails', async () => { + const archive = await archiveFixture() + const destination = path.join(temporary, 'project') + vi.spyOn(console, 'log').mockImplementation(() => {}) + const install = vi + .spyOn(managers, 'runPackageManager') + .mockRejectedValue(new Error('offline')) + const result = await createSite( + plan(destination, archive, { install: true }), + cliVersion, + repository, + ) + expect(result.installed).toBe(false) + expect(install).toHaveBeenCalledWith('yarn', ['install'], destination, { + capture: true, + signal: undefined, + }) + expect(result.failures).toEqual([ + expect.stringContaining('Retry yarn install'), + ]) + expect( + await readFile(path.join(destination, 'README.md'), 'utf8'), + ).toContain('PureStack') + }) + + it('skips Git initialization inside an existing repository', async () => { + const archive = await archiveFixture() + vi.spyOn(console, 'log').mockImplementation(() => {}) + const git = vi + .spyOn(processes, 'runProcess') + .mockResolvedValue(`${repository}\n`) + const npm = vi.spyOn(managers, 'runPackageManager') + const result = await createSite( + plan(path.join(temporary, 'project'), archive, { git: true }), + cliVersion, + repository, + ) + expect(result.git).toBe('existing') + expect(git).toHaveBeenCalledTimes(1) + expect(git.mock.calls[0].slice(0, 2)).toEqual([ + 'git', + ['rev-parse', '--show-toplevel'], + ]) + expect(npm).not.toHaveBeenCalled() + }) + + it.each([undefined, new AbortController().signal])( + 'initializes Git outside a repository with optional cancellation (%s)', + async (signal) => { + const archive = await archiveFixture() + const destination = path.join(temporary, 'project') + vi.spyOn(console, 'log').mockImplementation(() => {}) + const git = vi + .spyOn(processes, 'runProcess') + .mockRejectedValueOnce(new Error('not a repository')) + .mockResolvedValueOnce('Initialized repository') + await createSite( + plan(destination, archive, { git: true }), + cliVersion, + repository, + undefined, + { signal }, + ) + expect(git.mock.lastCall).toEqual([ + 'git', + ['init'], + destination, + { capture: true, signal }, + ]) + expect(git.mock.lastCall?.[3]?.signal).toBe(signal) + expect(git).toHaveBeenCalledTimes(2) + }, + ) + + it('does not install dependencies or initialize Git unless requested', async () => { + const archive = await archiveFixture() + vi.spyOn(console, 'log').mockImplementation(() => {}) + const npm = vi.spyOn(managers, 'runPackageManager') + const git = vi.spyOn(processes, 'runProcess') + await createSite( + plan(path.join(temporary, 'project'), archive), + cliVersion, + repository, + ) + expect(npm).not.toHaveBeenCalled() + expect(git).not.toHaveBeenCalled() + }) + + it('rejects npm pack results pointing outside the temporary directory', async () => { + vi.spyOn(processes, 'runNpm').mockResolvedValue( + JSON.stringify([{ filename: '../outside.tgz' }]), + ) + await expect( + fetchTemplate('starter', temporary, repository), + ).rejects.toThrow(/invalid template archive path/) + }) + + it('preserves explicitly included dotfiles through npm packing and site creation', async () => { + vi.stubEnv('npm_config_cache', path.join(temporary, 'npm-cache')) + const unpacked = await fixture() + const archive = await fetchTemplate(unpacked, temporary, repository) + const directory = path.join(temporary, 'packed-site') + await createSite(plan(directory, archive), cliVersion, repository) + for (const file of ['.gitignore', '.yarnrc.yml']) { + expect(await readFile(path.join(directory, file), 'utf8')).toBe( + await readFile(path.join(unpacked, 'template', file), 'utf8'), + ) + } + }) + + it('applies selected settings while preserving template configuration', async () => { + const archive = await archiveFixture() + const directory = path.join(temporary, 'configured-site') + await createSite( + plan(directory, archive, { + values: { + palette: 'mint', + 'navigation-mode': 'none', + search: false, + 'table-of-contents': false, + highlight: false, + 'site-url': 'https://example.com/', + 'base-path': '/docs/', + }, + }), + cliVersion, + repository, + ) + const config = JSON.parse( + await readFile(path.join(directory, 'content/siteConfig.json'), 'utf8'), + ) + expect(config).toMatchObject({ + style: { theme: { skin: 'standard', presets: ['mint'] } }, + navigation: { mode: 'none' }, + pagefind: { enabled: false }, + pageToc: { enabled: false }, + mdx: { disableHighlighter: true }, + sitemap: { enabled: true, baseUrl: 'https://example.com' }, + basePath: '/docs', + outDir: '../dist/dev', + publishDir: '../dist/publish', + }) + }) + + it('stops subsequent actions on cancellation during installation', async () => { + const archive = await archiveFixture() + const cancelled = new Error('Cancelled') + cancelled.name = 'AbortError' + vi.spyOn(managers, 'runPackageManager').mockRejectedValue(cancelled) + const git = vi.spyOn(processes, 'runProcess') + const directory = path.join(temporary, 'project') + await expect( + createSite( + plan(directory, archive, { install: true, git: true }), + cliVersion, + repository, + ), + ).rejects.toBe(cancelled) + expect(git).not.toHaveBeenCalled() + expect( + await readFile(path.join(directory, 'package.json'), 'utf8'), + ).toContain('purestack') + }) +}) diff --git a/packages/purestack/src/init/package-manager.test.ts b/packages/purestack/src/init/package-manager.test.ts new file mode 100644 index 00000000..02b101bb --- /dev/null +++ b/packages/purestack/src/init/package-manager.test.ts @@ -0,0 +1,231 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { preparePackageManager, runYarn } from './package-manager' +import * as processes from './process' +import type { TaskRunner } from './tasks' + +vi.mock('node:fs', async (importOriginal) => { + const original = await importOriginal<typeof import('node:fs')>() + return { ...original, existsSync: vi.fn(original.existsSync) } +}) + +let temporary: string + +beforeEach(async () => { + temporary = await mkdtemp(path.join(tmpdir(), 'purestack-yarn-runner-')) + vi.stubEnv('COREPACK_ROOT', undefined) + vi.stubEnv('npm_execpath', undefined) + vi.stubEnv('PATH', '') + vi.mocked(existsSync).mockReturnValue(false) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + await rm(temporary, { recursive: true, force: true }) +}) + +async function runner(relative: string) { + const file = path.join(temporary, relative) + await mkdir(path.dirname(file), { recursive: true }) + await writeFile( + file, + 'console.log(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }))', + ) + vi.mocked(existsSync).mockImplementation((candidate) => candidate === file) + return file +} + +describe('Yarn process lookup', () => { + it('uses COREPACK_ROOT when npm_execpath is a temporary Yarn launcher', async () => { + const file = await runner('user npm/node_modules/corepack/dist/corepack.js') + vi.stubEnv('COREPACK_ROOT', path.dirname(path.dirname(file))) + vi.stubEnv('npm_execpath', path.join(temporary, 'xfs/yarn')) + const output = await runYarn(['install'], temporary, { capture: true }) + expect(JSON.parse(output)).toEqual({ + args: ['yarn', 'install'], + cwd: temporary, + }) + }) + + it('locates a user npm installation of Corepack through PATH', async () => { + await runner('user npm/node_modules/corepack/dist/corepack.js') + vi.stubEnv('PATH', path.join(temporary, 'user npm')) + const output = await runYarn(['install'], temporary, { capture: true }) + expect(JSON.parse(output).args).toEqual(['yarn', 'install']) + }) + + it('reuses an active JavaScript Yarn CLI without Corepack', async () => { + const file = await runner('releases/yarn-4.18.1.cjs') + vi.stubEnv('npm_execpath', file) + const output = await runYarn(['install', '--mode=skip-build'], temporary, { + capture: true, + }) + expect(JSON.parse(output).args).toEqual(['install', '--mode=skip-build']) + }) + + it('locates standalone Yarn installed in a user npm prefix', async () => { + await runner('user npm/node_modules/yarn/bin/yarn.js') + vi.stubEnv('PATH', path.join(temporary, 'user npm')) + const output = await runYarn(['--version'], temporary, { capture: true }) + expect(JSON.parse(output).args).toEqual(['--version']) + }) + + it.each(['corepack', 'standalone'])( + 'labels failed Yarn commands using %s', + async (launcher) => { + const file = await runner( + launcher === 'corepack' + ? 'user npm/node_modules/corepack/dist/corepack.js' + : 'releases/yarn-4.18.1.cjs', + ) + if (launcher === 'corepack') + vi.stubEnv('COREPACK_ROOT', path.dirname(path.dirname(file))) + else vi.stubEnv('npm_execpath', file) + await writeFile( + file, + 'process.stderr.write("Registry unavailable\\n"); process.exitCode = 1', + ) + await expect( + runYarn(['install'], temporary, { capture: true }), + ).rejects.toThrow('yarn install failed (1).\nRegistry unavailable') + }, + ) + + it.runIf(process.platform === 'win32')( + 'reports missing Yarn without duplicating retry instructions', + () => { + expect(() => runYarn(['install'], temporary, { capture: true })).toThrow( + 'Ensure Yarn or Corepack is available on PATH.', + ) + expect(() => + runYarn(['install'], temporary, { capture: true }), + ).not.toThrow('retry yarn install') + }, + ) +}) + +describe('package manager setup', () => { + it('installs Corepack, enables Yarn, and prepares the project-pinned version on a fresh machine', async () => { + const prefix = path.join(temporary, 'npm-global') + const root = path.join(prefix, 'node_modules') + const corepack = path.join(root, 'corepack/dist/corepack.js') + const bin = process.platform === 'win32' ? prefix : path.join(prefix, 'bin') + let installed = false + vi.mocked(existsSync).mockImplementation( + (file) => installed && file === corepack, + ) + const npm = vi + .spyOn(processes, 'runNpm') + .mockImplementation(async (args) => { + if (args[0] === 'install') { + installed = true + return '' + } + if (args[0] === 'root') return root + if (args[0] === 'prefix') return prefix + throw new Error('Unexpected npm command') + }) + const run = vi.spyOn(processes, 'runProcess').mockResolvedValue('') + const stages: string[] = [] + const task: TaskRunner = (label, action) => { + stages.push(label) + return action() + } + await preparePackageManager('yarn', temporary, task) + expect(stages).toEqual(['Install Corepack', 'Enable Yarn', 'Prepare Yarn']) + expect(npm).toHaveBeenCalledWith( + ['install', '--global', 'corepack@latest', '--no-audit', '--no-fund'], + temporary, + { capture: true, signal: undefined }, + ) + expect(run.mock.calls.map((call) => call[1])).toEqual([ + [corepack, 'enable', 'yarn', '--install-directory', bin], + [corepack, 'install'], + ]) + await runYarn(['install'], temporary, { capture: true }) + expect(run).toHaveBeenLastCalledWith( + process.execPath, + [corepack, 'yarn', 'install'], + temporary, + { capture: true, label: 'yarn install' }, + ) + }) + + it('reuses Corepack and enabled Yarn without reinstalling them', async () => { + const file = await runner('corepack/dist/corepack.js') + const bin = path.join(temporary, 'bin') + vi.stubEnv('COREPACK_ROOT', path.dirname(path.dirname(file))) + vi.stubEnv('PATH', bin) + vi.mocked(existsSync).mockImplementation( + (entry) => + entry === file || + entry === + path.join(bin, process.platform === 'win32' ? 'yarn.cmd' : 'yarn'), + ) + const npm = vi.spyOn(processes, 'runNpm') + const run = vi.spyOn(processes, 'runProcess').mockResolvedValue('') + await preparePackageManager('yarn', temporary, (_label, action) => action()) + expect(npm).not.toHaveBeenCalled() + expect(run).toHaveBeenCalledExactlyOnceWith( + process.execPath, + [file, 'install'], + temporary, + { capture: true, label: 'corepack install' }, + ) + }) + + it('enables missing Yarn shims without reinstalling existing Corepack', async () => { + const file = await runner('corepack/dist/corepack.js') + vi.stubEnv('COREPACK_ROOT', path.dirname(path.dirname(file))) + const npm = vi.spyOn(processes, 'runNpm').mockResolvedValue(temporary) + const run = vi.spyOn(processes, 'runProcess').mockResolvedValue('') + await preparePackageManager('yarn', temporary, (_label, action) => action()) + expect(npm).toHaveBeenCalledExactlyOnceWith( + ['prefix', '--global'], + temporary, + { capture: true }, + ) + expect(run.mock.calls.map((call) => call[1][1])).toEqual([ + 'enable', + 'install', + ]) + }) + + it('reuses standalone Yarn when it matches the project pin', async () => { + const file = await runner('releases/yarn-4.18.1.cjs') + vi.stubEnv('npm_execpath', file) + await writeFile( + path.join(temporary, 'package.json'), + JSON.stringify({ packageManager: 'yarn@4.18.1' }), + ) + const npm = vi.spyOn(processes, 'runNpm') + vi.spyOn(processes, 'runProcess').mockResolvedValue('4.18.1\n') + await preparePackageManager('yarn', temporary, (_label, action) => action()) + expect(npm).not.toHaveBeenCalled() + }) + + it('stops setup when Corepack installation fails', async () => { + vi.spyOn(processes, 'runNpm').mockRejectedValue(new Error('offline')) + const run = vi.spyOn(processes, 'runProcess') + await expect( + preparePackageManager('yarn', temporary, (_label, action) => action()), + ).rejects.toThrow('offline') + expect(run).not.toHaveBeenCalled() + }) + + it('does not install tools for npm projects or cancelled operations', async () => { + const npm = vi.spyOn(processes, 'runNpm') + const task: TaskRunner = (_label, action) => action() + await preparePackageManager('npm', temporary, task) + const abort = new AbortController() + abort.abort() + await expect( + preparePackageManager('yarn', temporary, task, abort.signal), + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(npm).not.toHaveBeenCalled() + }) +}) diff --git a/packages/purestack/src/init/package-manager.ts b/packages/purestack/src/init/package-manager.ts new file mode 100644 index 00000000..d2b0c5cb --- /dev/null +++ b/packages/purestack/src/init/package-manager.ts @@ -0,0 +1,154 @@ +import { existsSync, realpathSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { type ProcessOptions, runNpm, runProcess } from './process' +import type { TaskRunner } from './tasks' + +export type PackageManager = 'npm' | 'yarn' + +function searchDirectories() { + return (process.env.PATH ?? '').split(path.delimiter).filter(Boolean) +} + +function findCorepack() { + const corepackRoots = [ + process.env.COREPACK_ROOT, + path.join(path.dirname(process.execPath), 'node_modules/corepack'), + ...searchDirectories().map((directory) => + path.join(directory, 'node_modules/corepack'), + ), + ] + const corepackCli = corepackRoots + .filter((root): root is string => Boolean(root)) + .map((root) => path.join(root, 'dist/corepack.js')) + .find((file) => existsSync(file)) + if (corepackCli) return corepackCli + if (process.platform !== 'win32') { + for (const directory of searchDirectories()) { + const executable = path.join(directory, 'corepack') + if (existsSync(executable)) return realpathSync(executable) + } + } + return undefined +} + +function findYarn() { + return [ + process.env.npm_execpath, + ...searchDirectories().map((directory) => + path.join(directory, 'node_modules/yarn/bin/yarn.js'), + ), + ].find( + (file) => + file && /\byarn(?:-[\d.]+)?\.(?:c?js)$/i.test(file) && existsSync(file), + ) +} + +/** Corepack selects the Yarn version pinned by the generated project. */ +export function runYarn( + args: string[], + cwd: string, + options: ProcessOptions = {}, +) { + const run = { ...options, label: ['yarn', args[0]].filter(Boolean).join(' ') } + const corepack = findCorepack() + if (corepack) + return runProcess(process.execPath, [corepack, 'yarn', ...args], cwd, run) + const yarnCli = findYarn() + if (yarnCli) return runProcess(process.execPath, [yarnCli, ...args], cwd, run) + if (process.platform === 'win32') + throw new Error( + 'Cannot locate Yarn. Ensure Yarn or Corepack is available on PATH.', + ) + return runProcess('corepack', ['yarn', ...args], cwd, run) +} + +/** Called only for an explicitly requested dependency install. */ +export async function preparePackageManager( + manager: PackageManager, + cwd: string, + task: TaskRunner, + signal?: AbortSignal, +) { + if (manager !== 'yarn') return + signal?.throwIfAborted() + let corepack = findCorepack() + if (!corepack && findYarn()) { + const manifest: unknown = JSON.parse( + await readFile(path.join(cwd, 'package.json'), 'utf8'), + ) + const pinned = + manifest && + typeof manifest === 'object' && + 'packageManager' in manifest && + typeof manifest.packageManager === 'string' + ? manifest.packageManager.slice('yarn@'.length) + : undefined + const version = await runYarn(['--version'], cwd, { capture: true, signal }) + if (version.trim() === pinned) return + } + let installed = false + if (!corepack) { + corepack = await task('Install Corepack', async () => { + await runNpm( + ['install', '--global', 'corepack@latest', '--no-audit', '--no-fund'], + cwd, + { capture: true, signal }, + ) + const root = ( + await runNpm(['root', '--global'], cwd, { capture: true, signal }) + ).trim() + if (!path.isAbsolute(root)) + throw new Error('npm returned an invalid global package directory.') + const entry = path.join(root, 'corepack/dist/corepack.js') + if (!existsSync(entry)) + throw new Error('Corepack installation did not create its launcher.') + process.env.COREPACK_ROOT = path.dirname(path.dirname(entry)) + return entry + }) + installed = true + } + const corepackCli = corepack + const hasYarnLauncher = searchDirectories().some((directory) => + existsSync( + path.join(directory, process.platform === 'win32' ? 'yarn.cmd' : 'yarn'), + ), + ) + if (installed || !hasYarnLauncher) { + await task('Enable Yarn', async () => { + const prefix = ( + await runNpm(['prefix', '--global'], cwd, { capture: true, signal }) + ).trim() + if (!path.isAbsolute(prefix)) + throw new Error('npm returned an invalid global installation prefix.') + const bin = + process.platform === 'win32' ? prefix : path.join(prefix, 'bin') + await runProcess( + process.execPath, + [corepackCli, 'enable', 'yarn', '--install-directory', bin], + cwd, + { capture: true, signal, label: 'corepack enable yarn' }, + ) + if (!searchDirectories().includes(bin)) + process.env.PATH = [bin, ...searchDirectories()].join(path.delimiter) + }) + } + await task('Prepare Yarn', () => + runProcess(process.execPath, [corepackCli, 'install'], cwd, { + capture: true, + signal, + label: 'corepack install', + }), + ) +} + +export function runPackageManager( + manager: PackageManager, + args: string[], + cwd: string, + options: ProcessOptions = {}, +) { + return manager === 'yarn' + ? runYarn(args, cwd, options) + : runNpm(args, cwd, options) +} diff --git a/packages/purestack/src/init/process.test.ts b/packages/purestack/src/init/process.test.ts new file mode 100644 index 00000000..a0fd804e --- /dev/null +++ b/packages/purestack/src/init/process.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runNpm, runProcess } from './process' + +let temporary: string + +beforeEach(async () => { + temporary = await mkdtemp(path.join(tmpdir(), 'purestack-process-')) +}) + +afterEach(async () => { + vi.unstubAllEnvs() + expect(path.dirname(path.resolve(temporary))).toBe(path.resolve(tmpdir())) + expect(path.basename(temporary)).toMatch(/^purestack-process-/) + await rm(temporary, { recursive: true, force: true }) +}) + +describe('process failure labels', () => { + it.each([ + { args: ['install'], label: 'npm install' }, + { args: [], label: 'npm' }, + ])( + 'reports $label instead of the Node launcher path', + async ({ args, label }) => { + const launcher = path.join(temporary, 'npm-cli.js') + await writeFile( + launcher, + 'process.stderr.write("Registry unavailable\\n"); process.exitCode = 1', + ) + vi.stubEnv('npm_execpath', launcher) + await expect(runNpm(args, temporary, { capture: true })).rejects.toThrow( + `${label} failed (1).\nRegistry unavailable`, + ) + }, + ) + + it('retains stderr and exit status when a caller supplies a command label', async () => { + await expect( + runProcess( + process.execPath, + [ + '-e', + 'process.stderr.write("Permission denied\\n"); process.exitCode = 7', + ], + temporary, + { capture: true, label: 'corepack enable yarn' }, + ), + ).rejects.toThrow('corepack enable yarn failed (7).\nPermission denied') + }) +}) diff --git a/packages/purestack/src/init/process.ts b/packages/purestack/src/init/process.ts new file mode 100644 index 00000000..7a724d9f --- /dev/null +++ b/packages/purestack/src/init/process.ts @@ -0,0 +1,87 @@ +import { spawn } from 'node:child_process' +import { existsSync, realpathSync } from 'node:fs' +import path from 'node:path' + +export interface ProcessOptions { + /** Collect stdout and stderr instead of streaming them to the terminal. */ + capture?: boolean + signal?: AbortSignal +} +export interface RunProcessOptions extends ProcessOptions { + /** Names the command in failures, e.g. `npm install` rather than the Node launcher. */ + label?: string +} + +export async function runProcess( + command: string, + args: string[], + cwd: string, + { + capture = false, + signal, + label = [path.basename(command), args[0]].filter(Boolean).join(' '), + }: RunProcessOptions = {}, +): Promise<string> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + shell: false, + signal, + }) + let stdout = '' + let stderr = '' + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) resolve(stdout) + else + reject( + new Error( + `${label} failed (${signal ?? code}).${stderr ? `\n${stderr.trim()}` : ''}`, + ), + ) + }) + }) +} + +export function findNpmCli(): string | undefined { + // npm_execpath may point to Yarn or pnpm. Only reuse it when it is npm's CLI. + const npmExecPath = process.env.npm_execpath + const nodeDirectory = path.dirname(process.execPath) + const candidates = [ + npmExecPath && /npm-cli\.js$/i.test(npmExecPath) ? npmExecPath : undefined, + path.join(nodeDirectory, 'node_modules/npm/bin/npm-cli.js'), + path.resolve(nodeDirectory, '../lib/node_modules/npm/bin/npm-cli.js'), + ] + const npmCli = candidates.find((file) => file && existsSync(file)) + if (npmCli) return npmCli + if (process.platform !== 'win32') { + for (const directory of (process.env.PATH ?? '').split(path.delimiter)) { + if (!directory) continue + const executable = path.join(directory, 'npm') + if (!existsSync(executable)) continue + const target = realpathSync(executable) + if (/npm-cli\.js$/i.test(target)) return target + } + } + return undefined +} + +export function runNpm( + args: string[], + cwd: string, + options: ProcessOptions = {}, +) { + const run = { ...options, label: ['npm', args[0]].filter(Boolean).join(' ') } + const npmCli = findNpmCli() + if (npmCli) return runProcess(process.execPath, [npmCli, ...args], cwd, run) + if (process.platform === 'win32') + throw new Error('Cannot locate npm. Install Node.js with npm, then retry.') + return runProcess('npm', args, cwd, run) +} diff --git a/packages/purestack/src/init/tasks.ts b/packages/purestack/src/init/tasks.ts new file mode 100644 index 00000000..3e9c7ff1 --- /dev/null +++ b/packages/purestack/src/init/tasks.ts @@ -0,0 +1,4 @@ +export type TaskRunner = <T>( + label: string, + action: () => Promise<T>, +) => Promise<T> diff --git a/packages/purestack/src/init/template-store.ts b/packages/purestack/src/init/template-store.ts new file mode 100644 index 00000000..2b62f272 --- /dev/null +++ b/packages/purestack/src/init/template-store.ts @@ -0,0 +1,107 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fetchTemplate, inspectTemplate, unpackTemplate } from './template' +import { readTemplateUi, type TemplateUi } from './template-ui' + +export interface LoadedTemplate { + source: string + root: string + name: string + version: string + description: string + ui: TemplateUi +} +export interface TemplateEnvironment { + cwd: string + packageRoot: string + version: string +} +export interface TemplateSourceStore { + load(source: string): Promise<LoadedTemplate> + dispose(): Promise<void> +} +export const officialTemplates = [ + { + id: 'starter', + label: 'Starter', + description: + 'A focused website with a home page, about page, and browser TypeScript.', + }, +] + +export async function localTemplate( + source: string, + environment: TemplateEnvironment, +) { + if (!officialTemplates.some((template) => template.id === source)) + return source + const root = path.resolve(environment.packageRoot, '../..') + try { + const manifest = JSON.parse( + await readFile(path.join(root, 'package.json'), 'utf8'), + ) + if ( + manifest.name !== 'purestack-repo' || + path.join(root, 'packages/purestack') !== environment.packageRoot + ) + return source + const directory = path.join(root, 'templates', source) + const template = JSON.parse( + await readFile(path.join(directory, 'package.json'), 'utf8'), + ) + return template.purestackTemplate === true ? directory : source + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return source + throw error + } +} + +/** One staged copy per source. Browsing a template never writes a user project. */ +export class TemplateStore implements TemplateSourceStore { + private directory?: Promise<string> + private readonly abort = new AbortController() + private readonly entries = new Map<string, Promise<LoadedTemplate>>() + constructor(private readonly environment: TemplateEnvironment) {} + load(source: string): Promise<LoadedTemplate> { + const existing = this.entries.get(source) + if (existing) return existing + const pending = this.fetch(source).catch((error) => { + this.entries.delete(source) + throw error + }) + this.entries.set(source, pending) + return pending + } + private async fetch(source: string): Promise<LoadedTemplate> { + this.directory ??= mkdtemp(path.join(tmpdir(), 'purestack-studio-')) + const stage = await mkdtemp(path.join(await this.directory, 'template-')) + const resolved = await localTemplate(source, this.environment) + const archive = await fetchTemplate( + resolved, + stage, + this.environment.cwd, + this.abort.signal, + ) + const root = path.join(stage, 'unpacked') + await unpackTemplate(archive, root) + const { pkg, manifest } = await inspectTemplate( + root, + this.environment.version, + ) + return { + source, + root, + name: pkg.name as string, + version: pkg.version as string, + description: manifest.description as string, + ui: await readTemplateUi(root), + } + } + async dispose() { + this.abort.abort() + await Promise.allSettled(this.entries.values()) + if (this.directory) + await rm(await this.directory, { recursive: true, force: true }) + } +} diff --git a/packages/purestack/src/init/template-ui.test.ts b/packages/purestack/src/init/template-ui.test.ts new file mode 100644 index 00000000..262e76df --- /dev/null +++ b/packages/purestack/src/init/template-ui.test.ts @@ -0,0 +1,240 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseInitArguments, parseTemplateOverrides } from '../cli/arguments' +import { + applyTemplateValues, + parseTemplateUi, + resolveTemplateValues, + templateFields, +} from './template-ui' + +function definition() { + return { + version: 1, + categories: [ + { + id: 'brand', + label: 'Brand', + description: 'Brand settings', + fields: [ + { + id: 'palette', + label: 'Palette', + description: 'Colors', + type: 'select', + default: 'blue', + choices: [ + { value: 'blue', label: 'Blue', color: '#2874b7' }, + { value: 'mint', label: 'Mint', color: '#6dd59a' }, + ], + bindings: [ + { path: ['style', 'theme', 'presets'], transform: 'array' }, + ], + }, + { + id: 'search', + label: 'Search', + description: 'Find pages', + type: 'toggle', + default: true, + bindings: [{ path: ['pagefind', 'enabled'] }], + }, + { + id: 'site-url', + label: 'URL', + description: 'Public address', + type: 'text', + default: '', + validation: 'url', + bindings: [ + { path: ['sitemap', 'baseUrl'], transform: 'trim-slashes' }, + { path: ['sitemap', 'enabled'], transform: 'nonempty' }, + ], + }, + ], + }, + ], + } +} +let temporary: string | undefined +afterEach(async () => { + if (temporary) await rm(temporary, { recursive: true, force: true }) + temporary = undefined +}) + +describe('template UI contract', () => { + it('resolves template defaults and validates values from the same schema as the menu', () => { + const ui = parseTemplateUi(definition()) + expect(templateFields(ui).map((field) => field.id)).toEqual([ + 'palette', + 'search', + 'site-url', + ]) + expect(resolveTemplateValues(ui, { palette: 'mint' })).toEqual({ + palette: 'mint', + search: true, + 'site-url': '', + }) + expect(() => resolveTemplateValues(ui, { unknown: 'value' })).toThrow( + 'Unknown template option', + ) + expect(() => + resolveTemplateValues(ui, { palette: 'not-a-choice' }), + ).toThrow('available values') + expect(() => resolveTemplateValues(ui, { search: 'true' })).toThrow('type') + }) + it('rejects unsafe binding paths, duplicate IDs, and unknown transforms', () => { + for (const unsafe of ['__proto__', 'constructor', 'prototype']) { + const input = definition() + input.categories[0].fields[0].bindings[0].path = [unsafe, 'polluted'] + expect(() => parseTemplateUi(input)).toThrow('binding') + } + const duplicate = definition() + duplicate.categories[0].fields[0].id = 'brand' + expect(() => parseTemplateUi(duplicate)).toThrow('duplicate') + const transform = definition() + transform.categories[0].fields[0].bindings[0] = { + path: ['style'], + transform: 'execute-js', + } + expect(() => parseTemplateUi(transform)).toThrow('transform') + }) + it('rejects invalid defaults, URL credentials, and malformed categories', () => { + const invalidDefault = definition() + invalidDefault.categories[0].fields[0].default = 'unknown' + expect(() => parseTemplateUi(invalidDefault)).toThrow('default') + const ui = parseTemplateUi(definition()) + expect(() => + resolveTemplateValues(ui, { + 'site-url': 'https://user:secret@example.com', + }), + ).toThrow('credentials') + expect(() => + parseTemplateUi({ + version: 1, + categories: [ + { + id: 'broken', + label: 'Broken', + description: 'Broken', + fields: 'not-an-array', + }, + ], + }), + ).toThrow('arrays') + }) + it('writes declarative bindings without changing unrelated template configuration', async () => { + temporary = await mkdtemp(path.join(tmpdir(), 'purestack-ui-test-')) + await mkdir(path.join(temporary, 'content')) + const file = path.join(temporary, 'content/siteConfig.json') + await writeFile( + file, + JSON.stringify({ + siteTitle: 'Original', + style: { theme: { skin: 'standard' } }, + extensions: { custom: { keep: true } }, + }), + ) + await applyTemplateValues(temporary, parseTemplateUi(definition()), { + palette: 'mint', + search: false, + 'site-url': 'https://example.com/', + }) + expect(JSON.parse(await readFile(file, 'utf8'))).toEqual({ + siteTitle: 'Original', + style: { theme: { skin: 'standard', presets: ['mint'] } }, + pagefind: { enabled: false }, + sitemap: { baseUrl: 'https://example.com', enabled: true }, + extensions: { custom: { keep: true } }, + }) + expect(Object.prototype).not.toHaveProperty('polluted') + }) +}) + +describe('native argument parsing', () => { + it('accepts optional Studio host and port, including automatic port selection', () => { + expect(parseInitArguments([], process.cwd())).toMatchObject({ + host: undefined, + port: undefined, + }) + expect( + parseInitArguments( + ['--host', '0.0.0.0', '--port', '4700'], + process.cwd(), + ), + ).toMatchObject({ host: '0.0.0.0', port: 4700 }) + expect( + parseInitArguments(['--host=::1', '--port=0'], process.cwd()), + ).toMatchObject({ host: '::1', port: 0 }) + expect(parseInitArguments(['--port=65535'], process.cwd()).port).toBe(65535) + }) + it.each([ + '', + ' ', + '-1', + '65536', + '1.5', + 'NaN', + 'Infinity', + '1e3', + '0x50', + '9'.repeat(400), + ])('rejects invalid Studio port %j', (port) => { + expect(() => parseInitArguments([`--port=${port}`], process.cwd())).toThrow( + 'Port must be an integer between 0 and 65535.', + ) + }) + it('rejects an empty host or missing network option values', () => { + expect(() => parseInitArguments(['--host= '], process.cwd())).toThrow( + 'Host cannot be empty.', + ) + for (const option of ['--host', '--port']) + expect(() => parseInitArguments([option], process.cwd())).toThrow() + }) + it('accepts init alone, preserves the working directory, and makes setup opt-in', () => { + expect(parseInitArguments([], process.cwd())).toMatchObject({ + directory: undefined, + template: 'starter', + install: false, + git: false, + yes: false, + }) + expect( + parseInitArguments( + ['--install', '--git', '--yes', 'my-site'], + process.cwd(), + ), + ).toMatchObject({ + directory: 'my-site', + install: true, + git: true, + yes: true, + }) + }) + it.each([ + '--no-install', + '--no-git', + '--git=false', + '--install=false', + '--preset', + ])('rejects removed option %s', (argument) => { + expect(() => + parseInitArguments(['my-site', argument], process.cwd()), + ).toThrow() + }) + it('parses template-specific overrides and rejects ambiguous values', () => { + const ui = parseTemplateUi(definition()) + expect( + parseTemplateOverrides(ui, ['palette=mint', 'search=false']), + ).toEqual({ palette: 'mint', search: false }) + expect(() => parseTemplateOverrides(ui, ['search=off'])).toThrow('type') + expect(() => + parseTemplateOverrides(ui, ['palette=blue', 'palette=mint']), + ).toThrow('twice') + expect(() => parseTemplateOverrides(ui, ['anything=value'])).toThrow( + 'Unknown', + ) + }) +}) diff --git a/packages/purestack/src/init/template-ui.ts b/packages/purestack/src/init/template-ui.ts new file mode 100644 index 00000000..31bbb005 --- /dev/null +++ b/packages/purestack/src/init/template-ui.ts @@ -0,0 +1,294 @@ +import { readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' + +export type OptionValue = string | boolean +export interface TemplateChoice { + value: OptionValue + label: string + description?: string + color?: string +} +export interface ConfigBinding { + path: string[] + transform?: 'identity' | 'array' | 'not' | 'nonempty' | 'trim-slashes' +} +export interface TemplateField { + id: string + label: string + description: string + type: 'text' | 'select' | 'toggle' + default: OptionValue + choices?: TemplateChoice[] + validation?: 'required' | 'url' | 'base-path' + bindings: ConfigBinding[] +} +export interface TemplateCategory { + id: string + label: string + description: string + categories: TemplateCategory[] + fields: TemplateField[] +} +export interface TemplateUi { + version: 1 + categories: TemplateCategory[] +} + +function record(value: unknown): value is Record<string, unknown> { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} +function text(value: unknown, label: string): string { + if (typeof value !== 'string' || !value.trim() || value.length > 2000) + throw new Error(`Invalid template UI ${label}.`) + return value +} +function value(input: unknown): OptionValue { + if (typeof input !== 'boolean' && typeof input !== 'string') + throw new Error('Template UI values must be strings or booleans.') + return input +} + +/** Validate untrusted template metadata before it can become navigation or writes. */ +export function parseTemplateUi(input: unknown): TemplateUi { + if (!record(input) || input.version !== 1 || !Array.isArray(input.categories)) + throw new Error('Invalid template UI. Expected version 1 and categories.') + const ids = new Set<string>() + function id(input: unknown) { + const result = text(input, 'id') + if ( + !/^[a-z][a-z0-9.-]*$/.test(result) || + ids.has(result) || + ids.size >= 250 + ) + throw new Error(`Invalid or duplicate template UI id: ${result}`) + ids.add(result) + return result + } + function field(input: unknown): TemplateField { + if ( + !record(input) || + !['text', 'select', 'toggle'].includes(String(input.type)) || + !Array.isArray(input.bindings) + ) + throw new Error('Invalid template UI field.') + const result: TemplateField = { + id: id(input.id), + label: text(input.label, 'field label'), + description: text(input.description, 'field description'), + type: input.type as TemplateField['type'], + default: value(input.default), + bindings: [], + } + if (input.validation !== undefined) { + if (!['required', 'url', 'base-path'].includes(String(input.validation))) + throw new Error(`Unknown validation for ${result.id}.`) + result.validation = input.validation as TemplateField['validation'] + } + if (input.choices !== undefined) { + if (!Array.isArray(input.choices) || input.choices.length > 100) + throw new Error(`Invalid choices for ${result.id}.`) + const values = new Set<OptionValue>() + result.choices = input.choices.map((choice) => { + if (!record(choice)) throw new Error(`Invalid choice for ${result.id}.`) + const choiceValue = value(choice.value) + if (values.has(choiceValue)) + throw new Error(`Duplicate choice in ${result.id}.`) + values.add(choiceValue) + if ( + choice.color !== undefined && + (typeof choice.color !== 'string' || + !/^#[a-f0-9]{6}$/i.test(choice.color)) + ) + throw new Error('Invalid template color swatch.') + return { + value: choiceValue, + label: text(choice.label, 'choice label'), + description: + typeof choice.description === 'string' + ? choice.description + : undefined, + color: choice.color as string | undefined, + } + }) + } + for (const binding of input.bindings) { + if ( + !record(binding) || + !Array.isArray(binding.path) || + !binding.path.length || + binding.path.length > 12 || + binding.path.some( + (key) => + typeof key !== 'string' || + !key || + ['__proto__', 'constructor', 'prototype'].includes(key), + ) + ) + throw new Error(`Invalid config binding for ${result.id}.`) + if ( + binding.transform !== undefined && + !['identity', 'array', 'not', 'nonempty', 'trim-slashes'].includes( + String(binding.transform), + ) + ) + throw new Error(`Invalid config transform for ${result.id}.`) + if (binding.transform === 'not' && result.type !== 'toggle') + throw new Error('The not transform requires a toggle.') + result.bindings.push({ + path: binding.path as string[], + transform: binding.transform as ConfigBinding['transform'], + }) + } + const invalid = validateField(result, result.default) + if (invalid) throw new Error(`Invalid default for ${result.id}: ${invalid}`) + return result + } + function category(input: unknown, depth: number): TemplateCategory { + if (!record(input) || depth > 5) + throw new Error('Invalid template UI category nesting.') + if ( + (input.categories !== undefined && !Array.isArray(input.categories)) || + (input.fields !== undefined && !Array.isArray(input.fields)) + ) + throw new Error('Category fields and categories must be arrays.') + return { + id: id(input.id), + label: text(input.label, 'category label'), + description: text(input.description, 'category description'), + categories: ((input.categories ?? []) as unknown[]).map((child) => + category(child, depth + 1), + ), + fields: ((input.fields ?? []) as unknown[]).map(field), + } + } + return { + version: 1, + categories: input.categories.map((item) => category(item, 0)), + } +} + +export function templateFields(ui: TemplateUi): TemplateField[] { + return ui.categories.flatMap(function collect(category): TemplateField[] { + return [...category.fields, ...category.categories.flatMap(collect)] + }) +} + +export function validateField(field: TemplateField, input: OptionValue) { + if ( + (field.type === 'toggle' && typeof input !== 'boolean') || + (field.type === 'text' && typeof input !== 'string') + ) + return 'Invalid value type.' + if ( + field.type === 'select' && + !field.choices?.some((choice) => choice.value === input) + ) + return 'Choose one of the available values.' + if (typeof input !== 'string') return undefined + if (input.length > 2000 || /[\r\n]/.test(input)) + return 'Use a single line of at most 2,000 characters.' + if (field.validation === 'required' && !input.trim()) + return 'This value is required.' + if (field.validation === 'url' && input) { + try { + const url = new URL(input) + if ( + !['https:', 'http:'].includes(url.protocol) || + url.username || + url.password || + url.search || + url.hash + ) + return 'Use a public http:// or https:// URL without credentials, query, or fragment.' + } catch { + return 'Use an absolute URL such as https://example.com.' + } + } + if ( + field.validation === 'base-path' && + input && + (!input.startsWith('/') || + /[\\?#\s]/.test(input) || + input + .split('/') + .slice(1) + .some( + (part, index, parts) => + part === '.' || + part === '..' || + (!part && index < parts.length - 1), + )) + ) + return 'Use a path such as /docs, without traversal, query, or fragment.' + return undefined +} + +export async function readTemplateUi(root: string): Promise<TemplateUi> { + const manifest = JSON.parse( + await readFile(path.join(root, 'template.json'), 'utf8'), + ) + if (manifest.ui === undefined) return { version: 1, categories: [] } + if (manifest.ui !== 'template.ui.json') + throw new Error('Template UI must reference template.ui.json.') + return parseTemplateUi( + JSON.parse(await readFile(path.join(root, manifest.ui), 'utf8')), + ) +} + +export function resolveTemplateValues( + ui: TemplateUi, + overrides: Record<string, OptionValue> = {}, +) { + const fields = templateFields(ui) + const result: Record<string, OptionValue> = {} + for (const key of Object.keys(overrides)) + if (!fields.some((field) => field.id === key)) + throw new Error(`Unknown template option: ${key}`) + for (const field of fields) { + const current = Object.hasOwn(overrides, field.id) + ? overrides[field.id] + : field.default + const invalid = validateField(field, current) + if (invalid) throw new Error(`${field.label}: ${invalid}`) + result[field.id] = current + } + return result +} + +export async function applyTemplateValues( + source: string, + ui: TemplateUi, + overrides: Record<string, OptionValue>, +) { + const values = resolveTemplateValues(ui, overrides) + const file = path.join(source, 'content/siteConfig.json') + const config: unknown = JSON.parse(await readFile(file, 'utf8')) + if (!record(config)) + throw new Error('siteConfig.json must contain an object.') + for (const field of templateFields(ui)) + for (const binding of field.bindings) { + let parent = config + for (const key of binding.path.slice(0, -1)) { + if (!Object.hasOwn(parent, key)) parent[key] = {} + const child = parent[key] + if (!record(child)) + throw new Error( + `Cannot write template binding ${binding.path.join('.')}.`, + ) + parent = child + } + const input = values[field.id] + const output = + binding.transform === 'array' + ? [input] + : binding.transform === 'not' + ? !input + : binding.transform === 'nonempty' + ? Boolean(input) + : binding.transform === 'trim-slashes' + ? String(input).replace(/\/+$/, '') + : input + parent[binding.path.at(-1) as string] = output + } + await writeFile(file, `${JSON.stringify(config, null, 2)}\n`) +} diff --git a/packages/purestack/src/init/template.ts b/packages/purestack/src/init/template.ts new file mode 100644 index 00000000..ba94096d --- /dev/null +++ b/packages/purestack/src/init/template.ts @@ -0,0 +1,385 @@ +import { constants } from 'node:fs' +import { + cp, + lstat, + mkdir, + readdir, + readFile, + writeFile, +} from 'node:fs/promises' +import path from 'node:path' +import { t, x } from 'tar' +import type { PackageManager } from './package-manager' +import { runNpm } from './process' +import { isCompatibleVersion, parseVersion } from './version' + +export const OFFICIAL_TEMPLATES = { + starter: '@purestack/template-starter', +} as const + +export async function assertEmptyDestination(directory: string) { + const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined + throw error + }) + if ( + info && + (!info.isDirectory() || + info.isSymbolicLink() || + (await readdir(directory)).length) + ) { + throw new Error(`Destination must be an empty directory: ${directory}`) + } +} + +export function resolveTemplateSpec(spec: string, cwd: string) { + const [alias, ...version] = spec.split('@') + if (Object.hasOwn(OFFICIAL_TEMPLATES, alias)) { + return `${OFFICIAL_TEMPLATES[alias as keyof typeof OFFICIAL_TEMPLATES]}${version.length ? `@${version.join('@')}` : '@latest'}` + } + if ( + path.isAbsolute(spec) || + spec.startsWith('./') || + spec.startsWith('../') || + spec.startsWith('.\\') || + spec.startsWith('..\\') + ) { + return path.resolve(cwd, spec) + } + // Deliberately accept registry packages and local paths, not executable Git sources. + if ( + !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[^\s/@]+)?$/.test( + spec, + ) + ) { + throw new Error( + 'Template must be an npm package, an official alias, or an explicit local path.', + ) + } + return spec +} + +export async function fetchTemplate( + spec: string, + temporary: string, + cwd: string, + signal?: AbortSignal, +) { + const resolved = resolveTemplateSpec(spec, cwd) + if (path.isAbsolute(resolved) && resolved.endsWith('.tgz')) { + const archive = path.join(temporary, 'template.tgz') + await cp(resolved, archive, { errorOnExist: true, force: false }) + return archive + } + const result: unknown = JSON.parse( + await runNpm( + [ + 'pack', + resolved, + '--ignore-scripts', + '--json', + '--pack-destination', + temporary, + ], + cwd, + { capture: true, signal }, + ), + ) + if ( + !Array.isArray(result) || + result.length !== 1 || + typeof result[0]?.filename !== 'string' + ) { + throw new Error('npm did not return a template archive.') + } + const archive = path.resolve(temporary, result[0].filename) + if (path.dirname(archive) !== temporary || !archive.endsWith('.tgz')) { + throw new Error('npm returned an invalid template archive path.') + } + return archive +} + +function safeArchivePath(name: string) { + const parts = name.replace(/\/$/, '').split('/') + if ( + parts[0] !== 'package' || + parts.some( + (part) => + !part || + part === '.' || + part === '..' || + /[\\:<>"|?*]/.test(part) || + [...part].some((character) => character.charCodeAt(0) < 32) || + /[. ]$/.test(part) || + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part), + ) + ) { + throw new Error(`Unsafe template archive path: ${name}`) + } +} + +export async function unpackTemplate(archive: string, directory: string) { + const seen = new Set<string>() + let totalSize = 0 + let invalid: Error | undefined + await t({ + file: archive, + strict: true, + onReadEntry(entry) { + try { + safeArchivePath(entry.path) + if (entry.type !== 'File' && entry.type !== 'Directory') { + throw new Error( + `Template archives cannot contain ${entry.type}: ${entry.path}`, + ) + } + const key = entry.path.replace(/\/$/, '').toLowerCase() + if (seen.has(key)) + throw new Error(`Duplicate template archive path: ${entry.path}`) + seen.add(key) + totalSize += entry.size + if (seen.size > 10000 || totalSize > 100 * 1024 * 1024) + throw new Error('Template exceeds the 100 MB / 10,000 entry limit.') + } catch (error) { + invalid ??= error as Error + } + }, + }) + if (invalid) throw invalid + await mkdir(directory) + await x({ + file: archive, + cwd: directory, + strip: 1, + strict: true, + noChmod: true, + }) +} + +async function readJson(file: string): Promise<Record<string, unknown>> { + const value: unknown = JSON.parse(await readFile(file, 'utf8')) + if (!isRecord(value)) throw new Error(`Expected a JSON object: ${file}`) + return value +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +async function writeJson(file: string, value: Record<string, unknown>) { + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`) +} + +async function listProjectFiles( + directory: string, + prefix = '', +): Promise<string[]> { + const result: string[] = [] + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (['.git', 'node_modules', '.npmrc'].includes(entry.name.toLowerCase())) + throw new Error( + `Template contains forbidden project entry: ${entry.name}`, + ) + const relative = path.join(prefix, entry.name) + if (entry.isDirectory()) + result.push( + ...(await listProjectFiles(path.join(directory, entry.name), relative)), + ) + else if (entry.isFile()) result.push(relative) + else throw new Error(`Template contains a non-regular file: ${relative}`) + } + return result +} + +export function projectName(directory: string) { + const name = path + .basename(directory) + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^[._-]+|[._-]+$/g, '') + if ( + !name || + name.length > 214 || + ['node_modules', 'favicon.ico'].includes(name) + ) + throw new Error( + 'Choose a directory name that can be used as an npm project name.', + ) + return name +} + +export async function inspectTemplate( + unpacked: string, + purestackVersion: string, +) { + const pkg = await readJson(path.join(unpacked, 'package.json')) + if ( + pkg.purestackTemplate !== true || + typeof pkg.name !== 'string' || + typeof pkg.version !== 'string' || + !parseVersion(pkg.version) + ) { + throw new Error('Not a versioned PureStack template package.') + } + const manifest = await readJson(path.join(unpacked, 'template.json')) + if ( + manifest.formatVersion !== 1 || + typeof manifest.description !== 'string' || + typeof manifest.purestack !== 'string' || + !parseVersion(manifest.purestack) + ) { + throw new Error( + 'Invalid or unsupported PureStack template manifest (expected formatVersion: 1 and a plain major.minor.patch purestack minimum version).', + ) + } + if (!isCompatibleVersion(purestackVersion, manifest.purestack)) { + throw new Error( + `${pkg.name}@${pkg.version} requires PureStack ${manifest.purestack} or newer within the same major version; this CLI is ${purestackVersion}. Use a compatible CLI or template version.`, + ) + } + return { pkg, manifest } +} + +/** Registry selectors only; npm/Yarn validate their version, range, or tag syntax. */ +function isRegistryDependency(version: string) { + const alias = + /^npm:(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@(.+))?$/.exec( + version, + ) + const selector = ( + alias?.[0] === version ? (alias[1] ?? 'latest') : version + ).trim() + return ( + /^[\w.*+^~<>=| -]+$/.test(selector) && + !['.', '..'].includes(selector) && + !/\.(?:tgz|tar\.gz)$/i.test(selector) + ) +} + +export async function prepareProject( + unpacked: string, + directory: string, + purestackVersion: string, + title = path.basename(directory), +) { + const { pkg } = await inspectTemplate(unpacked, purestackVersion) + const source = path.join(unpacked, 'template') + const files = await listProjectFiles(source) + for (const file of [ + 'package.json', + 'AGENTS.md', + 'README.md', + path.join('content', 'siteConfig.json'), + path.join('content', 'index.mdx'), + ]) { + if (!files.includes(file)) throw new Error(`Template is missing ${file}.`) + } + const project = await readJson(path.join(source, 'package.json')) + let packageManager: PackageManager = 'npm' + if (project.packageManager !== undefined) { + if (typeof project.packageManager !== 'string') + throw new Error('Project packageManager must pin a Yarn or npm version.') + const match = /^(yarn|npm)@(.+)$/.exec(project.packageManager) + if ( + !match || + match[0] !== project.packageManager || + !parseVersion(match[2]) + ) + throw new Error('Project packageManager must pin a Yarn or npm version.') + if (match[1] === 'yarn') packageManager = 'yarn' + } + const dependencies = project.dependencies + if ( + !isRecord(dependencies) || + dependencies.purestack !== '{{purestackVersion}}' + ) + throw new Error( + 'Template must declare dependencies.purestack as {{purestackVersion}}.', + ) + for (const field of [ + 'dependencies', + 'devDependencies', + 'optionalDependencies', + 'peerDependencies', + ]) { + const entries = project[field] + if (entries === undefined) continue + if (!isRecord(entries)) throw new Error(`Invalid project ${field}.`) + for (const [dependency, version] of Object.entries(entries)) { + if ( + typeof version !== 'string' || + (!isRegistryDependency(version) && + !( + (dependency === 'purestack' || + dependency.startsWith('@purestack/')) && + version === '{{purestackVersion}}' + )) + ) + throw new Error( + `Project ${field} must use published dependency versions.`, + ) + if (version === '{{purestackVersion}}') + entries[dependency] = purestackVersion + } + } + const scripts = project.scripts + if ( + !isRecord(scripts) || + ['dev', 'build', 'publish'].some( + (script) => typeof scripts[script] !== 'string', + ) + ) { + throw new Error('Template must provide dev, build, and publish scripts.') + } + for (const hook of [ + 'preinstall', + 'install', + 'postinstall', + 'prepare', + 'prepublish', + 'preprepare', + 'postprepare', + ]) { + if (Object.hasOwn(scripts, hook)) + throw new Error(`Template must not provide an installation hook: ${hook}`) + } + project.name = projectName(directory) + project.private = true + dependencies.purestack = purestackVersion + await writeJson(path.join(source, 'package.json'), project) + const configFile = path.join(source, 'content', 'siteConfig.json') + const config = await readJson(configFile) + config.siteTitle = title + if (isRecord(config.logo)) config.logo.brand = title + await writeJson(configFile, config) + // A lockfile marks an independent Yarn project, even inside another workspace. + if (packageManager === 'yarn' && !files.includes('yarn.lock')) { + await writeFile(path.join(source, 'yarn.lock'), '', { flag: 'wx' }) + files.push('yarn.lock') + } + return { + source, + files, + name: pkg.name, + version: pkg.version, + packageManager, + } +} + +export async function writeProject( + source: string, + files: string[], + directory: string, +) { + await assertEmptyDestination(directory) + await mkdir(directory, { recursive: true }) + for (const file of files) { + const destination = path.join(directory, file) + await mkdir(path.dirname(destination), { recursive: true }) + await cp(path.join(source, file), destination, { + errorOnExist: true, + force: false, + mode: constants.COPYFILE_EXCL, + }) + } +} diff --git a/packages/purestack/src/init/version.test.ts b/packages/purestack/src/init/version.test.ts new file mode 100644 index 00000000..d8e993cc --- /dev/null +++ b/packages/purestack/src/init/version.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { isCompatibleVersion, parseVersion } from './version' + +describe('release versions', () => { + it('parses numeric release components', () => { + expect(parseVersion('1.10.123')).toEqual({ + major: 1, + minor: 10, + patch: 123, + }) + expect(parseVersion('0.0.0')).toEqual({ major: 0, minor: 0, patch: 0 }) + }) + + it.each([ + '', + '1.2', + '1.2.3.4', + '01.2.3', + '1.02.3', + '1.2.03', + '-1.2.3', + 'v1.2.3', + '^1.2.3', + '>=1.2.3', + '1.2.x', + '1.2.3-beta.1', + '1.2.3+build', + ' 1.2.3', + '1.2.3\n', + '9007199254740992.0.0', + '1.9007199254740992.0', + '1.0.9007199254740992', + ])('rejects unsupported version %j', (value) => { + expect(parseVersion(value)).toBeUndefined() + }) + + it.each([ + ['1.2.9', '1.2.9', true], + ['1.2.10', '1.2.9', true], + ['1.10.0', '1.2.9', true], + ['1.2.8', '1.2.9', false], + ['1.1.99', '1.2.9', false], + ['2.0.0', '1.2.9', false], + ['0.2.0', '0.1.9', true], + ['1.0.0', '0.1.9', false], + ['1.2.9-beta.1', '1.2.9', false], + ['1.2.9', '^1.2.9', false], + ])('%s meets minimum %s: %s', (current, minimum, compatible) => { + expect(isCompatibleVersion(current, minimum)).toBe(compatible) + }) +}) diff --git a/packages/purestack/src/init/version.ts b/packages/purestack/src/init/version.ts new file mode 100644 index 00000000..26277b16 --- /dev/null +++ b/packages/purestack/src/init/version.ts @@ -0,0 +1,22 @@ +// Template contracts and package-manager pins use plain major.minor.patch releases. +export function parseVersion(value: string) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(value) + if (!match || match[0] !== value) return undefined + const major = Number(match[1]) + const minor = Number(match[2]) + const patch = Number(match[3]) + if (![major, minor, patch].every(Number.isSafeInteger)) return undefined + return { major, minor, patch } +} + +export function isCompatibleVersion(current: string, minimum: string) { + const version = parseVersion(current) + const required = parseVersion(minimum) + return ( + version !== undefined && + required !== undefined && + version.major === required.major && + (version.minor > required.minor || + (version.minor === required.minor && version.patch >= required.patch)) + ) +} diff --git a/packages/purestack/wizard/footer.mdx b/packages/purestack/wizard/footer.mdx new file mode 100644 index 00000000..6762b2e0 --- /dev/null +++ b/packages/purestack/wizard/footer.mdx @@ -0,0 +1,23 @@ +<SiteFooter + class="wizard-footer" + tone="neutral" + variant="none" + :copyright="'© ' + new Date().getFullYear() + ' PureStack Studio'" + ariaLabel="Wizard footer" + legalLabel="PureStack resources" +> + <template #legal> + <a + href="https://purestack.studio/guides/" + target="_blank" + rel="noopener noreferrer" + >Documentation ↗</a + > + <a + href="https://github.com/PureStackStudio/PureStack" + target="_blank" + rel="noopener noreferrer" + >GitHub ↗</a + > + </template> +</SiteFooter> diff --git a/packages/purestack/wizard/header.mdx b/packages/purestack/wizard/header.mdx new file mode 100644 index 00000000..d831693a --- /dev/null +++ b/packages/purestack/wizard/header.mdx @@ -0,0 +1 @@ +<TopBar :search="false" :signInEnabled="false" /> diff --git a/packages/purestack/wizard/index.mdx b/packages/purestack/wizard/index.mdx new file mode 100644 index 00000000..79039b85 --- /dev/null +++ b/packages/purestack/wizard/index.mdx @@ -0,0 +1,23 @@ +--- +title: PureStack Wizard +description: Configure your next PureStack website. +template: splash +layout: + showNav: false + showFooter: true + showToc: false +index: false +--- + +<div class="wizard-content w-full"> + <SectionHeader + eyebrow="LET'S MAKE SOMETHING YOURS" + title="Your next website starts here." + titleTag="h1" + subtitle="Choose a starting point and make the project your own. Move between sections at any time." + class="mb-6"/> + <RegorApp id="wizard-app" src="./modules/wizard.ts"/> + <noscript> + Enable JavaScript to configure your website with the wizard. + </noscript> +</div> diff --git a/packages/purestack/wizard/modules/components/creation-panel.ts b/packages/purestack/wizard/modules/components/creation-panel.ts new file mode 100644 index 00000000..ab9174a1 --- /dev/null +++ b/packages/purestack/wizard/modules/components/creation-panel.ts @@ -0,0 +1,22 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardCreationPanel { + studio: WizardModel +} + +const creationPanelTemplate = html`<section aria-label="Create your site"> + <SectionHeader class="mb-5" eyebrow="YOUR WORKSPACE" + :title="studio.status === 'closing' ? (studio.result ? 'Your workspace is saved.' : 'Studio is closed.') : studio.busy ? 'Bringing your site to life.' : 'Your next chapter starts here.'" + :subtitle="studio.status === 'closing' ? 'The local session is ending. Any files already created are preserved.' : studio.busy ? 'Follow each step as your project takes shape.' : 'Your source is ready. Open the live site and keep exploring.'"/> + <WizardCreationProgress r-if="studio.busy" :tasks="studio.tasks"/> + <WizardCreationResult r-if="studio.result && !studio.busy && studio.status !== 'closing'" :project="studio.result" :connected="studio.connection === 'connected'" + :restart="studio.restartPreview" :finish="studio.finish"/> +</section>` + +export function defineWizardCreationPanelComponent() { + return defineComponent<WizardCreationPanel>(creationPanelTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/components/creation-progress.ts b/packages/purestack/wizard/modules/components/creation-progress.ts new file mode 100644 index 00000000..b13f288a --- /dev/null +++ b/packages/purestack/wizard/modules/components/creation-progress.ts @@ -0,0 +1,49 @@ +import { + type ComputedRef, + computed, + defineComponent, + html, + type SRef, +} from 'regor' +import type { StudioTask } from '../shared/protocol' + +export interface WizardCreationProgress { + tasks: SRef<StudioTask[]> + completed?: ComputedRef<number> + currentLabel?: ComputedRef<string> +} + +const creationProgressTemplate = html`<Flex direction="column" class="gap-4" aria-label="Site creation progress"> + <Flex align="center" class="gap-3"> + <span class="wizard-progress-spinner" aria-hidden="true"></span> + <div role="status" aria-live="polite"><strong>{{ currentLabel }}</strong><p class="text-subtle fs-xs mt-2 mb-0">{{ completed }} of {{ tasks.length }} steps finished</p></div> + </Flex> + <progress class="wizard-progress-meter" :value="completed" :max="tasks.length || 1" aria-label="Completed setup steps"></progress> + <ol class="wizard-task-list m-0 p-0"> + <li r-for="task in tasks" :data-state="task.state" class="py-3"> + <span class="wizard-task-marker" aria-hidden="true">{{ task.state === 'done' ? '✓' : task.state === 'failed' ? '×' : '○' }}</span> + <span>{{ task.label }}</span><span class="text-subtle fs-xs">{{ task.state === 'running' ? 'In progress' : task.state === 'done' ? 'Complete' : task.state === 'failed' ? 'Needs attention' : 'Waiting' }}</span> + </li> + </ol> +</Flex>` + +export function defineWizardCreationProgressComponent() { + return defineComponent<WizardCreationProgress>(creationProgressTemplate, { + props: ['tasks'], + context: (head) => ({ + tasks: head.props.tasks, + completed: computed( + () => + head.props + .tasks() + .filter((task) => task.state === 'done' || task.state === 'failed') + .length, + ), + currentLabel: computed( + () => + head.props.tasks().find((task) => task.state === 'running')?.label ?? + 'Preparing your workspace…', + ), + }), + }) +} diff --git a/packages/purestack/wizard/modules/components/creation-result.ts b/packages/purestack/wizard/modules/components/creation-result.ts new file mode 100644 index 00000000..ac9d5eb4 --- /dev/null +++ b/packages/purestack/wizard/modules/components/creation-result.ts @@ -0,0 +1,32 @@ +import { defineComponent, html, type RefOrValue } from 'regor' +import type { StudioResult } from '../shared/protocol' + +export interface WizardCreationResult { + project: StudioResult + connected: RefOrValue<boolean> + restart: () => void + finish: () => void +} + +const creationResultTemplate = html`<Flex direction="column" class="gap-5"> + <Panel tone="accent" variant="surface" bodyClass="p-4"> + <p class="text-eyebrow mt-0 mb-2">PROJECT CREATED</p> + <p class="wizard-workspace-path fw-700 m-0">{{ project.directory }}</p> + <p class="fs-sm mt-3 mb-0">{{ project.template }}</p> + </Panel> + <FormStatus r-for="failure in project.failures" tone="warning">{{ failure }}</FormStatus> + <FormStatus r-if="project.previewError" tone="warning">{{ project.previewError }}</FormStatus> + <Flex wrap="true" class="gap-3"> + <BtnLink r-if="project.previewUrl" :href="project.previewUrl" target="_blank" rel="noopener noreferrer" tone="accent">Browse live site ↗</BtnLink> + <Btn r-if="!project.previewUrl" tone="accent" :disabled="!connected" @click="restart">Start live site</Btn> + <Btn variant="outline" :disabled="!connected" @click="finish">Finish Studio</Btn> + </Flex> + <p class="text-subtle fs-sm m-0">Studio stays open while you explore. Source edits appear on the live site automatically. Finishing Studio stops both local servers.</p> + <WizardNextSteps :commands="project.commands"/> +</Flex>` + +export function defineWizardCreationResultComponent() { + return defineComponent<WizardCreationResult>(creationResultTemplate, { + props: ['project', 'connected', 'restart', 'finish'], + }) +} diff --git a/packages/purestack/wizard/modules/components/dashboard.ts b/packages/purestack/wizard/modules/components/dashboard.ts new file mode 100644 index 00000000..8064b93d --- /dev/null +++ b/packages/purestack/wizard/modules/components/dashboard.ts @@ -0,0 +1,57 @@ +import { + type Component, + computed, + defineComponent, + html, + onMounted, + onUnmounted, +} from 'regor' +import { WizardModel, type WizardSection } from '../model' +import { defineWizardCreationPanelComponent } from './creation-panel' +import { defineWizardOptionsStepComponent } from './options-step' +import { defineWizardReviewStepComponent } from './review-step' +import { defineWizardSetupStepComponent } from './setup-step' +import { defineWizardSiteStepComponent } from './site-step' +import { defineWizardTemplatesStepComponent } from './templates-step' + +export class WizardDashboard { + readonly model = new WizardModel() + readonly components = { + site: defineWizardSiteStepComponent(), + templates: defineWizardTemplatesStepComponent(), + options: defineWizardOptionsStepComponent(), + setup: defineWizardSetupStepComponent(), + review: defineWizardReviewStepComponent(), + creation: defineWizardCreationPanelComponent(), + } satisfies Record< + WizardSection | 'creation', + Component<{ studio: WizardModel }> + > + readonly activeComponent = computed( + () => + this.components[ + this.model.showCreation() ? 'creation' : this.model.section() + ], + ) + + constructor() { + onMounted(this.model.connect) + onUnmounted(this.model.dispose) + } +} + +const dashboardTemplate = html`<Grid columns="1" columnsLg="28rem minmax(0, 1fr)" class="gap-5" alignItems="start"> + <WizardNavigation/> + <Panel tone="neutral" variant="surface" class="wizard-panel" bodyClass="p-5"> + <Flex direction="column" class="gap-5"> + <FormStatus tone="danger" r-if="model.error">{{ model.error }}</FormStatus> + <div :is="activeComponent"></div> + </Flex> + </Panel> +</Grid>` + +export function defineWizardDashboardComponent() { + return defineComponent<WizardDashboard>(dashboardTemplate, { + context: () => new WizardDashboard(), + }) +} diff --git a/packages/purestack/wizard/modules/components/index.ts b/packages/purestack/wizard/modules/components/index.ts new file mode 100644 index 00000000..596cf100 --- /dev/null +++ b/packages/purestack/wizard/modules/components/index.ts @@ -0,0 +1,48 @@ +import { + defineButtonComponents, + defineFlexComponents, + defineFormComponents, + defineFormInputField, + defineFormSelectField, + defineGridComponents, + defineIconComponents, + definePanelComponents, + defineSectionHeaderComponents, +} from '@purestack/ts-components' +import { lucide_chevron_down } from '@purestack/ts-svg-icons' +import { defineWizardCreationProgressComponent } from './creation-progress' +import { defineWizardCreationResultComponent } from './creation-result' +import { defineWizardDashboardComponent } from './dashboard' +import { defineWizardNavigationComponent } from './navigation' +import { defineWizardNextStepsComponent } from './next-steps' +import { defineWizardOptionCategoryComponent } from './option-category' +import { defineWizardOptionFieldComponent } from './option-field' +import { defineWizardStepComponent } from './step' +import { defineWizardStepActionsComponent } from './step-actions' +import { defineWizardTemplateCardComponent } from './template-card' + +export function defineWizardComponents() { + return { + ...defineButtonComponents(), + ...defineFlexComponents(), + ...defineFormComponents(), + ...defineFormInputField(), + ...defineFormSelectField(), + ...defineIconComponents((name) => + name === 'lucide:chevron-down' ? lucide_chevron_down : '', + ), + ...defineGridComponents(), + ...definePanelComponents(), + ...defineSectionHeaderComponents(), + wizardDashboard: defineWizardDashboardComponent(), + wizardNavigation: defineWizardNavigationComponent(), + wizardStep: defineWizardStepComponent(), + wizardStepActions: defineWizardStepActionsComponent(), + wizardTemplateCard: defineWizardTemplateCardComponent(), + wizardOptionCategory: defineWizardOptionCategoryComponent(), + wizardOptionField: defineWizardOptionFieldComponent(), + wizardCreationProgress: defineWizardCreationProgressComponent(), + wizardCreationResult: defineWizardCreationResultComponent(), + wizardNextSteps: defineWizardNextStepsComponent(), + } +} diff --git a/packages/purestack/wizard/modules/components/navigation.ts b/packages/purestack/wizard/modules/components/navigation.ts new file mode 100644 index 00000000..fe37a6b4 --- /dev/null +++ b/packages/purestack/wizard/modules/components/navigation.ts @@ -0,0 +1,29 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardNavigation { + studio: WizardModel +} + +const navigationTemplate = html`<Panel tone="neutral" variant="surfaceAlt" class="wizard-navigation" bodyClass="p-4"> + <p class="text-eyebrow mb-4">YOUR NEW WEBSITE</p> + <Flex container="nav" direction="column" class="gap-2" aria-label="Setup sections"> + <Btn r-for="entry in studio.sections" :tone="studio.section === entry.id ? 'accent' : 'neutral'" + :variant="studio.section === entry.id ? 'surface' : 'flat'" + :aria-current="!studio.showCreation && studio.section === entry.id ? 'step' : null" :disabled="studio.showCreation" @click="studio.select(entry.id)"> + <span class="wizard-nav-number">{{ entry.number }}</span>{{ entry.label }} + </Btn> + </Flex> + <div class="wizard-local-status mt-5 pt-4"> + <p class="text-eyebrow mb-0">LOCAL SESSION</p> + <p class="text-subtle fs-xs mt-2 mb-0" role="status">{{ studio.connection === 'connected' ? 'Connected to your workspace' : studio.connection === 'closed' ? 'Session closed' : 'Connecting to your workspace…' }}</p> + <p r-if="studio.cwd" class="wizard-workspace-path text-subtle fs-xs mt-2 mb-0">{{ studio.cwd }}</p> + </div> +</Panel>` + +export function defineWizardNavigationComponent() { + return defineComponent<WizardNavigation>(navigationTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/components/next-steps.ts b/packages/purestack/wizard/modules/components/next-steps.ts new file mode 100644 index 00000000..33ed004f --- /dev/null +++ b/packages/purestack/wizard/modules/components/next-steps.ts @@ -0,0 +1,18 @@ +import { defineComponent, html } from 'regor' + +export interface WizardNextSteps { + commands: string[] +} + +const nextStepsTemplate = html`<Panel tone="neutral" variant="surfaceAlt" bodyClass="p-4"> + <h3 class="fs-h5 mt-0 mb-2">Keep building from your terminal</h3> + <p class="text-subtle fs-sm mt-0 mb-3">After finishing Studio, run these commands to start the site again.</p> + <pre class="wizard-next-commands m-0"><code>{{ commands.join('\n') }}</code></pre> + <p class="text-subtle fs-xs mt-3 mb-0">README.md and AGENTS.md guide your next changes.</p> +</Panel>` + +export function defineWizardNextStepsComponent() { + return defineComponent<WizardNextSteps>(nextStepsTemplate, { + props: ['commands'], + }) +} diff --git a/packages/purestack/wizard/modules/components/option-category.ts b/packages/purestack/wizard/modules/components/option-category.ts new file mode 100644 index 00000000..a06ee4fa --- /dev/null +++ b/packages/purestack/wizard/modules/components/option-category.ts @@ -0,0 +1,21 @@ +import { defineComponent, html } from 'regor' +import type { WizardOptionCategory as OptionCategory } from '../model' + +export interface WizardOptionCategory { + category: OptionCategory +} + +const optionCategoryTemplate = html`<Panel tone="neutral" variant="surfaceAlt" bodyClass="p-4"> + <h3 class="fs-h5 mt-0 mb-2">{{ category.label }}</h3> + <p class="text-subtle fs-sm mt-0 mb-4">{{ category.description }}</p> + <Flex direction="column" class="gap-4"> + <WizardOptionField r-for="option in category.fields" :option="option"/> + <WizardOptionCategory r-for="child in category.categories" :category="child"/> + </Flex> +</Panel>` + +export function defineWizardOptionCategoryComponent() { + return defineComponent<WizardOptionCategory>(optionCategoryTemplate, { + props: ['category'], + }) +} diff --git a/packages/purestack/wizard/modules/components/option-field.ts b/packages/purestack/wizard/modules/components/option-field.ts new file mode 100644 index 00000000..a9322b4b --- /dev/null +++ b/packages/purestack/wizard/modules/components/option-field.ts @@ -0,0 +1,39 @@ +import { + type ComputedRef, + computed, + defineComponent, + html, + type SRef, + unref, +} from 'regor' +import type { WizardOption } from '../model' + +export interface WizardOptionField { + option: WizardOption | SRef<WizardOption> + choices?: ComputedRef<{ label: string; value: string }[]> +} + +const optionFieldTemplate = html`<div> + <FormCheck r-if="option.field.type === 'toggle'" :id="'option-' + option.field.id" + :label="option.field.label" :checked="option.value" tone="accent"/> + <FormInputField r-if="option.field.type === 'text'" :id="'option-' + option.field.id" + :label="option.field.label" :model="option.value" maxlength="2000"/> + <FormSelectField r-if="option.field.type === 'select'" :id="'option-' + option.field.id" + :label="option.field.label" :model="option.value" :options="choices"/> + <p class="text-subtle fs-xs mt-2 mb-0">{{ option.field.description }}</p> +</div>` + +export function defineWizardOptionFieldComponent() { + return defineComponent<WizardOptionField>(optionFieldTemplate, { + props: ['option'], + context: (head) => ({ + option: head.props.option, + choices: computed(() => + (unref(head.props.option).field.choices ?? []).map((choice) => ({ + label: choice.label, + value: String(choice.value), + })), + ), + }), + }) +} diff --git a/packages/purestack/wizard/modules/components/options-step.ts b/packages/purestack/wizard/modules/components/options-step.ts new file mode 100644 index 00000000..1fb427bf --- /dev/null +++ b/packages/purestack/wizard/modules/components/options-step.ts @@ -0,0 +1,25 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardOptionsStep { + studio: WizardModel +} + +const optionsStepTemplate = html`<WizardStep label="Template options" eyebrow="03 / OPTIONS" + heading="Make the starting point yours." description="These settings come from your selected template."> + <p r-if="studio.templateLoading" role="status" class="text-subtle">Loading template settings…</p> + <p r-if="!studio.templateLoading && !studio.categories.length" class="text-subtle">This template is ready to use with no additional settings.</p> + <Flex direction="column" class="gap-5" r-if="!studio.templateLoading"> + <WizardOptionCategory r-for="category in studio.categories" :category="category"/> + </Flex> + <template #actions> + <WizardStepActions :navigate="studio.select" back="templates" next="setup" nextLabel="Project setup" :disabled="studio.templateLoading"/> + </template> +</WizardStep>` + +export function defineWizardOptionsStepComponent() { + return defineComponent<WizardOptionsStep>(optionsStepTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/components/review-step.ts b/packages/purestack/wizard/modules/components/review-step.ts new file mode 100644 index 00000000..e3f74bcc --- /dev/null +++ b/packages/purestack/wizard/modules/components/review-step.ts @@ -0,0 +1,27 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardReviewStep { + studio: WizardModel +} + +const reviewStepTemplate = html`<WizardStep label="Review configuration" eyebrow="05 / REVIEW" + heading="Your project, at a glance." description="Check your choices before creating your website."> + <dl class="wizard-review gap-4 fs-sm"> + <template r-for="entry in studio.summary"><dt>{{ entry.label }}</dt><dd>{{ entry.value }}</dd></template> + </dl> + <p class="text-subtle fs-sm mt-5 mb-0">Your source files will be created in the selected folder. Studio will start a live site and stay open.</p> + <template #actions> + <Flex class="wizard-actions mt-5 pt-4" justify="between" wrap="true"> + <Btn variant="outline" @click="studio.select('setup')">← Back</Btn> + <Btn tone="accent" :disabled="!studio.canCreate" @click="studio.create">Create site →</Btn> + </Flex> + </template> +</WizardStep>` + +export function defineWizardReviewStepComponent() { + return defineComponent<WizardReviewStep>(reviewStepTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/components/setup-step.ts b/packages/purestack/wizard/modules/components/setup-step.ts new file mode 100644 index 00000000..fecc2cb7 --- /dev/null +++ b/packages/purestack/wizard/modules/components/setup-step.ts @@ -0,0 +1,30 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardSetupStep { + studio: WizardModel +} + +const setupStepTemplate = html`<WizardStep label="Project setup" eyebrow="04 / SETUP" + heading="The finishing touches." description="Choose what happens after your project files are created."> + <Flex direction="column" class="gap-5"> + <div> + <FormCheck id="wizard-install" label="Install dependencies" :checked="studio.install" tone="accent"/> + <p class="text-subtle fs-xs mt-2 mb-0">Use the package manager configured by the template.</p> + </div> + <div> + <FormCheck id="wizard-git" label="Initialize Git" :checked="studio.git" tone="accent"/> + <p class="text-subtle fs-xs mt-2 mb-0">Create a repository, or reuse an existing one.</p> + </div> + </Flex> + <template #actions> + <WizardStepActions :navigate="studio.select" back="options" next="review" nextLabel="Review configuration"/> + </template> +</WizardStep>` + +export function defineWizardSetupStepComponent() { + return defineComponent<WizardSetupStep>(setupStepTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/components/site-step.ts b/packages/purestack/wizard/modules/components/site-step.ts new file mode 100644 index 00000000..b8e1f62c --- /dev/null +++ b/packages/purestack/wizard/modules/components/site-step.ts @@ -0,0 +1,26 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardSiteStep { + studio: WizardModel +} + +const siteStepTemplate = html`<WizardStep label="Site details" eyebrow="01 / SITE" + heading="Give your project a home." description="Choose its name and where you'd like to keep the source."> + <Flex direction="column" class="gap-4"> + <FormInputField id="wizard-title" label="Site title" :model="studio.title" :required="true" maxlength="2000"/> + <p class="text-subtle fs-xs m-0">The name people see on your website.</p> + <FormInputField id="wizard-directory" label="Project folder" :model="studio.directory" :required="true" maxlength="2000"/> + <p class="text-subtle fs-xs m-0">Relative to the folder where you started the CLI.</p> + </Flex> + <template #actions> + <WizardStepActions :navigate="studio.select" next="templates" nextLabel="Choose a template"/> + </template> +</WizardStep>` + +export function defineWizardSiteStepComponent() { + return defineComponent<WizardSiteStep>(siteStepTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/components/step-actions.ts b/packages/purestack/wizard/modules/components/step-actions.ts new file mode 100644 index 00000000..06dcd44d --- /dev/null +++ b/packages/purestack/wizard/modules/components/step-actions.ts @@ -0,0 +1,22 @@ +import { defineComponent, html, type RefOrValue } from 'regor' +import type { WizardNavigate, WizardSection } from '../model' + +export interface WizardStepActions { + back?: WizardSection + next?: WizardSection + nextLabel?: string + navigate: WizardNavigate + disabled?: RefOrValue<boolean> +} + +const stepActionsTemplate = html`<Flex class="wizard-actions mt-5 pt-4" + :justify="back ? (next ? 'between' : 'start') : 'end'" wrap="true"> + <Btn r-if="back" variant="outline" @click="navigate(back)">← Back</Btn> + <Btn r-if="next" tone="accent" :disabled="disabled" @click="navigate(next)">{{ nextLabel }} →</Btn> +</Flex>` + +export function defineWizardStepActionsComponent() { + return defineComponent<WizardStepActions>(stepActionsTemplate, { + props: ['back', 'next', 'nextLabel', 'navigate', 'disabled'], + }) +} diff --git a/packages/purestack/wizard/modules/components/step.ts b/packages/purestack/wizard/modules/components/step.ts new file mode 100644 index 00000000..de7b3d88 --- /dev/null +++ b/packages/purestack/wizard/modules/components/step.ts @@ -0,0 +1,20 @@ +import { defineComponent, html } from 'regor' + +export interface WizardStep { + label: string + eyebrow: string + heading: string + description: string +} + +const stepTemplate = html`<section :aria-label="label"> + <SectionHeader :eyebrow="eyebrow" :title="heading" :subtitle="description" class="mb-5"/> + <slot></slot> + <slot name="actions"></slot> +</section>` + +export function defineWizardStepComponent() { + return defineComponent<WizardStep>(stepTemplate, { + props: ['label', 'eyebrow', 'heading', 'description'], + }) +} diff --git a/packages/purestack/wizard/modules/components/template-card.ts b/packages/purestack/wizard/modules/components/template-card.ts new file mode 100644 index 00000000..3c519600 --- /dev/null +++ b/packages/purestack/wizard/modules/components/template-card.ts @@ -0,0 +1,27 @@ +import { defineComponent, html, type RefOrValue } from 'regor' +import type { WizardTemplateEntry } from '../model' + +export interface WizardTemplateCard { + entry: WizardTemplateEntry + selected: RefOrValue<boolean> + choose: (id: string) => void +} + +const templateCardTemplate = html`<Panel :tone="selected ? 'accent' : 'neutral'" + variant="surfaceAlt" class="wizard-template-card" bodyClass="p-4"> + <Flex direction="column" class="gap-4"> + <Flex class="wizard-template-marker fw-700" align="center" justify="center" aria-hidden="true">{{ entry.marker }}</Flex> + <h3 class="fs-h5 m-0">{{ entry.title }}</h3> + <p class="text-subtle fs-sm m-0">{{ entry.description }}</p> + <Btn :tone="selected ? 'accent' : 'neutral'" :variant="selected ? 'solid' : 'outline'" + :aria-pressed="selected" @click="choose(entry.id)"> + {{ selected ? 'Selected' : 'Choose template' }} + </Btn> + </Flex> +</Panel>` + +export function defineWizardTemplateCardComponent() { + return defineComponent<WizardTemplateCard>(templateCardTemplate, { + props: ['entry', 'selected', 'choose'], + }) +} diff --git a/packages/purestack/wizard/modules/components/templates-step.ts b/packages/purestack/wizard/modules/components/templates-step.ts new file mode 100644 index 00000000..4021a08f --- /dev/null +++ b/packages/purestack/wizard/modules/components/templates-step.ts @@ -0,0 +1,28 @@ +import { defineComponent, html } from 'regor' +import type { WizardModel } from '../model' +import { WizardDashboard } from './dashboard' + +export interface WizardTemplatesStep { + studio: WizardModel +} + +const templatesStepTemplate = html`<WizardStep label="Starting template" eyebrow="02 / TEMPLATE" + heading="A good place to begin." description="Start with a complete website, then make it yours."> + <Grid columns="1" columnsLg="2" class="gap-4"> + <WizardTemplateCard r-for="entry in studio.templates" :entry="entry" :selected="studio.template === entry.id" :choose="studio.chooseTemplate"/> + </Grid> + <Flex direction="column" class="gap-3 mt-5"> + <FormInputField id="wizard-template-source" label="Use another template" :model="studio.templateSource" + placeholder="@scope/template or ./path/to/template" :disabled="studio.templateLoading"/> + <Flex justify="end"><Btn variant="outline" :disabled="studio.templateLoading" @click="studio.loadTemplate">{{ studio.templateLoading ? 'Loading template…' : 'Load template' }}</Btn></Flex> + </Flex> + <template #actions> + <WizardStepActions :navigate="studio.select" back="site" next="options" nextLabel="Template options" :disabled="studio.templateLoading"/> + </template> +</WizardStep>` + +export function defineWizardTemplatesStepComponent() { + return defineComponent<WizardTemplatesStep>(templatesStepTemplate, { + context: (head) => ({ studio: head.requireContext(WizardDashboard).model }), + }) +} diff --git a/packages/purestack/wizard/modules/connection.ts b/packages/purestack/wizard/modules/connection.ts new file mode 100644 index 00000000..240f689f --- /dev/null +++ b/packages/purestack/wizard/modules/connection.ts @@ -0,0 +1,93 @@ +import { + STUDIO_SOCKET_PATH, + type StudioCommand, + type StudioEvent, +} from './shared/protocol' + +export type StudioConnectionState = + | 'connecting' + | 'connected' + | 'reconnecting' + | 'closed' + +/** Browser transport only: reconnects never resubmit a mutating command. */ +export class StudioConnection { + private socket?: WebSocket + private retry?: ReturnType<typeof setTimeout> + private stopped = false + private attempts = 0 + + constructor( + private readonly receive: (event: StudioEvent) => void, + private readonly state: (value: StudioConnectionState) => void, + ) {} + + connect(location: Location) { + const token = new URLSearchParams(location.hash.slice(1)).get('token') + if (!token) { + this.state('closed') + this.receive({ + type: 'error', + message: + 'Open Studio using the complete link printed in your terminal.', + }) + return + } + const url = new URL(STUDIO_SOCKET_PATH, location.href) + url.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + url.searchParams.set('token', token) + const open = () => { + if (this.stopped) return + this.state(this.attempts ? 'reconnecting' : 'connecting') + const socket = new WebSocket(url) + this.socket = socket + socket.onopen = () => { + this.attempts = 0 + this.state('connected') + } + socket.onmessage = (message) => { + try { + const event = JSON.parse(String(message.data)) as StudioEvent + if (event.type !== 'snapshot' && event.type !== 'error') + throw new Error('Unknown message.') + if (event.type === 'snapshot' && event.snapshot.status === 'closing') + this.stopped = true + this.receive(event) + } catch { + this.receive({ + type: 'error', + message: 'Studio received an invalid response.', + }) + socket.close(4002, 'Invalid response') + } + } + socket.onclose = () => { + if (this.stopped) { + this.state('closed') + return + } + this.state('reconnecting') + this.retry = setTimeout( + open, + Math.min(5000, 300 * 2 ** Math.min(this.attempts++, 5)), + ) + } + socket.onerror = () => { + /* close schedules a single reconnect. */ + } + } + open() + } + + send(command: StudioCommand) { + if (this.socket?.readyState !== WebSocket.OPEN) return false + this.socket.send(JSON.stringify(command)) + return true + } + + close() { + this.stopped = true + if (this.retry) clearTimeout(this.retry) + this.socket?.close() + } +} diff --git a/packages/purestack/wizard/modules/model.ts b/packages/purestack/wizard/modules/model.ts new file mode 100644 index 00000000..1d5d5c76 --- /dev/null +++ b/packages/purestack/wizard/modules/model.ts @@ -0,0 +1,274 @@ +import { computed, ref, type SRef, sref } from 'regor' +import type { + OptionValue, + TemplateCategory, + TemplateField, +} from '../../src/init/template-ui' +import { StudioConnection, type StudioConnectionState } from './connection' +import type { + StudioCommand, + StudioResult, + StudioSnapshot, + StudioTask, + StudioTemplate, +} from './shared/protocol' + +export type WizardSection = + | 'site' + | 'templates' + | 'options' + | 'setup' + | 'review' +export type WizardNavigate = (section: WizardSection) => void + +export interface WizardNavigationEntry { + id: WizardSection + number: string + label: string +} + +export interface WizardTemplateEntry { + id: string + marker: string + title: string + description: string +} + +export interface WizardSummaryEntry { + label: string + value: string +} + +export interface WizardOption { + field: TemplateField + value: SRef<OptionValue> +} + +export interface WizardOptionCategory { + id: string + label: string + description: string + fields: WizardOption[] + categories: WizardOptionCategory[] +} + +export class WizardModel { + readonly section = ref<WizardSection>('site') + readonly title = ref('My Site') + readonly directory = ref('my-site') + readonly template = ref('starter') + readonly install = ref(false) + readonly git = ref(false) + readonly error = ref('') + readonly cwd = ref('') + readonly connection = ref<StudioConnectionState>('connecting') + readonly status = ref<StudioSnapshot['status']>('idle') + readonly tasks = sref<StudioTask[]>([]) + readonly result = sref<StudioResult | null>(null) + readonly templateDetails = sref<StudioTemplate | null>(null) + readonly templateLoading = ref(false) + readonly categories = sref<WizardOptionCategory[]>([]) + readonly templateSource = ref('') + readonly busy = computed( + () => + this.status() === 'creating' || + this.tasks().some((task) => task.state === 'running'), + ) + readonly showCreation = computed( + () => + this.status() === 'creating' || + this.status() === 'closing' || + Boolean(this.result()), + ) + readonly canCreate = computed( + () => + this.connection() === 'connected' && + !this.templateLoading() && + !this.busy(), + ) + private client?: StudioConnection + private initialized = false + /** Sent by this tab, not yet acknowledged by a server loading snapshot. */ + private requestedTemplate?: string + private serverLoadingTemplate = false + readonly sections: readonly WizardNavigationEntry[] = [ + { id: 'site', number: '01', label: 'Site details' }, + { id: 'templates', number: '02', label: 'Starting template' }, + { id: 'options', number: '03', label: 'Template options' }, + { id: 'setup', number: '04', label: 'Project setup' }, + { id: 'review', number: '05', label: 'Review' }, + ] + readonly templates = sref<WizardTemplateEntry[]>([ + { + id: 'starter', + marker: 'S', + title: 'Starter', + description: + 'A focused website with a home page, about page, and browser TypeScript.', + }, + ]) + readonly summary = computed<WizardSummaryEntry[]>(() => [ + { label: 'Title', value: this.title() }, + { label: 'Folder', value: this.directory() }, + { + label: 'Template', + value: + this.templates().find((entry) => entry.id === this.template())?.title ?? + this.template(), + }, + { + label: 'Dependencies', + value: this.install() ? 'Install dependencies' : 'Install later', + }, + { + label: 'Git', + value: this.git() ? 'Initialize or reuse' : 'Set up later', + }, + ]) + + chooseTemplate = (id: string) => { + if (this.busy() || !this.client) return + this.error('') + this.requestedTemplate = this.send({ type: 'template', source: id }) + ? id + : undefined + this.updateTemplateLoading() + } + + loadTemplate = () => { + if (!this.templateSource().trim()) { + this.error('Enter a template package, archive, or local folder.') + return + } + this.chooseTemplate(this.templateSource().trim()) + } + + connect = () => { + if (typeof location === 'undefined') return + this.client = new StudioConnection( + (event) => { + if (event.type === 'error') { + this.error(event.message) + // A rejected request never reaches the server's loading state. + this.requestedTemplate = undefined + this.updateTemplateLoading() + } else this.applySnapshot(event.snapshot) + }, + (state) => { + this.connection(state) + // The reconnect snapshot reports any load the server did receive. + if (state !== 'connected') { + this.requestedTemplate = undefined + this.updateTemplateLoading() + } + }, + ) + this.client.connect(location) + } + + dispose = () => this.client?.close() + + create = () => { + this.select('review') + if (this.section() !== 'review' || !this.canCreate()) return + const values: Record<string, OptionValue> = {} + const collect = (categories: WizardOptionCategory[]) => { + for (const category of categories) { + for (const option of category.fields) { + const input = option.value() + values[option.field.id] = + option.field.type === 'select' + ? (option.field.choices?.find( + (choice) => String(choice.value) === String(input), + )?.value ?? input) + : input + } + collect(category.categories) + } + } + collect(this.categories()) + this.send({ + type: 'create', + configuration: { + title: this.title(), + directory: this.directory(), + template: this.template(), + install: this.install(), + git: this.git(), + values, + }, + }) + } + + restartPreview = () => this.send({ type: 'preview' }) + finish = () => this.send({ type: 'finish' }) + + private send(command: StudioCommand) { + if (this.client?.send(command)) return true + this.error( + 'Studio is reconnecting. Your choices are preserved; try again when connected.', + ) + return false + } + + /** Loading lasts until the server acknowledges the request and finishes the latest one. */ + private updateTemplateLoading() { + this.templateLoading( + this.requestedTemplate !== undefined || this.serverLoadingTemplate, + ) + } + + private applySnapshot(snapshot: StudioSnapshot) { + this.cwd(snapshot.cwd) + this.templates(snapshot.templates) + if (!this.initialized) { + const config = snapshot.configuration + this.title(config.title) + this.directory(config.directory) + this.install(config.install) + this.git(config.git) + this.template(config.template) + this.initialized = true + } + if (snapshot.loadingTemplate === this.requestedTemplate) + this.requestedTemplate = undefined + this.serverLoadingTemplate = snapshot.loadingTemplate !== undefined + this.updateTemplateLoading() + // Only a confirmed template change replaces the options, keeping edits otherwise. + if (this.templateDetails()?.source !== snapshot.template.source) { + this.templateDetails(snapshot.template) + this.template(snapshot.template.source) + const category = (entry: TemplateCategory): WizardOptionCategory => ({ + id: entry.id, + label: entry.label, + description: entry.description, + fields: entry.fields.map((field) => ({ + field, + value: sref<OptionValue>( + snapshot.configuration.values[field.id] ?? field.default, + ), + })), + categories: entry.categories.map(category), + }) + this.categories(snapshot.template.ui.categories.map(category)) + } + this.status(snapshot.status) + this.tasks(snapshot.tasks) + this.result(snapshot.result ?? null) + if (snapshot.error) this.error(snapshot.error) + } + + select: WizardNavigate = (section) => { + if (this.showCreation()) return + this.error('') + if ( + section === 'review' && + (!this.title().trim() || !this.directory().trim()) + ) { + this.section('site') + this.error('Enter a site title and project folder before reviewing.') + return + } + this.section(section) + } +} diff --git a/packages/purestack/wizard/modules/purestack.config.ts b/packages/purestack/wizard/modules/purestack.config.ts new file mode 100644 index 00000000..5d2af5d6 --- /dev/null +++ b/packages/purestack/wizard/modules/purestack.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from '@purestack/ts-ssg' +import { wizardPlugin } from './wizardPlugin' + +export default defineConfig({ plugins: [wizardPlugin] }) diff --git a/packages/purestack/wizard/modules/shared/protocol.ts b/packages/purestack/wizard/modules/shared/protocol.ts new file mode 100644 index 00000000..4be19ad4 --- /dev/null +++ b/packages/purestack/wizard/modules/shared/protocol.ts @@ -0,0 +1,53 @@ +import type { + CreateSitePlan, + CreateSiteResult, +} from '../../../src/init/create-site' +import type { OptionValue, TemplateUi } from '../../../src/init/template-ui' + +export const STUDIO_SOCKET_PATH = '/__purestack/studio' +export type StudioConfiguration = CreateSitePlan +export interface StudioTemplate { + source: string + name: string + version: string + description: string + ui: TemplateUi +} +export interface StudioTemplateChoice { + id: string + title: string + description: string + marker: string +} +export interface StudioTask { + label: string + state: 'pending' | 'running' | 'done' | 'failed' +} +export interface StudioResult extends CreateSiteResult { + commands: string[] + previewUrl?: string + previewError?: string +} +export interface StudioSnapshot { + cwd: string + version: string + configuration: StudioConfiguration + templates: StudioTemplateChoice[] + template: StudioTemplate + /** Source of the latest template selection while the server loads it. */ + loadingTemplate?: string + status: 'idle' | 'creating' | 'ready' | 'failed' | 'closing' + tasks: StudioTask[] + result?: StudioResult + error?: string +} +export type StudioCommand = + | { type: 'template'; source: string } + | { type: 'create'; configuration: StudioConfiguration } + | { type: 'preview' } + | { type: 'finish' } +export type StudioEvent = + | { type: 'snapshot'; snapshot: StudioSnapshot } + | { type: 'error'; message: string } + +export type { OptionValue, TemplateUi } diff --git a/packages/purestack/wizard/modules/skin.ts b/packages/purestack/wizard/modules/skin.ts new file mode 100644 index 00000000..9ceb9c56 --- /dev/null +++ b/packages/purestack/wizard/modules/skin.ts @@ -0,0 +1,124 @@ +import { type ThemePalette, themeSkins } from '@purestack/ts-style' + +export function createWizardPalettes() { + const palettes = themeSkins.standard.create({ + accent: '#ff8a34', + neutral: '#383838', + secondary: '#f4ad55', + feature: '#ee7043', + }) + refinePalette(palettes.light, { + canvas: '#f7f7f7', + surface: '#ffffff', + surfaceAlt: '#f0f0f0', + text: '#242424', + subtle: '#686868', + border: '#dedede', + shadow: '0 8px 28px rgba(0, 0, 0, 0.045)', + }) + refinePalette(palettes.dark, { + canvas: '#121212', + surface: '#1c1c1c', + surfaceAlt: '#171717', + text: '#ededed', + subtle: '#adadad', + border: '#373737', + shadow: '0 12px 32px rgba(0, 0, 0, 0.16)', + }) + refineAccent(palettes.light, { + text: '#a34408', + surface: '#fff0e4', + border: '#f0bd94', + button: '#ff8a34', + hover: '#f07824', + active: '#df6818', + buttonText: '#171717', + }) + refineAccent(palettes.dark, { + text: '#ffad6b', + surface: '#252525', + border: '#ff8a34', + button: '#ff8a34', + hover: '#ffa45c', + active: '#f07824', + buttonText: '#171717', + }) + return palettes +} + +interface WizardAccent { + text: string + surface: string + border: string + button: string + hover: string + active: string + buttonText: string +} + +function refineAccent(palette: ThemePalette, colors: WizardAccent) { + const accent = palette.semanticTone.accent + accent.tone = colors.text + accent.text.default = colors.text + accent.text.subtle = colors.text + accent.root.text.default = colors.text + accent.root.text.subtle = colors.text + for (const surface of [accent.surface, accent.surfaceAlt]) { + surface.rest.background = colors.surface + surface.rest.bgcolor = colors.surface + surface.rest.text = colors.text + surface.rest.border = colors.border + } + for (const [state, background] of [ + [accent.button.rest, colors.button], + [accent.button.hover, colors.hover], + [accent.button.active, colors.active], + ] as const) { + state.background = background + state.bgcolor = background + state.border = background + state.text = colors.buttonText + } +} + +interface WizardColors { + canvas: string + surface: string + surfaceAlt: string + text: string + subtle: string + border: string + shadow: string +} + +function refinePalette(palette: ThemePalette, colors: WizardColors) { + palette.font.family.base = "'Segoe UI', ui-sans-serif, system-ui, sans-serif" + palette.radii.sm = '0.45rem' + palette.radii.md = '0.7rem' + palette.radii.lg = '1rem' + palette.effect.panelShadow = colors.shadow + palette.effect.softShadow = colors.shadow + for (const tone of [palette.current, palette.semanticTone.neutral]) { + tone.tone = colors.text + tone.canvas = colors.canvas + tone.canvascolor = colors.text + tone.text.default = colors.text + tone.text.subtle = colors.subtle + tone.border.default = colors.border + tone.border.subtle = colors.border + for (const [surface, background] of [ + [tone.surface, colors.surface], + [tone.surfaceAlt, colors.surfaceAlt], + ] as const) { + surface.rest.background = background + surface.rest.bgcolor = background + surface.rest.border = colors.border + surface.rest.text = colors.text + } + } + const neutralRoot = palette.semanticTone.neutral.root + neutralRoot.text.default = colors.text + neutralRoot.text.subtle = colors.subtle + neutralRoot.border.default = colors.border + neutralRoot.border.subtle = colors.border +} diff --git a/packages/purestack/wizard/modules/styles/creation.ts b/packages/purestack/wizard/modules/styles/creation.ts new file mode 100644 index 00000000..dc6e6703 --- /dev/null +++ b/packages/purestack/wizard/modules/styles/creation.ts @@ -0,0 +1,62 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { WizardStyle } from './index' + +export function registerCreationStyles( + root: WizardStyle, + palette: ThemePalette, +) { + root.select('.wizard-workspace-path').overflowWrap('anywhere') + root + .select('.wizard-progress-spinner') + .width('2.5rem') + .height('2.5rem') + .flexShrink('0') + .border(`3px solid ${palette.current.border.subtle}`) + .borderTopColor(palette.semanticTone.accent.text.default) + .borderRadius('50%') + .animation('wizard-progress-spin 900ms linear infinite') + root + .keyframes('wizard-progress-spin') + .select('to') + .transform('rotate(360deg)') + root + .media('prefers-reduced-motion: reduce') + .select('.wizard-progress-spinner') + .animation('none') + root + .select('.wizard-progress-meter') + .width('100%') + .height('0.6rem') + .accentColor(palette.semanticTone.accent.button.rest.bgcolor) + root + .select('.wizard-progress-meter::-webkit-progress-bar') + .backgroundColor(palette.current.surfaceAlt.rest.bgcolor) + .borderRadius(palette.radii.pill) + root + .select('.wizard-progress-meter::-webkit-progress-value') + .backgroundColor(palette.semanticTone.accent.button.rest.bgcolor) + .borderRadius(palette.radii.pill) + root + .select('.wizard-progress-meter::-moz-progress-bar') + .backgroundColor(palette.semanticTone.accent.button.rest.bgcolor) + .borderRadius(palette.radii.pill) + root.select('.wizard-task-list').listStyle('none') + root + .select('.wizard-task-list li') + .display('grid') + .gridTemplateColumns('1.25rem minmax(0, 1fr) auto') + .gap('0.75rem') + .alignItems('center') + .borderBottom(`1px solid ${palette.current.border.subtle}`) + root + .select('.wizard-task-list li[data-state="running"]') + .color(palette.semanticTone.accent.text.default) + root + .select('.wizard-task-list li[data-state="failed"]') + .color(palette.semanticTone.danger.text.default) + root.select('.wizard-task-marker').fontWeight('700') + root + .select('.wizard-next-commands') + .whiteSpace('pre-wrap') + .overflowWrap('anywhere') +} diff --git a/packages/purestack/wizard/modules/styles/dashboard.ts b/packages/purestack/wizard/modules/styles/dashboard.ts new file mode 100644 index 00000000..f5696d65 --- /dev/null +++ b/packages/purestack/wizard/modules/styles/dashboard.ts @@ -0,0 +1,34 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { WizardStyle } from './index' + +export function registerDashboardStyles( + root: WizardStyle, + palette: ThemePalette, +) { + root + .select('.wizard-panel') + .minWidth('0') + .boxShadow(palette.effect.panelShadow) + root + .select('.wizard-panel .section-header .text-title') + .fontSize(palette.font.size.h3) + .letterSpacing('-0.025em') + root.select('.wizard-template-card').height('100%') + root.select('.wizard-template-card .panel__body').height('100%') + root + .select('.wizard-template-marker') + .width('2.75rem') + .height('2.75rem') + .borderRadius(palette.radii.md) + .backgroundColor(palette.semanticTone.accent.surface.rest.background) + .color(palette.semanticTone.accent.text.default) + root + .select('.wizard-review') + .display('grid') + .gridTemplateColumns('minmax(5rem, 8rem) minmax(0, 1fr)') + root.select('.wizard-review dt').color(palette.current.text.subtle) + root.select('.wizard-review dd').margin('0').overflowWrap('anywhere') + root + .select('.wizard-panel .wizard-actions') + .borderTop(`1px solid ${palette.current.border.subtle}`) +} diff --git a/packages/purestack/wizard/modules/styles/footer.ts b/packages/purestack/wizard/modules/styles/footer.ts new file mode 100644 index 00000000..3694e2b6 --- /dev/null +++ b/packages/purestack/wizard/modules/styles/footer.ts @@ -0,0 +1,34 @@ +import { BREAKPOINTS, mediaBelow, type ThemePalette } from '@purestack/ts-style' +import type { WizardStyle } from './index' + +export function registerFooterStyles(root: WizardStyle, palette: ThemePalette) { + root.select('.wizard-footer').backgroundColor(palette.current.canvas) + root + .select('.wizard-footer .site-footer__bottom') + .padding('1.25rem 2rem') + .gap('1rem') + .fontSize(palette.font.size.xs) + root + .select('.wizard-footer .site-footer__copyright') + .color(palette.current.text.default) + .fontWeight('600') + root.select('.wizard-footer .site-footer__legal').gap('1.5rem') + root.select('.wizard-footer a').textDecoration('none') + root + .select('.wizard-footer a:hover') + .color(palette.semanticTone.accent.text.default) + root + .select('.wizard-footer a:focus-visible') + .outline(`2px solid ${palette.semanticTone.accent.button.rest.bgcolor}`) + .outlineOffset('4px') + root + .media(mediaBelow(BREAKPOINTS.md)) + .select('.wizard-footer .site-footer__bottom') + .padding('1.25rem 1rem') + .flexDirection('column') + .alignItems('flex-start') + root + .media(mediaBelow(BREAKPOINTS.md)) + .select('.wizard-footer .site-footer__legal') + .justifyContent('flex-start') +} diff --git a/packages/purestack/wizard/modules/styles/index.ts b/packages/purestack/wizard/modules/styles/index.ts new file mode 100644 index 00000000..ae77e8dd --- /dev/null +++ b/packages/purestack/wizard/modules/styles/index.ts @@ -0,0 +1,17 @@ +import { styleBuilder, themes } from '@purestack/ts-style' +import { registerCreationStyles } from './creation' +import { registerDashboardStyles } from './dashboard' +import { registerFooterStyles } from './footer' +import { registerShellStyles } from './shell' + +export type WizardStyle = ReturnType<typeof styleBuilder.get> + +export function registerWizardStyles() { + themes.forEach((theme, palette) => { + const root = styleBuilder.get(theme) + registerShellStyles(root, palette) + registerDashboardStyles(root, palette) + registerCreationStyles(root, palette) + registerFooterStyles(root, palette) + }) +} diff --git a/packages/purestack/wizard/modules/styles/shell.ts b/packages/purestack/wizard/modules/styles/shell.ts new file mode 100644 index 00000000..3f44975b --- /dev/null +++ b/packages/purestack/wizard/modules/styles/shell.ts @@ -0,0 +1,53 @@ +import { BREAKPOINTS, mediaBelow, type ThemePalette } from '@purestack/ts-style' +import type { WizardStyle } from './index' + +export function registerShellStyles(root: WizardStyle, palette: ThemePalette) { + root + .select('body.template-splash') + .backgroundColor(palette.current.canvas) + .color(palette.current.text.default) + .fontFamily(palette.font.family.base) + .minHeight('100dvh') + .display('flex') + .flexDirection('column') + root.select('body.template-splash > main').flex('1') + root.select('.template-splash .splash').maxWidth('none').padding('0') + root.select('.wizard-content').padding('3.5rem 2rem 2rem') + root.select('.wizard-content > .section-header').maxWidth('45rem') + root + .select('.wizard-content > .section-header .text-title') + .fontSize('clamp(2rem, 3.5vw, 3rem)') + .letterSpacing('-0.045em') + .lineHeight('1.15') + root + .select('.wizard-content > .section-header .text-eyebrow') + .color(palette.semanticTone.accent.text.default) + root + .select('.wizard-content > .section-header .text-tagline') + .lineHeight('1.8') + root.select('.wizard-navigation').position('sticky').top('6rem') + root + .select('.wizard-navigation .btn') + .justifyContent('flex-start') + .width('100%') + root + .select('.wizard-navigation .btn__label') + .whiteSpace('normal') + .textAlign('start') + root + .select('.wizard-navigation .wizard-nav-number') + .fontSize(palette.font.size.xs) + .opacity('0.6') + .marginRight('0.75rem') + root + .select('.wizard-navigation .wizard-local-status') + .borderTop(`1px solid ${palette.current.border.subtle}`) + root + .media(mediaBelow(BREAKPOINTS.lg)) + .select('.wizard-navigation') + .position('static') + root + .media(mediaBelow(BREAKPOINTS.md)) + .select('.wizard-content') + .padding('2rem 1rem 1rem') +} diff --git a/packages/purestack/wizard/modules/wizard.ts b/packages/purestack/wizard/modules/wizard.ts new file mode 100644 index 00000000..9f6d7093 --- /dev/null +++ b/packages/purestack/wizard/modules/wizard.ts @@ -0,0 +1,7 @@ +import { createApp, html } from 'regor' +import { defineWizardComponents } from './components' + +createApp( + { components: defineWizardComponents() }, + { selector: '#wizard-app', template: html`<WizardDashboard/>` }, +) diff --git a/packages/purestack/wizard/modules/wizardPlugin.ts b/packages/purestack/wizard/modules/wizardPlugin.ts new file mode 100644 index 00000000..056a2d81 --- /dev/null +++ b/packages/purestack/wizard/modules/wizardPlugin.ts @@ -0,0 +1,13 @@ +import { definePlugin } from '@purestack/ts-ssg' +import { createWizardPalettes } from './skin' +import { registerWizardStyles } from './styles' + +export const wizardPlugin = definePlugin({ + name: 'purestack-studio-wizard', + skins: { + wizard: { + create: createWizardPalettes, + }, + }, + hooks: { onConfigResolved: () => registerWizardStyles() }, +}) diff --git a/packages/purestack/wizard/siteConfig.json b/packages/purestack/wizard/siteConfig.json new file mode 100644 index 00000000..f6952cc5 --- /dev/null +++ b/packages/purestack/wizard/siteConfig.json @@ -0,0 +1,24 @@ +{ + "siteTitle": "PureStack Wizard", + "logo": { + "brand": "PureStack", + "suffix": ".", + "icon": "tabler:stack-2", + "href": "/", + "size": "md", + "ariaLabel": "PureStack home", + "markStyle": "plain" + }, + "style": { + "fileName": "wizard.css", + "href": "/assets/wizard.css", + "themes": ["light", "dark"], + "theme": { "skin": "wizard" } + }, + "navigation": { "mode": "none" }, + "auth": { "enabled": false }, + "consent": { "enabled": false }, + "pagefind": { "enabled": false }, + "sitemap": { "enabled": false }, + "mdx": { "disableHighlighter": true } +} diff --git a/packages/ts-components/src/standard/doughnutChart/doughnutChart.ts b/packages/ts-components/src/standard/doughnutChart/doughnutChart.ts index 678fe6fd..9878eebf 100644 --- a/packages/ts-components/src/standard/doughnutChart/doughnutChart.ts +++ b/packages/ts-components/src/standard/doughnutChart/doughnutChart.ts @@ -434,7 +434,7 @@ function resolvePositiveSegments( const value = readChartNumber(segment?.value, 0) return { label: resolveChartText(segment?.label) || `Segment ${index + 1}`, - value: value > 0 ? value : 0, + value: Math.max(value, 0), color: resolveChartText(segment?.color), } }) diff --git a/packages/ts-components/src/standard/form/formStyle.ts b/packages/ts-components/src/standard/form/formStyle.ts index 55e4c25f..09de3027 100644 --- a/packages/ts-components/src/standard/form/formStyle.ts +++ b/packages/ts-components/src/standard/form/formStyle.ts @@ -128,6 +128,7 @@ function registerFormSelectFieldStyles( ) { registerFormSelectControlStyles(theme) registerFormSelectOptionStyles(theme, palette) + registerFormSelectPickerStyles(theme, palette) registerFormSelectIconStyles(theme) } @@ -154,13 +155,77 @@ function registerFormSelectOptionStyles( palette: ThemePalette, ) { styleBuilder - .select('option, optgroup', theme) - .backgroundColor(palette.current.canvascolor) + .select('.form-block__select option, .form-block__select optgroup', theme) + .backgroundColor(palette.current.surface.rest.background) + .color(palette.current.text.default) + .fontFamily(palette.font.family.base) + .apply(palette.applyFont(palette.font.size.sm)) styleBuilder - .select('option:disabled, optgroup:disabled', theme) + .select( + '.form-block__select option:disabled, .form-block__select optgroup:disabled', + theme, + ) .color(palette.current.text.subtle) } +function registerFormSelectPickerStyles( + theme: ThemeMode, + palette: ThemePalette, +) { + // Keep the existing appearance:none rule for browsers without base-select. + // A separate rule lets unsupported values be ignored without losing it. + styleBuilder + .select('.form-block__select-shell > .form-block__select', theme) + .appearance('base-select') + .colorScheme(theme) + styleBuilder.select('.form-block__select::picker-icon', theme).display('none') + styleBuilder + .select('.form-block__select::picker(select)', theme) + .appearance('base-select') + .boxSizing('border-box') + .minWidth('anchor-size(width)') + .maxHeight('min(20rem, 60vh)') + .overflowY('auto') + .margin('0.375rem 0 0') + .padding('0.35rem') + .border(`1px solid ${palette.current.border.default}`) + .borderRadius(palette.radii.md) + .background(palette.current.surface.rest.background) + .color(palette.current.text.default) + .fontFamily(palette.font.family.base) + .apply(palette.applyFont(palette.font.size.sm)) + .boxShadow(palette.effect.softShadow) + styleBuilder + .select('.form-block__select option', theme) + .display('flex') + .alignItems('center') + .gap('0.75em') + .padding('0.6em 0.75em') + .border('1px solid transparent') + .borderRadius(palette.radii.sm) + .cursor('pointer') + styleBuilder + .select('.form-block__select option:checked:not(:disabled)', theme) + .background(palette.semanticTone.accent.surface.rest.background) + .color(palette.semanticTone.accent.surface.rest.text) + .fontWeight(palette.font.weight.w600) + styleBuilder + .select( + '.form-block__select option:hover:not(:disabled), .form-block__select option:focus-visible:not(:disabled)', + theme, + ) + .background(palette.current.surfaceAlt.rest.background) + .color(palette.current.text.default) + .borderColor(palette.semanticTone.accent.tone) + .outline('none') + styleBuilder + .select('.form-block__select option:disabled', theme) + .cursor('not-allowed') + styleBuilder + .select('.form-block__select option::checkmark', theme) + .color(palette.semanticTone.accent.tone) +} + function registerFormSelectIconStyles(theme: ThemeMode) { styleBuilder .select( diff --git a/packages/ts-css/README.md b/packages/ts-css/README.md index 2ee10292..443f3111 100644 --- a/packages/ts-css/README.md +++ b/packages/ts-css/README.md @@ -30,21 +30,17 @@ import { s } from '@purestack/ts-css' const style = s() -style - .select('body') - .css({ - margin: '0', - fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif", - }) +style.select('body').css({ + margin: '0', + fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif", +}) -style - .select('.button') - .css({ - background: '#1e90ff', - color: '#fff', - padding: '0.5rem 1rem', - borderRadius: '0.5rem', - }) +style.select('.button').css({ + background: '#1e90ff', + color: '#fff', + padding: '0.5rem 1rem', + borderRadius: '0.5rem', +}) const css = style.toPrettyCSS() console.log(await css) @@ -79,9 +75,9 @@ style.select('.card').css({ ```ts const root = s('.card') -root.select('> .title') // ".card > .title" -root.select(':hover') // ".card:hover" -root.select('.icon') // ".card .icon" +root.select('> .title') // ".card > .title" +root.select(':hover') // ".card:hover" +root.select('.icon') // ".card .icon" ``` ### Media Queries @@ -105,9 +101,11 @@ const frames = style.keyframes('zt-memory-cycle') frames.select('0%, 4%, 40%, 100%').transform('translateY(0)') frames.select('12%, 28%').transform('translateY(-8px)') -style.select('.zt-slab-memory') +style + .select('.zt-slab-memory') .animation('zt-memory-cycle 8s ease-in-out infinite') -style.select('.zt-slab-memory') +style + .select('.zt-slab-memory') .media('prefers-reduced-motion: reduce') .animation('none !important') ``` @@ -261,11 +259,10 @@ style.select('.hero').css({ fontWeight: '700', }) -const page = h('html') - .push( - h('head').push(h('style').raw(style.toCSS())), - h('body').push(h('div').class('hero').text('Hello')), - ) +const page = h('html').push( + h('head').push(h('style').raw(style.toCSS())), + h('body').push(h('div').class('hero').text('Hello')), +) console.log(page.toHtml()) ``` @@ -279,20 +276,17 @@ Returns raw CSS as a string. ### `toPrettyCSS(options?)` Formats output using Prettier (parser: `css`) with defaults: - - `tabWidth: 2` - `endOfLine: 'lf'` ## API Overview Exports: - - `s(selector?)` -> `Style` - `Style` (extends `BaseStyle`) - `getColors`, `getGradient` (color utilities) Key `BaseStyle` methods: - - `set(name, value)` - `css({...})` - `select(selector)` diff --git a/packages/ts-html/README.md b/packages/ts-html/README.md index 5e27d321..def07d5d 100644 --- a/packages/ts-html/README.md +++ b/packages/ts-html/README.md @@ -28,17 +28,16 @@ pnpm add @purestack/ts-html ```ts import { h } from '@purestack/ts-html' -const html = h('html') - .push( - h('head').push( - h('title').text('Hello'), - h('meta').attr({ charset: 'utf-8' }), - ), - h('body').push( - h('main').push(h('h1').text('Hello world')), - h('script').raw('console.log("hello")'), - ), - ) +const html = h('html').push( + h('head').push( + h('title').text('Hello'), + h('meta').attr({ charset: 'utf-8' }), + ), + h('body').push( + h('main').push(h('h1').text('Hello world')), + h('script').raw('console.log("hello")'), + ), +) const out = await html.toPrettyHtml() console.log(out) @@ -62,7 +61,7 @@ Mutating methods change the current node and return it for chaining. Use fresh n const base = h('input') const typed = base.clone().attr({ type: 'text' }) -base.toHtml() // <input/> +base.toHtml() // <input/> typed.toHtml() // <input type="text"/> ``` @@ -79,7 +78,6 @@ const raw = h('p').raw('<b>raw</b>') ### Attributes `ts-html` exposes multiple attribute helpers to help with type safety: - - `attr(...)` for tag-specific attributes - `attrAll(...)` for all attributes - `attrGlobal(...)` for global HTML attributes @@ -133,7 +131,6 @@ const doc = h('html').push(head, h('body')) ``` Relevant types: - - `BasicHeadConfig` (high-level SEO config) - `HeadConfig` (low-level, explicit head element lists) diff --git a/packages/ts-images/README.md b/packages/ts-images/README.md index 5cc878b1..1a5314d8 100644 --- a/packages/ts-images/README.md +++ b/packages/ts-images/README.md @@ -36,7 +36,9 @@ import { defineConfig } from '@purestack/ts-ssg' import { imagesPlugin } from '@purestack/ts-images' export default defineConfig({ - plugins: [imagesPlugin({ widths: [480, 960, 1440], formats: ['avif', 'webp'] })], + plugins: [ + imagesPlugin({ widths: [480, 960, 1440], formats: ['avif', 'webp'] }), + ], }) ``` @@ -79,26 +81,26 @@ can override. Settings are also available under typed `extensions.images`: } ``` -| Setting | Default / behavior | -| --- | --- | -| `enabled` | `true`; `false` leaves image output untouched | -| `widths` | `[480, 960, 1440]`; 1–16 widths, each an integer from 1 to 16384; no upscaling | -| `formats` | `['avif', 'webp']`; order determines `<source>` preference; JPEG/PNG fallback is automatic | -| `quality` | AVIF 50, WebP 80, JPEG 82; each 1–100 | -| `sizes` | `100vw`; configure it for the image's actual layout slot | -| `loading` | `lazy`; may be `eager` or `false` to omit the default | -| `decoding` | `async`; may be `sync`, `auto`, or `false` | -| `priority` | `false`; fills missing `loading="eager"` and `fetchpriority="high"` when enabled | -| `lossless` | `false`; `true` enables lossless modern encoders and a PNG fallback | -| `metadata` | `strip`; auto-orient first, convert to sRGB, then omit metadata; `preserve` retains metadata with orientation normalized | -| `include` / `exclude` | `['**/*']` / `[]`; glob patterns on content-root-relative paths using `/` | -| `cacheDir` | OS temporary cache, namespaced by content root; custom paths resolve from `rootDir` and must be outside content and output directories | -| `cacheMaxAgeDays` | `30`; entries unused for this many days expire; `0` disables automatic cleanup | -| `concurrency` | `2`; per-site limit from 1–16, including encoding and output emission | -| `maxInputPixels` | 40 million | -| `maxSourceBytes` | 50 MiB | -| `onError` | `error`; `warn` retains the original image when optimization fails; invalid content URLs still obey core URL validation | -| `presets` | Named per-image settings, inheriting the resolved global image settings | +| Setting | Default / behavior | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true`; `false` leaves image output untouched | +| `widths` | `[480, 960, 1440]`; 1–16 widths, each an integer from 1 to 16384; no upscaling | +| `formats` | `['avif', 'webp']`; order determines `<source>` preference; JPEG/PNG fallback is automatic | +| `quality` | AVIF 50, WebP 80, JPEG 82; each 1–100 | +| `sizes` | `100vw`; configure it for the image's actual layout slot | +| `loading` | `lazy`; may be `eager` or `false` to omit the default | +| `decoding` | `async`; may be `sync`, `auto`, or `false` | +| `priority` | `false`; fills missing `loading="eager"` and `fetchpriority="high"` when enabled | +| `lossless` | `false`; `true` enables lossless modern encoders and a PNG fallback | +| `metadata` | `strip`; auto-orient first, convert to sRGB, then omit metadata; `preserve` retains metadata with orientation normalized | +| `include` / `exclude` | `['**/*']` / `[]`; glob patterns on content-root-relative paths using `/` | +| `cacheDir` | OS temporary cache, namespaced by content root; custom paths resolve from `rootDir` and must be outside content and output directories | +| `cacheMaxAgeDays` | `30`; entries unused for this many days expire; `0` disables automatic cleanup | +| `concurrency` | `2`; per-site limit from 1–16, including encoding and output emission | +| `maxInputPixels` | 40 million | +| `maxSourceBytes` | 50 MiB | +| `onError` | `error`; `warn` retains the original image when optimization fails; invalid content URLs still obey core URL validation | +| `presets` | Named per-image settings, inheriting the resolved global image settings | The plugin owns normalization in the configuration pipeline. All resolver overrides finish before normalization and processor creation. Omitting the diff --git a/packages/ts-minidom/src/createDom.globals.test.ts b/packages/ts-minidom/src/createDom.globals.test.ts new file mode 100644 index 00000000..0c0791ba --- /dev/null +++ b/packages/ts-minidom/src/createDom.globals.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { createDom, runInDom } from './createDom' + +// Globals are installed once per module, so this file starts from Node's own. +const original = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') +const nodeStorage = {} as Storage +const read = vi.fn(() => nodeStorage) +const write = vi.fn() +Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + enumerable: true, + get: read, + set: write, +}) + +beforeEach(() => { + read.mockClear() + write.mockClear() +}) + +afterAll(() => { + if (original) Object.defineProperty(globalThis, 'localStorage', original) + else Reflect.deleteProperty(globalThis, 'localStorage') +}) + +describe('original DOM globals', () => { + it('stay unread until code outside a DOM reads them', () => { + const restore = createDom('<html><body></body></html>') + try { + localStorage.setItem('theme', 'dark') + runInDom('<html><body></body></html>', () => { + expect(localStorage.getItem('theme')).toBeNull() + }) + expect(localStorage.getItem('theme')).toBe('dark') + expect(read).not.toHaveBeenCalled() + } finally { + restore() + } + expect(globalThis.localStorage).toBe(nodeStorage) + expect(read).toHaveBeenCalledOnce() + }) + + it('are replaced by assignment without writing through to them', () => { + const replacement = {} as Storage + globalThis.localStorage = replacement + expect(globalThis.localStorage).toBe(replacement) + expect(write).not.toHaveBeenCalled() + expect(read).not.toHaveBeenCalled() + }) +}) diff --git a/packages/ts-minidom/src/createDom.ts b/packages/ts-minidom/src/createDom.ts index 8728577e..3c5813a9 100644 --- a/packages/ts-minidom/src/createDom.ts +++ b/packages/ts-minidom/src/createDom.ts @@ -103,7 +103,7 @@ function installDomGlobals() { globalsInstalled = true const globals = globalThis as Record<string, unknown> for (const key of DOM_GLOBAL_KEYS) { - processDom[key] = globals[key] + keepOriginal(processDom, key, Object.getOwnPropertyDescriptor(globals, key)) Object.defineProperty(globals, key, { configurable: true, get: () => currentDom()[key], @@ -114,6 +114,36 @@ function installDomGlobals() { } } +/** + * Exposes a global's original value through the process DOM without reading + * it early. Node's Web Storage getters warn when read without a storage file. + */ +function keepOriginal( + dom: DomGlobals, + key: (typeof DOM_GLOBAL_KEYS)[number], + original: PropertyDescriptor | undefined, +) { + const get = original?.get + if (!get) { + dom[key] = original?.value + return + } + Object.defineProperty(dom, key, { + configurable: true, + enumerable: true, + get: () => get.call(globalThis), + // Assigning replaces the original rather than writing through to it. + set: (value: unknown) => { + Object.defineProperty(dom, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }) + }, + }) +} + /** * The scope used until `useDomScope` sets another: the store stays current * from the start of `run` until its callback, or the promise it returns, diff --git a/packages/ts-page-scripts/README.md b/packages/ts-page-scripts/README.md index ee393980..f845652f 100644 --- a/packages/ts-page-scripts/README.md +++ b/packages/ts-page-scripts/README.md @@ -25,11 +25,11 @@ or build options and list the other plugins you need. Three separate plugins from this package are also enabled by default: -| Factory | Plugin name | Responsibility | -| --- | --- | --- | -| `authStateHintPlugin()` | `purestack:auth-state-hint` | Apply the signed-in hint before stylesheets load, when auth is enabled | -| `consentPlugin()` | `purestack:consent` | Initialize the consent browser runtime when consent is enabled | -| `analyticsPlugin()` | `purestack:analytics` | Add direct GA4 scripts without consent, or contribute the consent-managed GA4 service during configuration resolution | +| Factory | Plugin name | Responsibility | +| ----------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `authStateHintPlugin()` | `purestack:auth-state-hint` | Apply the signed-in hint before stylesheets load, when auth is enabled | +| `consentPlugin()` | `purestack:consent` | Initialize the consent browser runtime when consent is enabled | +| `analyticsPlugin()` | `purestack:analytics` | Add direct GA4 scripts without consent, or contribute the consent-managed GA4 service during configuration resolution | Each factory can be selected independently of `pageScriptsPlugin()`. Omitting analytics removes automatic GA4 service registration as well as direct scripts; diff --git a/packages/ts-render/README.md b/packages/ts-render/README.md index ac0102e6..2e3fa038 100644 --- a/packages/ts-render/README.md +++ b/packages/ts-render/README.md @@ -22,12 +22,20 @@ import type { PageContext } from '@purestack/ts-common' import { appendRuntimeScripts } from '@purestack/ts-page-scripts' import { renderApp } from '@purestack/ts-render' -export function renderStandalone(html: string, components: object, context: PageContext) { +export function renderStandalone( + html: string, + components: object, + context: PageContext, +) { return renderApp(html, { components, context, onRendered: (document, runtimeEmbeds) => - appendRuntimeScripts(document, runtimeEmbeds, context.pageInfo.frontmatter.embed), + appendRuntimeScripts( + document, + runtimeEmbeds, + context.pageInfo.frontmatter.embed, + ), }) } ``` diff --git a/packages/ts-ssg-vscode/README.md b/packages/ts-ssg-vscode/README.md index e8c538bc..42c69688 100644 --- a/packages/ts-ssg-vscode/README.md +++ b/packages/ts-ssg-vscode/README.md @@ -7,7 +7,6 @@ PureStack Component Tools turns VS Code into a real authoring environment for Pu It understands component tags in Markdown and MDX, understands Regor markup inside TypeScript `html` and `svg` tagged templates, and adds the editing behavior that makes those files feel first-class instead of “HTML inside a string”. This extension is built for the PureStack workflow: - - author content in `.md`, `.mdx`, and `.rmdx` - build interactive UI with Regor components - write dynamic templates in TypeScript @@ -54,7 +53,6 @@ This extension is built for the PureStack workflow: ### TypeScript The extension understands Regor markup editing inside these tagged templates: - - `html` - `svg` @@ -77,7 +75,6 @@ Component tags inside `.md`, `.mdx`, and `.rmdx` participate in navigation, hove Regor supports `.mdx` and `.rmdx` files for Markdown plus Regor components. Use `.mdx` if you prefer the familiar MDX extension. Use `.rmdx` if you want to make the Regor-specific dialect explicit. In `.mdx` and `.rmdx`, the extension also adds: - - official MDX syntax highlighting - Regor-aware attribute highlighting - `PageFrontmatter`-aware YAML frontmatter IntelliSense @@ -90,7 +87,6 @@ In `.mdx` and `.rmdx`, the extension also adds: ## Navigate From Markup To Code Place the cursor on a component tag such as `<Flex>` or `<Btn>` and use: - - `F12` - `Ctrl+Click` - `Go to Definition` @@ -98,7 +94,6 @@ Place the cursor on a component tag such as `<Flex>` or `<Btn>` and use: If the cursor is on an attribute, the extension will try to jump to the prop declaration instead of only the component type. Resolution works for both: - - components defined inside the current workspace - Regor component libraries installed in `node_modules` that declare `regorComponents` in their `package.json` @@ -113,7 +108,6 @@ Example: ``` At `size=""`, the extension can suggest literal values when the prop type resolves to something like: - - `'sm' | 'md' | 'lg'` - `true | false` @@ -124,17 +118,14 @@ Prop value analysis unwraps generic wrappers recursively before type analysis, s The extension formats only the markup-aware regions it owns. In TypeScript: - - only the content of `html` and `svg` tagged templates is reformatted - surrounding TypeScript stays untouched In Regor MDX: - - standalone markup blocks are formatted - Markdown prose, frontmatter, and fenced code blocks are left alone Available commands: - - `PureStack: Format HTML Templates` - `PureStack: Format Document With HTML Templates` @@ -143,7 +134,6 @@ Available commands: ## Catch Broken Markup Early Invalid HTML structure inside supported markup regions is surfaced as diagnostics: - - in TypeScript template bodies - in standalone Regor MDX markup blocks @@ -152,7 +142,6 @@ Expressions are masked during validation so the error points at the surrounding ## Type Markup Faster While editing supported markup: - - typing `>` after an opening tag inserts the matching closing tag - typing `/` at the end of an opening tag completes `/>` - void HTML elements are not given unnecessary closing tags @@ -187,7 +176,6 @@ completes to: Opening and closing tag names can be edited together through VS Code linked editing support. Important: - - enable `editor.linkedEditing` With that setting on, renaming `<Card>` updates `</Card>` at the same time in supported TypeScript templates and MDX markup. @@ -197,7 +185,6 @@ With that setting on, renaming `<Card>` updates `</Card>` at the same time in su Component resolution is intentionally simple and predictable. The extension: - - scans workspace `.ts` files - keeps workspace files that contain `defineComponent` - looks for exported `interface`, `type`, or `class` names matching the component tag @@ -207,7 +194,6 @@ The extension: - normalizes naming so PascalCase, kebab-case, and case differences resolve the same way This gives PureStack authoring a clean convention: - - markup stays ergonomic - component source stays easy to locate - shared component libraries can participate in navigation @@ -217,19 +203,16 @@ This gives PureStack authoring a clean convention: Prop suggestions are driven by the component metadata extracted from `defineComponent`. The extension reads: - - the component type argument passed to `defineComponent<T>()` - the `props` declaration Supported prop declaration shapes: - - `defineComponent(..., ['name', 'size'])` - `defineComponent(..., { props: ['name', 'size'] })` Only declared props are suggested. Context-only fields that are not exposed as props are ignored. For value completion, the extension currently does best when the final unwrapped type resolves to: - - string literal unions - boolean - boolean literals @@ -240,14 +223,12 @@ For value completion, the extension currently does best when the final unwrapped ### TypeScript templates Inside TypeScript tagged templates, the extension provides: - - HTML or SVG highlighting - CSS highlighting in style regions - TypeScript highlighting inside `${...}` - TypeScript highlighting inside Regor `{{ ... }}` interpolation Behavior split: - - `html` and `svg` receive the full editing pipeline: formatting, diagnostics, auto-close, and linked editing - `raw` remains syntax-highlighted, but is intentionally excluded from formatting and tag-editing behavior @@ -256,7 +237,6 @@ Behavior split: Regor MDX support is built on the official MDX grammar and then extended for PureStack authoring. That includes: - - MDX language registration for `.mdx` and `.rmdx`, with Regor MDX aliases - Regor MDX file icon - language configuration @@ -269,7 +249,6 @@ The extension does not try to turn Regor MDX into a second TypeScript template s ## Formatting Rules PureStack formatting is intentionally conservative. - - it formats only markup regions - it preserves embedded expressions - it respects editor indentation settings @@ -287,7 +266,6 @@ The formatter uses Prettier’s HTML parser inside the extension bundle, with Pu The extension respects normal VS Code editing behavior where appropriate. Useful settings: - - `editor.formatOnSave` Enables save-time formatting for supported TypeScript templates and MDX markup blocks. - `editor.linkedEditing` diff --git a/packages/ts-ssg/NAVIGATION.md b/packages/ts-ssg/NAVIGATION.md index ddd90dc8..23aa891b 100644 --- a/packages/ts-ssg/NAVIGATION.md +++ b/packages/ts-ssg/NAVIGATION.md @@ -5,6 +5,7 @@ the data model, config surface, and how the build pipeline injects navigation into page templates. ## Goals + - Support **dynamic menus** derived from folder content. - Support **custom menus** defined per folder. - Keep the system **predictable**, **mergeable**, and **easy to override**. @@ -163,7 +164,6 @@ that group's children. A nested `docs/usage/_nav.json` can still refine the `usage` folder by defining its own `sequence`. Sequence entries can match: - - auto pages by source path, route, basename, or extensionless name, such as `getting-started.md`, `getting-started`, or `/docs/getting-started/` - auto folders by folder path or route, such as `usage/` or `/docs/usage/` @@ -200,9 +200,7 @@ Unknown icon keys are ignored. Relative URLs inside `_nav.json` resolve from the folder that contains the file: ```json -[ - { "title": "Local Child", "url": "child/" } -] +[{ "title": "Local Child", "url": "child/" }] ``` In `content/guide/_nav.json`, that becomes `/guide/child/`. @@ -213,14 +211,15 @@ Per-page overrides are supported via `frontmatter.nav`: ```md --- + title: Intro order: 2 nav: - title: Start Here - order: 1 - badge: New - icon: iconoir:star - hidden: false +title: Start Here +order: 1 +badge: New +icon: iconoir:star +hidden: false --- ``` diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index 8ac5caec..28267692 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -1,7 +1,6 @@ # `@purestack/ts-ssg` Static site generator for Markdown/Regor MDX content with: - - deterministic file-based routing, - built-in themed UI components (Regor), - incremental rebuilds with manifest tracking, @@ -59,13 +58,11 @@ await startDevServer({ ## CLI Usage `src/cli.ts` provides the CLI: - - `build` - `serve` - `publish` Flags: - - `--content ./content` or `--content=./content` (required; directory must contain `siteConfig.json`) - `--clean` (`build`, `serve`; empty the output directory and ignore its incremental manifest) - `--no-cache` (`build`, `serve`, `publish`; skip persistent plugin caches outside the output directory, such as highlighted code and optimized images) @@ -110,7 +107,6 @@ a real launch. ## Config Resolution Config comes from: - 1. defaults, 2. `contentDir/siteConfig.json`, 3. runtime input (`buildSite(...)` / `startDevServer(...)`) as highest priority. @@ -244,7 +240,6 @@ layout: ``` Notes: - - `layout.navMode` accepts only `sidebar` or `drawer` (invalid values throw). - `draft: true` hides page from generated navigation. - Title fallback order for nav: `nav.title` -> `title` -> first `# heading` -> filename. @@ -253,14 +248,12 @@ Notes: ## Navigation Types: - - `auto`: generated from content. - `custom`: from nav files only. - `hybrid`: auto + nav files (merge/override per folder). - `none`: disabled. Exact behavior by mode (from `buildNavigation` + `resolveBaseFolderItems`): - - `auto`: builds only automatic navigation from content frontmatter/headings. Custom nav files are not read. - `custom`: reads nav files and uses only their items. - `hybrid`: reads nav files and combines them with auto items. @@ -273,7 +266,6 @@ Nav files are discovered per folder only in `custom` and `hybrid` modes. `"roots": ["docs"]` makes every page under `docs/` use the `docs` menu. Each nav file may be: - 1. an array (treated as `override`), or 2. an object: @@ -284,7 +276,11 @@ Each nav file may be: "pageLinks": true, "sequence": ["index.md", "github", "guide/", "guide/install.md"], "items": [ - { "id": "github", "title": "GitHub", "url": "https://github.com/acme/docs" }, + { + "id": "github", + "title": "GitHub", + "url": "https://github.com/acme/docs" + }, { "title": "Guide", "url": "/guide/", @@ -317,17 +313,17 @@ A plugin is a named bundle of extensions: skins, components, templates, Markdown Nine plugins provide the standard site features and are enabled automatically for builds, publishing, and the development server: -| Package | Factory | Features | -| --- | --- | --- | -| `@purestack/ts-components` | `componentsPlugin()` | Standard Regor components, SVG icons, and component styles | -| `@purestack/ts-theme` | `themePlugin()` | `doc` and `splash` layouts, theme CSS files and links, critical colors, theme switching, generated favicons, and Markdown table presentation | -| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Menus, code copying, search UI, table of contents, Tabs, and Modal runtimes | -| `@purestack/ts-page-scripts` | `authStateHintPlugin()` | Early signed-in state hint | -| `@purestack/ts-page-scripts` | `consentPlugin()` | Consent browser runtime | -| `@purestack/ts-page-scripts` | `analyticsPlugin()` | Direct or consent-managed GA4 integration | -| `@purestack/ts-seo` | `seoPlugin()` | Social previews, canonical URLs, and translated alternate links | -| `@purestack/ts-highlight` | `highlightPlugin()` | Shiki and highlight.js backends and configured default selection | -| `@purestack/ts-pagefind` | `pagefindPlugin()` | Full-build and lazy development search indexing | +| Package | Factory | Features | +| ---------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `@purestack/ts-components` | `componentsPlugin()` | Standard Regor components, SVG icons, and component styles | +| `@purestack/ts-theme` | `themePlugin()` | `doc` and `splash` layouts, theme CSS files and links, critical colors, theme switching, generated favicons, and Markdown table presentation | +| `@purestack/ts-page-scripts` | `pageScriptsPlugin()` | Menus, code copying, search UI, table of contents, Tabs, and Modal runtimes | +| `@purestack/ts-page-scripts` | `authStateHintPlugin()` | Early signed-in state hint | +| `@purestack/ts-page-scripts` | `consentPlugin()` | Consent browser runtime | +| `@purestack/ts-page-scripts` | `analyticsPlugin()` | Direct or consent-managed GA4 integration | +| `@purestack/ts-seo` | `seoPlugin()` | Social previews, canonical URLs, and translated alternate links | +| `@purestack/ts-highlight` | `highlightPlugin()` | Shiki and highlight.js backends and configured default selection | +| `@purestack/ts-pagefind` | `pagefindPlugin()` | Full-build and lazy development search indexing | These factories and `createDefaultPlugins()` are also exported from `@purestack/ts-ssg` and `purestack`. The stylesheet provider initializes before component factories. Components and templates resolve before every plugin's `onConfigResolved` hook runs in plugin order. Names do not change that order. Components are scoped to each build; config hooks can override them through `context.components`. @@ -403,13 +399,15 @@ const productPlugin = definePlugin({ }) await buildSite({ - siteConfig: { contentDir: './content', style: { theme: { skin: 'product' } } }, + siteConfig: { + contentDir: './content', + style: { theme: { skin: 'product' } }, + }, options: { plugins: [productPlugin] }, }) ``` Plugins apply in order: - - **Skins** are available while the site config resolves, so `style.theme.skin` can select one; they are removed afterwards and never leak into another build. A plugin skin cannot reuse a built-in skin's name, such as `standard`. - **Components** are built from the resolved site config and may replace built-in components by name. - **Templates** may replace the built-in `doc` and `splash` templates by name. @@ -514,9 +512,12 @@ export const brandPlugin = definePlugin({ config: { resolve({ config }) { const brand = config.extensions.brand - if (brand !== undefined && - (brand === null || typeof brand !== 'object' || - typeof brand.label !== 'string')) { + if ( + brand !== undefined && + (brand === null || + typeof brand !== 'object' || + typeof brand.label !== 'string') + ) { throw new Error('extensions.brand requires a string label.') } return { extensions: { brand: { label: brand?.label ?? 'Docs' } } } @@ -546,7 +547,7 @@ import { renderCustomCode } from './custom-code' export const customCodePlugin = definePlugin({ name: 'custom-code', markdown: { - resolveHighlighter: (lang) => lang === 'custom' ? renderCustomCode : null, + resolveHighlighter: (lang) => (lang === 'custom' ? renderCustomCode : null), }, }) ``` @@ -568,12 +569,15 @@ import { createHighlighter } from 'shiki' import grammar from './mylang.tmLanguage.json' with { type: 'json' } export default defineConfig({ - plugins: [highlightPlugin({ - createShiki: (options) => createHighlighter({ - ...options, - langs: [...options.langs, grammar], + plugins: [ + highlightPlugin({ + createShiki: (options) => + createHighlighter({ + ...options, + langs: [...options.langs, grammar], + }), }), - })], + ], }) ``` @@ -592,14 +596,12 @@ Only one search provider may be installed. Replace `purestack:pagefind` by name, ### Hooks Every page render, in a full build or a dev server re-render, runs the page hooks: - - `onPageStart(context, file)`: before the page renders. - `onPageDocument(context, page)`: on the rendered document, before it becomes HTML and before its links resolve. `page` holds the `document`, `file`, `frontmatter`, `urlPath`, and recorded `runtimeEmbeds`. The hook may await: the document, and the global `document` with it, stay this page's while other pages render. - `onPageRendered(context, page)`: after it becomes HTML; changes to `page.html` are written. - `onPageWritten(context, page)`: after the HTML file is written. Each full build also runs, in order: - - `onConfigResolved(context)`: before any output; register styles here. - `onContentDiscovered(context, files)`: after content discovery, before pages render. - `onNavigationBuilt(context, navigation)` @@ -608,10 +610,14 @@ Each full build also runs, in order: By default, the dev server prepares routes and shared navigation when it starts. Pages render on their first request and are cached until an edit affects them; static assets copy on request. Site or plugin config changes prepare a fresh request cache. Page hooks run only for pages that are requested; `onBuildComplete` belongs to full builds. With `--full-render` (or `startDevServer({ fullRender: true })`), startup runs one full build, including all pages, assets, and the complete search index. Site requests wait for that initial build to finish. After startup, watched changes use the same incremental invalidation and rendering on request as the default mode. +An initial build error is logged while the dev server keeps watching for fixes. +Embedded callers can set `startDevServer({ failOnInitialBuildError: true })` to +reject startup and close the server instead. Studio previews use this option to +report startup failures. + ## Templates Built-in templates: - - `doc` (default) - `splash` @@ -646,7 +652,6 @@ The built-in skin is `standard`. Select a skin with `style.theme.skin` in `siteC Add skins with a plugin's `skins` field. `themeSkins` in `@purestack/ts-style` holds the registered skins; see the [Themes guide](https://purestack.studio/guides/styling/themes/) for creating one. `style.themes` controls generated files: - - `assets/site.css` for `light` - `assets/site.dark.css` for `dark` - `assets/site.<theme>.css` for additional themes @@ -662,7 +667,6 @@ Add skins with a plugin's `skins` field. `themeSkins` in `@purestack/ts-style` h - Code highlighting with highlight.js or Shiki `siteConfig.mdx` options: - - `highlighter`: `"highlightjs"` (default) or `"shiki"` - `disableHighlighter`: skip highlighting - `compileMdAsMdx`: compile `.md` files as Regor MDX (default `true`); set `false` to keep `.md` as plain Markdown @@ -676,18 +680,15 @@ Add skins with a plugin's `skins` field. `themeSkins` in `@purestack/ts-style` h ## Incremental Build and Manifest `ts-ssg` stores incremental metadata at: - - `<outDir>/.ts-ssg/manifest.json` Tracked: - - content file signatures (`mtimeMs`, `size`) - asset signatures - style signature - config compatibility signature In dev/watch mode: - - file changes apply incrementally when safe, - a page affected by a shared change, such as a header, footer, or navigation edit, renders again on its next request, before it is served, - site config changes refresh shared state and invalidate cached pages, @@ -702,11 +703,9 @@ In dev/watch mode: ### Pagefind When enabled, full builds run Pagefind indexing over `outDir` and write to: - - `<outDir>/pagefind` Optional config: - - `pagefind.enabled`: enables Pagefind indexing and the built-in search runtime. Defaults to `true`. - `pagefind.excludePaths`: array of route prefixes excluded from indexing (e.g. `["/privacy/", "/imprint/", "/terms/"]`). @@ -719,12 +718,10 @@ When disabled, stale `<outDir>/pagefind` output is removed. Build logs include i ### Sitemap / Robots If enabled: - - sitemap written to `<outDir>/<sitemap.fileName>` - robots written to `<outDir>/<sitemap.robots.fileName>` when `robots.enabled` Validation: - - sitemap requires non-empty absolute `sitemap.baseUrl` when enabled. - sitemap and robots URLs include `basePath` when configured. @@ -758,7 +755,6 @@ content/ ``` Supported URL strategies: - - `prefix-all`: public URLs include the locale, e.g. `/en/docs/` and `/de/docs/`. - `hidden`: output is still written under locale folders, but canonical public routes stay unprefixed, e.g. `/docs/`. Hosts can select the locale using @@ -774,7 +770,6 @@ alternate links when localized URLs are distinct. `ts-ssg` can gate optional third-party scripts behind explicit consent. Key behavior: - - no non-essential service scripts execute before consent, - reject is as easy as accept (`accept all` and `reject non-essential`), - users can reopen settings any time via a persistent privacy button, @@ -825,15 +820,53 @@ Configure GA4 directly in `siteConfig.json`: ``` Behavior: - - if consent is disabled, GA4 is injected directly in page head, - if consent is enabled, GA4 is automatically added as a consent-gated service, - defaults: `serviceId: "ga4"`, `consentCategory: "analytics"`. +## Embedded servers and WebSockets + +`startDevServer()` resolves after listening and initial shared setup. Its handle +provides the actual `url` (including `basePath`) and an idempotent async `close()`. +Use `port: 0` to select an available port and `handleSignals: false` when an +embedding application owns shutdown. +Set `logLevel: 'WARN'` to initialize the default logger with warnings and errors +only. An existing caller-owned logger is preserved. + +Optional `webSockets` endpoints share the existing HTTP listener: + +```ts +const server = await startDevServer({ + host: '127.0.0.1', + port: 0, + handleSignals: false, + build: { siteConfig: { contentDir: './content' } }, + webSockets: [ + { + path: '/events', + connect(peer) { + peer.send('Connected') + peer.onMessage((message) => peer.send(message)) + peer.onClose(() => console.log('Disconnected')) + }, + }, + ], +}) +console.log(server.url) +// Later: await server.close() +``` + +Each endpoint has an exact `path`, an optional `maxPayload` in bytes (default +1 MiB), and an optional synchronous `authorize(request)` callback. Returning +false rejects the upgrade with HTTP 403. Use authorization and origin checks for +endpoints that expose local operations. Paths are independent of the site's +`basePath`; unmatched upgrades receive 404. Connections send and receive strings +or binary `Uint8Array` messages. Listener registration returns unsubscribe +functions. Shutdown closes WebSocket peers and live-reload streams too. + ## API Surface Functions: - - `buildSite`, `startDevServer`, `runCli` - `definePlugin`, `defineConfig` - `resolveSiteConfig` @@ -843,12 +876,11 @@ Functions: - `defaultTemplates`, `resolvePageTemplate` Types: - - Build: `BuildInput`, `BuildOptions`, `BuildResult`, `BuildCountSummary`, `PublishOptions` - Plugins and hooks: `PureStackPlugin`, `PureStackConfig`, `PureStackMarkdown`, `GeneratedPage`, `PageGenerationContext`, `BuildHooks`, `BuildContext`, `ResolvedContentFile`, `PageDocument`, `PageRenderResult`, `NavigationTree`, `WriteStylesResult` - Templates: `PageTemplateMap`, `PageTemplate`, `PageTemplateInput`, `PageInfo`, `PageFrontmatter` - Config and components: `SiteConfig`, `SiteConfigInput`, `PageContext` -- Dev server: `DevServerInput`, `DevServerOptions`, `DevServerHandle` +- Dev server: `DevServerInput`, `DevServerOptions`, `DevServerHandle`, `DevWebSocketEndpoint`, `DevWebSocketConnection`, `DevWebSocketMessage` - Highlighting: `MdxCodeHighlighter`, `MdxCodeLangs`, `MdxCodeThemes`, `MdxShikiFactory` Related packages: `@purestack/ts-html` builds template markup (`h`), `@purestack/ts-style` provides skins (`themeSkins`, `ThemeSkin`), and `@purestack/ts-components` provides the built-in components. diff --git a/packages/ts-ssg/ROADMAP.md b/packages/ts-ssg/ROADMAP.md deleted file mode 100644 index 2bdda4ba..00000000 --- a/packages/ts-ssg/ROADMAP.md +++ /dev/null @@ -1,53 +0,0 @@ -# ts-ssg Roadmap - -This roadmap is staged to build a minimal but extensible SSG with MD/MDX support and a PureStack-based rendering pipeline. - -## Phase 0: Alignment (now) -- [x] Draft architecture and roadmap. -- [ ] Decide on config file name and default content root. -- [ ] Confirm MDX compiler choice and bridging strategy to PureStack nodes. - -## Phase 1: Foundations -- [ ] Create `config/` loader with validated schema. -- [ ] Implement content discovery and frontmatter parsing. -- [ ] Define content model types: `ContentEntry`, `Collection`, `Route`. -- [ ] Build a minimal renderer that writes HTML for a single page. - -## Phase 2: Markdown Rendering -- [ ] Integrate Markdown parser (remark/rehype). -- [ ] Render Markdown into PureStack nodes or HTML fragments. -- [ ] Implement basic layouts: `doc` and `splash`. -- [ ] Add a basic theme shell (header, footer, nav). - -## Phase 3: MDX Support -- [ ] Integrate MDX compiler. -- [ ] Build adapter to PureStack HTML nodes. -- [ ] Support MDX component imports in content. -- [ ] Validate sample content rendering end-to-end. - -## Phase 4: Navigation + Sidebar -- [ ] Build sidebar tree from frontmatter and file structure. -- [ ] Implement breadcrumbs and prev/next. -- [ ] Add auto-generated TOC for headings. - -## Phase 5: Build System -- [ ] Output directory structure with clean/write. -- [ ] Asset copying and URL handling. -- [ ] Emit sitemap and RSS (optional flag). -- [ ] Emit search index JSON (basic). - -## Phase 6: Dev Experience -- [ ] `dev` server with file watch and partial rebuild. -- [ ] CLI progress reporting and errors. -- [ ] `preview` server for built output. - -## Phase 7: Extensibility -- [ ] Plugin hooks for build pipeline stages. -- [ ] Theme package interface and starter theme. -- [ ] Content collections API for multiple doc sets. - -## Immediate Next Steps (suggested) -- Finalize config shape and minimal content model. -- Prototype MD -> HTML rendering using PureStack nodes. -- Render `packages/ts-ssg/sample-content/index.mdx` with a basic layout. - diff --git a/packages/ts-ssg/sample-content-i18n/README.md b/packages/ts-ssg/sample-content-i18n/README.md index af7de139..e05a9ce1 100644 --- a/packages/ts-ssg/sample-content-i18n/README.md +++ b/packages/ts-ssg/sample-content-i18n/README.md @@ -1,15 +1,13 @@ -PureStack i18n Sample -===================== +# PureStack i18n Sample This sample is a compact bilingual site for the ts-ssg i18n pipeline. Run it from the repository root: - yarn tsx packages/ts-ssg/src/cli.ts --content ./packages/ts-ssg/sample-content-i18n --clean - yarn tsx packages/ts-ssg/src/cli.ts serve --content ./packages/ts-ssg/sample-content-i18n --port 4175 +yarn tsx packages/ts-ssg/src/cli.ts --content ./packages/ts-ssg/sample-content-i18n --clean +yarn tsx packages/ts-ssg/src/cli.ts serve --content ./packages/ts-ssg/sample-content-i18n --port 4175 It demonstrates: - - global root content in index.mdx. - en and de locale folders. - prefix-all URLs such as /en/docs/ and /de/docs/. @@ -17,12 +15,11 @@ It demonstrates: - localized navigation roots via navigation.roots: ["docs"]. - page translation groups from matching route shapes. - <html lang="...">, canonical URLs, and hreflang alternates. -- current-locale markdown link rewriting for relative and root-absolute .md links. -- explicit locale links such as /de/docs/routing.md. +- current-locale markdown link rewriting for relative and root-absolute .mdx links. +- explicit locale links such as /de/docs/routing.mdx. - sitemap and robots output for prefixed localized routes. To inspect the generated result after a build, open: - - ../dist/i18n-site/index.html - ../dist/i18n-site/en/index.html - ../dist/i18n-site/de/index.html @@ -31,7 +28,7 @@ To inspect the generated result after a build, open: To try the hidden URL strategy, change siteConfig.json: - "urlStrategy": "hidden" +"urlStrategy": "hidden" The output still writes locale folders, while public routes stay unprefixed for hosts that choose the locale by cookie, query string, or Accept-Language. diff --git a/packages/ts-ssg/sample-content-i18n/de/docs/index.md b/packages/ts-ssg/sample-content-i18n/de/docs/index.mdx similarity index 69% rename from packages/ts-ssg/sample-content-i18n/de/docs/index.md rename to packages/ts-ssg/sample-content-i18n/de/docs/index.mdx index 354767f2..3ffc3009 100644 --- a/packages/ts-ssg/sample-content-i18n/de/docs/index.md +++ b/packages/ts-ssg/sample-content-i18n/de/docs/index.mdx @@ -8,12 +8,12 @@ nav: # Dokumentation -Diese Seite wird aus `de/docs/index.md` erzeugt und unter `/de/docs/` +Diese Seite wird aus `de/docs/index.mdx` erzeugt und unter `/de/docs/` veroeffentlicht. Sie zeigt lokalisierte Navigationswurzeln. Die Site Config setzt `navigation.roots: ["docs"]`, und der Builder loest diese Wurzel pro Sprache auf: `en/docs` fuer Englisch und `de/docs` fuer Deutsch. -Weiter mit [Routing](routing.md), oder direkt zum Verhalten von -[Content Links](links.md). +Weiter mit [Routing](routing.mdx), oder direkt zum Verhalten von +[Content Links](links.mdx). diff --git a/packages/ts-ssg/sample-content-i18n/de/docs/links.md b/packages/ts-ssg/sample-content-i18n/de/docs/links.mdx similarity index 51% rename from packages/ts-ssg/sample-content-i18n/de/docs/links.md rename to packages/ts-ssg/sample-content-i18n/de/docs/links.mdx index 2fad8f0f..d1edfaf1 100644 --- a/packages/ts-ssg/sample-content-i18n/de/docs/links.md +++ b/packages/ts-ssg/sample-content-i18n/de/docs/links.mdx @@ -11,17 +11,16 @@ Der Markdown Compiler schreibt Links auf `.md`, `.mdx` und `.rmdx` Dateien zu oeffentlichen Routen um. In einer i18n Site fliesst die aktuelle Quellsprache in diese Entscheidung ein. -| Markdown href | Gerenderter href | -| --- | --- | -| `routing.md` | `/de/docs/routing/` | -| `/docs/routing.md` | `/de/docs/routing/` | -| `/en/docs/routing.md` | `/en/docs/routing/` | +| Markdown href | Gerenderter href | +| --------------------- | ------------------- | +| `routing.mdx` | `/de/docs/routing/` | +| `/docs/routing.mdx` | `/de/docs/routing/` | +| `/en/docs/routing.mdx` | `/en/docs/routing/` | Probiere die Links aus: - -- [Relativer Routing Link](routing.md) -- [Root-absoluter Routing Link](/docs/routing.md) -- [Expliziter englischer Routing Link](/en/docs/routing.md) +- [Relativer Routing Link](routing.mdx) +- [Root-absoluter Routing Link](/docs/routing.mdx) +- [Expliziter englischer Routing Link](/en/docs/routing.mdx) Nutze die Vor/Zurueck Links unten, um die lokalisierte `_nav.json` Reihenfolge zu pruefen. diff --git a/packages/ts-ssg/sample-content-i18n/de/docs/routing.md b/packages/ts-ssg/sample-content-i18n/de/docs/routing.mdx similarity index 52% rename from packages/ts-ssg/sample-content-i18n/de/docs/routing.md rename to packages/ts-ssg/sample-content-i18n/de/docs/routing.mdx index 14ec67f3..076fbc7d 100644 --- a/packages/ts-ssg/sample-content-i18n/de/docs/routing.md +++ b/packages/ts-ssg/sample-content-i18n/de/docs/routing.mdx @@ -10,13 +10,13 @@ nav: Mit `urlStrategy: "prefix-all"` beginnt jede oeffentliche Route mit der Sprache: -| Quelldatei | Oeffentliche URL | Ausgabedatei | -| --- | --- | --- | -| `de/index.mdx` | `/de/` | `de/index.html` | -| `de/docs/routing.md` | `/de/docs/routing/` | `de/docs/routing/index.html` | -| `en/docs/routing.md` | `/en/docs/routing/` | `en/docs/routing/index.html` | +| Quelldatei | Oeffentliche URL | Ausgabedatei | +| -------------------- | ------------------- | ---------------------------- | +| `de/index.mdx` | `/de/` | `de/index.html` | +| `de/docs/routing.mdx` | `/de/docs/routing/` | `de/docs/routing/index.html` | +| `en/docs/routing.mdx` | `/en/docs/routing/` | `en/docs/routing/index.html` | -Die passende englische Seite ist [hier](/en/docs/routing.md). Die Startseite +Die passende englische Seite ist [hier](/en/docs/routing.mdx). Die Startseite ist [hier](/index.mdx), und dieser root-absolute Content Link bleibt trotzdem in der aktuellen Sprache. @@ -24,7 +24,6 @@ in der aktuellen Sprache. Baue dieses Beispiel und pruefe das generierte HTML. Du solltest Folgendes sehen: - - `<html lang="de">` - eine Canonical URL mit `/de/docs/routing/` - alternative `hreflang` Links fuer `en` und `de` diff --git a/packages/ts-ssg/sample-content-i18n/de/index.mdx b/packages/ts-ssg/sample-content-i18n/de/index.mdx index d9b12b7b..e428c3b6 100644 --- a/packages/ts-ssg/sample-content-i18n/de/index.mdx +++ b/packages/ts-ssg/sample-content-i18n/de/index.mdx @@ -38,9 +38,9 @@ PureStack Uebersetzungsalternativen in den Metadaten. ## Links ausprobieren -- Relativer Markdown Link: [Dokumentation lesen](docs/index.md) -- Root-absoluter Markdown Link: [Zum Routing](/docs/routing.md) -- Expliziter Locale Link: [Englische Routing Seite](/en/docs/routing.md) +- Relativer Markdown Link: [Dokumentation lesen](docs/index.mdx) +- Root-absoluter Markdown Link: [Zum Routing](/docs/routing.mdx) +- Expliziter Locale Link: [Englische Routing Seite](/en/docs/routing.mdx) Die ersten beiden Links bleiben deutsch. Der explizite Locale Link springt absichtlich zur englischen Seite. diff --git a/packages/ts-ssg/sample-content-i18n/en/docs/index.md b/packages/ts-ssg/sample-content-i18n/en/docs/index.mdx similarity index 68% rename from packages/ts-ssg/sample-content-i18n/en/docs/index.md rename to packages/ts-ssg/sample-content-i18n/en/docs/index.mdx index 226301a0..cfb36ad8 100644 --- a/packages/ts-ssg/sample-content-i18n/en/docs/index.md +++ b/packages/ts-ssg/sample-content-i18n/en/docs/index.mdx @@ -8,10 +8,10 @@ nav: # Documentation -This page is generated from `en/docs/index.md` and published at `/en/docs/`. +This page is generated from `en/docs/index.mdx` and published at `/en/docs/`. It demonstrates localized navigation roots. The site config declares `navigation.roots: ["docs"]`, and the builder resolves that root under each locale folder: `en/docs` for English and `de/docs` for German. -Continue with [Routing](routing.md), or jump to [content-link behavior](links.md). +Continue with [Routing](routing.mdx), or jump to [content-link behavior](links.mdx). diff --git a/packages/ts-ssg/sample-content-i18n/en/docs/links.md b/packages/ts-ssg/sample-content-i18n/en/docs/links.mdx similarity index 50% rename from packages/ts-ssg/sample-content-i18n/en/docs/links.md rename to packages/ts-ssg/sample-content-i18n/en/docs/links.mdx index 7c30b066..859f7750 100644 --- a/packages/ts-ssg/sample-content-i18n/en/docs/links.md +++ b/packages/ts-ssg/sample-content-i18n/en/docs/links.mdx @@ -10,17 +10,16 @@ nav: The markdown compiler rewrites links to `.md`, `.mdx`, and `.rmdx` files into public routes. In an i18n site, the current source locale is part of that decision. -| Markdown href | Rendered href | -| --- | --- | -| `routing.md` | `/en/docs/routing/` | -| `/docs/routing.md` | `/en/docs/routing/` | -| `/de/docs/routing.md` | `/de/docs/routing/` | +| Markdown href | Rendered href | +| --------------------- | ------------------- | +| `routing.mdx` | `/en/docs/routing/` | +| `/docs/routing.mdx` | `/en/docs/routing/` | +| `/de/docs/routing.mdx` | `/de/docs/routing/` | Try each link: - -- [Relative routing link](routing.md) -- [Root-absolute routing link](/docs/routing.md) -- [Explicit German routing link](/de/docs/routing.md) +- [Relative routing link](routing.mdx) +- [Root-absolute routing link](/docs/routing.mdx) +- [Explicit German routing link](/de/docs/routing.mdx) Use the previous/next page links below to confirm the localized `_nav.json` sequence. diff --git a/packages/ts-ssg/sample-content-i18n/en/docs/routing.md b/packages/ts-ssg/sample-content-i18n/en/docs/routing.mdx similarity index 51% rename from packages/ts-ssg/sample-content-i18n/en/docs/routing.md rename to packages/ts-ssg/sample-content-i18n/en/docs/routing.mdx index fe1b5781..c8afb4bd 100644 --- a/packages/ts-ssg/sample-content-i18n/en/docs/routing.md +++ b/packages/ts-ssg/sample-content-i18n/en/docs/routing.mdx @@ -10,20 +10,19 @@ nav: With `urlStrategy: "prefix-all"`, every generated public route starts with the locale: -| Source file | Public URL | Output file | -| --- | --- | --- | -| `en/index.mdx` | `/en/` | `en/index.html` | -| `en/docs/routing.md` | `/en/docs/routing/` | `en/docs/routing/index.html` | -| `de/docs/routing.md` | `/de/docs/routing/` | `de/docs/routing/index.html` | +| Source file | Public URL | Output file | +| -------------------- | ------------------- | ---------------------------- | +| `en/index.mdx` | `/en/` | `en/index.html` | +| `en/docs/routing.mdx` | `/en/docs/routing/` | `en/docs/routing/index.html` | +| `de/docs/routing.mdx` | `/de/docs/routing/` | `de/docs/routing/index.html` | -The equivalent German page is [here](/de/docs/routing.md). The home page is +The equivalent German page is [here](/de/docs/routing.mdx). The home page is [here](/index.mdx), and this root-absolute content link still resolves inside the current locale. ## Metadata to inspect Build this sample and inspect the generated HTML. You should see: - - `<html lang="en">` - a canonical URL ending in `/en/docs/routing/` - alternate `hreflang` links for `en` and `de` diff --git a/packages/ts-ssg/sample-content-i18n/en/index.mdx b/packages/ts-ssg/sample-content-i18n/en/index.mdx index 517b1fab..6178a444 100644 --- a/packages/ts-ssg/sample-content-i18n/en/index.mdx +++ b/packages/ts-ssg/sample-content-i18n/en/index.mdx @@ -38,9 +38,9 @@ metadata includes translation alternates. ## Try the links -- Relative markdown link: [Read the docs](docs/index.md) -- Root-absolute markdown link: [Go to routing](/docs/routing.md) -- Explicit locale link: [German routing page](/de/docs/routing.md) +- Relative markdown link: [Read the docs](docs/index.mdx) +- Root-absolute markdown link: [Go to routing](/docs/routing.mdx) +- Explicit locale link: [German routing page](/de/docs/routing.mdx) The first two links stay in English. The explicit locale link intentionally jumps to German. diff --git a/packages/ts-ssg/sample-content/guide/content.mdx b/packages/ts-ssg/sample-content/guide/content.mdx index c76abe51..b5b234ab 100644 --- a/packages/ts-ssg/sample-content/guide/content.mdx +++ b/packages/ts-ssg/sample-content/guide/content.mdx @@ -12,7 +12,7 @@ nav: # Content and navigation -The content tree is the site map. The generator turns `index.mdx` into `/`, `platform.mdx` into `/platform/`, and `journal/weekly-review.md` into `/journal/weekly-review/`. +The content tree is the site map. The generator turns `index.mdx` into `/`, `platform.mdx` into `/platform/`, and `journal/weekly-review.mdx` into `/journal/weekly-review/`. ## Frontmatter describes the page diff --git a/packages/ts-ssg/sample-content/guide/getting-started.mdx b/packages/ts-ssg/sample-content/guide/getting-started.mdx index f181a578..6f6e46a9 100644 --- a/packages/ts-ssg/sample-content/guide/getting-started.mdx +++ b/packages/ts-ssg/sample-content/guide/getting-started.mdx @@ -46,7 +46,7 @@ Start with words and routes before fine-tuning visual details. This keeps the de | Introduce a product | `index.mdx` and `platform.mdx` | | Explain a plan | `plans.mdx` | | Show product data | `workspace.mdx` | -| Publish an article | `journal/weekly-review.md` | +| Publish an article | `journal/weekly-review.mdx` | | Collect an inquiry | `contact.mdx` and `contact-demo.ts` | ## Use the guide as a map diff --git a/packages/ts-ssg/sample-content/journal/index.md b/packages/ts-ssg/sample-content/journal/index.mdx similarity index 100% rename from packages/ts-ssg/sample-content/journal/index.md rename to packages/ts-ssg/sample-content/journal/index.mdx diff --git a/packages/ts-ssg/sample-content/journal/weekly-review.md b/packages/ts-ssg/sample-content/journal/weekly-review.mdx similarity index 82% rename from packages/ts-ssg/sample-content/journal/weekly-review.md rename to packages/ts-ssg/sample-content/journal/weekly-review.mdx index e6a1849c..c5dbc670 100644 --- a/packages/ts-ssg/sample-content/journal/weekly-review.md +++ b/packages/ts-ssg/sample-content/journal/weekly-review.mdx @@ -23,12 +23,12 @@ That sentence gives readers a current picture. The details underneath can suppor ## Use a small, repeatable structure -| Section | What belongs here | -| --- | --- | -| Progress | Milestones completed or moved forward | -| Next | The two or three actions that matter most | -| Decisions | A question, an owner, and a target date | -| Risks | A blocker and the help needed to remove it | +| Section | What belongs here | +| --------- | ------------------------------------------ | +| Progress | Milestones completed or moved forward | +| Next | The two or three actions that matter most | +| Decisions | A question, an owner, and a target date | +| Risks | A blocker and the help needed to remove it | Keep each section short. Link to a [workspace view](/workspace/) for the full trail of tasks and decisions. diff --git a/packages/ts-ssg/src/build/incremental/incremental.md b/packages/ts-ssg/src/build/incremental/incremental.md index 6118c4b5..e5f3644e 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.md +++ b/packages/ts-ssg/src/build/incremental/incremental.md @@ -8,7 +8,6 @@ ## Purpose `incremental.ts` provides the incremental build engine for `ts-ssg`. It owns: - - The incremental builder API (`createIncrementalBuilder`). - The file-change application logic (`applyChange`). - Full rebuild orchestration (`buildAll`). @@ -58,7 +57,6 @@ HTML from earlier sessions. No background loop renders the rest of the site. ## `buildAll(reason)` This is the canonical full build path: - - Resolves user hooks from `input.hooks`. - Prepares the output directory and discovers static asset candidates. - Discovers content, runs page generators, and rebuilds navigation, once @@ -83,7 +81,6 @@ become dirty and render on their next request; edited script bundles and static assets are rebuilt/copied when requested too. In full-build mode, the changed page renders right away. `IncrementalBuildResult.markedPages` counts pages invalidated by the change, so the dev server can reload the browser. - 1. **Ignore changes outside `contentDir`**: relative path starting with `..`. 2. **`siteConfig.json`** ➜ `fullRebuild = true`. Config shapes every output. 3. **Header or footer partial** (edit, add, or delete) ➜ compile the partials @@ -117,7 +114,6 @@ Plugin `pages` generators run inside `discoverSiteContent`, so every discovery, full or incremental, sees the same content list. A generated page is a `ContentFile` whose `source` is its text, or a function that returns it; its `absPath` names no file. `readContentSource` reads either, or a real file. - - `readContentSignature` signs a generated page by its source size (0 for a source function) and the time it was generated, so the manifest keeps an entry and an `outPath` for it like any page. No file event names a generated @@ -131,7 +127,6 @@ full or incremental, sees the same content list. A generated page is a ## Manifest assembly `buildManifest` composes a fresh `BuildManifest` from: - - Current signatures for content files (`resolveOutPath`). - Current signatures for assets (`resolveStaticOutPath`). - A styles signature, computed via `computeStylesSignature`. @@ -142,7 +137,6 @@ state. ## Styles signature `computeStylesSignature`: - - Orders themes deterministically (`orderThemes`). - Ensures themes are registered (`styleBuilder.ensureThemes`). - Reads each generated CSS file, hashes contents (SHA-256), and records outputs. @@ -160,7 +154,6 @@ even when the build inputs are the same. ## Extension points If you add new build outputs or config that affect correctness: - - Extend `BuildManifest` and `manifestConfigFromSiteConfig`. - Update `isCompatibleManifest` and bump `MANIFEST_VERSION`. - Consider how `applyChange` should classify and process the new file type. diff --git a/packages/ts-ssg/src/build/logger.test.ts b/packages/ts-ssg/src/build/logger.test.ts index 8330ccf5..3cc594f1 100644 --- a/packages/ts-ssg/src/build/logger.test.ts +++ b/packages/ts-ssg/src/build/logger.test.ts @@ -1,5 +1,5 @@ import { getLogger, hasLogger } from 'logpot' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { ensureLogger } from './logger' describe('ensureLogger', () => { @@ -7,13 +7,28 @@ describe('ensureLogger', () => { await getLogger().close() }) - it('creates the default logger once and keeps a logger that exists', async () => { + it('uses the requested minimum level and keeps a logger that exists', async () => { expect(hasLogger()).toBe(false) - await ensureLogger() + expect(await ensureLogger('WARN')).toBe(true) const logger = getLogger() - await ensureLogger() + expect(await ensureLogger('DEBUG')).toBe(false) expect(getLogger()).toBe(logger) + const output = vi.spyOn(process.stdout, 'write') + try { + logger.debug('hidden-debug-marker') + logger.info('hidden-info-marker') + logger.warn('visible-warning-marker') + logger.error('visible-error-marker') + await logger.flush() + const text = output.mock.calls.map(([chunk]) => String(chunk)).join('') + expect(text).not.toContain('hidden-debug-marker') + expect(text).not.toContain('hidden-info-marker') + expect(text).toContain('visible-warning-marker') + expect(text).toContain('visible-error-marker') + } finally { + output.mockRestore() + } }) }) diff --git a/packages/ts-ssg/src/build/logger.ts b/packages/ts-ssg/src/build/logger.ts index ea5ed2a8..8e7c9f1e 100644 --- a/packages/ts-ssg/src/build/logger.ts +++ b/packages/ts-ssg/src/build/logger.ts @@ -1,10 +1,18 @@ -import { createLogger, hasLogger } from 'logpot' +import { createLogger, hasLogger, type LevelName, STD_LEVEL_DEF } from 'logpot' /** * Builds log through logpot. A caller can set up its own logger first; * otherwise the default console logger is created once and kept, since it * holds no timers that would keep the process alive. + * Returns whether this call created the logger and owns its cleanup. */ -export async function ensureLogger() { - if (!hasLogger()) await createLogger() +export async function ensureLogger(logLevel?: LevelName) { + if (hasLogger()) return false + await createLogger({ + logLevel: + logLevel === undefined + ? undefined + : STD_LEVEL_DEF.getLevelNumber(logLevel), + }) + return true } diff --git a/packages/ts-ssg/src/build/manifest.md b/packages/ts-ssg/src/build/manifest.md index 404754a4..79c15e5c 100644 --- a/packages/ts-ssg/src/build/manifest.md +++ b/packages/ts-ssg/src/build/manifest.md @@ -21,7 +21,6 @@ The manifest is written to: ``` The path is derived by `manifestPath(outDir)` using: - - `MANIFEST_DIRNAME = ".ts-ssg"` - `MANIFEST_FILENAME = "manifest.json"` @@ -31,7 +30,6 @@ static assets. ## Data model `BuildManifest` is a normalized snapshot: - - `version`: schema version (`MANIFEST_VERSION`, currently `1`). - `generatedAt`: epoch millis updated on every write. - `config`: the build-relevant subset of `SiteConfig`. @@ -49,7 +47,6 @@ by build logic to decide if a file needs to be reprocessed. `ManifestConfig` is intentionally limited to settings that affect output correctness: - - `contentDir`, `outDir`, `siteTitle` - `style.fileName`, `style.href`, `style.themes`, `style.pretty` @@ -71,7 +68,6 @@ captures the config snapshot, and initializes empty maps for content and assets. ### `isCompatibleManifest(manifest, config)` Guards incremental builds. It returns `false` when: - - the manifest version differs from `MANIFEST_VERSION`, or - any of the config fields in the snapshot differ, or - the shape of `style.themes` is not an array or differs by value order. @@ -83,7 +79,6 @@ output would be incorrect. Loads `{outDir}/.ts-ssg/manifest.json` and performs lightweight shape checks. It returns: - - `BuildManifest` when the JSON parses and has the expected object shape. - `null` when the file does not exist (`ENOENT`) or fails basic validation. @@ -104,7 +99,6 @@ pipeline to compare current disk state with the manifest. ## Implementation usage patterns Typical build flow (conceptual): - 1. `readManifest(outDir)` 2. If `null`, call `createEmptyManifest(config)` and perform a full build. 3. If present, call `isCompatibleManifest(manifest, config)`: @@ -121,7 +115,6 @@ only volatile field. ## Extension points If you add build features that affect output validity, extend: - - `ManifestConfig` and `manifestConfigFromSiteConfig` - `isCompatibleManifest` checks - `BuildManifest` fields (prefer version bump when changing schema) diff --git a/packages/ts-ssg/src/cli-runner.ts b/packages/ts-ssg/src/cli-runner.ts index 691cdb3c..f7b9f3b5 100644 --- a/packages/ts-ssg/src/cli-runner.ts +++ b/packages/ts-ssg/src/cli-runner.ts @@ -24,8 +24,8 @@ export async function runCli(args: string[]) { await createLogger() loggerCreated = true if (cli.command === 'serve') { - keepLoggerOpen = true await startDevServer({ ...cli.input, configFile: cli.configFile }) + keepLoggerOpen = true return } const loaded = cli.configFile @@ -52,14 +52,14 @@ export async function runCli(args: string[]) { type CliCommand = 'build' | 'serve' | 'publish' | 'help' -interface CliState { +export interface CliState { command: CliCommand input: DevServerInput contentDir?: string configFile?: string } -function parseCliArgs(args: string[]): CliState { +export function parseCliArgs(args: string[], cwd = process.cwd()): CliState { const command = args[0] if ( !command || @@ -106,7 +106,7 @@ function parseCliArgs(args: string[]): CliState { } const contentDir = readValue('--content') if (contentDir) { - state.contentDir = path.resolve(contentDir) + state.contentDir = path.resolve(cwd, contentDir) state.input.build ??= {} state.input.build.siteConfig ??= {} state.input.build.siteConfig.contentDir = state.contentDir diff --git a/packages/ts-ssg/src/dev/server-embedded.test.ts b/packages/ts-ssg/src/dev/server-embedded.test.ts new file mode 100644 index 00000000..c3a7e937 --- /dev/null +++ b/packages/ts-ssg/src/dev/server-embedded.test.ts @@ -0,0 +1,199 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import http from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { createLogger } from 'logpot' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { + type DevServerHandle, + type DevServerInput, + startDevServer, +} from './server' + +let logger: Awaited<ReturnType<typeof createLogger>> +beforeAll(async () => { + logger = await createLogger({ filter: () => false }) +}) +afterAll(async () => { + await logger.close() +}) + +describe('embedded dev server', () => { + it.each([ + ['0.0.0.0', '127.0.0.1'], + ['::', '[::1]'], + ['::1', '[::1]'], + ])('logs and serves a browsable URL for host %s', async (host, urlHost) => { + const root = await mkdtemp(path.join(tmpdir(), 'purestack-server-url-')) + const info = vi.spyOn(logger, 'info') + let server: DevServerHandle | undefined + try { + await writeFile(path.join(root, 'index.mdx'), '# Browsable site') + server = await startDevServer({ + host, + port: 0, + handleSignals: false, + watch: false, + liveReload: false, + build: { + siteConfig: { + rootDir: root, + contentDir: root, + outDir: path.join(root, 'out'), + basePath: '/docs', + }, + options: { cache: false }, + }, + }) + const url = server.url + expect(new URL(url)).toMatchObject({ + protocol: 'http:', + hostname: urlHost, + pathname: '/docs/', + }) + expect(info).toHaveBeenCalledWith('serving at', { url }) + const html = await new Promise<string>((resolve, reject) => { + http + .get(url, (response) => { + let body = '' + response.setEncoding('utf8') + response.on('data', (chunk: string) => { + body += chunk + }) + response.on('end', () => resolve(body)) + response.on('error', reject) + }) + .on('error', reject) + }) + expect(html).toContain('Browsable site') + } finally { + info.mockRestore() + await server?.close() + await rm(root, { recursive: true, force: true }) + } + }) + + it('returns a ready ephemeral URL, rejects a busy port and closes idempotently', async () => { + const closeLogger = vi.spyOn(logger, 'close') + const root = await mkdtemp(path.join(tmpdir(), 'purestack-server-')) + await writeFile(path.join(root, 'index.md'), '# Embedded site') + const input = { + host: '127.0.0.1', + port: 0, + handleSignals: false, + watch: false, + build: { + siteConfig: { + rootDir: root, + contentDir: root, + outDir: path.join(root, 'out'), + }, + options: { cache: false }, + }, + } + const listeners = process.listenerCount('SIGINT') + const server = await startDevServer(input) + try { + expect(new URL(server.url).port).not.toBe('0') + expect( + await fetch(server.url).then((response) => response.text()), + ).toContain('Embedded site') + await expect( + startDevServer({ ...input, port: Number(new URL(server.url).port) }), + ).rejects.toMatchObject({ code: 'EADDRINUSE' }) + expect(closeLogger).not.toHaveBeenCalled() + expect(process.listenerCount('SIGINT')).toBe(listeners) + await Promise.all([server.close(), server.close()]) + expect(closeLogger).not.toHaveBeenCalled() + } finally { + await server.close() + closeLogger.mockRestore() + await rm(root, { recursive: true, force: true }) + } + }) +}) + +describe('initial dev build failures', () => { + async function brokenSite(fullRender: boolean) { + const root = await mkdtemp(path.join(tmpdir(), 'purestack-broken-server-')) + const dataFile = path.join(root, 'pages.json') + await writeFile(path.join(root, 'index.mdx'), '# Recovered site') + await writeFile(dataFile, '{') + const prepared = vi.fn() + const input: DevServerInput = { + host: '127.0.0.1', + port: 0, + handleSignals: false, + liveReload: false, + fullRender, + build: { + siteConfig: { + rootDir: root, + contentDir: root, + outDir: path.join(root, 'out'), + }, + options: { + cache: false, + plugins: [ + { + name: 'fixture-data', + pages: async () => { + JSON.parse(await readFile(dataFile, 'utf8')) + prepared() + return [] + }, + }, + ], + }, + }, + } + return { root, dataFile, input, prepared } + } + + it.each([false, true])( + 'keeps watching and recovers after fixing plugin data (fullRender %s)', + async (fullRender) => { + const { root, dataFile, input, prepared } = await brokenSite(fullRender) + let server: DevServerHandle | undefined + try { + server = await startDevServer(input) + const url = server.url + const broken = await fetch(url) + await broken.text() + expect(broken.status).toBe(500) + expect(prepared).not.toHaveBeenCalled() + await writeFile(dataFile, '{}') + await expect + .poll(() => prepared.mock.calls.length, { timeout: 5000 }) + .toBeGreaterThan(0) + const response = await fetch(url) + expect(response.ok).toBe(true) + expect(await response.text()).toContain('Recovered site') + } finally { + await server?.close() + await rm(root, { recursive: true, force: true }) + } + }, + ) + + it.each([false, true])( + 'rejects initial build failures when explicitly requested (fullRender %s)', + async (fullRender) => { + const { root, input } = await brokenSite(fullRender) + let server: DevServerHandle | undefined + try { + await expect( + startDevServer({ ...input, failOnInitialBuildError: true }).then( + (handle) => { + server = handle + return handle + }, + ), + ).rejects.toThrow(/Plugin "fixture-data" failed in pages/) + } finally { + await server?.close() + await rm(root, { recursive: true, force: true }) + } + }, + ) +}) diff --git a/packages/ts-ssg/src/dev/server.ts b/packages/ts-ssg/src/dev/server.ts index ad55a335..c113c591 100644 --- a/packages/ts-ssg/src/dev/server.ts +++ b/packages/ts-ssg/src/dev/server.ts @@ -3,7 +3,7 @@ import http from 'node:http' import path from 'node:path' import type { I18nConfig } from '@purestack/ts-common' import { logError, stripBasePath, withBasePath } from '@purestack/ts-util' -import { getLogger, type Logger } from 'logpot' +import { getLogger, type LevelName, type Logger } from 'logpot' import { createIncrementalBuildSession, type IncrementalBuilder, @@ -30,6 +30,7 @@ import { writeHtmlResponse, } from './static-files' import { toPathKey, watchFiles, watchTree } from './watch-tree' +import { attachWebSockets, type DevWebSocketEndpoint } from './websocket' export interface DevServerOptions { host?: string @@ -41,7 +42,14 @@ export interface DevServerOptions { } export interface DevServerInput extends DevServerOptions { + /** Reject startup on an initial build error instead of staying open for fixes. */ + failOnInitialBuildError?: boolean + /** Minimum level for the default logger; an existing caller-owned logger is kept. */ + logLevel?: LevelName build?: BuildInput + webSockets?: readonly DevWebSocketEndpoint[] + /** Embedded callers can own process signals and close the returned handle. */ + handleSignals?: boolean /** * A `purestack.config.ts` whose plugins the server adds to the build. It * loads again when the config or a local file it imports changes. @@ -50,6 +58,7 @@ export interface DevServerInput extends DevServerOptions { } export interface DevServerHandle { + readonly url: string close: () => Promise<void> } @@ -78,7 +87,7 @@ type RebuildRequestState = { export async function startDevServer( input: DevServerInput = {}, ): Promise<DevServerHandle> { - await ensureLogger() + const ownsLogger = await ensureLogger(input.logLevel) const logger = getLogger() const baseBuildInput: BuildInput = { ...input.build, @@ -102,8 +111,14 @@ export async function startDevServer( let devMiddleware = composeDevMiddleware(session.plugins) const log = getLogger() - const { host, port, watch, liveReload, fullRender } = - resolveDevServerOptions(input) + const { + host, + port: requestedPort, + watch, + liveReload, + fullRender, + } = resolveDevServerOptions(input) + let port = requestedPort let setupReady: Promise<void> = Promise.resolve() const activeResponses = new Set<Promise<void>>() const beginSiteRequest = async () => { @@ -127,6 +142,7 @@ export async function startDevServer( const shutdownController = new AbortController() let watcher: { close: () => void } | undefined const requestState = createRebuildRequestState() + let rebuildDone: Promise<void> = Promise.resolve() const emitState = (reason: string) => { if (!liveReload) return @@ -158,6 +174,8 @@ export async function startDevServer( requestState.pending = true if (requestState.inFlight) return requestState.inFlight = true + const finished = Promise.withResolvers<void>() + rebuildDone = finished.promise try { while (requestState.pending && !shuttingDown) { requestState.pending = false @@ -169,8 +187,10 @@ export async function startDevServer( } } catch (error) { logError(log, error, 'rebuild failed') + if (input.failOnInitialBuildError && !initialSetupDone) throw error } finally { requestState.inFlight = false + finished.resolve() } } @@ -185,7 +205,11 @@ export async function startDevServer( : undefined } - const displayHost = host === '0.0.0.0' ? LOOPBACK_HOST : host + const displayHost = + host === '0.0.0.0' ? LOOPBACK_HOST : host === '::' ? '::1' : host + const urlHost = displayHost.includes(':') ? `[${displayHost}]` : displayHost + const getUrl = () => + `http://${urlHost}:${port}${withBasePath(config.basePath, '/')}` let incremental = session.builder const rebuild = async ( @@ -216,13 +240,14 @@ export async function startDevServer( await incremental.prepareForRequests() if (!initialSetupDone) { log.info('serving at', { - url: `http://${displayHost}:${port}${withBasePath(config.basePath, '/')}`, + url: getUrl(), }) initialSetupDone = true } if (!shuttingDown) notifyReload(reason) } catch (error) { logError(log, error, 'dev setup failed') + if (input.failOnInitialBuildError && !initialSetupDone) throw error } finally { prepared.resolve() } @@ -273,8 +298,7 @@ export async function startDevServer( const server = http.createServer( createDevServerRequestHandler({ - host, - port, + getUrl, outDir: config.outDir, basePath: config.basePath, i18n: config.i18n, @@ -296,44 +320,33 @@ export async function startDevServer( }) server.keepAliveTimeout = 1000 server.headersTimeout = 5000 - - if (watch) { - watcher = await watchTree(config.contentDir, (filePath) => { - scheduleRebuild(`content change: ${filePath}`, filePath) - }) - log.info('watching content', { contentDir: config.contentDir }) - } - watchConfigDependencies() - - void requestRebuild() - server.listen(port, host, () => { - log.info('dev server listening', { - host, - port, - outDir: config.outDir, - liveReload, - watch, - fullRender, - }) - }) - - const shutdown = async () => { - if (shuttingDown) return - shuttingDown = true - shutdownController.abort() - requestState.pending = false - requestState.changedPaths.clear() - if (requestState.timer) { - clearTimeout(requestState.timer) - requestState.timer = undefined - } - process.off('SIGINT', handleSignal) - process.off('SIGTERM', handleSignal) - watcher?.close() - dependencyWatcher?.close() - await new Promise<void>((resolve) => server.close(() => resolve())) - await logger.close() - } + const closeWebSockets = attachWebSockets( + server, + input.webSockets ?? [], + (error) => logError(log, error, 'WebSocket endpoint failed'), + ) + let closing: Promise<void> | undefined + const shutdown = () => + (closing ??= (async () => { + shuttingDown = true + shutdownController.abort() + requestState.pending = false + requestState.changedPaths.clear() + if (requestState.timer) { + clearTimeout(requestState.timer) + requestState.timer = undefined + } + process.off('SIGINT', handleSignal) + process.off('SIGTERM', handleSignal) + watcher?.close() + dependencyWatcher?.close() + for (const response of clients.keys()) response.end() + clients.clear() + await closeWebSockets() + await new Promise<void>((resolve) => server.close(() => resolve())) + await rebuildDone + if (ownsLogger) await logger.close() + })()) const handleSignal = (signal: NodeJS.Signals) => { log.info('dev server shutting down', { signal }) @@ -342,10 +355,38 @@ export async function startDevServer( }) } - process.on('SIGINT', handleSignal) - process.on('SIGTERM', handleSignal) - - return { close: shutdown } + if (input.handleSignals !== false) { + process.on('SIGINT', handleSignal) + process.on('SIGTERM', handleSignal) + } + try { + await new Promise<void>((resolve, reject) => { + server.once('error', reject) + server.listen(port, host, () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('No TCP address.') + port = address.port + if (watch) { + watcher = await watchTree(config.contentDir, (filePath) => { + scheduleRebuild(`content change: ${filePath}`, filePath) + }) + log.info('watching content', { contentDir: config.contentDir }) + } + watchConfigDependencies() + await requestRebuild() + return { + url: getUrl(), + close: shutdown, + } + } catch (error) { + await shutdown() + throw error + } } /** Loads the project config and tells which changed files belong to it. */ @@ -402,8 +443,7 @@ function createRebuildRequestState(): RebuildRequestState { } type DevServerRequestHandlerInput = { - host: string - port: number + getUrl: () => string outDir: string basePath: string i18n: I18nConfig @@ -422,8 +462,7 @@ type DevServerRequestHandlerInput = { function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { const { - host, - port, + getUrl, outDir, basePath, i18n, @@ -443,7 +482,7 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { return } - const { pathname } = new URL(req.url, `http://${host}:${port}`) + const { pathname } = new URL(req.url, getUrl()) const liveReloadPath = withBasePath(basePath, LIVE_RELOAD_PATH) if (liveReload && pathname === liveReloadPath) { diff --git a/packages/ts-ssg/src/dev/websocket-frames.ts b/packages/ts-ssg/src/dev/websocket-frames.ts new file mode 100644 index 00000000..0fe3e0ad --- /dev/null +++ b/packages/ts-ssg/src/dev/websocket-frames.ts @@ -0,0 +1,128 @@ +import { isUtf8 } from 'node:buffer' + +export class WebSocketProtocolError extends Error { + constructor( + readonly code: number, + message: string, + ) { + super(message) + } +} + +export interface WebSocketFrame { + opcode: number + final: boolean + data: Buffer +} + +/** Incremental RFC 6455 client-frame decoder; no compression is negotiated. */ +export class WebSocketFrameDecoder { + private buffer: Buffer = Buffer.alloc(0) + private fragmentOpcode = 0 + private fragments: Buffer[] = [] + private fragmentSize = 0 + + constructor(private readonly maxPayload: number) {} + + push(chunk: Buffer, receive: (frame: WebSocketFrame) => boolean) { + this.buffer = this.buffer.length + ? Buffer.concat([this.buffer, chunk]) + : chunk + while (this.buffer.length >= 2) { + const frame = this.readFrame() + if (!frame) return + if (frame.opcode >= 8) { + if (!receive(frame)) return + continue + } + if (frame.opcode === 0) { + if (!this.fragmentOpcode) this.fail('Unexpected continuation.') + } else { + if (this.fragmentOpcode) this.fail('Unfinished fragmented message.') + this.fragmentOpcode = frame.opcode + } + this.fragmentSize += frame.data.length + if (this.fragmentSize > this.maxPayload) + throw new WebSocketProtocolError(1009, 'Message too large.') + this.fragments.push(frame.data) + if (!frame.final) continue + const data = Buffer.concat(this.fragments, this.fragmentSize) + const opcode = this.fragmentOpcode + this.fragments = [] + this.fragmentOpcode = 0 + this.fragmentSize = 0 + if (opcode === 1 && !isUtf8(data)) + throw new WebSocketProtocolError(1007, 'Invalid UTF-8.') + if (!receive({ opcode, final: true, data })) return + } + } + + private readFrame(): WebSocketFrame | undefined { + const first = this.buffer[0] + const second = this.buffer[1] + const opcode = first & 0x0f + const final = Boolean(first & 0x80) + if (first & 0x70 || !(second & 0x80)) + this.fail('Client frames must be masked without extensions.') + if (![0, 1, 2, 8, 9, 10].includes(opcode)) this.fail('Invalid opcode.') + let size = second & 0x7f + let offset = 2 + if (opcode >= 8 && (!final || size > 125)) + this.fail('Invalid control frame.') + if (size === 126) { + if (this.buffer.length < 4) return + size = this.buffer.readUInt16BE(2) + if (size < 126) this.fail('Noncanonical length.') + offset = 4 + } else if (size === 127) { + if (this.buffer.length < 10) return + const length = this.buffer.readBigUInt64BE(2) + if (length < 65536n || length >> 63n) this.fail('Invalid length.') + if (length > BigInt(this.maxPayload)) + throw new WebSocketProtocolError(1009, 'Message too large.') + size = Number(length) + offset = 10 + } + if (size > this.maxPayload) + throw new WebSocketProtocolError(1009, 'Frame too large.') + if (this.buffer.length < offset + 4 + size) return + const mask = this.buffer.subarray(offset, offset + 4) + offset += 4 + const data = Buffer.from(this.buffer.subarray(offset, offset + size)) + for (let i = 0; i < data.length; i++) data[i] ^= mask[i % 4] + this.buffer = this.buffer.subarray(offset + size) + return { opcode, final, data } + } + + private fail(message: string): never { + throw new WebSocketProtocolError(1002, message) + } +} + +export function encodeWebSocketFrame(opcode: number, data: Uint8Array) { + const size = data.byteLength + const header = Buffer.alloc(size < 126 ? 2 : size < 65536 ? 4 : 10) + header[0] = 0x80 | opcode + header[1] = size < 126 ? size : size < 65536 ? 126 : 127 + if (header.length === 4) header.writeUInt16BE(size, 2) + if (header.length === 10) header.writeBigUInt64BE(BigInt(size), 2) + return Buffer.concat([header, data]) +} + +export function validCloseCode(code: number) { + return ( + (code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || + (code >= 3000 && code <= 4999) + ) +} + +export function validateClose(data: Buffer) { + if (data.length === 1) + throw new WebSocketProtocolError(1002, 'Invalid close payload.') + if (data.length >= 2) { + if (!validCloseCode(data.readUInt16BE(0))) + throw new WebSocketProtocolError(1002, 'Invalid close code.') + if (!isUtf8(data.subarray(2))) + throw new WebSocketProtocolError(1007, 'Invalid close reason.') + } +} diff --git a/packages/ts-ssg/src/dev/websocket.test.ts b/packages/ts-ssg/src/dev/websocket.test.ts new file mode 100644 index 00000000..2324833e --- /dev/null +++ b/packages/ts-ssg/src/dev/websocket.test.ts @@ -0,0 +1,177 @@ +import { once } from 'node:events' +import http from 'node:http' +import { describe, expect, it } from 'vitest' +import { attachWebSockets, type DevWebSocketConnection } from './websocket' +import { + encodeWebSocketFrame, + WebSocketFrameDecoder, + WebSocketProtocolError, +} from './websocket-frames' + +function clientFrame(opcode: number, text: string | Buffer, final = true) { + const data = Buffer.from(text) + const header = Buffer.alloc(data.length < 126 ? 2 : 4) + header[0] = (final ? 0x80 : 0) | opcode + header[1] = 0x80 | Math.min(data.length, 126) + if (header.length === 4) header.writeUInt16BE(data.length, 2) + const mask = Buffer.from([1, 2, 3, 4]) + for (let i = 0; i < data.length; i++) data[i] ^= mask[i % 4] + return Buffer.concat([header, mask, data]) +} + +describe('WebSocket framing', () => { + it('reassembles split frames, fragmented UTF-8 and interleaved ping frames', () => { + const decoder = new WebSocketFrameDecoder(1024) + const frames: { opcode: number; text: string }[] = [] + const unicode = Buffer.from('Hello 🌍') + const stream = Buffer.concat([ + clientFrame(1, unicode.subarray(0, 8), false), + clientFrame(9, 'ping'), + clientFrame(0, unicode.subarray(8)), + clientFrame(2, 'binary'), + ]) + for (const byte of stream) + decoder.push(Buffer.from([byte]), (frame) => { + frames.push({ opcode: frame.opcode, text: frame.data.toString() }) + return true + }) + expect(frames).toEqual([ + { opcode: 9, text: 'ping' }, + { opcode: 1, text: 'Hello 🌍' }, + { opcode: 2, text: 'binary' }, + ]) + }) + + it.each([ + ['unmasked', encodeWebSocketFrame(1, Buffer.from('hello')), 1002], + ['unexpected continuation', clientFrame(0, 'hello'), 1002], + ['fragmented ping', clientFrame(9, 'ping', false), 1002], + ['invalid UTF-8', clientFrame(1, Buffer.from([0xff])), 1007], + ['oversized frame', clientFrame(2, Buffer.alloc(1025)), 1009], + [ + 'overlapping messages', + Buffer.concat([clientFrame(1, 'one', false), clientFrame(1, 'two')]), + 1002, + ], + [ + 'oversized fragmented message', + Buffer.concat([ + clientFrame(1, 'a'.repeat(600), false), + clientFrame(0, 'b'.repeat(600)), + ]), + 1009, + ], + ])('rejects %s', (_name, bytes, code) => { + try { + new WebSocketFrameDecoder(1024).push(bytes, () => true) + throw new Error('Invalid frame was accepted.') + } catch (error) { + expect(error).toBeInstanceOf(WebSocketProtocolError) + expect((error as WebSocketProtocolError).code).toBe(code) + } + }) +}) + +describe('WebSocket endpoints on an existing HTTP listener', () => { + it('routes and authorizes before upgrading, supports server push and closes peers on shutdown', async () => { + const server = http.createServer((_req, res) => res.end('ordinary HTTP')) + const errors: unknown[] = [] + let peer: DevWebSocketConnection | undefined + const close = attachWebSockets( + server, + [ + { + path: '/studio', + authorize: (request) => request.url === '/studio?token=secret', + connect: (connection) => { + peer = connection + connection.send('welcome') + connection.onMessage((message) => connection.send(message)) + }, + }, + ], + (error) => errors.push(error), + ) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('No port.') + const base = `http://127.0.0.1:${address.port}` + let client: WebSocket | undefined + try { + expect(await fetch(base).then((response) => response.text())).toBe( + 'ordinary HTTP', + ) + expect(await upgradeStatus(base, '/unknown')).toBe(404) + expect(await upgradeStatus(base, '/studio')).toBe(403) + client = new WebSocket( + `${base.replace('http:', 'ws:')}/studio?token=secret`, + ) + const messages: string[] = [] + client.addEventListener('message', (event) => + messages.push(String(event.data)), + ) + await new Promise<void>((resolve, reject) => { + client?.addEventListener('open', () => resolve(), { once: true }) + client?.addEventListener('error', reject, { once: true }) + }) + client.send('hello') + await waitFor(() => messages.includes('hello')) + peer?.send('server initiated') + await waitFor(() => messages.includes('server initiated')) + expect(messages).toEqual(['welcome', 'hello', 'server initiated']) + client.binaryType = 'arraybuffer' + const binary = new Promise<ArrayBuffer>((resolve) => + client?.addEventListener( + 'message', + (event) => { + if (event.data instanceof ArrayBuffer) resolve(event.data) + }, + { once: true }, + ), + ) + const payload = new Uint8Array(70000).fill(42) + client.send(payload) + expect(new Uint8Array(await binary)).toEqual(payload) + const closed = new Promise<CloseEvent>((resolve) => + client?.addEventListener('close', resolve, { once: true }), + ) + await close() + expect((await closed).code).toBe(1001) + expect(errors).toEqual([]) + } finally { + client?.close() + await close() + await new Promise<void>((resolve) => server.close(() => resolve())) + } + }) +}) + +async function upgradeStatus(base: string, pathname: string) { + return new Promise<number>((resolve, reject) => { + const request = http.get( + new URL(pathname, base), + { + headers: { + Connection: 'Upgrade', + Upgrade: 'websocket', + 'Sec-WebSocket-Version': '13', + 'Sec-WebSocket-Key': 'AQIDBAUGBwgJCgsMDQ4PEC==', + }, + }, + (response) => { + response.resume() + resolve(response.statusCode ?? 0) + }, + ) + request.on('error', reject) + }) +} + +async function waitFor(condition: () => boolean) { + const deadline = Date.now() + 2000 + while (!condition()) { + if (Date.now() > deadline) throw new Error('WebSocket message timed out.') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} diff --git a/packages/ts-ssg/src/dev/websocket.ts b/packages/ts-ssg/src/dev/websocket.ts new file mode 100644 index 00000000..7e53b475 --- /dev/null +++ b/packages/ts-ssg/src/dev/websocket.ts @@ -0,0 +1,222 @@ +import { createHash } from 'node:crypto' +import type { IncomingMessage, Server } from 'node:http' +import type { Duplex } from 'node:stream' +import { + encodeWebSocketFrame, + validateClose, + validCloseCode, + WebSocketFrameDecoder, + WebSocketProtocolError, +} from './websocket-frames' + +export type DevWebSocketMessage = string | Uint8Array +export interface DevWebSocketConnection { + readonly open: boolean + send(message: DevWebSocketMessage): boolean + close(code?: number, reason?: string): void + onMessage(listener: (message: DevWebSocketMessage) => void): () => void + onClose(listener: () => void): () => void +} + +export interface DevWebSocketEndpoint { + /** Exact URL pathname, independent of the site's basePath. */ + path: string + maxPayload?: number + /** Runs before accepting the handshake. Return false to reject with 403. */ + authorize?: (request: IncomingMessage) => boolean + connect(connection: DevWebSocketConnection, request: IncomingMessage): void +} + +const DEFAULT_MAX_PAYLOAD = 1024 * 1024 + +/** Attaches optional endpoints to the existing HTTP server, including cleanup. */ +export function attachWebSockets( + server: Server, + endpoints: readonly DevWebSocketEndpoint[], + reportError: (error: unknown) => void, +) { + const routes = new Map<string, DevWebSocketEndpoint>() + const peers = new Set<WebSocketPeer>() + for (const endpoint of endpoints) { + if ( + !endpoint.path.startsWith('/') || + /[?#]/.test(endpoint.path) || + routes.has(endpoint.path) + ) + throw new Error(`Invalid or duplicate WebSocket path: ${endpoint.path}`) + const limit = endpoint.maxPayload ?? DEFAULT_MAX_PAYLOAD + if (!Number.isSafeInteger(limit) || limit < 125) + throw new Error( + 'WebSocket maxPayload must be an integer of at least 125.', + ) + routes.set(endpoint.path, endpoint) + } + const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => { + socket.on('error', () => socket.destroy()) + try { + const endpoint = routes.get( + new URL(request.url ?? '/', 'http://localhost').pathname, + ) + if (!endpoint) return reject(socket, 404, 'Not Found') + if (endpoint.authorize && !endpoint.authorize(request)) + return reject(socket, 403, 'Forbidden') + const key = request.headers['sec-websocket-key'] + if ( + request.method !== 'GET' || + request.headers.upgrade?.toLowerCase() !== 'websocket' || + !request.headers.connection + ?.toLowerCase() + .split(/\s*,\s*/) + .includes('upgrade') || + request.headers['sec-websocket-version'] !== '13' || + typeof key !== 'string' || + !/^[A-Za-z0-9+/]{22}==$/.test(key) + ) + return reject(socket, 400, 'Bad Request') + const accept = createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64') + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ) + const peer = new WebSocketPeer( + socket, + endpoint.maxPayload ?? DEFAULT_MAX_PAYLOAD, + reportError, + ) + peers.add(peer) + peer.onClose(() => peers.delete(peer)) + endpoint.connect(peer, request) + if (head.length) peer.receive(head) + } catch (error) { + reportError(error) + socket.destroy() + } + } + if (routes.size) server.on('upgrade', upgrade) + return async () => { + server.off('upgrade', upgrade) + await Promise.all([...peers].map((peer) => peer.shutdown())) + } +} + +function reject(socket: Duplex, code: number, reason: string) { + socket.end( + `HTTP/1.1 ${code} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`, + ) +} + +class WebSocketPeer implements DevWebSocketConnection { + private readonly decoder: WebSocketFrameDecoder + private readonly messages = new Set<(message: DevWebSocketMessage) => void>() + private readonly closed = new Set<() => void>() + private closing = false + private timer?: NodeJS.Timeout + private readonly finished = Promise.withResolvers<void>() + + constructor( + private readonly socket: Duplex, + private readonly maxPayload: number, + private readonly reportError: (error: unknown) => void, + ) { + this.decoder = new WebSocketFrameDecoder(maxPayload) + socket.on('data', (chunk: Buffer) => this.receive(chunk)) + socket.once('close', () => { + this.closing = true + if (this.timer) clearTimeout(this.timer) + for (const listener of this.closed) { + try { + listener() + } catch (error) { + this.reportError(error) + } + } + this.closed.clear() + this.messages.clear() + this.finished.resolve() + }) + socket.once('end', () => socket.end()) + } + + get open() { + return !this.closing && !this.socket.destroyed + } + + send(message: DevWebSocketMessage) { + if (!this.open) return false + const data = typeof message === 'string' ? Buffer.from(message) : message + if ( + data.byteLength > this.maxPayload || + this.socket.writableLength > this.maxPayload * 4 + ) { + this.close(1009, 'Outgoing queue too large.') + return false + } + this.socket.write( + encodeWebSocketFrame(typeof message === 'string' ? 1 : 2, data), + ) + return true + } + + onMessage(listener: (message: DevWebSocketMessage) => void) { + this.messages.add(listener) + return () => { + this.messages.delete(listener) + } + } + + onClose(listener: () => void) { + this.closed.add(listener) + return () => { + this.closed.delete(listener) + } + } + + close(code = 1000, reason = '') { + if (!validCloseCode(code) || Buffer.byteLength(reason) > 123) + throw new Error('Invalid WebSocket close code or reason.') + const data = Buffer.alloc(2 + Buffer.byteLength(reason)) + data.writeUInt16BE(code) + data.write(reason, 2) + this.end(data) + } + + async shutdown() { + this.close(1001, 'Server shutting down.') + await this.finished.promise + } + + receive(chunk: Buffer) { + try { + this.decoder.push(chunk, ({ opcode, data }) => { + if (opcode === 8) { + validateClose(data) + if (!this.closing) this.end(data) + this.socket.end() + return false + } + if (this.closing) return true + if (opcode === 9) this.socket.write(encodeWebSocketFrame(10, data)) + else if (opcode !== 10) { + for (const listener of this.messages) + listener(opcode === 1 ? data.toString('utf8') : data) + } + return true + }) + } catch (error) { + if (!(error instanceof WebSocketProtocolError)) this.reportError(error) + this.close( + error instanceof WebSocketProtocolError ? error.code : 1011, + 'Connection error.', + ) + } + } + + private end(data: Buffer) { + if (!this.open) return + this.closing = true + this.socket.write(encodeWebSocketFrame(8, data)) + this.timer = setTimeout(() => this.socket.destroy(), 500) + this.timer.unref() + } +} diff --git a/packages/ts-ssg/src/index.ts b/packages/ts-ssg/src/index.ts index e638bcef..f604e690 100644 --- a/packages/ts-ssg/src/index.ts +++ b/packages/ts-ssg/src/index.ts @@ -40,15 +40,24 @@ export { buildSite, type PublishOptions, } from './build/site' -export { runCli } from './cli-runner' +export { type CliState, parseCliArgs, runCli } from './cli-runner' export { type DefaultSiteConfig, resolveSiteConfig } from './config/config' -export { defineConfig, type PureStackConfig } from './config/project-config' +export { + defineConfig, + findProjectConfig, + type PureStackConfig, +} from './config/project-config' export { type DevServerHandle, type DevServerInput, type DevServerOptions, startDevServer, } from './dev/server' +export type { + DevWebSocketConnection, + DevWebSocketEndpoint, + DevWebSocketMessage, +} from './dev/websocket' export { normalizeFrontmatter, parseFrontmatterSource, diff --git a/packages/ts-ssg/src/publish-ideas.md b/packages/ts-ssg/src/publish-ideas.md deleted file mode 100644 index d846dcf6..00000000 --- a/packages/ts-ssg/src/publish-ideas.md +++ /dev/null @@ -1,29 +0,0 @@ -For publish mode, I’d keep it boring and high-signal. The next useful things are probably: - -1. Asset hashing / cache busting - Emit `site.<hash>.css`, JS bundles with hashes, and update HTML references. This matters more than squeezing tiny CSS variable names. - -2. Publish manifest - Write a small manifest with build time, output dir, page count, asset count, commit/version if available, and config signature. Helpful for deployment/debugging. - -3. Strict validation - In `--publish`, fail on things dev can tolerate: - - missing `baseUrl` when sitemap is enabled - - broken internal links - - missing referenced static assets - - duplicate routes - - invalid frontmatter - -4. No dev artifacts - Ensure publish output excludes diagnostics, temporary error pages, debug manifests if you ever decide those should be dev-only. - -5. HTML/CSS/JS minification boundary - You already have HTML and CSS direction. JS bundles from esbuild are likely already optimized, but publish can be the place where “production bundle settings” become explicit. - -6. Sitemap/robots as publish-first - Maybe dev builds skip sitemap/pagefind by default later, and publish always runs them unless disabled. - -7. Output summary - CLI should print a compact publish summary: pages, assets, size, publishDir, duration. Very useful when deploying. - -My taste: next best feature is **asset hashing**, then **strict validation**. Those make publish mode feel genuinely production-grade without adding UI or conceptual weight. \ No newline at end of file diff --git a/packages/ts-style/README.md b/packages/ts-style/README.md index da83ebe9..edebfcc2 100644 --- a/packages/ts-style/README.md +++ b/packages/ts-style/README.md @@ -30,9 +30,11 @@ const root = styleBuilder.get(theme) const frames = root.keyframes('zt-memory-cycle') frames.select('0%, 4%, 40%, 100%').transform('translateY(0)') frames.select('12%, 28%').transform('translateY(-8px)') -root.select('.zt-slab-memory') +root + .select('.zt-slab-memory') .animation('zt-memory-cycle 8s ease-in-out infinite') -root.select('.zt-slab-memory') +root + .select('.zt-slab-memory') .media('prefers-reduced-motion: reduce') .animation('none !important') ``` diff --git a/packages/ts-util-node/README.md b/packages/ts-util-node/README.md index f7682e68..2f7ba3bb 100644 --- a/packages/ts-util-node/README.md +++ b/packages/ts-util-node/README.md @@ -97,13 +97,13 @@ when a batch fails. Standalone highlighter users must await it themselves. Other plugin caches can use existing lifecycle hooks or explicit `try/finally` around their computation batches. -| Option | Default | Purpose | -| --- | --- | --- | -| `directory` | `os.tmpdir()/purestack-cache` | Parent of the namespace directory | -| `maxMemoryEntries` | `4096` | Maximum decoded values and buffered mutations per instance | -| `maxMemoryBytes` | `16777216` (16 MiB) | Approximate budget split across decoded values, loaded shards, and buffered writes; encoding and in-flight flushes need temporary memory | -| `maxAgeDays` | `30` | Disk retention for unused entries; `0` disables cleanup | -| `flushIntervalMs` | `2000` | Best-effort periodic persistence; `0` disables the timer | +| Option | Default | Purpose | +| ------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `directory` | `os.tmpdir()/purestack-cache` | Parent of the namespace directory | +| `maxMemoryEntries` | `4096` | Maximum decoded values and buffered mutations per instance | +| `maxMemoryBytes` | `16777216` (16 MiB) | Approximate budget split across decoded values, loaded shards, and buffered writes; encoding and in-flight flushes need temporary memory | +| `maxAgeDays` | `30` | Disk retention for unused entries; `0` disables cleanup | +| `flushIntervalMs` | `2000` | Best-effort periodic persistence; `0` disables the timer | Files live under `<directory>/<namespace>/00.json` through `1f.json`, with larger values in `blobs/<hash>.cache`. The key hash covers the version and input diff --git a/scripts/bump-version.ts b/scripts/bump-version.ts index 80391128..c6218062 100644 --- a/scripts/bump-version.ts +++ b/scripts/bump-version.ts @@ -1,7 +1,8 @@ -import { globSync, readFileSync, writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { bumpVersion, versionPattern } from './version' +import { workspaceManifestPaths } from './workspaces' const projectRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -36,10 +37,7 @@ function prepareUpdate(relativePath: string, pattern: RegExp) { if (updated !== source) updates.push({ relativePath, source: updated }) } -const manifests = [ - 'package.json', - ...globSync('packages/*/package.json', { cwd: projectRoot }).sort(), -] +const manifests = ['package.json', ...workspaceManifestPaths(projectRoot)] for (const manifestPath of manifests) { const manifest = JSON.parse( readFileSync(path.join(projectRoot, manifestPath), 'utf8'), diff --git a/scripts/bundle.ts b/scripts/bundle.ts index f71ceeec..1bcf8284 100644 --- a/scripts/bundle.ts +++ b/scripts/bundle.ts @@ -1,10 +1,11 @@ -import { existsSync, globSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { pathToFileURL } from 'node:url' import { build, type InlineConfig } from 'tsdown' import { timeIt } from './timeIt' +import { workspaceManifestPaths } from './workspaces' interface PackageJson extends Record<string, unknown> { name: string @@ -52,7 +53,7 @@ const generatedTsconfigPath = path.join( projectRoot, 'tsconfig.bundle.generated.json', ) -const packageJsonPaths = globSync('packages/*/package.json').sort() +const packageJsonPaths = workspaceManifestPaths(projectRoot) const allPackages = packageJsonPaths.map(readPackageJson) const publishPackages = sortByWorkspaceDependencies( allPackages.filter(isPublishPackage), @@ -332,7 +333,6 @@ async function bundlePackage(pkg: PublishPackage) { minify: false, deps: { neverBundle: pkg.externalPackageNames, - skipNodeModulesBundle: true, }, outputOptions: { sourcemap: true, diff --git a/scripts/check-eol.ts b/scripts/check-eol.ts index 94103e91..04d08b22 100644 --- a/scripts/check-eol.ts +++ b/scripts/check-eol.ts @@ -6,7 +6,6 @@ const root = process.cwd() const ignoredDirectories = new Set([ '.git', '.pnp', - '.puregate', '.vs', '.yarn', 'artifacts', diff --git a/scripts/cleanup.ts b/scripts/cleanup.ts index c46fafbb..c3787120 100644 --- a/scripts/cleanup.ts +++ b/scripts/cleanup.ts @@ -1,32 +1,16 @@ -import { globSync, readFileSync } from 'node:fs' +import { globSync } from 'node:fs' import { rm } from 'node:fs/promises' import path from 'node:path' import { timeIt } from './timeIt' - -interface PackageJson extends Record<string, unknown> { - name: string -} +import { workspaceManifestPaths } from './workspaces' const projectRoot = process.cwd() -const packages: PackageJson[] = globSync(['packages/**/package.json']).map( - (p) => JSON.parse(readFileSync(path.join(p), 'utf8')), -) - -const mainPkgName = - packages.map((x) => x.name).filter((n) => !n.startsWith('@'))[0] ?? - packages - .map((x) => x.name)[0] - .split('/')[0] - .substring(1) - -const unscope = (name: string) => name.replace(`@${mainPkgName}/`, '') - const distPaths = [ path.join(projectRoot, 'dist'), - ...packages.map((pkg) => - path.join(projectRoot, 'packages', unscope(pkg.name), 'dist'), + ...workspaceManifestPaths(projectRoot).map((manifest) => + path.join(projectRoot, path.dirname(manifest), 'dist'), ), ] diff --git a/scripts/fixtures/toolchain-worker.ts b/scripts/fixtures/toolchain-worker.ts new file mode 100644 index 00000000..14ec6f29 --- /dev/null +++ b/scripts/fixtures/toolchain-worker.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { + preparePackageManager, + runPackageManager, + runYarn, +} from '../../packages/purestack/src/init/package-manager' +import { runProcess } from '../../packages/purestack/src/init/process' +import type { TaskRunner } from '../../packages/purestack/src/init/tasks' + +async function main() { + const root = process.argv[2] + const npmCli = process.argv[3] + const packageManager = process.argv[4] + assert( + root && path.isAbsolute(root), + 'Missing isolated installation directory.', + ) + assert(npmCli && path.isAbsolute(npmCli), 'Missing npm launcher.') + assert(packageManager?.startsWith('yarn@'), 'Missing pinned Yarn version.') + const prefix = path.join(root, 'npm-global') + const project = path.join(root, 'site') + const nodeDirectory = path.dirname(process.execPath) + const version = packageManager.slice('yarn@'.length).split('+')[0] + await mkdir(project) + await writeFile( + path.join(project, 'package.json'), + JSON.stringify({ + name: 'fresh-toolchain-site', + private: true, + packageManager, + }), + ) + await writeFile(path.join(project, 'yarn.lock'), '') + + delete process.env.COREPACK_ROOT + delete process.env.NODE_PATH + process.env.npm_execpath = npmCli + process.env.npm_config_prefix = prefix + process.env.npm_config_cache = path.join(root, 'npm-cache') + process.env.COREPACK_HOME = path.join(root, 'corepack-cache') + process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = '0' + process.env.YARN_ENABLE_GLOBAL_CACHE = 'false' + process.env.YARN_GLOBAL_FOLDER = path.join(root, 'yarn-global') + process.env.PATH = nodeDirectory + + const stages: string[] = [] + const task: TaskRunner = async (label, action) => { + stages.push(label) + console.log(`${label}...`) + return action() + } + const signal = AbortSignal.timeout(120000) + await preparePackageManager('yarn', project, task, signal) + assert.deepEqual(stages, ['Install Corepack', 'Enable Yarn', 'Prepare Yarn']) + assert.equal( + (await runYarn(['--version'], project, { capture: true, signal })).trim(), + version, + ) + await runPackageManager('yarn', ['install', '--mode=skip-build'], project, { + signal, + }) + assert.match( + await readFile(path.join(project, 'yarn.lock'), 'utf8'), + /__metadata/, + ) + + const toolBin = + process.platform === 'win32' ? prefix : path.join(prefix, 'bin') + assert( + existsSync( + path.join(toolBin, process.platform === 'win32' ? 'yarn.cmd' : 'yarn'), + ), + ) + // Verify the installed global launchers after discarding the bootstrap hint. + delete process.env.COREPACK_ROOT + assert.equal( + (await runYarn(['--version'], project, { capture: true, signal })).trim(), + version, + ) + const launcherVersion = + process.platform === 'win32' + ? await runProcess( + process.env.ComSpec ?? 'C:\\Windows\\System32\\cmd.exe', + ['/d', '/s', '/c', 'yarn --version'], + project, + { capture: true, signal }, + ) + : await runProcess('yarn', ['--version'], project, { + capture: true, + signal, + }) + assert.equal( + launcherVersion.trim(), + version, + 'The installed Yarn launcher must be runnable from PATH.', + ) + const corepack = path.join( + prefix, + process.platform === 'win32' + ? 'node_modules/corepack/dist/corepack.js' + : 'lib/node_modules/corepack/dist/corepack.js', + ) + assert.match( + await runProcess(process.execPath, [corepack, '--version'], project, { + capture: true, + signal, + }), + /\d+\.\d+\.\d+/, + ) + + stages.length = 0 + await preparePackageManager('yarn', project, task, signal) + assert.deepEqual( + stages, + ['Prepare Yarn'], + 'Second setup should reuse the installed tools.', + ) +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/pack-templates.ts b/scripts/pack-templates.ts new file mode 100644 index 00000000..682d8726 --- /dev/null +++ b/scripts/pack-templates.ts @@ -0,0 +1,45 @@ +import { cp, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { fetchTemplate } from '../packages/purestack/src/init/template' +import { workspaceManifestPaths } from './workspaces' + +/** npm respects explicit dotfile entries; Yarn pack excludes these files. */ +export async function packTemplate(directory: string) { + const temporary = await mkdtemp( + path.join(tmpdir(), 'purestack-template-pack-'), + ) + try { + const archive = await fetchTemplate(directory, temporary, directory) + const destination = path.join(directory, 'package.tgz') + await cp(archive, destination) + console.log(`Template archive generated in ${destination}`) + return destination + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +async function main() { + for (const file of workspaceManifestPaths()) { + const manifest: unknown = JSON.parse(await readFile(file, 'utf8')) + if ( + manifest && + typeof manifest === 'object' && + 'purestackTemplate' in manifest && + manifest.purestackTemplate === true + ) + await packTemplate(path.resolve(path.dirname(file))) + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/scripts/publish-new-packages.ts b/scripts/publish-new-packages.ts index e568fcb8..c53d6312 100644 --- a/scripts/publish-new-packages.ts +++ b/scripts/publish-new-packages.ts @@ -1,7 +1,8 @@ import { execFile } from 'node:child_process' -import { existsSync, globSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' import { promisify } from 'node:util' +import { workspaceManifestPaths } from './workspaces' const execFileAsync = promisify(execFile) const projectRoot = process.cwd() @@ -26,6 +27,7 @@ interface PackageJson { private?: boolean version?: string exports?: Record<string, unknown> + purestackTemplate?: boolean dependencies?: Record<string, string> peerDependencies?: Record<string, string> optionalDependencies?: Record<string, string> @@ -130,14 +132,13 @@ function isExecError(error: unknown): error is { } function discoverPublishablePackages() { - return globSync('packages/*/package.json') - .sort() + return workspaceManifestPaths(projectRoot) .map(readPackage) .filter( (pkg) => !pkg.packageJson.private && isPureStackPackageName(pkg.packageJson.name) && - Boolean(pkg.packageJson.exports), + Boolean(pkg.packageJson.exports || pkg.packageJson.purestackTemplate), ) } diff --git a/scripts/publish-package.ts b/scripts/publish-package.ts index 06d90baf..8a3ff99b 100644 --- a/scripts/publish-package.ts +++ b/scripts/publish-package.ts @@ -1,7 +1,9 @@ import { spawn } from 'node:child_process' -import { globSync, readFileSync } from 'node:fs' +import { readFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { packTemplate } from './pack-templates' +import { workspaceManifestPaths } from './workspaces' const projectRoot = fileURLToPath(new URL('..', import.meta.url)) @@ -10,6 +12,7 @@ interface PackageJson { version?: string private?: boolean exports?: Record<string, unknown> + purestackTemplate?: boolean } function run(command: string, args: string[]) { @@ -48,9 +51,7 @@ package, then runs npm publish --dry-run without uploading it. const requested = names[0] const packageNames = new Set([requested, `@purestack/${requested}`]) - const packages = globSync('packages/*/package.json', { - cwd: projectRoot, - }).map((file) => ({ + const packages = workspaceManifestPaths(projectRoot).map((file) => ({ directory: path.dirname(path.join(projectRoot, file)), manifest: JSON.parse( readFileSync(path.join(projectRoot, file), 'utf8'), @@ -59,7 +60,11 @@ package, then runs npm publish --dry-run without uploading it. const selected = packages.find((pkg) => packageNames.has(pkg.manifest.name)) if (!selected) throw new Error(`Unknown workspace package: ${requested}`) const { directory, manifest } = selected - if (manifest.private || !manifest.exports || !manifest.version) + if ( + manifest.private || + (!manifest.exports && !manifest.purestackTemplate) || + !manifest.version + ) throw new Error(`${manifest.name} is not a versioned, publishable package.`) // Yarn's npm_execpath can point to a shell shim. Use the Corepack entry directly. @@ -75,8 +80,11 @@ package, then runs npm publish --dry-run without uploading it. ) // Reuse the repository's bundling and verification before packing fresh output. - await yarn(['bundle']) - await yarn(['workspace', manifest.name, 'pack', '--out', tarball]) + if (manifest.purestackTemplate) await packTemplate(directory) + else { + await yarn(['bundle']) + await yarn(['workspace', manifest.name, 'pack', '--out', tarball]) + } const publishArgs = [ 'publish', diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 10a6d152..db260f0f 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,4 +1,9 @@ { "extends": "../tsconfig.json", - "include": ["./*.ts"] + "include": [ + "./*.ts", + "./fixtures/*.ts", + "../packages/purestack/src/init/*.ts" + ], + "exclude": ["../packages/purestack/src/init/*.test.ts"] } diff --git a/scripts/verify-templates.ts b/scripts/verify-templates.ts new file mode 100644 index 00000000..327e0b02 --- /dev/null +++ b/scripts/verify-templates.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + type PackageManager, + runPackageManager, +} from '../packages/purestack/src/init/package-manager' +import { runProcess } from '../packages/purestack/src/init/process' +import { workspaceManifestPaths } from './workspaces' + +const root = process.cwd() +const packages = workspaceManifestPaths(root).map((file) => { + const directory = path.resolve(root, path.dirname(file)) + const manifest = JSON.parse(readFileSync(path.join(root, file), 'utf8')) as { + name: string + version: string + private?: boolean + exports?: unknown + purestackTemplate?: boolean + } + return { directory, manifest, archive: path.join(directory, 'package.tgz') } +}) +const framework = + packages.find((pkg) => pkg.manifest.name === 'purestack') ?? + (() => { + throw new Error('Missing purestack package.') + })() +const requested = process.argv.slice(2) +const templates = packages.filter( + (pkg) => + pkg.manifest.purestackTemplate && + (!requested.length || + requested.includes(pkg.manifest.name) || + requested.includes( + pkg.manifest.name.replace('@purestack/template-', ''), + )), +) +assert(templates.length > 0, 'Missing template packages.') +const overrides = Object.fromEntries( + packages + .filter( + (pkg) => + !pkg.manifest.private && pkg.manifest.exports && pkg !== framework, + ) + .map((pkg) => [ + pkg.manifest.name, + `file:${pkg.archive.replaceAll('\\', '/')}`, + ]), +) + +async function main() { + // The project must not inherit workspace packages through parent node_modules. + const temporary = await mkdtemp( + path.join(tmpdir(), 'purestack-template-verification-'), + ) + try { + for (const template of templates) { + const directory = path.join( + temporary, + template.manifest.name.split('/').at(-1) ?? 'site', + ) + console.log( + `\nVerifying ${template.manifest.name}@${template.manifest.version} outside the monorepo...`, + ) + await runProcess( + process.execPath, + [ + path.join(framework.directory, 'dist/cli.mjs'), + 'init', + directory, + '--template', + template.archive, + '--yes', + '--title', + 'Template verification', + ], + temporary, + ) + const packageFile = path.join(directory, 'package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + assert.equal(project.dependencies.purestack, framework.manifest.version) + assert( + !Object.keys(project.dependencies).some((name) => + name.includes('template-'), + ), + ) + // Exercise unpublished framework tarballs while resolving all third-party + // dependencies normally. No source directories or workspace links are used. + project.dependencies.purestack = `file:${framework.archive.replaceAll('\\', '/')}` + const manager: PackageManager = project.packageManager?.startsWith( + 'yarn@', + ) + ? 'yarn' + : 'npm' + if (manager === 'yarn') project.resolutions = overrides + else project.overrides = overrides + await writeFile(packageFile, `${JSON.stringify(project, null, 2)}\n`) + if (manager === 'yarn') { + // Keep verification caches within the disposable project, too. + const yarnConfig = path.join(directory, '.yarnrc.yml') + await writeFile( + yarnConfig, + `${await readFile(yarnConfig, 'utf8')}\nenableGlobalCache: false\nglobalFolder: ${JSON.stringify(path.join(temporary, 'yarn-global'))}\n`, + ) + } + await runPackageManager( + manager, + manager === 'yarn' + ? ['install'] + : ['install', '--no-audit', '--no-fund'], + directory, + ) + for (const script of ['check', 'test:ci', 'build', 'publish']) { + if (typeof project.scripts?.[script] === 'string') + await runPackageManager(manager, ['run', script], directory) + } + for (const output of ['site', 'publish']) { + const html = await readFile( + path.join(directory, 'dist', output, 'index.html'), + 'utf8', + ) + assert( + html.includes('Template verification'), + `${output} is missing the customized title.`, + ) + } + console.log(`Verified ${template.manifest.name}.`) + } + } finally { + await rm(temporary, { recursive: true, force: true }) + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/verify-toolchain.ts b/scripts/verify-toolchain.ts new file mode 100644 index 00000000..b247ad9a --- /dev/null +++ b/scripts/verify-toolchain.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { build } from 'esbuild' +import { findNpmCli, runProcess } from '../packages/purestack/src/init/process' + +async function main() { + const npmCli = findNpmCli() + assert(npmCli, 'npm is required to verify first-run tool installation.') + const template: unknown = JSON.parse( + await readFile( + new URL('../templates/starter/template/package.json', import.meta.url), + 'utf8', + ), + ) + assert( + template && + typeof template === 'object' && + 'packageManager' in template && + typeof template.packageManager === 'string', + 'Starter must pin its package manager.', + ) + const root = await mkdtemp(path.join(tmpdir(), 'purestack-toolchain-')) + try { + const bin = path.join(root, 'node-bin') + await mkdir(bin) + const node = path.join( + bin, + process.platform === 'win32' ? 'node.exe' : 'node', + ) + // A separate Node executable prevents finding bundled Corepack beside it. + await copyFile(process.execPath, node) + if (process.platform !== 'win32') await chmod(node, 0o755) + const worker = path.join(root, 'verify.mjs') + await build({ + entryPoints: [ + fileURLToPath( + new URL('./fixtures/toolchain-worker.ts', import.meta.url), + ), + ], + outfile: worker, + bundle: true, + platform: 'node', + format: 'esm', + target: 'node22', + }) + await runProcess( + node, + [worker, root, npmCli, template.packageManager], + root, + ) + console.log( + `Fresh Corepack/Yarn setup verified on ${process.platform}/${process.arch}.`, + ) + } finally { + assert( + path.dirname(root) === path.resolve(tmpdir()), + 'Unexpected verification directory.', + ) + assert( + path.basename(root).startsWith('purestack-toolchain-'), + 'Unexpected verification directory.', + ) + await rm(root, { recursive: true, force: true }) + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +}) diff --git a/scripts/workspaces.ts b/scripts/workspaces.ts new file mode 100644 index 00000000..bf606fc4 --- /dev/null +++ b/scripts/workspaces.ts @@ -0,0 +1,23 @@ +import { globSync, readFileSync } from 'node:fs' +import path from 'node:path' + +/** Discover package manifests from the repository's declared workspace roots. */ +export function workspaceManifestPaths(root = process.cwd()) { + const manifest: unknown = JSON.parse( + readFileSync(path.join(root, 'package.json'), 'utf8'), + ) + if ( + !manifest || + typeof manifest !== 'object' || + !('workspaces' in manifest) || + !Array.isArray(manifest.workspaces) || + !manifest.workspaces.every( + (pattern): pattern is string => typeof pattern === 'string', + ) + ) + throw new Error('Expected workspace patterns in the root package.json.') + return globSync( + manifest.workspaces.map((pattern) => `${pattern}/package.json`), + { cwd: root }, + ).sort() +} diff --git a/sites/.gitignore b/sites/.gitignore new file mode 100644 index 00000000..5781f1c9 --- /dev/null +++ b/sites/.gitignore @@ -0,0 +1,2 @@ +# Each subdirectory is a CLI-generated site. Shared files remain tracked. +/*/ diff --git a/sites/README.md b/sites/README.md new file mode 100644 index 00000000..f0b2a5fb --- /dev/null +++ b/sites/README.md @@ -0,0 +1,74 @@ +# PureStack CLI development sites + +Use this folder to exercise the PureStack CLI and create any number of test +sites. This README and `.gitignore` are shared repository files. Generated site +directories are Git-ignored. + +Run all commands below from inside `sites/`. The workspace launcher runs the +local CLI with `tsx` and preserves your current folder as the destination base. + +```sh +yarn purestack init +``` + +Optionally choose the wizard address and port: + +```sh +yarn purestack init --host 127.0.0.1 --port 4700 +``` + +The defaults are `127.0.0.1` and an automatically assigned port. Server logging +starts at warning level; creation progress and next steps remain visible. + +The CLI serves the wizard's ordinary PureStack content at the URL printed in +your terminal. Open the complete link to configure site details, templates, +template-defined options, and project setup. Choices are kept while moving +between sections. **Create site** shows progress and starts the generated site; +**Browse live site** opens it in a new tab. Studio stays open while you explore, +and source edits reload the live site automatically. + +Sites are created inside this folder, for example `sites/my-site`. **Finish +Studio** or Ctrl+C stops both local servers while preserving the generated +project. The terminal prints commands for continuing independently. Studio and +preview build output use system temporary folders rather than the repo root. + +In this checkout, official template choices use local source automatically. +Choose a new destination for each site; existing nonempty folders are preserved. + +Each generated Yarn site gets its own `yarn.lock` before installation, so Yarn +treats it as a separate project inside this repository. Keep the shared `sites/` +folder without a lockfile so `yarn purestack init` continues to use the repo launcher. + +For creation without prompts: + +```sh +yarn purestack init my-site --template starter --yes +yarn purestack init my-site --template starter --set palette=mint --set search=true --yes +``` + +Dependency installation and Git initialization are opt-in with `--install` and +`--git`. Leave these flags out to test with the existing workspace dependencies. +When installing a Yarn project, the CLI also installs missing Corepack, enables +Yarn, and prepares the version pinned by the generated project. +Use `--template ../templates/starter` or a local `.tgz` to test an +explicit template source. + +Preview or build a site with the same CLI: + +```sh +yarn purestack serve --content ./my-site/content +yarn purestack build --content ./my-site/content +yarn purestack publish --content ./my-site/content +``` + +To test additional creation options: + +```sh +yarn purestack init --help +``` + +Template menus and settings come from each package's `template.ui.json`. +See [the Site Studio guide](../docs/site-studio.md) for the schema and architecture. + +Generated files are the CLI's normal output. There are no site-specific wrapper +commands or modifications to the generated projects. diff --git a/skills/purestack-development/SKILL.md b/skills/purestack-development/SKILL.md new file mode 100644 index 00000000..8751cacd --- /dev/null +++ b/skills/purestack-development/SKILL.md @@ -0,0 +1,248 @@ +--- +name: purestack-development +description: Build, redesign, or extend websites exclusively with PureStack and Regor. Use for every user request to create or modify a PureStack site, including its pages, components, styling, themes, layouts, and browser interactivity. Enforces TypeScript-first implementation, MDX, customized ThemePalette-based skins derived from PureStack's standard generator, fluent style APIs, reusable built-in components, and maintainable Regor components. +--- + +# PureStack Website Development + +Build high-quality, visually distinctive, maintainable websites **exclusively with PureStack and Regor**. Follow the rules below as implementation constraints, not optional preferences. + +## Non-negotiable rules + +1. **PureStack + Regor only.** Do not introduce React, Vue, Angular, Svelte, Next.js, Astro, Tailwind, Bootstrap, third-party UI kits, other styling systems, or alternative rendering/routing frameworks. Use PureStack, Regor, their official packages, and their existing facilities. Use native browser APIs from TypeScript only when PureStack/Regor provide no suitable capability. +2. **Use the PureStack source model.** Author site behavior, components, templates, and styles in **TypeScript**; pages and shared page fragments in **MDX** (`.mdx`/`.rmdx`, or `.md` for content-only prose); and site settings in `siteConfig.json` and, where needed, `purestack.config.ts`. Static image, font, and other media assets are fine. Do not add handwritten `.css`, `.scss`, `.sass`, `.less`, `.js`, `.jsx`, `.tsx`, standalone HTML, or inline JavaScript/CSS workarounds. Generated CSS/JS output from PureStack is expected and permitted. +3. **No redundant implementations.** Before writing any component, utility, interaction, style, or infrastructure code, check whether PureStack already provides it. If it does, **reuse it rather than reimplementing it**. Never replace a framework feature with custom code merely for convenience. +4. **Use native PureStack building blocks first.** Prefer the built-in primitives and higher-level components where appropriate, including `Flex`, `Grid`, `Panel`, `SectionHeader`, `BtnLink`, `Icon`, `IconFrame`, `Tabs`, `TabPane`, `ExpandablePanel`, `TopBar`, `SiteFooter`, `ThemeToggle`, and other catalog components. Build site-specific Regor compositions on top of them to achieve a recognizable, visually rich identity. Reuse must not result in a generic-looking site. +5. **Standard skin as a foundation, not a finished design.** Use `themeSkins.standard.create(...)` to generate complete, internally consistent light/dark palettes, then **customize the returned `ThemePalette` values in TypeScript to establish a distinctive site identity**. Site-specific palette customization is an expected part of professional site design, not an exceptional last resort. Adjust the visual system as appropriate: semantic tones and surfaces, typography, radii, shadows/effects, backgrounds, and interaction states. A site must not retain the stock appearance merely because the standard skin already works. Prefer cohesive palette overrides over scattering hardcoded colors or component-level visual fixes throughout the codebase. +6. **Typed, fluent styling only.** Use PureStack's typed style builder and property-specific fluent methods such as `.color(...)`, `.backgroundImage(...)`, `.borderRadius(...)`, `.opacity(...)`, etc. **Never use `.css({...})` or a `css()` function** in site code, even if an older example does. Never hand-author a stylesheet or inject CSS strings. Organize custom rules into small functions grouped by section or concern. +7. **Use utilities before custom rules.** Check PureStack's generated spacing, typography, layout, responsive, border, shadow, motion, and semantic-tone utility classes. If a built-in utility accomplishes the task, use it. Create custom classes, reusable TypeScript helpers, selectors, or keyframe animations only for behavior or styling not supplied by those utilities. +8. **Light and dark are mandatory.** Every site must support at least `light` and `dark`, with an actual, accessible `<ThemeToggle/>` in the shared interface. Check both themes across all components, custom effects, and interaction states. Do not make a static theme-only design. +9. **Use MDX headers and footers.** PureStack supports shared `header.mdx` and `footer.mdx` files. Reuse them for global navigation, branding, the theme toggle, and shared footer content instead of duplicating the same markup on each page. Prefer native header/footer components whenever suitable. +10. **Choose build-time versus browser-time intentionally.** PureStack/Regor components can render during site generation or execute in the browser. Render static content at build time; opt into browser execution only for stateful interaction and capabilities that actually require it. Use `RegorApp` for browser-mounted Regor apps and `PageScript` for browser-side TypeScript when applicable. Do not turn an otherwise static site into a client-rendered application. +11. **Export Regor component contexts.** Every custom Regor component must use an **exported interface or exported class** as its context type and define the component as `defineComponent<MyComponent>(...)`. This is essential for programmatic discovery from MDX and Regor `html` templates, and for VS Code navigation. Avoid anonymous, private, or inferred context types for component definitions. +12. **Divide and conquer with components. Never write a very long template in a single `html` block.** A page or dashboard component must compose focused child components rather than contain every section, form, navigation area, and conditional view inline. Extract substantial sections even when they are used only once; reuse is not a prerequisite for a component. Give each nontrivial component its own TypeScript module. Do not evade this rule by minifying markup, concatenating template strings, or moving fragments into string-returning helpers. Keep component registration explicit and easy to inspect. +13. **Maintain production quality.** Use clear names, strong types, small focused modules, semantic HTML/MDX, accessibility, responsive behavior, consistent visual language, minimal runtime work, and robust error handling. No placeholder implementations, dead code, unnecessary abstractions, or quick fixes that undermine the architecture. + +## Type safety and framework limitations + +Always create type-safe TypeScript code. Prefer inference, explicit contracts, correct generics, `satisfies`, and runtime validation with type narrowing. Avoid type casts/assertions; never hide a typing problem with `as any`, double assertions such as `as unknown as T`, unchecked non-null assertions, or TypeScript suppression comments. + +When types do not fit, inspect the installed PureStack/Regor APIs and source for a supported, type-safe approach. If no such approach exists because of a PureStack or Regor limitation, explain which framework is responsible, show the concrete typing problem and a minimal example, and ask the user for a workaround and to report the issue for a framework fix. Pause the affected implementation instead of silently bypassing the type system; continue independent work where possible. + +## Icons + +PureStack provides more than **11,000 icons**. Use them where they improve navigation, actions, status, or content clarity. PureStack components support icons; use their supported icon props instead of rebuilding icon markup. +- **Server-side rendering and static generation:** use `getSvgIcon` from `@purestack/ts-svg-icons`. +- **Client-side code:** import only the required SVG constants as named, tree-shake-friendly exports from `@purestack/ts-svg-icons`. Avoid `getSvgIcon`, namespace imports, or dynamic whole-catalog lookups in browser bundles. +- Verify icon names and exported identifiers against the installed generated icon lists before using them; never guess names. For example, a verified `lucide:check` lookup corresponds to the `lucide_check` SVG export. + +## Required workflow + +### 1. Inspect before implementing + +- Read the site's existing `siteConfig.json`, `purestack.config.ts`, plugins, theme files, components, shared MDX layout files, and nearby examples. +- Consult the actual installed PureStack/Regor version, the official component catalog, and source definitions before choosing APIs, props, icons, utilities, or behavior. **Do not invent framework APIs or utility classes.** +- Look for existing components and generated features covering the request: forms, navigation, dialogs, tabs, data display, theming, consent, search, browser runtime, animations, etc. +- Preserve established patterns unless they conflict with this skill's explicit rules; modernize noncompliant code when touching it where reasonably scoped. + +### 2. Design the site as a composition + +- Identify page hierarchy, shared shell, reusable visual patterns, and the few distinctive design elements that give the site its identity. +- Compose with PureStack primitives and their semantic tones/variants first. Add site-specific exported Regor components where reuse or design complexity justifies them. +- Define a coherent spacing system, visual hierarchy, typography, color roles, responsive behavior, and accessible interaction states. Translate the site's visual identity into deliberate `ThemePalette` customizations in both light and dark modes; do not rely on layout tweaks alone to differentiate the design. +- Prefer simple MDX markup for content and native components; use TypeScript components for meaningful behavior or recurring complex compositions. + +**Colocate MDX pages with their dependencies.** When an MDX file has imports or scripts, create a folder with the same name and move the MDX file and its page-specific dependencies into it. Prefer a `modules/` folder at the content root for shared application code. Update affected references and preserve the page's route. + +### 3. Build a site-specific skin from the standard generator + +**Always begin with** `themeSkins.standard.create(...)`, which returns a complete `light`/`dark` `ThemePalette` pair. **Then customize those palettes in TypeScript.** The generator provides consistent component defaults; the tailored palettes provide the site's unique design language. Do not treat `themeSkins.standard.create()` as the final appearance for a branded website. + +A high-quality site should have deliberate choices about its typography, scale, corner shapes, elevation, surface hierarchy, semantic colors, accents, and contrast. Override the relevant `ThemePalette` properties with `merge` (or another type-safe approach compatible with the installed version), rather than rebuilding all component styles or adding ad hoc component overrides. Customize each theme independently where necessary, while keeping the same recognizable identity and preserving accessibility. + +Leverage TypeScript to programmatically generate custom theme palettes using calculated colors, color transformations, and reusable algorithms, creating rich, cohesive, and distinctive visual identities instead of manually defining every color. + +**Example: derive and tailor the standard skin in a site plugin.** The example values illustrate the technique; choose values for the actual site's art direction. + +```ts +import { definePlugin } from '@purestack/ts-ssg' +import { themeSkins, type ThemePalette } from '@purestack/ts-style' +import { merge, type DeepPartial } from '@purestack/ts-util' + +const sharedIdentity = { + font: { + family: { base: "'Manrope', system-ui, sans-serif" }, + size: { body: '1rem', display: '4.75rem' }, + }, + radii: { sm: '5px', md: '12px', lg: '20px' }, +} satisfies DeepPartial<ThemePalette> + +export const siteThemePlugin = definePlugin({ + name: 'site-identity', + skins: { + editorial: { + create: () => { + const standard = themeSkins.standard.create({ + accent: '#6253d8', + neutral: '#293248', + secondary: '#168f83', + feature: '#cb598a', + }) + + return { + light: merge(standard.light, { + ...sharedIdentity, + effect: { panelShadow: '0 10px 30px rgb(35 43 66 / 0.10)' }, + }), + dark: merge(standard.dark, { + ...sharedIdentity, + effect: { panelShadow: '0 14px 38px rgb(0 0 0 / 0.36)' }, + }), + } + }, + }, + }, +}) +``` + +This is only a starting example. For stronger differentiation, customize other supported `ThemePalette` fields, including semantic tone surfaces and component state colors, when the site calls for it. Inspect the installed types before choosing fields. When changing a semantic tone, keep its background/text/border states coordinated and check focus and hover contrast in both themes. Let PureStack's built-in components consume the customized palette instead of restyling each component manually. + +Select the registered site-specific skin and enable both themes in `siteConfig.json`: + +```json +{ + "style": { + "fileName": "site.css", + "href": "/assets/site.css", + "themes": ["light", "dark"], + "theme": { "skin": "editorial" } + } +} +``` + +Register the plugin in `purestack.config.ts`. Include `<ThemeToggle/>` in the shared UI. Do not build a parallel theme state system. A direct `"skin": "standard"` configuration is appropriate only when an intentionally unbranded starter/default appearance is requested, not as the habitual endpoint of a finished branded site. + +### 4. Place global chrome in MDX + +Use shared `header.mdx` and `footer.mdx`, or their appropriate directory-level counterparts, rather than page-by-page duplication. Include `<ThemeToggle/>` in the visible, accessible header/navigation UI even if using a custom header composition. Put the site's canonical brand, navigation, social links, and footer structure there. + +### 5. Implement typed, discoverable Regor components + +Divide the interface into components **before** writing its templates: +- Keep the root template focused on layout and composition. Extract navigation, substantial content sections, form steps, review panels, and complex repeated items into named components with one clear responsibility each. +- Give each child an explicit, typed context and use props for its local inputs. Keep shared state in the appropriate ancestor context, and local behavior with the component that owns it. Do not duplicate state merely to split the markup. +- Keep each `html` block short enough to understand as one cohesive piece. If it grows to contain several distinct sections or conditional screens, split it into components before adding more markup. A wizard's steps belong in separate components, not a growing chain of inline `r-if` sections in one template. +- Extract meaningful UI responsibilities rather than wrapping every trivial element. When extending an existing oversized template, extract the affected sections instead of making the monolith larger. + +Use a dedicated file per nontrivial component, but **organize component folders logically rather than accumulating every component in one flat directory**. Group related components by feature or responsibility, for example `components/shell/`, `components/templates/`, `components/options/`, and `components/creation/`. Keep feature-specific helpers and child components with their owning feature; reserve `components/shared/` for components actually reused across features. Add subfolders as responsibilities grow, using meaningful names rather than arbitrary file-count limits or a folder for every trivial component. Keep imports and component registration explicit when reorganizing. + +**Share substantial context with Regor's `requireContext`.** When several components need a lot of shared context, use a common ancestor's class context and access it with `head.requireContext(SharedContext)`. Avoid dozens of forwarded prop bindings or large per-screen prop maps. + +The **exported context type** and the generic argument to `defineComponent` must be the same: + +```ts +import { defineComponent, html, type RefOrValue } from 'regor' + +export interface FeatureSpotlight { + title?: RefOrValue<string> + summary?: RefOrValue<string> +} + +const featureSpotlightTemplate = html`<Panel tone="accent" variant="surface"> + <h3>{{ title }}</h3> + <p>{{ summary }}</p> +</Panel>` + +export function defineFeatureSpotlightComponent() { + return defineComponent<FeatureSpotlight>(featureSpotlightTemplate, { + props: ['title', 'summary'], + }) +} +``` + +Register site-specific components explicitly in the site's PureStack plugin so MDX and nested Regor `html` templates can resolve them. Export context interfaces/classes, definition factories, and only the helpers that other modules require. Use reactive Regor primitives for browser state; avoid manually rewriting the DOM when the framework already supports the interaction. + +### 6. Implement styles in TypeScript, with fluent methods + +Register styles through the PureStack theme style builder, typically from the plugin's `onConfigResolved` hook. Use `themes.forEach((theme, palette) => ...)` so the same rules receive the correct light/dark semantic values. **Split style registration by feature or responsibility.** + +```ts +import type { Style } from '@purestack/ts-css' +import { + BREAKPOINTS, + mediaBelow, + styleBuilder, + themes, + type ThemePalette, +} from '@purestack/ts-style' + +export function registerSiteStyles() { + themes.forEach((theme, palette) => { + const root = styleBuilder.get(theme) + registerHeroEffects(root, palette) + registerResponsiveEffects(root) + }) +} + +function registerHeroEffects(root: Style, palette: ThemePalette) { + root + .select('.site .hero-glow') + .backgroundImage( + `radial-gradient(circle, ${palette.semanticTone.accent.tone}, transparent 70%)`, + ) + .pointerEvents('none') +} + +function registerResponsiveEffects(root: Style) { + root + .media(mediaBelow(BREAKPOINTS.md)) + .select('.site .hero-glow') + .opacity('0.35') +} +``` + +This is an example of a _custom_ visual effect; routine spacing, grid composition, typography, and hover effects should normally use framework components and generated utility classes in MDX/templates. Define animations with the fluent `.keyframes(name)` API and fluent frame declarations when the built-in motion utilities are insufficient. Honor `prefers-reduced-motion` for nonessential animation. + +**Forbidden styling examples:** `.css({ ... })`, `css(...)`, manually maintained `.css` files, `<style>` elements containing handwritten rules, injected CSS text, and third-party utility frameworks. If a fluent method or utility is uncertain, inspect the installed API and confirm it rather than guessing. + +### 7. Choose the correct rendering phase + +| Requirement | Default approach | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | +| Static pages, navigation, headings, editorial content, feature cards | MDX and build-time Regor/PureStack components | +| Shared headers, footers, site shell | `header.mdx`, `footer.mdx`, native PureStack components | +| Repeated visually rich compositions | Site-specific typed Regor components, rendered at build time when possible | +| Reactive forms, interactive state, client-side widgets | Browser-side Regor through `RegorApp` | +| Small browser enhancement not needing a Regor app | A TypeScript entry via `PageScript`, when needed | +| Theming and styling | Standard generator **plus site-specific `ThemePalette` customization**, generated utilities, typed fluent style rules | + +Do not add client-side execution for content that can be rendered during the build. Avoid unnecessary hydration, large bundles, duplicated runtime state, and client-side DOM work. + +### 8. Verify the finished site + +Before reporting completion, check: +- The project builds and the site renders through the PureStack CLI; TypeScript type checking passes. +- Every implemented behavior uses the PureStack/Regor API actually installed; there are no parallel third-party frontend or styling systems. +- No handwritten CSS/JavaScript and no `.css(...)`/`css(...)` calls appear in new site code. +- Components and utilities already available in PureStack were reused rather than recreated. +- A site-specific skin starts from `themeSkins.standard.create()` and meaningfully customizes the returned `ThemePalette` for the site's unique identity. Both light and dark modes are generated, visually coherent, accessible, and switchable through `<ThemeToggle/>`. +- Shared `header.mdx`/`footer.mdx` are used appropriately. +- Each custom Regor component has an exported interface/class, `defineComponent<ExportedContext>()`, clear registration, and a focused module. +- No very long, monolithic `html` blocks remain in the implemented interface. Root templates compose focused child components; substantial sections and conditional screens have their own components with explicit data/action contracts. +- Build-time and browser-time execution are deliberate; static content works without JavaScript where applicable. +- The site is responsive and visually coherent at mobile, tablet, and desktop widths in **both themes**. +- Keyboard navigation, focus visibility, labels, contrast, semantic landmarks/headings, and reduced-motion behavior are appropriate. +- Generated outputs, dependency changes, and source files contain no unnecessary artifacts or unused code. + +## Authoritative references + +Check these sources against the installed version, especially when a documented example uses an older or less-preferred convention: +- [PureStack documentation](https://purestack.studio/) +- [Component catalog](https://purestack.studio/components/) +- [Themes and standard skin](https://purestack.studio/guides/styling/themes/) +- [Generated utility classes](https://purestack.studio/guides/styling/utility-css-classes/) +- [PureStack repository](https://github.com/PureStackStudio/PureStack) +- [Reference website source](https://github.com/PureStackStudio/PureStack/tree/main/frontend) +- [Regor website](https://regor.purestack.studio/) + +**Precedence:** These skill rules determine _how_ to implement a PureStack site. The installed framework's types and source determine which APIs exist. Standard skin examples demonstrate a _foundation_; always consider the returned `ThemePalette` the site-specific design surface. When examples use `.css({...})`, rewrite them using verified fluent property methods rather than copying that usage. diff --git a/skills/purestack-development/SKILL_shorter.md b/skills/purestack-development/SKILL_shorter.md new file mode 100644 index 00000000..934b19ed --- /dev/null +++ b/skills/purestack-development/SKILL_shorter.md @@ -0,0 +1,53 @@ +--- + +# PureStack Development + +## Core Rules + +1. **PureStack + Regor only.** Never introduce React, Vue, Tailwind, other UI frameworks, or redundant dependencies. + +2. **TypeScript + MDX.** Write components, behavior, and styles in TypeScript; pages in MDX; configuration in `siteConfig.json` and `purestack.config.ts`. No handwritten CSS, JavaScript, JSX, TSX, or HTML files. + +3. **Reuse PureStack.** Inspect existing APIs, components, utilities, and plugins before implementing anything. Prefer built-in components such as `Flex`, `Grid`, `Panel`, `BtnLink`, `Tabs`, `Icon`, and others. Build custom components when needed for distinctive designs. + +4. **Custom themes.** Always start with `themeSkins.standard.create()`, then customize the returned light/dark `ThemePalette` values in TypeScript. Leverage TypeScript to programmatically generate sophisticated palettes using calculated colors and transformations. Avoid generic, stock-looking sites. + +5. **Fluent styling only.** Use PureStack's typed style builder with property-specific methods such as `.color()`, `.backgroundImage()`, and `.borderRadius()`. Never use `.css()`, `css()`, injected CSS, or handwritten stylesheets. Prefer built-in utility classes before custom rules. + +6. **Light and dark modes.** Both are mandatory, with an accessible `<ThemeToggle/>`. Customize and verify both palettes. + +7. **Shared layouts.** Use `header.mdx` and `footer.mdx` for shared navigation, branding, and footer content. + +8. **Small components.** Divide complex interfaces into focused Regor components. Never create giant `html` templates. Organize modules by feature, with explicit registration and typed contracts. + +9. **Exported Regor contexts.** Every custom component must have an exported interface/class and use `defineComponent<ExportedContext>(...)` for MDX discovery and editor navigation. + +10. **Rendering strategy.** Prefer static generation. Use `RegorApp` for reactive browser interfaces and `PageScript` for small browser enhancements. Avoid unnecessary client-side execution. + +11. **Strict type safety.** Avoid `as any`, double assertions, suppression comments, and unsafe workarounds. When framework limitations block correct typing, report the issue instead of bypassing it. + +12. **Icons.** Use `@purestack/ts-svg-icons`: `getSvgIcon` for server/build-time usage, named SVG imports for browser code. Verify identifiers against installed exports. + +13. **Production quality.** Maintain responsive layouts, accessibility, semantic markup, strong typing, clear architecture, and minimal runtime overhead. + +14. **Colocate MDX dependencies.** When an MDX file has imports or scripts, create a folder with the same name and move the MDX file and its page-specific dependencies into it. Prefer a `modules/` folder at the content root for shared application code. Update affected references and preserve the page's route. + +15. **Share substantial context with `requireContext`.** When several components need a lot of shared context, use a common ancestor's class context and access it with `head.requireContext(SharedContext)`. Avoid dozens of forwarded prop bindings or large per-screen prop maps. + +## Workflow + +1. Inspect the existing project, installed PureStack/Regor APIs, documentation, and source before coding. +2. Design the site using native components, focused Regor compositions, and a distinct customized `ThemePalette`. +3. Implement styles through fluent TypeScript APIs, grouped into focused functions. +4. Verify builds, type checking, responsive layouts, both themes, accessibility, and adherence to all rules. + +## References + +- [PureStack Documentation](https://purestack.studio/) +- [Component Catalog](https://purestack.studio/components/) +- [Themes](https://purestack.studio/guides/styling/themes/) +- [Utility Classes](https://purestack.studio/guides/styling/utility-css-classes/) +- [GitHub Repository](https://github.com/PureStackStudio/PureStack) +- [Regor](https://regor.purestack.studio/) + +**Always verify implementation details against the installed framework version.** diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 00000000..30ecefce --- /dev/null +++ b/templates/README.md @@ -0,0 +1,26 @@ +# PureStack templates + +Each directory is a publishable npm workspace containing a complete site: + +| Directory | Package | CLI alias | +| ---------- | ----------------------------- | --------- | +| `starter/` | `@purestack/template-starter` | `starter` | + +From `sites/`, run `yarn purestack init`. Official aliases use these local +templates automatically in this checkout. To select a folder explicitly: + +```sh +yarn purestack init my-site --template ../templates/starter --yes +``` + +Templates share the product version with framework packages. The root +`templates/*` workspace pattern includes new folders in packing, dependency +checks, version bumps, and release discovery. Add a template's alias and catalog +entry in the CLI when making it an official wizard choice. + +Keep `.gitignore` and `.yarnrc.yml` under their real names and explicitly list +them in the template package's `files` array. `yarn package` uses npm to pack +template packages so these files are preserved. + +See [the template package contract](../docs/template-scaffolding.md) for required +files, configuration options, verification, and publishing. diff --git a/templates/starter/LICENSE b/templates/starter/LICENSE new file mode 100644 index 00000000..c5ac1cea --- /dev/null +++ b/templates/starter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ahmed Yasin Koculu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/templates/starter/README.md b/templates/starter/README.md new file mode 100644 index 00000000..4461eee7 --- /dev/null +++ b/templates/starter/README.md @@ -0,0 +1,12 @@ +# PureStack starter template + +Create a site with `npx purestack@latest init my-site --template starter --yes`. +This package distributes complete project source. It is copied by the CLI and +does not become a dependency of the generated project. + +Generated projects use pinned Yarn 4, Vite-powered Vitest app tests, and Biome +for linting, formatting, and import organization. See the generated +[README](template/README.md) for commands and requirements. + +To test unpublished changes, pass this folder or its packed `.tgz` to +`purestack init --template`. See [the template contract](../../docs/template-scaffolding.md). diff --git a/templates/starter/package.json b/templates/starter/package.json new file mode 100644 index 00000000..3efcddfc --- /dev/null +++ b/templates/starter/package.json @@ -0,0 +1,24 @@ +{ + "name": "@purestack/template-starter", + "version": "1.1.19", + "description": "A small, complete PureStack website ready to customize.", + "purestackTemplate": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/PureStackStudio/PureStack.git", + "directory": "templates/starter" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "template.json", + "template.ui.json", + "template", + "template/.gitignore", + "template/.yarnrc.yml", + "README.md", + "LICENSE" + ] +} diff --git a/templates/starter/template.json b/templates/starter/template.json new file mode 100644 index 00000000..644eef87 --- /dev/null +++ b/templates/starter/template.json @@ -0,0 +1,6 @@ +{ + "formatVersion": 1, + "description": "A small site with a home page, about page, and browser TypeScript example.", + "purestack": "1.1.19", + "ui": "template.ui.json" +} diff --git a/templates/starter/template.ui.json b/templates/starter/template.ui.json new file mode 100644 index 00000000..12dddaf2 --- /dev/null +++ b/templates/starter/template.ui.json @@ -0,0 +1,189 @@ +{ + "version": 1, + "categories": [ + { + "id": "appearance", + "label": "Appearance", + "description": "Shape your site. Light and dark themes are included.", + "categories": [ + { + "id": "colors", + "label": "Colors", + "description": "Coordinated palettes for surfaces, accents, and components.", + "fields": [ + { + "id": "palette", + "label": "Color palette", + "description": "Matching light and dark colors for your whole site.", + "type": "select", + "default": "blue", + "choices": [ + { + "value": "blue", + "label": "Ocean", + "description": "Clear blue with cool, composed surfaces.", + "color": "#2874b7" + }, + { + "value": "mint", + "label": "Mint", + "description": "Soft green with a fresh, calm character.", + "color": "#6dd59a" + }, + { + "value": "green", + "label": "Forest", + "description": "Natural green with earthy neutrals.", + "color": "#50b728" + }, + { + "value": "pink", + "label": "Orchid", + "description": "Expressive purple and pink accents.", + "color": "#ac21d7" + }, + { + "value": "orange", + "label": "Ember", + "description": "Warm orange and deep complementary tones.", + "color": "#c7460a" + }, + { + "value": "red", + "label": "Crimson", + "description": "Confident red with slate neutrals.", + "color": "#ea1111" + } + ], + "bindings": [ + { + "path": ["style", "theme", "presets"], + "transform": "array" + } + ] + } + ] + } + ] + }, + { + "id": "features", + "label": "Features", + "description": "Decide how readers explore and use your content.", + "categories": [ + { + "id": "navigation", + "label": "Navigation", + "description": "Page navigation and a useful outline of each page.", + "fields": [ + { + "id": "navigation-mode", + "label": "Page navigation", + "description": "Automatic navigation follows page files and frontmatter ordering.", + "type": "select", + "default": "auto", + "choices": [ + { + "value": "auto", + "label": "Automatic" + }, + { + "value": "none", + "label": "Hidden" + } + ], + "bindings": [ + { + "path": ["navigation", "mode"] + } + ] + }, + { + "id": "table-of-contents", + "label": "Table of contents", + "description": "Show an outline built from the current page's headings.", + "type": "toggle", + "default": true, + "bindings": [ + { + "path": ["pageToc", "enabled"] + } + ] + } + ] + }, + { + "id": "content-tools", + "label": "Content tools", + "description": "Search and code examples.", + "fields": [ + { + "id": "search", + "label": "Site search", + "description": "Build a Pagefind index of your content when publishing.", + "type": "toggle", + "default": true, + "bindings": [ + { + "path": ["pagefind", "enabled"] + } + ] + }, + { + "id": "highlight", + "label": "Syntax highlighting", + "description": "Highlight code examples in Markdown and MDX pages.", + "type": "toggle", + "default": true, + "bindings": [ + { + "path": ["mdx", "disableHighlighter"], + "transform": "not" + } + ] + } + ] + } + ] + }, + { + "id": "publishing", + "label": "Publishing", + "description": "Set the public address and mount path. You can change these later.", + "fields": [ + { + "id": "site-url", + "label": "Public site URL", + "description": "Optional. An address such as https://example.com enables a sitemap.", + "type": "text", + "default": "", + "validation": "url", + "bindings": [ + { + "path": ["sitemap", "baseUrl"], + "transform": "trim-slashes" + }, + { + "path": ["sitemap", "enabled"], + "transform": "nonempty" + } + ] + }, + { + "id": "base-path", + "label": "Public base path", + "description": "Leave empty for the domain root, or use a path such as /docs.", + "type": "text", + "default": "", + "validation": "base-path", + "bindings": [ + { + "path": ["basePath"], + "transform": "trim-slashes" + } + ] + } + ] + } + ] +} diff --git a/templates/starter/template/.gitignore b/templates/starter/template/.gitignore new file mode 100644 index 00000000..655f9991 --- /dev/null +++ b/templates/starter/template/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.ts-ssg/ +.yarn/ +coverage/ +*.log +.env +.env.* +!.env.example diff --git a/templates/starter/template/.yarnrc.yml b/templates/starter/template/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/templates/starter/template/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/templates/starter/template/AGENTS.md b/templates/starter/template/AGENTS.md new file mode 100644 index 00000000..71d8dc9c --- /dev/null +++ b/templates/starter/template/AGENTS.md @@ -0,0 +1,42 @@ +# Working on this PureStack site + +## Commands and validation + +- Use the Yarn version pinned in `package.json`; install with `yarn install`. +- Preview: `yarn dev` (local server with live reload). +- Verify content and configuration: `yarn build`. +- Test app code: `yarn test` (watch) or `yarn test:ci` (once). +- Code quality: `yarn check`; safe fixes: `yarn check:fix`. +- Formatting: `yarn format`; lint only: `yarn lint`. +- Verify the release: `yarn publish`; inspect `dist/publish` before deployment. +- `publish` creates local output. Deployment is a separate action. + +## Source map + +- `content/siteConfig.json`: title, logo, theme, navigation, and output paths. +- `content/index.mdx`: home page; `content/about.mdx`: about page. +- `content/purestack.config.ts`: site plugins for components, styles, and templates. +- `content/hello.ts`: browser code loaded with `<PageScript src="./hello.ts" />`. +- `tests/`: Vitest app tests; keep tests outside the published content directory. +- `vitest.config.ts`: Vite-powered test configuration, not the site server. +- `biome.json`: code quality, formatting, and import organization. +- Other files in `content/`, including images, are copied as public assets. +- `dist/` and `node_modules/` are generated. Change source instead of output. +- Commit `yarn.lock` after installing. Keep Yarn's `node-modules` linker enabled. + +## Customization rules + +Routes follow filenames: `about.mdx` becomes `/about/` and `index.mdx` becomes +`/`. Pages support frontmatter such as `title`, `description`, `template`, and +`nav.order`. Both `.mdx` and `.rmdx` use PureStack's Regor MDX pipeline; use +PureStack components and supported markup, rather than assuming React semantics. + +Keep imports on published package APIs. Declare every directly imported package +in this project's package.json. Do not refer to a PureStack monorepo checkout or +introduce workspace dependencies. Browser behavior belongs in a TypeScript file +loaded through `PageScript`; register shared components through a site plugin. + +After changes, run the build and inspect affected routes in the preview. Before +deployment, verify page titles, descriptions, navigation, links, and release +output. Consult <https://purestack.studio/guides/> for APIs; verify component +properties and icon names before using them. diff --git a/templates/starter/template/README.md b/templates/starter/template/README.md new file mode 100644 index 00000000..8327c84d --- /dev/null +++ b/templates/starter/template/README.md @@ -0,0 +1,45 @@ +# My PureStack site + +Use Node.js 22.12+ (22 LTS), 24 LTS, or 26+ and Yarn 4.18.1, pinned in +`package.json`. When dependency installation is selected in Studio or with +`--install`, the CLI installs missing Corepack, enables Yarn, and prepares the +project-pinned Yarn version automatically. If you skipped installation, follow +<https://yarnpkg.com/getting-started/install> if Yarn is not already available. + +Run `yarn install` if dependencies were not installed during creation, then +`yarn dev`. Open the address printed by the server. Edit +`content/index.mdx` to change your home page; the preview reloads as you work. + +## Customize + +- Set your title and brand in `content/siteConfig.json`. +- Add `.md`, `.mdx`, or `.rmdx` files under `content/` to create routes. +- Edit `content/about.mdx` for your about page. +- `content/hello.ts` demonstrates browser behavior loaded with `PageScript`. +- Add plugins to `content/purestack.config.ts` for custom components and themes. + +Run `yarn build` to generate `dist/site`. Run `yarn publish` to create +a clean, minified release in `dist/publish`. These commands write local files; +deploy the release folder with your static hosting provider. + +## Tests and code quality + +- `yarn test`: run app tests in watch mode with Vitest, powered by Vite. +- `yarn test:ci`: run tests once, suitable for CI. +- `yarn lint`: check code with Biome's linter. +- `yarn format`: format supported source files with Biome. +- `yarn check`: check lint, formatting, and import organization together. +- `yarn check:fix`: apply Biome's safe fixes and formatting. + +Add automated app tests under `tests/` as `*.test.ts`. The greeting example +tests the browser entry in PureStack's DOM utilities without a browser server. +Keep tests outside `content/` so they are not published as site assets. PureStack +still builds and serves the site; Vite is used by Vitest for tests only. + +The CLI creates an empty `yarn.lock` so this site is independent even when created +inside another Yarn workspace. `yarn install` fills it with dependency resolutions; +commit the resulting lockfile. The project uses Yarn's `node-modules` linker. +Biome checks supported code and JSON; edit MDX content separately. + +Read `AGENTS.md` before making automated changes. Framework documentation: +<https://purestack.studio/guides/>. diff --git a/templates/starter/template/biome.json b/templates/starter/template/biome.json new file mode 100644 index 00000000..22add3c0 --- /dev/null +++ b/templates/starter/template/biome.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.6.2/schema.json", + "files": { + "includes": [ + "**", + "!**/.*", + "!**/dist", + "!**/node_modules", + "!**/.yarn", + "!**/coverage" + ], + "ignoreUnknown": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "asNeeded" + } + }, + "json": { + "formatter": { + "expand": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended", + "correctness": { + "noUnusedImports": "error", + "noUnusedVariables": "error" + }, + "suspicious": { + "noEmptyInterface": "off" + }, + "complexity": { + "noUselessSwitchCase": "off" + } + } + }, + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/templates/starter/template/content/about.mdx b/templates/starter/template/content/about.mdx new file mode 100644 index 00000000..9b48ffd8 --- /dev/null +++ b/templates/starter/template/content/about.mdx @@ -0,0 +1,14 @@ +--- +title: About +description: Tell visitors about your project. +template: doc +nav: + order: 1 +--- + +# About this site + +Replace this page with your story. Explain what you are building, who it helps, +and how visitors can get started. + +[Back to the home page](/). diff --git a/templates/starter/template/content/hello.ts b/templates/starter/template/content/hello.ts new file mode 100644 index 00000000..6495bc0e --- /dev/null +++ b/templates/starter/template/content/hello.ts @@ -0,0 +1,8 @@ +const button = document.querySelector<HTMLButtonElement>('#hello-button') +const output = document.querySelector<HTMLOutputElement>('#hello-output') + +button?.addEventListener('click', () => { + if (output) output.textContent = 'Hello from your new PureStack site!' +}) + +export {} diff --git a/templates/starter/template/content/index.mdx b/templates/starter/template/content/index.mdx new file mode 100644 index 00000000..bf8b956d --- /dev/null +++ b/templates/starter/template/content/index.mdx @@ -0,0 +1,25 @@ +--- +title: Welcome +description: Your new PureStack website starts here. +template: doc +nav: + order: 0 +--- + +# Make this site yours + +Edit `content/index.mdx`, save, and watch your changes appear. +Add a page to the content folder to create another route, or visit [About](/about/). + +<Panel tone="info" variant="surface"> + <p>Your content, components, styles, and browser code live in this project.</p> +</Panel> + +## Add a little interaction + +<button id="hello-button" type="button">Say hello</button> +<output id="hello-output" aria-live="polite"></output> + +<PageScript src="./hello.ts" /> + +The button's behavior is in `content/hello.ts`. PureStack bundles it for the browser. diff --git a/templates/starter/template/content/purestack.config.ts b/templates/starter/template/content/purestack.config.ts new file mode 100644 index 00000000..bcbcf59c --- /dev/null +++ b/templates/starter/template/content/purestack.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'purestack' + +export default defineConfig({ plugins: [] }) diff --git a/templates/starter/template/content/siteConfig.json b/templates/starter/template/content/siteConfig.json new file mode 100644 index 00000000..a09af98a --- /dev/null +++ b/templates/starter/template/content/siteConfig.json @@ -0,0 +1,14 @@ +{ + "siteTitle": "My Site", + "logo": { + "brand": "My Site", + "href": "/" + }, + "style": { + "theme": { + "skin": "standard" + } + }, + "outDir": "../dist/dev", + "publishDir": "../dist/publish" +} diff --git a/templates/starter/template/package.json b/templates/starter/template/package.json new file mode 100644 index 00000000..fb0c9288 --- /dev/null +++ b/templates/starter/template/package.json @@ -0,0 +1,27 @@ +{ + "name": "purestack-starter", + "private": true, + "type": "module", + "packageManager": "yarn@4.18.1", + "scripts": { + "dev": "purestack serve --content ./content --port 5000", + "build": "purestack build --content ./content", + "publish": "purestack publish --content ./content", + "test": "vitest", + "test:ci": "vitest run", + "lint": "biome lint .", + "format": "biome format --write .", + "check": "biome check .", + "check:fix": "biome check --write ." + }, + "dependencies": { + "purestack": "{{purestackVersion}}" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "@biomejs/biome": "^2.6.2", + "@purestack/ts-minidom": "{{purestackVersion}}", + "vite": "^8.3.5", + "vitest": "^5.0.5" + } +} diff --git a/templates/starter/template/tests/hello.test.ts b/templates/starter/template/tests/hello.test.ts new file mode 100644 index 00000000..818683bf --- /dev/null +++ b/templates/starter/template/tests/hello.test.ts @@ -0,0 +1,27 @@ +import { runInDom } from '@purestack/ts-minidom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +beforeEach(() => vi.resetModules()) + +describe('home page greeting', () => { + it('announces the greeting when the visitor clicks the button', () => + runInDom( + '<button id="hello-button" type="button">Say hello</button><output id="hello-output" aria-live="polite"></output>', + async (document) => { + await import('../content/hello') + const button = + document.querySelector<HTMLButtonElement>('#hello-button') + const output = + document.querySelector<HTMLOutputElement>('#hello-output') + + expect(output?.textContent).toBe('') + button?.click() + expect(output?.textContent).toBe('Hello from your new PureStack site!') + }, + )) + + it('can load on a page without the greeting controls', () => + runInDom('<main>Another page</main>', async () => { + await expect(import('../content/hello')).resolves.toBeDefined() + })) +}) diff --git a/templates/starter/template/vitest.config.ts b/templates/starter/template/vitest.config.ts new file mode 100644 index 00000000..8ed1d36a --- /dev/null +++ b/templates/starter/template/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['tests/**/*.test.ts'], + clearMocks: true, + }, +}) diff --git a/tsconfig.json b/tsconfig.json index 9fd50726..7af99942 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,5 +23,5 @@ "@purestack/*": ["./packages/*/src"] } }, - "include": ["./packages/*/src"] + "include": ["./packages/*/src", "./packages/purestack/wizard"] } diff --git a/yarn.lock b/yarn.lock index 4ec3f1dc..d3afd6b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -155,19 +155,25 @@ __metadata: languageName: node linkType: hard -"@biomejs/biome@npm:^2.5.14": - version: 2.5.14 - resolution: "@biomejs/biome@npm:2.5.14" - dependencies: - "@biomejs/cli-darwin-arm64": "npm:2.5.14" - "@biomejs/cli-darwin-x64": "npm:2.5.14" - "@biomejs/cli-linux-arm64": "npm:2.5.14" - "@biomejs/cli-linux-arm64-musl": "npm:2.5.14" - "@biomejs/cli-linux-x64": "npm:2.5.14" - "@biomejs/cli-linux-x64-musl": "npm:2.5.14" - "@biomejs/cli-win32-arm64": "npm:2.5.14" - "@biomejs/cli-win32-x64": "npm:2.5.14" +"@biomejs/biome@npm:^2.6.2": + version: 2.6.2 + resolution: "@biomejs/biome@npm:2.6.2" + dependencies: + "@biomejs/cli-android-arm64": "npm:2.6.2" + "@biomejs/cli-android-x64": "npm:2.6.2" + "@biomejs/cli-darwin-arm64": "npm:2.6.2" + "@biomejs/cli-darwin-x64": "npm:2.6.2" + "@biomejs/cli-linux-arm64": "npm:2.6.2" + "@biomejs/cli-linux-arm64-musl": "npm:2.6.2" + "@biomejs/cli-linux-x64": "npm:2.6.2" + "@biomejs/cli-linux-x64-musl": "npm:2.6.2" + "@biomejs/cli-win32-arm64": "npm:2.6.2" + "@biomejs/cli-win32-x64": "npm:2.6.2" dependenciesMeta: + "@biomejs/cli-android-arm64": + optional: true + "@biomejs/cli-android-x64": + optional: true "@biomejs/cli-darwin-arm64": optional: true "@biomejs/cli-darwin-x64": @@ -186,62 +192,76 @@ __metadata: optional: true bin: biome: bin/biome - checksum: 10c0/db612f71783a33a11666d09febe8310993474671ca41191ae9c0fa7bfe96f7f6814a5f57dea445e70a88991d8d09a6c5212135aa2950158f320824e4f48ec0a4 + checksum: 10c0/d106419ffeb53dea085c230b96879d94e19a0237eea824cacf669ae9b725380d960cd53ffd8910f0e5aee5ef1f6916148cd4f30aa1b255f9e2c94eec9c096738 + languageName: node + linkType: hard + +"@biomejs/cli-android-arm64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-android-arm64@npm:2.6.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@biomejs/cli-android-x64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-android-x64@npm:2.6.2" + conditions: os=android & cpu=x64 languageName: node linkType: hard -"@biomejs/cli-darwin-arm64@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-darwin-arm64@npm:2.5.14" +"@biomejs/cli-darwin-arm64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-darwin-arm64@npm:2.6.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@biomejs/cli-darwin-x64@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-darwin-x64@npm:2.5.14" +"@biomejs/cli-darwin-x64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-darwin-x64@npm:2.6.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@biomejs/cli-linux-arm64-musl@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-linux-arm64-musl@npm:2.5.14" +"@biomejs/cli-linux-arm64-musl@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-linux-arm64-musl@npm:2.6.2" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@biomejs/cli-linux-arm64@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-linux-arm64@npm:2.5.14" +"@biomejs/cli-linux-arm64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-linux-arm64@npm:2.6.2" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@biomejs/cli-linux-x64-musl@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-linux-x64-musl@npm:2.5.14" +"@biomejs/cli-linux-x64-musl@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-linux-x64-musl@npm:2.6.2" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@biomejs/cli-linux-x64@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-linux-x64@npm:2.5.14" +"@biomejs/cli-linux-x64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-linux-x64@npm:2.6.2" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@biomejs/cli-win32-arm64@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-win32-arm64@npm:2.5.14" +"@biomejs/cli-win32-arm64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-win32-arm64@npm:2.6.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@biomejs/cli-win32-x64@npm:2.5.14": - version: 2.5.14 - resolution: "@biomejs/cli-win32-x64@npm:2.5.14" +"@biomejs/cli-win32-x64@npm:2.6.2": + version: 2.6.2 + resolution: "@biomejs/cli-win32-x64@npm:2.6.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -1003,6 +1023,12 @@ __metadata: languageName: node linkType: hard +"@purestack/template-starter@workspace:templates/starter": + version: 0.0.0-use.local + resolution: "@purestack/template-starter@workspace:templates/starter" + languageName: unknown + linkType: soft + "@purestack/ts-common@workspace:^, @purestack/ts-common@workspace:packages/ts-common": version: 0.0.0-use.local resolution: "@purestack/ts-common@workspace:packages/ts-common" @@ -1618,12 +1644,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^25.6.2": - version: 25.6.2 - resolution: "@types/node@npm:25.6.2" +"@types/node@npm:^26.6.5": + version: 26.6.5 + resolution: "@types/node@npm:26.6.5" dependencies: - undici-types: "npm:~7.19.0" - checksum: 10c0/7f540331aa3ab88c285aeaf2eb43e3992f54f0cdb7f3593d156af67b199d4eaf56590fa1c310a00aa58ff69dba668cb3915a157fe83cd6b40a73bb338a12f09a + undici-types: "npm:~8.9.0" + checksum: 10c0/4f791f69466ace7842c09e7bef96cb1ddbb65cf3db916ef7e84f0e2b2a17220b6b816dc7539d01520cadfb17ed7169b8214910900493e0a83be8bc7d124ec20a languageName: node linkType: hard @@ -4689,7 +4715,7 @@ __metadata: languageName: node linkType: hard -"postcss@npm:^8.5.28": +"postcss@npm:^8.5.30": version: 8.5.31 resolution: "postcss@npm:8.5.31" dependencies: @@ -4760,14 +4786,14 @@ __metadata: version: 0.0.0-use.local resolution: "purestack-repo@workspace:." dependencies: - "@biomejs/biome": "npm:^2.5.14" - "@types/node": "npm:^25.6.2" + "@biomejs/biome": "npm:^2.6.2" + "@types/node": "npm:^26.6.5" esbuild: "npm:^0.28.0" tsdown: "npm:0.23.0" tsx: "npm:^4.23.15" typescript: "npm:7.0.2" typescript-parser: "npm:typescript@6.0.3" - vite: "npm:^8.3.3" + vite: "npm:^8.3.5" vitest: "npm:^5.0.3" languageName: unknown linkType: soft @@ -4776,7 +4802,13 @@ __metadata: version: 0.0.0-use.local resolution: "purestack@workspace:packages/purestack" dependencies: + "@purestack/ts-components": "workspace:^" + "@purestack/ts-minidom": "workspace:^" "@purestack/ts-ssg": "workspace:^" + "@purestack/ts-style": "workspace:^" + "@purestack/ts-svg-icons": "workspace:^" + regor: "npm:^1.7.6" + tar: "npm:^7.5.22" bin: purestack: ./dist/cli.mjs languageName: unknown @@ -4965,7 +4997,7 @@ __metadata: languageName: node linkType: hard -"rolldown@npm:~1.2.11": +"rolldown@npm:~1.2.14": version: 1.2.15 resolution: "rolldown@npm:1.2.15" dependencies: @@ -5485,7 +5517,7 @@ __metadata: languageName: node linkType: hard -"tar@npm:^7.4.3": +"tar@npm:^7.4.3, tar@npm:^7.5.22": version: 7.5.22 resolution: "tar@npm:7.5.22" dependencies: @@ -5850,10 +5882,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.19.0": - version: 7.19.2 - resolution: "undici-types@npm:7.19.2" - checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e +"undici-types@npm:~8.9.0": + version: 8.9.0 + resolution: "undici-types@npm:8.9.0" + checksum: 10c0/51c712c20c087368f62c6fc0f2bc49fa76bfe5dec28ff01584bb677ff29b0622569783c02d43e151143808c48c0503f804093bd2c491b10f2a830138034cb451 languageName: node linkType: hard @@ -6007,19 +6039,19 @@ __metadata: languageName: node linkType: hard -"vite@npm:^8.3.3": - version: 8.3.3 - resolution: "vite@npm:8.3.3" +"vite@npm:^8.3.5": + version: 8.3.5 + resolution: "vite@npm:8.3.5" dependencies: fsevents: "npm:~2.3.3" lightningcss: "npm:^1.33.0" picomatch: "npm:^4.0.7" - postcss: "npm:^8.5.28" - rolldown: "npm:~1.2.11" + postcss: "npm:^8.5.30" + rolldown: "npm:~1.2.14" tinyglobby: "npm:^0.2.17" peerDependencies: "@types/node": ^20.19.0 || >=22.12.0 - "@vitejs/devtools": ^0.7.1 + "@vitejs/devtools": ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: ">=1.21.0" less: ^4.0.0 @@ -6060,7 +6092,7 @@ __metadata: optional: true bin: vite: bin/vite.js - checksum: 10c0/a1fa945ad1dd98d43e45d491eee11505083b19e00f8c5549cd0771273717f7bdb431eef71ef3568d1effa6ba63719811e5151f3fbec84be5a017d4975470a0d9 + checksum: 10c0/33991600925b6717429c14a2c2907554aed077489ee6ff7815f9f49d5de559f5173ccb0472f2b3f9eef2a49e2e014f6cb624db8d4adebb37baad3a8cbc15065a languageName: node linkType: hard From 01c516dc789a5ae51e43ae8cec565a3494d9bec9 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu <koculu@gmail.com> Date: Thu, 22 Oct 2026 04:04:17 +0200 Subject: [PATCH 2/6] Fix CI. --- .github/workflows/ci.yml | 15 ++++++++++++--- .yarnrc.yml | 2 ++ packages/purestack/src/init/create-site.ts | 5 ++++- packages/purestack/src/init/init.test.ts | 19 ++++++++++++------- .../src/init/package-manager.test.ts | 7 +++++-- scripts/fixtures/toolchain-worker.ts | 10 +++++++--- scripts/verify-templates.ts | 3 ++- templates/starter/template/.yarnrc.yml | 2 ++ 8 files changed, 46 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97150902..2d4958db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,17 @@ jobs: node-version: '26' package-manager-cache: false - - name: Prepare repository tools + # Node 25 and later no longer bundle Corepack. Runner images ship Yarn 1, + # whose global launcher blocks Corepack's own (EEXIST on Windows). + - name: Install and enable Corepack + shell: bash run: | + npm uninstall --global yarn npm install --global corepack@latest corepack enable - yarn install --immutable + + - name: Install dependencies + run: yarn install --immutable - name: Test package manager setup run: yarn vitest run packages/purestack/src/init/package-manager.test.ts @@ -66,9 +72,12 @@ jobs: node-version: '26' package-manager-cache: false - # Node 25 and later no longer bundle Corepack. + # Node 25 and later no longer bundle Corepack. Runner images ship Yarn 1, + # whose global launcher blocks Corepack's own (EEXIST on Windows). - name: Install and enable Corepack + shell: bash run: | + npm uninstall --global yarn npm install --global corepack@latest corepack enable diff --git a/.yarnrc.yml b/.yarnrc.yml index 3186f3f0..ed2c3e2d 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1 +1,3 @@ nodeLinker: node-modules +# Install fresh releases right away, including just-published PureStack packages. +npmMinimalAgeGate: 0 diff --git a/packages/purestack/src/init/create-site.ts b/packages/purestack/src/init/create-site.ts index 979ae1f5..03b706d3 100644 --- a/packages/purestack/src/init/create-site.ts +++ b/packages/purestack/src/init/create-site.ts @@ -108,8 +108,11 @@ export async function createSite( perform, options.signal, ) + // The first install writes the lockfile, even where CI makes Yarn immutable. + const install = + packageManager === 'yarn' ? ['install', '--no-immutable'] : ['install'] await perform('Install dependencies', () => - runPackageManager(packageManager, ['install'], directory, { + runPackageManager(packageManager, install, directory, { capture: true, signal: options.signal, }), diff --git a/packages/purestack/src/init/init.test.ts b/packages/purestack/src/init/init.test.ts index 015f5b79..b60d101a 100644 --- a/packages/purestack/src/init/init.test.ts +++ b/packages/purestack/src/init/init.test.ts @@ -208,9 +208,12 @@ describe('project generation', () => { expect(await readFile(path.join(destination, 'yarn.lock'), 'utf8')).toBe('') expect(project.packageManager).toBe('yarn@4.18.1') expect(project.devDependencies['@purestack/ts-minidom']).toBe(cliVersion) - expect( - await readFile(path.join(destination, '.yarnrc.yml'), 'utf8'), - ).toContain('nodeLinker: node-modules') + const yarnConfig = await readFile( + path.join(destination, '.yarnrc.yml'), + 'utf8', + ) + expect(yarnConfig).toContain('nodeLinker: node-modules') + expect(yarnConfig).toContain('npmMinimalAgeGate: 0') expect(Object.keys(project.dependencies)).toEqual(['purestack']) expect( JSON.parse( @@ -482,10 +485,12 @@ describe('project generation', () => { repository, ) expect(result.installed).toBe(false) - expect(install).toHaveBeenCalledWith('yarn', ['install'], destination, { - capture: true, - signal: undefined, - }) + expect(install).toHaveBeenCalledWith( + 'yarn', + ['install', '--no-immutable'], + destination, + { capture: true, signal: undefined }, + ) expect(result.failures).toEqual([ expect.stringContaining('Retry yarn install'), ]) diff --git a/packages/purestack/src/init/package-manager.test.ts b/packages/purestack/src/init/package-manager.test.ts index 02b101bb..61012c4d 100644 --- a/packages/purestack/src/init/package-manager.test.ts +++ b/packages/purestack/src/init/package-manager.test.ts @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -15,7 +15,10 @@ vi.mock('node:fs', async (importOriginal) => { let temporary: string beforeEach(async () => { - temporary = await mkdtemp(path.join(tmpdir(), 'purestack-yarn-runner-')) + // Child processes report resolved paths, e.g. /private/var on macOS. + temporary = await realpath( + await mkdtemp(path.join(tmpdir(), 'purestack-yarn-runner-')), + ) vi.stubEnv('COREPACK_ROOT', undefined) vi.stubEnv('npm_execpath', undefined) vi.stubEnv('PATH', '') diff --git a/scripts/fixtures/toolchain-worker.ts b/scripts/fixtures/toolchain-worker.ts index 14ec6f29..448d7d5e 100644 --- a/scripts/fixtures/toolchain-worker.ts +++ b/scripts/fixtures/toolchain-worker.ts @@ -59,9 +59,13 @@ async function main() { (await runYarn(['--version'], project, { capture: true, signal })).trim(), version, ) - await runPackageManager('yarn', ['install', '--mode=skip-build'], project, { - signal, - }) + // Like createSite: the first install writes the lockfile despite CI immutability. + await runPackageManager( + 'yarn', + ['install', '--no-immutable', '--mode=skip-build'], + project, + { signal }, + ) assert.match( await readFile(path.join(project, 'yarn.lock'), 'utf8'), /__metadata/, diff --git a/scripts/verify-templates.ts b/scripts/verify-templates.ts index 327e0b02..98c1ff36 100644 --- a/scripts/verify-templates.ts +++ b/scripts/verify-templates.ts @@ -107,8 +107,9 @@ async function main() { } await runPackageManager( manager, + // Like createSite: the first install writes the lockfile despite CI immutability. manager === 'yarn' - ? ['install'] + ? ['install', '--no-immutable'] : ['install', '--no-audit', '--no-fund'], directory, ) diff --git a/templates/starter/template/.yarnrc.yml b/templates/starter/template/.yarnrc.yml index 3186f3f0..ed2c3e2d 100644 --- a/templates/starter/template/.yarnrc.yml +++ b/templates/starter/template/.yarnrc.yml @@ -1 +1,3 @@ nodeLinker: node-modules +# Install fresh releases right away, including just-published PureStack packages. +npmMinimalAgeGate: 0 From 6ace8c636f12e9c62e3deaefa02deefa1a6ef786 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu <koculu@gmail.com> Date: Thu, 22 Oct 2026 04:11:13 +0200 Subject: [PATCH 3/6] Fix CI verification of templates failure. --- .../purestack/src/cli/terminal/commands.test.ts | 7 +++++-- scripts/verify-templates.ts | 15 +++++++++------ templates/starter/template/README.md | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/purestack/src/cli/terminal/commands.test.ts b/packages/purestack/src/cli/terminal/commands.test.ts index ef32ed0f..86a24d9a 100644 --- a/packages/purestack/src/cli/terminal/commands.test.ts +++ b/packages/purestack/src/cli/terminal/commands.test.ts @@ -7,10 +7,13 @@ import { changeDirectoryCommand } from './commands' let temporary: string beforeAll(async () => { - temporary = await mkdtemp(path.join(tmpdir(), 'purestack-shell-')) + // Shells keep the form they start in: RUNNER~1 on Windows, /private/var on macOS. + temporary = await realpath( + await mkdtemp(path.join(tmpdir(), 'purestack-shell-')), + ) }) afterAll(async () => { - expect(path.dirname(path.resolve(temporary))).toBe(path.resolve(tmpdir())) + expect(path.dirname(temporary)).toBe(await realpath(tmpdir())) expect(path.basename(temporary)).toMatch(/^purestack-shell-/) await rm(temporary, { recursive: true, force: true }) }) diff --git a/scripts/verify-templates.ts b/scripts/verify-templates.ts index 98c1ff36..7945c89e 100644 --- a/scripts/verify-templates.ts +++ b/scripts/verify-templates.ts @@ -8,6 +8,7 @@ import { runPackageManager, } from '../packages/purestack/src/init/package-manager' import { runProcess } from '../packages/purestack/src/init/process' +import { resolveCoreSiteConfig } from '../packages/ts-ssg/src/config/core-config' import { workspaceManifestPaths } from './workspaces' const root = process.cwd() @@ -117,14 +118,16 @@ async function main() { if (typeof project.scripts?.[script] === 'string') await runPackageManager(manager, ['run', script], directory) } - for (const output of ['site', 'publish']) { - const html = await readFile( - path.join(directory, 'dist', output, 'index.html'), - 'utf8', - ) + // Each template chooses its output folders; resolve them as the build does. + const { outDir, publishDir } = resolveCoreSiteConfig({ + rootDir: directory, + contentDir: path.join(directory, 'content'), + }) + for (const output of [outDir, publishDir]) { + const html = await readFile(path.join(output, 'index.html'), 'utf8') assert( html.includes('Template verification'), - `${output} is missing the customized title.`, + `${path.relative(directory, output)} is missing the customized title.`, ) } console.log(`Verified ${template.manifest.name}.`) diff --git a/templates/starter/template/README.md b/templates/starter/template/README.md index 8327c84d..1b1fe8b2 100644 --- a/templates/starter/template/README.md +++ b/templates/starter/template/README.md @@ -18,7 +18,7 @@ Run `yarn install` if dependencies were not installed during creation, then - `content/hello.ts` demonstrates browser behavior loaded with `PageScript`. - Add plugins to `content/purestack.config.ts` for custom components and themes. -Run `yarn build` to generate `dist/site`. Run `yarn publish` to create +Run `yarn build` to generate `dist/dev`. Run `yarn publish` to create a clean, minified release in `dist/publish`. These commands write local files; deploy the release folder with your static hosting provider. From eea24105575194efc1a681ebe9f7e921d8555d69 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu <koculu@gmail.com> Date: Thu, 22 Oct 2026 07:07:35 +0200 Subject: [PATCH 4/6] Improve starter template. --- .gitignore | 1 + .../getting-started/studioSetupStep.ts | 29 ++ frontend/components/studioComponents.ts | 4 +- .../guides/purestack-cli/studio-options.png | Bin 0 -> 61807 bytes .../guides/purestack-cli/studio-review.png | Bin 0 -> 58196 bytes .../guides/purestack-cli/studio-site.png | Bin 0 -> 56497 bytes .../guides/getting-started/purestack-cli.mdx | 248 +++++------------- frontend/purestack.studio/index.mdx | 51 ++-- .../purestack/src/cli/wizard/session.test.ts | 7 +- packages/purestack/src/init/init.test.ts | 8 +- packages/purestack/src/init/template-store.ts | 2 +- packages/purestack/wizard/modules/model.ts | 2 +- templates/starter/README.md | 6 + templates/starter/package.json | 2 +- templates/starter/template.json | 2 +- templates/starter/template.ui.json | 2 +- templates/starter/template/AGENTS.md | 33 ++- templates/starter/template/README.md | 40 ++- templates/starter/template/content/about.mdx | 32 ++- templates/starter/template/content/footer.mdx | 3 + .../guide/checklist/checklist-component.ts | 69 +++++ .../content/guide/checklist/checklist.mdx | 27 ++ .../content/guide/checklist/checklist.ts | 7 + .../starter/template/content/guide/index.mdx | 102 +++++++ templates/starter/template/content/header.mdx | 9 + templates/starter/template/content/hello.ts | 8 - templates/starter/template/content/index.mdx | 28 +- .../modules/components/landing/context.ts | 5 + .../landing/foundation/feature-card.ts | 22 ++ .../landing/foundation/foundation.ts | 28 ++ .../modules/components/landing/hero/canvas.ts | 36 +++ .../modules/components/landing/hero/hero.ts | 17 ++ .../modules/components/landing/hero/intro.ts | 32 +++ .../components/landing/hero/message.ts | 21 ++ .../components/landing/hero/principle.ts | 18 ++ .../modules/components/landing/index.ts | 23 ++ .../modules/components/landing/next-step.ts | 26 ++ .../modules/components/landing/page.ts | 14 + .../modules/components/shell/footer/brand.ts | 22 ++ .../modules/components/shell/footer/footer.ts | 33 +++ .../modules/components/shell/footer/index.ts | 14 + .../components/shell/footer/navigation.ts | 19 ++ .../components/shell/footer/start-card.ts | 21 ++ .../content/modules/content/landing.ts | 90 +++++++ .../content/modules/starter-plugin.ts | 15 ++ .../template/content/modules/styles/footer.ts | 125 +++++++++ .../template/content/modules/styles/home.ts | 59 +++++ .../template/content/modules/styles/index.ts | 13 + .../content/modules/styles/landing/canvas.ts | 99 +++++++ .../modules/styles/landing/foundation.ts | 45 ++++ .../content/modules/styles/landing/hero.ts | 23 ++ .../modules/styles/landing/next-step.ts | 20 ++ .../template/content/modules/styles/shell.ts | 117 +++++++++ .../template/content/modules/theme/palette.ts | 106 ++++++++ .../template/content/purestack.config.ts | 3 +- .../starter/template/content/siteConfig.json | 32 ++- templates/starter/template/package.json | 4 +- .../starter/template/tests/checklist.test.ts | 44 ++++ .../starter/template/tests/hello.test.ts | 27 -- 59 files changed, 1600 insertions(+), 295 deletions(-) create mode 100644 frontend/components/getting-started/studioSetupStep.ts create mode 100644 frontend/purestack.studio/assets/guides/purestack-cli/studio-options.png create mode 100644 frontend/purestack.studio/assets/guides/purestack-cli/studio-review.png create mode 100644 frontend/purestack.studio/assets/guides/purestack-cli/studio-site.png create mode 100644 templates/starter/template/content/footer.mdx create mode 100644 templates/starter/template/content/guide/checklist/checklist-component.ts create mode 100644 templates/starter/template/content/guide/checklist/checklist.mdx create mode 100644 templates/starter/template/content/guide/checklist/checklist.ts create mode 100644 templates/starter/template/content/guide/index.mdx create mode 100644 templates/starter/template/content/header.mdx delete mode 100644 templates/starter/template/content/hello.ts create mode 100644 templates/starter/template/content/modules/components/landing/context.ts create mode 100644 templates/starter/template/content/modules/components/landing/foundation/feature-card.ts create mode 100644 templates/starter/template/content/modules/components/landing/foundation/foundation.ts create mode 100644 templates/starter/template/content/modules/components/landing/hero/canvas.ts create mode 100644 templates/starter/template/content/modules/components/landing/hero/hero.ts create mode 100644 templates/starter/template/content/modules/components/landing/hero/intro.ts create mode 100644 templates/starter/template/content/modules/components/landing/hero/message.ts create mode 100644 templates/starter/template/content/modules/components/landing/hero/principle.ts create mode 100644 templates/starter/template/content/modules/components/landing/index.ts create mode 100644 templates/starter/template/content/modules/components/landing/next-step.ts create mode 100644 templates/starter/template/content/modules/components/landing/page.ts create mode 100644 templates/starter/template/content/modules/components/shell/footer/brand.ts create mode 100644 templates/starter/template/content/modules/components/shell/footer/footer.ts create mode 100644 templates/starter/template/content/modules/components/shell/footer/index.ts create mode 100644 templates/starter/template/content/modules/components/shell/footer/navigation.ts create mode 100644 templates/starter/template/content/modules/components/shell/footer/start-card.ts create mode 100644 templates/starter/template/content/modules/content/landing.ts create mode 100644 templates/starter/template/content/modules/starter-plugin.ts create mode 100644 templates/starter/template/content/modules/styles/footer.ts create mode 100644 templates/starter/template/content/modules/styles/home.ts create mode 100644 templates/starter/template/content/modules/styles/index.ts create mode 100644 templates/starter/template/content/modules/styles/landing/canvas.ts create mode 100644 templates/starter/template/content/modules/styles/landing/foundation.ts create mode 100644 templates/starter/template/content/modules/styles/landing/hero.ts create mode 100644 templates/starter/template/content/modules/styles/landing/next-step.ts create mode 100644 templates/starter/template/content/modules/styles/shell.ts create mode 100644 templates/starter/template/content/modules/theme/palette.ts create mode 100644 templates/starter/template/tests/checklist.test.ts delete mode 100644 templates/starter/template/tests/hello.test.ts diff --git a/.gitignore b/.gitignore index 6f0919e1..604362c6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ tsconfig.bundle.generated.json /.tmp* /frontend/out/ /frontend/purestack.studio/legal/_private/ +/private-docs diff --git a/frontend/components/getting-started/studioSetupStep.ts b/frontend/components/getting-started/studioSetupStep.ts new file mode 100644 index 00000000..34c13bb7 --- /dev/null +++ b/frontend/components/getting-started/studioSetupStep.ts @@ -0,0 +1,29 @@ +import { defineComponent, html } from 'regor' + +export interface StudioSetupStep { + heading: string + description: string +} + +const setupStepTemplate = html`<Panel tone="accent" variant="surface" class="p-0 mb-5" bodyClass="p-4"> + <Flex align="center" class="gap-3 mb-3"> + <IconFrame name="tabler:browser" tone="accent" variant="surface"/> + <strong>{{ heading }}</strong> + </Flex> + <p class="mt-0 mb-3 fs-sm">{{ description }}</p> + <ol class="m-0 pl-4 fs-sm"> + <li>Choose your template, colors, and features.</li> + <li>Review your choices and create your site.</li> + <li>Browse it live, then finish Studio when you’re ready.</li> + </ol> +</Panel>` + +export function defineStudioSetupStepComponent() { + return defineComponent<StudioSetupStep>(setupStepTemplate, { + context: () => ({ + heading: 'Configure your site in your browser', + description: + 'init starts a local PureStack setup website. Open the URL printed in your terminal to get started.', + }), + }) +} diff --git a/frontend/components/studioComponents.ts b/frontend/components/studioComponents.ts index 6cc86ddd..3ecd950e 100644 --- a/frontend/components/studioComponents.ts +++ b/frontend/components/studioComponents.ts @@ -1,7 +1,8 @@ +import { defineStudioSetupStepComponent } from './getting-started/studioSetupStep' import { defineStudioActivityChartComponent } from './studioActivityChart' import { defineStudioBrandComponent } from './studioBrand' -import { defineStudioCommandComponent } from './studioCommand' import { defineStudioColorPickerComponent } from './studioColorPicker' +import { defineStudioCommandComponent } from './studioCommand' import { defineStudioCopyComponent } from './studioCopy' import { defineStudioFaqComponent } from './studioFaq' import { defineStudioFeatureComponent } from './studioFeature' @@ -19,6 +20,7 @@ export function defineStudioComponents() { studioCopy: defineStudioCopyComponent(), studioFaq: defineStudioFaqComponent(), studioCommand: defineStudioCommandComponent(), + studioSetupStep: defineStudioSetupStepComponent(), studioActivityChart: defineStudioActivityChartComponent(), studioWorkbench: defineStudioWorkbenchComponent(), } diff --git a/frontend/purestack.studio/assets/guides/purestack-cli/studio-options.png b/frontend/purestack.studio/assets/guides/purestack-cli/studio-options.png new file mode 100644 index 0000000000000000000000000000000000000000..214bfca8a5ffb430395855aeb634a3d4300d952f GIT binary patch literal 61807 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%0A?sMFizb+a$hs9?!dw)P8{-k(E|wuCsO7 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 Ax&QzG literal 0 HcmV?d00001 diff --git a/frontend/purestack.studio/assets/guides/purestack-cli/studio-review.png b/frontend/purestack.studio/assets/guides/purestack-cli/studio-review.png new file mode 100644 index 0000000000000000000000000000000000000000..a96df9f2a9853e0d11b4a9c24f8cd93be3204d0d GIT binary patch literal 58196 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≦VWS;tsctAe^WTUMb+K9 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%0A6|$H(0CGAA%k7~g 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 IFWUd{AEhst{{R30 literal 0 HcmV?d00001 diff --git a/frontend/purestack.studio/assets/guides/purestack-cli/studio-site.png b/frontend/purestack.studio/assets/guides/purestack-cli/studio-site.png new file mode 100644 index 0000000000000000000000000000000000000000..b8cdb5297588f451e9645b1dc59d69799cf0202e GIT binary patch literal 56497 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 SX8Laf1qBC&b8FY~v;G5QytS<W literal 0 HcmV?d00001 diff --git a/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx b/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx index 053c6fee..3058cad4 100644 --- a/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx +++ b/frontend/purestack.studio/guides/getting-started/purestack-cli.mdx @@ -1,6 +1,6 @@ --- title: PureStack CLI -description: Create a content site, preview changes, build static files, and prepare a clean publish artifact with the PureStack CLI. +description: Open PureStack Studio, shape your starter site, and make it your own. template: doc layout: showNav: true @@ -13,224 +13,110 @@ nav: # PureStack CLI -The `purestack` command builds a static site from a content directory. It discovers Markdown and Regor MDX pages, creates routes and navigation, generates theme styles, copies assets, and builds a search index when enabled. The same command also runs a development server and prepares a clean folder for publishing. +Choose a starting point, find your colors, and see your new site. PureStack Studio guides you through it in your browser. -Install `purestack` in your project with `yarn add purestack`. The commands below use the installed `purestack` executable through Yarn. Run them from your project root. You can also explore the <a href="https://github.com/PureStackStudio/PureStack/tree/main/packages/ts-ssg/sample-content" target="_blank" rel="noopener">repository's sample site</a>. +## Open Studio -## Build a first site - -Create a standalone project from an official template: - -```sh -npx purestack@latest init my-site --install --yes -cd my-site -yarn dev -``` - -The `starter` template creates a small site with a home page, about page, and -browser TypeScript example. Run `npx purestack@latest init` to open Site Studio. -The CLI serves ordinary wizard content through PureStack's existing server and -prints its local URL. Configure site details, templates, template-defined -settings, and project setup, then create the site with animated progress. -Browse the live site while Studio stays open; source edits reload automatically. -Choose **Finish Studio** to stop both local servers. Use `--yes` to create a -site directly without opening Studio. - -```sh -npx purestack@latest init my-site --template starter --git --yes -npx purestack@latest init my-site --template starter --yes -``` - -`--git` initializes a repository without creating a commit. If the project is -already inside a repository, the CLI keeps using that repository. `--install` -installs dependencies with the template's package manager (Yarn for starter). -Both actions are opt-in. `--yes` skips all prompts -and uses defaults for omitted settings; the default template is starter. - -Ctrl+C stops Studio and its live site. Direct creation with `--yes` requires a directory. - -For scripted customization, use `--set palette=mint --set search=false`. -The template's `template.ui.json` defines its categories, fields, choices, -defaults, and configuration bindings. Use `true` or `false` for toggle values. -Unspecified values use the template's declared defaults. - -Use `--title "My Documentation"` to set the site title and logo brand. Template -versions can be selected explicitly with `--template starter@1.1.19`. Full npm -package names and explicit local package-folder or `.tgz` paths are supported -too. Run `npx purestack@latest init --help` for the complete option list. - -The destination must be absent or empty. Templates become editable project -source and are not installed as project dependencies. Each project includes -`README.md` and `AGENTS.md` with customization guidance. Run `yarn build` for -development output and `yarn publish` for a local release artifact. - -### Set up a content directory manually - -Create this folder beside your project's `package.json`: - -```text -content/ - siteConfig.json - index.mdx - about.mdx - assets/ - logo.png -``` - -The CLI requires `siteConfig.json` at the root of the directory passed to `--content`. Start with: - -```json -{ - "siteTitle": "Acme Docs", - "logo": { "brand": "Acme Docs", "href": "/" }, - "style": { "theme": { "skin": "standard" } }, - "outDir": "../dist/site", - "publishDir": "../dist/publish" -} -``` - -Save that JSON as `content/siteConfig.json`. The two relative output paths are resolved from `content`, so they point to `dist/site` and `dist/publish` beside it. Light and dark theme styles are generated by default. The [Site configuration guide](/guides/getting-started/site-config/) explains every setting, including navigation, search, sitemap, localization, and consent. - -Now add a home page at `content/index.mdx`: - -```mdx ---- -title: Welcome -description: Start here to learn Acme. -template: doc -nav: - order: 0 ---- - -# Welcome - -Read the [about page](/about/) or start writing your own guide. -``` - -Add `content/about.mdx` with its own title and body. Then start the preview server: +With Node.js 22.12+ and npm installed, run this where you want to create your project: ```sh -yarn purestack serve --content ./content +npx purestack@latest init ``` -Open the URL printed as `serving at` once shared setup finishes. Pages render as you open them. With the default settings the URL is `http://127.0.0.1:4173/`. The server binds to `0.0.0.0` by default; pass `--host 127.0.0.1` if it should listen only on your machine. If `basePath` is set in `siteConfig.json`, the printed URL includes it. - -## Understand the content folder - -The CLI recognizes `.md`, `.mdx`, and `.rmdx` as pages. Both `.mdx` and `.rmdx` use the Regor MDX pipeline. `.md` also uses that pipeline by default; `mdx.compileMdAsMdx` can change how `.md` is compiled. See the [Regor guide](/guides/extending/regor/) for markup and component usage. - -Routes follow filenames and folders: +The command starts a local PureStack website: your setup wizard. Click the URL printed in your terminal and keep the terminal open while you explore. -| Source file | Public route | Generated page | -| --- | --- | --- | -| `index.mdx` | `/` | `index.html` | -| `about.mdx` | `/about/` | `about/index.html` | -| `guide/index.md` | `/guide/` | `guide/index.html` | -| `guide/guide.mdx` | `/guide/` | `guide/index.html` | -| `guide/install.rmdx` | `/guide/install/` | `guide/install/index.html` | +## Shape your site -`guide/index.md` and `guide/guide.mdx` are alternative names for the same route; do not keep both. The build reports duplicate routes as an error. Page frontmatter supplies a page's title, description, template, navigation order, and layout choices. If a page omits `template`, PureStack uses the documentation template. +**1. Name it.** Choose a title and project folder, relative to where you ran the command. -Set `index: false` in frontmatter for a preview or unlisted page. PureStack still builds it and resolves links to it, but excludes it from generated navigation, search, and the sitemap and adds a robots `noindex` tag. `hidden: true` only hides a page from navigation. +<figure class="m-0 my-5"> + <a href="/assets/guides/purestack-cli/studio-site.png" target="_blank" rel="noopener"> + <img src="/assets/guides/purestack-cli/studio-site.png" width="1440" height="820" loading="lazy" alt="PureStack Studio site details with a site title, project folder, and navigation between setup sections." class="w-full rounded-md b-1 b-subtle"/> + </a> + <figcaption class="fs-sm text-subtle mt-2">Give your site a name. Move between setup sections whenever you need.</figcaption> +</figure> -Files such as `assets/logo.png` are copied into the output folder with their relative paths intact. The root `siteConfig.json` and navigation files are inputs to the build, not public assets. You can place `_nav.json` in a folder for custom or hybrid navigation, and `header.mdx` or `footer.mdx` for shared page sections. The [Site configuration guide](/guides/getting-started/site-config/) shows how to enable and order navigation. A Markdown or MDX file whose name, or a folder above it, starts with `_` is never a page; pages show it with [`<import-content>`](/guides/content/shared-content/). +**2. Shape it.** Pick **Starter**, explore the palettes, and choose your features. Go back to adjust anything before creating. -A `purestack.config.ts` beside `siteConfig.json` adds code of your own, such as a brand skin, components, templates, or generated pages, through [plugins](/guides/extending/plugins/). Every command loads it when it is present; like other `.ts` files in the content folder, it is never copied to the output. +<figure class="m-0 my-5"> + <a href="/assets/guides/purestack-cli/studio-options.png" target="_blank" rel="noopener"> + <img src="/assets/guides/purestack-cli/studio-options.png" width="1440" height="820" loading="lazy" alt="PureStack Studio template options showing appearance settings and the Ember color palette." class="w-full rounded-md b-1 b-subtle"/> + </a> + <figcaption class="fs-sm text-subtle mt-2">Your template brings its own settings. Choose a palette and the features you want.</figcaption> +</figure> -## Commands at a glance +**3. Create it.** Select **Install dependencies**, optionally enable Git, and click **Create site** after reviewing. Watch the progress, then choose **Browse live site**. -Every build command requires `--content <dir>`. The path is resolved from the working directory and must contain `siteConfig.json`. +<figure class="m-0 my-5"> + <a href="/assets/guides/purestack-cli/studio-review.png" target="_blank" rel="noopener"> + <img src="/assets/guides/purestack-cli/studio-review.png" width="1440" height="820" loading="lazy" alt="PureStack Studio review screen summarizing the site title, folder, starter template, and dependency installation before creating the site." class="w-full rounded-md b-1 b-subtle"/> + </a> + <figcaption class="fs-sm text-subtle mt-2">Review your choices, then create your site and open it live.</figcaption> +</figure> -| Command | Result | Output folder | -| --- | --- | --- | -| `build` | Generates the complete static site. | `outDir` | -| `serve` | Starts an HTTP server, renders pages on request, and watches content by default. | `outDir` | -| `publish` | Cleans and builds a release artifact. | `publishDir` | +Studio can set up Yarn during installation. Git setup creates or reuses a repository without making a commit. -Use `yarn purestack --help` or `yarn purestack help` to print the CLI's own usage summary. Options can be written as `--content ./content` or `--content=./content`. +<Panel tone="accent" variant="surface" class="p-0" bodyClass="p-5"> + <h3 class="mt-0">A real site. Your source.</h3> + <p class="mb-0">A responsive landing page, About page, light and dark themes, a customization guide, and an interactive launch checklist. All yours to edit, with guidance for you and your AI assistant.</p> +</Panel> -Use `yarn purestack --version` or `yarn purestack -v` to print the installed PureStack version. +## Make the first change -### `build`: generate static files +Open your project in your editor. Change the headline in `content/modules/content/landing.ts` and save. Your words appear on the live site. -```sh -yarn purestack build --content ./content -yarn purestack build --content ./content --clean -``` - -`build` renders all pages, writes the theme CSS and favicon when configured, copies static assets, writes a build manifest, and creates a Pagefind index when search is enabled. It writes a sitemap and `robots.txt` only when sitemap generation is enabled in `siteConfig.json`. The command exits when the build finishes; serve the resulting folder with your static host. - -`--clean` removes the configured `outDir` before rebuilding it. Use it when you need an output folder containing only files from the current build. Without `--clean`, PureStack keeps the directory and rewrites current outputs. +| Make it yours | Start here | +| --- | --- | +| Words, sections, and buttons | `content/modules/content/landing.ts` | +| Site name, logo, and palette | `content/siteConfig.json` | +| Your story | `content/about.mdx` | +| Layouts and components | `content/modules/components/` | -`--no-cache` bypasses reads and writes to persistent computation caches kept outside the output directory, such as highlighted code and optimized images. It works with `build`, `serve`, and `publish`. Use it to compare uncached rendering or investigate a stale cached result. Existing cache files stay in place; a cold build with caching enabled also spends time populating its cache. +Your project’s `README.md`, `AGENTS.md`, and `/guide/` show the way. [PureStack Component Tools](/guides/getting-started/vscode-extension/) helps you edit pages and components in VS Code. -During `serve`, pages still render on request and reuse their rendered output until invalidated. `--no-cache` affects computations when a page renders; it does not force rendering on every request. To rebuild into an empty output directory with persistent caches bypassed, combine `--clean` and `--no-cache` (`publish` already cleans its output directory). +## Come back and keep building -### `serve`: preview and edit +**Finish Studio** stops both local servers and keeps your project. Next time: ```sh -yarn purestack serve --content ./content -yarn purestack serve --content ./content --host 127.0.0.1 --port 4300 -yarn purestack serve --content ./content --clean --full-render +cd my-site +yarn dev ``` -The server uses port `4173` and host `0.0.0.0` unless you override them. By default, startup prepares routes, navigation, and shared page sections. Each page renders on its first request and is cached for later requests; static assets copy when requested. Watched edits invalidate affected pages so they render again on the next request, and open pages reload automatically. Changes to `siteConfig.json`, `purestack.config.ts`, or a file the config imports refresh shared setup and invalidate cached pages. - -Use `--full-render` when you need every page, asset, and the complete search index generated before browsing. It runs one full build at startup, and site requests wait for that build to finish. After startup, watched edits use the same incremental invalidation and rendering on request as the default mode. - -When search is enabled, the default mode builds its search index when a Pagefind asset is requested and includes only pages opened in the current session. `--full-render` starts with a complete search index; later changes are handled incrementally. `build` and `publish` always index the complete site. +Replace `my-site` with your folder name. Skipped installation? Run `yarn install` first. -These options belong to `serve`: +## Everyday commands -| Option | Effect | +| Command | What it does | | --- | --- | -| `--host <host>` | Listen on a particular interface, for example `127.0.0.1`. | -| `--port <port>` | Listen on a different port. | -| `--clean` | Remove `outDir` before setup and when config changes reset shared setup. | -| `--no-cache` | Skip persistent caches, such as highlighted code and optimized images. | -| `--full-render` | Build every page, asset, and the complete search index once at startup. Watched edits remain incremental. | -| `--no-watch` | Keep the server running without watching the content directory. Pages still render on request, or all at startup with `--full-render`. | -| `--no-reload` | Keep watching and updating the site, but do not inject the browser live reload script. | +| `yarn dev` | Open your site locally. Saved changes reload in your browser. | +| `yarn check` | Check code quality and formatting. | +| `yarn test` | Run app tests while you work. | +| `yarn build` | Generate the complete site in `dist/dev`. | +| `yarn publish` | Prepare a clean release in `dist/publish`, ready for your host. | -Watching and browser reload are separate. For example, `--no-reload` is useful if another browser tool manages refreshes. The server displays page build errors as diagnostic pages so you can correct the source and continue editing. +Ready to go live? Follow the [deployment guide](/guides/publishing/deployment/). -### `publish`: prepare a release folder +<ExpandablePanel title="Need more control from the terminal?" variant="surface"> + +<p>You can prefill Studio without skipping it:</p> ```sh -yarn purestack publish --content ./content +npx purestack@latest init my-site --title "My next idea" ``` -`publish` uses `publishDir` instead of `outDir`. It cleans that directory before building, minifies generated HTML and browser scripts, disables pretty printed CSS, and treats asset build errors as failures. It creates the same site content and optional search and sitemap outputs as `build`, according to `siteConfig.json`. +<p>Run <code>npx purestack@latest init --help</code> for template selection, preset options, Studio’s host and port, and direct creation for scripts. For existing content, use commands such as <code>yarn purestack build --content ./content</code>.</p> -Point your deployment process at `publishDir` after a successful run. `publish` prepares files locally; it does not upload them. It accepts `--content` and `--no-cache`, with no separate `--clean` flag because cleaning is built in. +<h3 id="build-generate-static-files"><code>build</code>: generate static files</h3> -## Choose the public URL +<p>Writes the complete site to <code>outDir</code>. Use <code>--clean</code> to empty it first or <code>--no-cache</code> to bypass persistent caches.</p> -The output directory is a filesystem path; `basePath` is a URL path. For a site hosted under `https://example.com/docs/`, set this in `content/siteConfig.json`: +<h3 id="serve-preview-and-edit"><code>serve</code>: preview and edit</h3> -```json -{ - "siteTitle": "Acme Docs", - "basePath": "/docs", - "outDir": "../dist/site", - "publishDir": "../dist/publish", - "sitemap": { - "enabled": true, - "baseUrl": "https://example.com" - } -} -``` +<p>Watches edits and reloads your browser. <code>--port</code> changes the address; <code>--full-render</code> builds every page before browsing.</p> -The home page stays at `dist/site/index.html`, but links and the development server use `/docs/`. Your production host must serve the published folder at `/docs/`. `sitemap.baseUrl` supplies the origin; PureStack combines it with `basePath` for sitemap and canonical URLs. See [Site configuration](/guides/getting-started/site-config/) for the full path and SEO rules. +<h3 id="publish-prepare-a-release-folder"><code>publish</code>: prepare a release folder</h3> -## When a command fails +<p>Cleans <code>publishDir</code> and prepares an optimized release locally. Set your public URL in <a href="/guides/getting-started/site-config/">site configuration</a>.</p> -| Symptom | Check | -| --- | --- | -| `Missing required --content <dir> option` | Pass `--content` to `build`, `serve`, or `publish`. | -| `Missing required siteConfig.json` | Check the path passed to `--content` and the file's exact name. | -| `Unknown option` | Run `yarn purestack --help`; `--host`, `--port`, `--no-watch`, `--no-reload`, and `--full-render` apply only to `serve`. | -| `Could not bundle …purestack.config.ts` | Check the imports in `purestack.config.ts` and the files it imports; the message names the import that failed. | -| `Duplicate content routes detected` | Find two files that map to the same URL, such as `guide/index.md` and `guide/guide.mdx`. | -| A page or asset returns 404 under a subpath | Check `basePath`, the URL printed by `serve`, and the path at which your host serves the output folder. | -| `publish` fails while `serve` displayed an error page | Fix the page or asset error shown by the development server, then rerun `publish`. | +</ExpandablePanel> diff --git a/frontend/purestack.studio/index.mdx b/frontend/purestack.studio/index.mdx index 364aa3ed..3a1afcde 100644 --- a/frontend/purestack.studio/index.mdx +++ b/frontend/purestack.studio/index.mdx @@ -522,30 +522,29 @@ card.<span class="syntax-blue">toCSS</span>()</code></pre> <span class="eyebrow">03 / START BUILDING</span> <h2 id="start-title">Make something<br/><span>of your own.</span></h2> <p> - Install <code>purestack</code> from npm to build your own site. Explore - the source and run the included examples with the steps here, or follow - the <a href="/guides/getting-started/purestack-cli/">CLI guide</a> to start a project. + Start PureStack Studio with one command, then open it in your browser. + Choose a starting point, find your colors, and make it yours. </p> <ol class="start-steps"> <li> <span>01</span> <div> - <strong>Get the source</strong> - <p>Clone the repository and install with Yarn.</p> + <strong>Open Studio</strong> + <p>Run init. Follow the link in your terminal.</p> </div> </li> <li> <span>02</span> <div> - <strong>Start the playground</strong> - <p>Run the included content site locally.</p> + <strong>Find your starting point</strong> + <p>Choose a template, colors, and the features you need.</p> </div> </li> <li> <span>03</span> <div> - <strong>Make it yours</strong> - <p>Edit a page. Add a component. Keep going.</p> + <strong>Create it. See it. Make it yours.</strong> + <p>Browse your new site. Your content and code are yours to shape.</p> </div> </li> </ol> @@ -553,34 +552,28 @@ card.<span class="syntax-blue">toCSS</span>()</code></pre> <div class="start-terminal"> <div class="terminal-heading"> <span><Icon name="tabler:terminal-2"/>Your terminal</span - ><span>FROM SOURCE</span> + ><span>START WITH STUDIO</span> </div> <div class="terminal-body"> <StudioCommand - target="command-clone" - label="Copy clone command" - description="# Clone the project" - command="git clone https://github.com/PureStackStudio/PureStack.git"/> - <StudioCommand - target="command-install" - label="Copy install commands" - description="# Install dependencies" - :command="'cd PureStack\nyarn install'"/> + target="command-init" + label="Copy Studio command" + description="# Your new site starts here" + command="npx purestack@latest init"/> + <StudioSetupStep/> <StudioCommand target="command-dev" label="Copy development command" - description="# Start the sample site" + description="# After setup, keep building in your project folder" command="yarn dev"/> <div class="terminal-address"> - <span class="status-dot"></span>Open localhost:4173 in your browser + <span class="status-dot"></span>Choose. Create. Browse your live site. </div> </div> <a class="terminal-footer" - href="https://github.com/PureStackStudio/PureStack#development" - target="_blank" - rel="noopener" - >Setup details in the README <span aria-hidden="true">↗</span></a + href="/guides/getting-started/purestack-cli/" + >Your first site, step by step <span aria-hidden="true">↗</span></a > </div> </Grid> @@ -624,10 +617,10 @@ card.<span class="syntax-blue">toCSS</span>()</code></pre> talk to whichever backend your product uses. </StudioFaq> <StudioFaq title="How do I install PureStack?"> - Add the published package to your project with <code>yarn add purestack</code>. - The quick start above runs the included sample from a repository checkout; - the <a href="/guides/getting-started/purestack-cli/">CLI guide</a> shows how to build your - own site with the installed package. + Run <code>npx purestack@latest init</code> and open the link in your + terminal. Studio guides you through choosing and creating your site, + including optional dependency installation. Follow the + <a href="/guides/getting-started/purestack-cli/">CLI guide</a> for your first steps. </StudioFaq> </div> </Grid> diff --git a/packages/purestack/src/cli/wizard/session.test.ts b/packages/purestack/src/cli/wizard/session.test.ts index 81163496..4e4c7c10 100644 --- a/packages/purestack/src/cli/wizard/session.test.ts +++ b/packages/purestack/src/cli/wizard/session.test.ts @@ -1,4 +1,5 @@ import { + cp, mkdir, mkdtemp, readdir, @@ -54,7 +55,11 @@ class BrowserPeer implements DevWebSocketConnection { async function fixture(previewFails = false) { const cwd = await mkdtemp(path.join(tmpdir(), 'purestack-studio-test-')) - const root = path.resolve('templates/starter') + const root = path.join(cwd, 'template') + await cp(path.resolve('templates/starter'), root, { + recursive: true, + filter: (source) => path.basename(source) !== 'node_modules', + }) const template = { root, source: 'starter', diff --git a/packages/purestack/src/init/init.test.ts b/packages/purestack/src/init/init.test.ts index b60d101a..eb97b60e 100644 --- a/packages/purestack/src/init/init.test.ts +++ b/packages/purestack/src/init/init.test.ts @@ -66,6 +66,7 @@ async function fixture(template = 'starter') { const unpacked = path.join(temporary, 'fixture') await cp(path.join(repository, 'templates', template), unpacked, { recursive: true, + filter: (source) => path.basename(source) !== 'node_modules', }) return unpacked } @@ -214,7 +215,10 @@ describe('project generation', () => { ) expect(yarnConfig).toContain('nodeLinker: node-modules') expect(yarnConfig).toContain('npmMinimalAgeGate: 0') - expect(Object.keys(project.dependencies)).toEqual(['purestack']) + expect(project.dependencies).toMatchObject({ + '@purestack/ts-style': cliVersion, + regor: '^1.7.6', + }) expect( JSON.parse( await readFile( @@ -606,7 +610,7 @@ describe('project generation', () => { await readFile(path.join(directory, 'content/siteConfig.json'), 'utf8'), ) expect(config).toMatchObject({ - style: { theme: { skin: 'standard', presets: ['mint'] } }, + style: { theme: { skin: 'starter', presets: ['mint'] } }, navigation: { mode: 'none' }, pagefind: { enabled: false }, pageToc: { enabled: false }, diff --git a/packages/purestack/src/init/template-store.ts b/packages/purestack/src/init/template-store.ts index 2b62f272..3badd725 100644 --- a/packages/purestack/src/init/template-store.ts +++ b/packages/purestack/src/init/template-store.ts @@ -26,7 +26,7 @@ export const officialTemplates = [ id: 'starter', label: 'Starter', description: - 'A focused website with a home page, about page, and browser TypeScript.', + 'A polished website with light and dark themes, a customization guide, and an interactive launch checklist.', }, ] diff --git a/packages/purestack/wizard/modules/model.ts b/packages/purestack/wizard/modules/model.ts index 1d5d5c76..5352dd30 100644 --- a/packages/purestack/wizard/modules/model.ts +++ b/packages/purestack/wizard/modules/model.ts @@ -104,7 +104,7 @@ export class WizardModel { marker: 'S', title: 'Starter', description: - 'A focused website with a home page, about page, and browser TypeScript.', + 'A polished website with light and dark themes, a customization guide, and an interactive launch checklist.', }, ]) readonly summary = computed<WizardSummaryEntry[]>(() => [ diff --git a/templates/starter/README.md b/templates/starter/README.md index 4461eee7..9f3c53b7 100644 --- a/templates/starter/README.md +++ b/templates/starter/README.md @@ -4,6 +4,12 @@ Create a site with `npx purestack@latest init my-site --template starter --yes`. This package distributes complete project source. It is copied by the CLI and does not become a dependency of the generated project. +The starter includes a responsive home page, About page, customization guide, +shared navigation/footer, and an interactive launch checklist. Its custom skin +refines the standard PureStack palette for both light and dark, while preserving +the wizard's color choices. Starter copy explains what to replace; it makes no +fictional business claims. + Generated projects use pinned Yarn 4, Vite-powered Vitest app tests, and Biome for linting, formatting, and import organization. See the generated [README](template/README.md) for commands and requirements. diff --git a/templates/starter/package.json b/templates/starter/package.json index 3efcddfc..e7e1d72b 100644 --- a/templates/starter/package.json +++ b/templates/starter/package.json @@ -1,7 +1,7 @@ { "name": "@purestack/template-starter", "version": "1.1.19", - "description": "A small, complete PureStack website ready to customize.", + "description": "A polished general-purpose PureStack site with clear customization guidance.", "purestackTemplate": true, "license": "MIT", "repository": { diff --git a/templates/starter/template.json b/templates/starter/template.json index 644eef87..7689265c 100644 --- a/templates/starter/template.json +++ b/templates/starter/template.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "description": "A small site with a home page, about page, and browser TypeScript example.", + "description": "A considered general-purpose site with light and dark themes, a customization guide, and an interactive launch checklist.", "purestack": "1.1.19", "ui": "template.ui.json" } diff --git a/templates/starter/template.ui.json b/templates/starter/template.ui.json index 12dddaf2..7447bad1 100644 --- a/templates/starter/template.ui.json +++ b/templates/starter/template.ui.json @@ -16,7 +16,7 @@ "label": "Color palette", "description": "Matching light and dark colors for your whole site.", "type": "select", - "default": "blue", + "default": "orange", "choices": [ { "value": "blue", diff --git a/templates/starter/template/AGENTS.md b/templates/starter/template/AGENTS.md index 71d8dc9c..d4cb7bd0 100644 --- a/templates/starter/template/AGENTS.md +++ b/templates/starter/template/AGENTS.md @@ -15,8 +15,17 @@ - `content/siteConfig.json`: title, logo, theme, navigation, and output paths. - `content/index.mdx`: home page; `content/about.mdx`: about page. -- `content/purestack.config.ts`: site plugins for components, styles, and templates. -- `content/hello.ts`: browser code loaded with `<PageScript src="./hello.ts" />`. +- `content/modules/content/landing.ts`: editable landing page copy and destinations. +- `content/modules/components/landing/`: build-time landing components grouped by responsibility. +- `content/modules/components/shell/footer/`: footer brand, navigation, and guide card. +- `content/header.mdx`, `content/footer.mdx`: shared navigation and footer. +- `content/guide/index.mdx`: a customization guide; keep its file map accurate. +- `content/purestack.config.ts`: plugin registration. +- `content/modules/starter-plugin.ts`: the site's theme and style plugin. +- `content/modules/theme/palette.ts`: custom light/dark palettes built from + `themeSkins.standard.create()`; preserve selectable color presets. +- `content/modules/styles/`: fluent style rules grouped by shell, footer, and landing sections. +- `content/guide/checklist/`: colocated MDX and Regor TypeScript interaction example. - `tests/`: Vitest app tests; keep tests outside the published content directory. - `vitest.config.ts`: Vite-powered test configuration, not the site server. - `biome.json`: code quality, formatting, and import organization. @@ -34,7 +43,25 @@ PureStack components and supported markup, rather than assuming React semantics. Keep imports on published package APIs. Declare every directly imported package in this project's package.json. Do not refer to a PureStack monorepo checkout or introduce workspace dependencies. Browser behavior belongs in a TypeScript file -loaded through `PageScript`; register shared components through a site plugin. +loaded through `RegorApp` or `PageScript`; register shared components through a +site plugin. Use exported, typed Regor component contexts. Divide larger +interfaces into focused components and organize shared code by responsibility. + +Use PureStack and Regor, native components, generated utilities, and typed fluent +styles. Do not introduce handwritten CSS, React/JSX, or third-party UI libraries. +Keep `.mdx` for pages with frontmatter, imports, or scripts. Colocate page-specific +code with its page; keep shared code under `content/modules/`. + +Before changing content, establish the owner's purpose, audience, tone, and real +facts. Ask for missing information. Replace prompts with useful content; do not +add invented testimonials, customer counts, prices, or achievements. Update links +when removing starter pages. Keep the home page focused on a clear message and +next action. Support both themes, keyboard interaction, and small screens. + +Always write type-safe code. Avoid type casts and suppression comments. Verify +framework APIs and icons from the installed packages. If correct typing is +blocked by a PureStack or Regor limitation, report the issue and ask the owner for +a workaround rather than bypassing the types. After changes, run the build and inspect affected routes in the preview. Before deployment, verify page titles, descriptions, navigation, links, and release diff --git a/templates/starter/template/README.md b/templates/starter/template/README.md index 1b1fe8b2..21e31fe8 100644 --- a/templates/starter/template/README.md +++ b/templates/starter/template/README.md @@ -8,15 +8,38 @@ project-pinned Yarn version automatically. If you skipped installation, follow Run `yarn install` if dependencies were not installed during creation, then `yarn dev`. Open the address printed by the server. Edit -`content/index.mdx` to change your home page; the preview reloads as you work. +`content/modules/content/landing.ts` to change your home page copy; the preview reloads as you work. ## Customize -- Set your title and brand in `content/siteConfig.json`. -- Add `.md`, `.mdx`, or `.rmdx` files under `content/` to create routes. -- Edit `content/about.mdx` for your about page. -- `content/hello.ts` demonstrates browser behavior loaded with `PageScript`. -- Add plugins to `content/purestack.config.ts` for custom components and themes. +- Start with the headline, introduction, and primary button in `content/modules/content/landing.ts`. +- `content/index.mdx` composes the landing page. Its focused components live under + `content/modules/components/landing/`, grouped into hero and foundation features. +- Replace the prompts in `content/about.mdx` with your own facts and story. +- Set your title, logo, and palette in `content/siteConfig.json`. The default is + orange; `blue`, `mint`, `green`, `pink`, and `red` also work in light and dark. +- Set `favicon` in `content/siteConfig.json` to a verified PureStack icon name. +- Edit `content/header.mdx` for header navigation. The shared footer is composed in + `content/footer.mdx`; its brand, navigation, and guide card components live under + `content/modules/components/shell/footer/`. +- Add `.mdx` pages under `content/` to create routes. Use `.md` for plain prose + without frontmatter. Keep code and scripts beside their MDX page; the checklist + at `content/guide/checklist/` shows this structure. +- Refine colors, typography, and corners in `content/modules/theme/palette.ts`. + This skin customizes PureStack's standard palette rather than replacing it. +- Keep custom fluent style rules in `content/modules/styles/`. Prefer native + components and generated utilities for ordinary layout and spacing. +- Register additional plugins in `content/purestack.config.ts`. + +Open `/guide/` for a file map and customization examples, and `/guide/checklist/` +for the small interactive Regor app. The checklist is a reminder for the current +visit; it does not automatically verify or change your files. Its component is +`content/guide/checklist/checklist-component.ts`. + +The home page, About page, shared header, and footer contain deliberate starter +copy. Replace it with your own content before launch. Remove the guide and +checklist if they are no longer useful, updating links to them at the same time. +Keep real claims and destinations; do not invent testimonials or statistics. Run `yarn build` to generate `dist/dev`. Run `yarn publish` to create a clean, minified release in `dist/publish`. These commands write local files; @@ -31,8 +54,9 @@ deploy the release folder with your static hosting provider. - `yarn check`: check lint, formatting, and import organization together. - `yarn check:fix`: apply Biome's safe fixes and formatting. -Add automated app tests under `tests/` as `*.test.ts`. The greeting example -tests the browser entry in PureStack's DOM utilities without a browser server. +Add automated app tests under `tests/` as `*.test.ts`. The checklist example +tests checkbox interaction, computed progress, completion, and reset in +PureStack's DOM utilities without a browser server. Keep tests outside `content/` so they are not published as site assets. PureStack still builds and serves the site; Vite is used by Vitest for tests only. diff --git a/templates/starter/template/content/about.mdx b/templates/starter/template/content/about.mdx index 9b48ffd8..48116e1b 100644 --- a/templates/starter/template/content/about.mdx +++ b/templates/starter/template/content/about.mdx @@ -1,14 +1,32 @@ --- -title: About -description: Tell visitors about your project. +title: Tell your story +description: Introduce the people, purpose, and point of view behind your site. template: doc +layout: + navMode: drawer + showToc: false nav: + title: About order: 1 + icon: lucide:file-text --- -# About this site +<SectionHeader eyebrow="The story behind the site" title="Good things start with a reason." titleTag="h1" titleClass="starter-page-title" subtitle="Use this page to introduce the people and purpose behind your work. A few honest paragraphs are a better beginning than a collection of big claims."/> -Replace this page with your story. Explain what you are building, who it helps, -and how visitors can get started. - -[Back to the home page](/). +<Grid columns="1" columnsLg="minmax(0, 1.2fr) minmax(0, 0.8fr)" alignItems="start" class="gap-6"> + <div> + <h2 class="mt-0">What brings you here?</h2> + <p>Start with what you do and the people it is for. Whether this is a personal project, a small business, or a community, give readers enough context to understand what they have found.</p> + <h2>What do you believe in?</h2> + <p>Share the point of view behind your work. Describe an approach, a useful principle, or a problem you care about solving. Use specific details that are true for you, and remove any section that does not serve your story.</p> + <h2>Where should people go next?</h2> + <p>Give visitors one useful next step: explore your work, read a helpful page, or contact you. Replace the link below with a real destination when it is ready.</p> + <BtnLink href="/" tone="accent" icon="lucide:arrow-right" iconPosition="end">Explore the site</BtnLink> + </div> + <Panel variant="surfaceAlt" class="p-0" bodyClass="p-5"> + <p class="text-eyebrow">A useful writing prompt</p> + <h2 class="fs-h4 mt-0">Write for one real person.</h2> + <p class="text-subtle">What would you tell someone discovering your work for the first time? Start there. Keep your own voice.</p> + <p class="fs-sm m-0">For an AI assistant: use the owner's supplied facts. Ask for missing details instead of inventing customers, results, or a company history.</p> + </Panel> +</Grid> diff --git a/templates/starter/template/content/footer.mdx b/templates/starter/template/content/footer.mdx new file mode 100644 index 00000000..18427bd6 --- /dev/null +++ b/templates/starter/template/content/footer.mdx @@ -0,0 +1,3 @@ +<SiteFooter tone="neutral" variant="none" class="starter-footer" copyright="Built with PureStack. Made to be made yours."> + <StarterFooter/> +</SiteFooter> diff --git a/templates/starter/template/content/guide/checklist/checklist-component.ts b/templates/starter/template/content/guide/checklist/checklist-component.ts new file mode 100644 index 00000000..28f1583d --- /dev/null +++ b/templates/starter/template/content/guide/checklist/checklist-component.ts @@ -0,0 +1,69 @@ +import { computed, defineComponent, html, sref } from 'regor' + +export class LaunchChecklist { + readonly steps = [ + { + title: 'Make the name and identity yours.', + detail: 'Set your title, logo, and palette in siteConfig.json.', + }, + { + title: 'Say what you do and who it helps.', + detail: + 'Replace the home page headline and introduction with your message.', + }, + { + title: 'Tell your story with real details.', + detail: + 'Replace the About page prompts. Remove any claims you cannot support.', + }, + { + title: 'Give every link a useful destination.', + detail: + 'Check navigation, buttons, and contact links. Remove unused example pages.', + }, + { + title: 'Try a small screen and both themes.', + detail: 'Check readable text, clear controls, and the mobile navigation.', + }, + { + title: 'Build, review, and share.', + detail: + 'Run yarn check, yarn test:ci, yarn build, and yarn publish. Review the release before uploading it.', + }, + ].map((step, index) => ({ + ...step, + id: `launch-step-${index}`, + done: sref(false), + })) + readonly completed = computed( + () => this.steps.filter((step) => step.done()).length, + ) + readonly message = computed(() => + this.completed() === this.steps.length + ? 'All checks complete. Take one last look, then share your site.' + : 'A little progress, one useful step at a time.', + ) + reset = () => { + for (const step of this.steps) step.done(false) + } +} + +const checklistTemplate = html`<section aria-label="Launch checklist"> + <div class="flex flex-wrap justify-between align-center gap-3 mb-4"> + <strong class="fs-h4">Your launch checklist</strong> + <span class="text-subtle fs-sm" role="status">{{ completed }} of {{ steps.length }} complete</span> + </div> + <progress class="starter-checklist-progress" :value="completed" :max="steps.length" aria-label="Launch checklist progress"></progress> + <label r-for="step in steps" :for="step.id" class="starter-checklist-row"> + <input type="checkbox" :id="step.id" r-model="step.done" class="starter-checklist-input"/> + <span class="d-flex flex-column gap-1"><strong>{{ step.title }}</strong><span class="text-subtle fs-sm">{{ step.detail }}</span></span> + </label> + <p class="text-subtle fs-sm mt-4 mb-3" aria-live="polite">{{ message }}</p> + <button type="button" @click="reset" class="px-3 py-2 rounded-md b-1 b-subtle tone-fill-surface text-subtle cursor-pointer fw-600">Reset checklist</button> +</section>` + +export function defineLaunchChecklistComponent() { + return defineComponent<LaunchChecklist>(checklistTemplate, { + context: () => new LaunchChecklist(), + }) +} diff --git a/templates/starter/template/content/guide/checklist/checklist.mdx b/templates/starter/template/content/guide/checklist/checklist.mdx new file mode 100644 index 00000000..651f0f2e --- /dev/null +++ b/templates/starter/template/content/guide/checklist/checklist.mdx @@ -0,0 +1,27 @@ +--- +title: Your first launch +description: A small interactive checklist to help you finish and share your PureStack site. +template: doc +layout: + navMode: drawer + showToc: false +nav: + title: Launch checklist + order: 0 + icon: lucide:check-check +--- + +<SectionHeader eyebrow="A few thoughtful checks" title="Ready when you are." titleTag="h1" titleClass="starter-page-title" subtitle="A useful site starts with a clear message and a little care. Work through these steps before sharing yours."/> + +<Grid columns="1" columnsLg="minmax(0, 1.35fr) minmax(0, 0.65fr)" alignItems="start" class="gap-6"> + <Panel variant="surface" class="p-0" bodyClass="p-5"> + <RegorApp src="./checklist.ts" id="launch-checklist"/> + </Panel> + <Panel variant="surfaceAlt" class="p-0" bodyClass="p-5"> + <IconFrame name="lucide:sparkles" tone="accent" variant="surface"/> + <h2 class="fs-h4 mt-4">Small interaction.<br/>Real source you can use.</h2> + <p class="text-subtle">This is a Regor app inside a PureStack page. Checkboxes update the progress without reloading the page.</p> + <p class="fs-sm">Checkmarks last for this visit. They are personal reminders; they do not inspect or edit your project.</p> + <BtnLink href="/guide/#find-the-right-file" variant="link" icon="lucide:arrow-right" iconPosition="end" class="p-0">Find your next change</BtnLink> + </Panel> +</Grid> diff --git a/templates/starter/template/content/guide/checklist/checklist.ts b/templates/starter/template/content/guide/checklist/checklist.ts new file mode 100644 index 00000000..31ddaac4 --- /dev/null +++ b/templates/starter/template/content/guide/checklist/checklist.ts @@ -0,0 +1,7 @@ +import { createApp, html } from 'regor' +import { defineLaunchChecklistComponent } from './checklist-component' + +createApp( + { components: { launchChecklist: defineLaunchChecklistComponent() } }, + { selector: '#launch-checklist', template: html`<LaunchChecklist/>` }, +) diff --git a/templates/starter/template/content/guide/index.mdx b/templates/starter/template/content/guide/index.mdx new file mode 100644 index 00000000..b35026d4 --- /dev/null +++ b/templates/starter/template/content/guide/index.mdx @@ -0,0 +1,102 @@ +--- +title: Make this site yours +description: A practical map for changing the words, design, pages, and interactions in your PureStack site. +template: doc +layout: + navMode: drawer + showToc: true +nav: + title: Make it yours + order: 2 + icon: lucide:book-open +--- + +<SectionHeader eyebrow="Your site, your direction" title="A clear place to start." titleTag="h1" titleClass="starter-page-title" subtitle="You do not need to change everything at once. Start with your message, choose a visual direction, then add what your visitors need."/> + +## Start with your message + +Edit `content/modules/content/landing.ts`. Replace the headline, introduction, and primary button +with three things: what you do, who it helps, and what visitors should do next. +The landing components under `content/modules/components/landing/` handle presentation. +Keep the layout while you find the words. `content/index.mdx` composes the page. + +Then replace the prompts in `content/about.mdx` with your own story. Use facts you +can stand behind. You can remove the guide and checklist pages before launch, or +keep them as a reference while you work. + +<Panel variant="surfaceAlt" class="p-0" bodyClass="p-5"> + <strong>Working with an AI assistant?</strong> + <p class="mb-0">Give it your audience, purpose, tone, and real content. Ask it to read README.md and AGENTS.md first, preserve the PureStack components, and show what it changed. It should ask for missing facts.</p> +</Panel> + +## Choose what to change + +<Tabs ariaLabel="Customization areas" tone="neutral" variant="surface" tabVariant="outline"> + <TabPane id="customize-content" label="Content" icon="lucide:file-text" class="p-4"> + <h3 class="mt-0">Write pages in Markdown and MDX.</h3> + <p>Pages are ordinary source files. Add a page, give it a useful title, and choose where it belongs in navigation.</p> + +```mdx +--- +title: Our work +description: A short introduction to what we do. +nav: + order: 3 +--- + +# Work worth sharing + +Tell visitors what you do and why it matters. +``` + + <p class="mb-0">Save this as <code>content/work.mdx</code> to create <code>/work/</code>. Update links in the header and footer when the new page is ready.</p> + </TabPane> + <TabPane id="customize-appearance" label="Appearance" icon="lucide:palette" class="p-4"> + <h3 class="mt-0">One design language, two themes.</h3> + <p>Set your site title and logo in <code>content/siteConfig.json</code>. Its <code>style.theme.presets</code> setting selects a palette: orange, pink, blue, mint, green, or red. Use the header's theme toggle to check both modes.</p> + <p>The starter skin lives in <code>content/modules/theme/palette.ts</code>. It starts from PureStack's standard palette and refines typography, surfaces, and corners for light and dark. Keep brand-wide choices here.</p> + <p class="mb-0">Custom layout rules are grouped in <code>content/modules/styles/</code> and use PureStack's typed style builder. Prefer built-in components and utility classes for everyday layout.</p> + </TabPane> + <TabPane id="customize-interaction" label="Interaction" icon="lucide:check-check" class="p-4"> + <h3 class="mt-0">Add behavior where it helps.</h3> + <p>These tabs are a built-in PureStack component. The launch checklist shows a small Regor app with reactive controls, computed progress, and a reset action.</p> + <p>Its page and TypeScript files live together in <code>content/guide/checklist/</code>. Tests live separately under <code>tests/</code>. Static pages stay static; interactive parts mount only where they are used.</p> + <BtnLink href="/guide/checklist/" variant="outline" icon="lucide:arrow-right" iconPosition="end">Try the checklist</BtnLink> + </TabPane> +</Tabs> + +## Find the right file + +| What you want to change | Where to start | +| --- | --- | +| Name, logo, palette, search, navigation settings | `content/siteConfig.json` | +| Home page headline, introduction, sections, and buttons | `content/modules/content/landing.ts` | +| Landing page components and composition | `content/modules/components/landing/`, `content/index.mdx` | +| About page story | `content/about.mdx` | +| Shared navigation and footer | `content/header.mdx`, `content/footer.mdx`, `content/modules/components/shell/footer/` | +| Site-wide colors, typography, and shape | `content/modules/theme/palette.ts` | +| Custom layout and appearance rules | `content/modules/styles/` | +| Site plugin registration | `content/purestack.config.ts` | +| Browser interaction example | `content/guide/checklist/` | + +## Keep the useful parts + +<Flex direction="column" class="gap-3"> + <ExpandablePanel title="How do I add pages and images?" icon="lucide:file-text" variant="surface"> + <p>Add <code>.mdx</code> pages under <code>content/</code>. Use frontmatter for titles, descriptions, and navigation order. Images can live in a page's own folder or a shared assets folder, and be linked with ordinary relative paths.</p> + <p class="mb-0">When a page imports code or includes scripts, put the page and its private dependencies in a folder with the same name. The checklist is a working example. Keep shared application code in <code>content/modules/</code>.</p> + </ExpandablePanel> + <ExpandablePanel title="How do I add components or plugins?" icon="lucide:layers" variant="surface"> + <p>Use PureStack's built-in components first. Register additional components, theme skins, or plugins through <code>content/purestack.config.ts</code>. Split larger interfaces into small components with exported, typed contexts.</p> + <p class="mb-0">The starter plugin in <code>content/modules/starter-plugin.ts</code> is the example to follow. Declare any package you import directly in <code>package.json</code>.</p> + </ExpandablePanel> + <ExpandablePanel title="What should I check before publishing?" icon="lucide:circle-check" variant="surface"> + <p>Replace the starter copy, check links and mobile layouts, try both themes, and run the project's checks. Set your public site URL when you have one.</p> + <p class="mb-0"><code>yarn publish</code> builds a release in <code>dist/publish</code>. It creates local files; upload that folder to your static host when you are ready.</p> + </ExpandablePanel> +</Flex> + +<Flex wrap="true" class="gap-3 mt-5 mb-5"> + <BtnLink href="/guide/checklist/" tone="accent" icon="lucide:arrow-right" iconPosition="end">Work through the launch checklist</BtnLink> + <BtnLink href="https://purestack.studio/guides/" variant="outline" icon="lucide:book-open">PureStack documentation</BtnLink> +</Flex> diff --git a/templates/starter/template/content/header.mdx b/templates/starter/template/content/header.mdx new file mode 100644 index 00000000..a32b6e8d --- /dev/null +++ b/templates/starter/template/content/header.mdx @@ -0,0 +1,9 @@ +<TopBar tone="neutral" variant="surface" signInEnabled="false" class="starter-topbar"> + <template #before-actions> + <Flex container="nav" align="center" class="starter-top-links gap-4" aria-label="Main navigation"> + <a href="/">Home</a> + <a href="/about/">About</a> + <a href="/guide/">Make it yours</a> + </Flex> + </template> +</TopBar> diff --git a/templates/starter/template/content/hello.ts b/templates/starter/template/content/hello.ts deleted file mode 100644 index 6495bc0e..00000000 --- a/templates/starter/template/content/hello.ts +++ /dev/null @@ -1,8 +0,0 @@ -const button = document.querySelector<HTMLButtonElement>('#hello-button') -const output = document.querySelector<HTMLOutputElement>('#hello-output') - -button?.addEventListener('click', () => { - if (output) output.textContent = 'Hello from your new PureStack site!' -}) - -export {} diff --git a/templates/starter/template/content/index.mdx b/templates/starter/template/content/index.mdx index bf8b956d..e637b684 100644 --- a/templates/starter/template/content/index.mdx +++ b/templates/starter/template/content/index.mdx @@ -1,25 +1,15 @@ --- -title: Welcome -description: Your new PureStack website starts here. +title: A home for your next idea +description: A considered starting point for your work, business, or community. Make it yours with PureStack. template: doc +layout: + navMode: drawer + showToc: false nav: + title: Home order: 0 + icon: lucide:sparkles --- -# Make this site yours - -Edit `content/index.mdx`, save, and watch your changes appear. -Add a page to the content folder to create another route, or visit [About](/about/). - -<Panel tone="info" variant="surface"> - <p>Your content, components, styles, and browser code live in this project.</p> -</Panel> - -## Add a little interaction - -<button id="hello-button" type="button">Say hello</button> -<output id="hello-output" aria-live="polite"></output> - -<PageScript src="./hello.ts" /> - -The button's behavior is in `content/hello.ts`. PureStack bundles it for the browser. +<!-- Edit the copy in modules/content/landing.ts. Components live in modules/components/landing/. --> +<LandingPage/> diff --git a/templates/starter/template/content/modules/components/landing/context.ts b/templates/starter/template/content/modules/components/landing/context.ts new file mode 100644 index 00000000..dd9d525f --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/context.ts @@ -0,0 +1,5 @@ +import { landingContent } from '../../content/landing' + +export class LandingPage { + readonly content = landingContent +} diff --git a/templates/starter/template/content/modules/components/landing/foundation/feature-card.ts b/templates/starter/template/content/modules/components/landing/foundation/feature-card.ts new file mode 100644 index 00000000..79b4eac9 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/foundation/feature-card.ts @@ -0,0 +1,22 @@ +import { defineComponent, html, type RefOrValue } from 'regor' +import type { LandingFeature } from '../../../content/landing' + +export interface LandingFeatureCard { + card: RefOrValue<LandingFeature> +} + +const featureTemplate = html`<Panel variant="surface" class="landing-feature-card p-0 lift-1 transition" bodyClass="landing-feature-body"> + <Flex justify="between" align="center" class="landing-feature-heading"> + <IconFrame :name="card.icon" tone="accent" variant="surface"/> + <span class="landing-feature-number" aria-hidden="true">{{ card.number }}</span> + </Flex> + <h3>{{ card.title }}</h3> + <p class="landing-body">{{ card.description }}</p> + <BtnLink :href="card.link.href" variant="link" icon="lucide:arrow-up-right" iconPosition="end" class="landing-card-link">{{ card.link.label }}</BtnLink> +</Panel>` + +export function defineLandingFeatureCardComponent() { + return defineComponent<LandingFeatureCard>(featureTemplate, { + props: ['card'], + }) +} diff --git a/templates/starter/template/content/modules/components/landing/foundation/foundation.ts b/templates/starter/template/content/modules/components/landing/foundation/foundation.ts new file mode 100644 index 00000000..dc6399d5 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/foundation/foundation.ts @@ -0,0 +1,28 @@ +import { defineComponent, html } from 'regor' +import type { landingContent } from '../../../content/landing' +import { LandingPage } from '../context' + +export interface LandingFoundation { + foundation: typeof landingContent.foundation +} + +const foundationTemplate = html`<section class="landing-foundation" aria-labelledby="foundation-title"> + <Grid columns="1" columnsLg="minmax(0, 1.25fr) minmax(0, 0.75fr)" alignItems="end" class="landing-section-heading"> + <div> + <p class="landing-eyebrow">{{ foundation.eyebrow }}</p> + <h2 id="foundation-title" class="landing-section-title">{{ foundation.title }}</h2> + </div> + <p class="landing-body">{{ foundation.description }}</p> + </Grid> + <Grid columns="1" columnsMd="3" class="landing-features"> + <LandingFeatureCard r-for="card in foundation.cards" :card="card"/> + </Grid> +</section>` + +export function defineLandingFoundationComponent() { + return defineComponent<LandingFoundation>(foundationTemplate, { + context: (head) => ({ + foundation: head.requireContext(LandingPage).content.foundation, + }), + }) +} diff --git a/templates/starter/template/content/modules/components/landing/hero/canvas.ts b/templates/starter/template/content/modules/components/landing/hero/canvas.ts new file mode 100644 index 00000000..15f1ae29 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/hero/canvas.ts @@ -0,0 +1,36 @@ +import { defineComponent, html } from 'regor' +import type { landingContent } from '../../../content/landing' +import { LandingPage } from '../context' + +export interface LandingHeroCanvas { + canvas: typeof landingContent.canvas +} + +const canvasTemplate = html`<div class="landing-canvas-scene"> + <div class="landing-canvas-backdrop" aria-hidden="true"></div> + <Panel variant="surface" class="landing-canvas p-0" bodyClass="p-0"> + <Flex align="center" justify="between" class="landing-canvas-toolbar"> + <Flex class="landing-window-dots" aria-hidden="true"><span></span><span></span><span></span></Flex> + <span class="landing-canvas-label">{{ canvas.label }}</span> + <Icon name="lucide:layout-template" aria-hidden="true"/> + </Flex> + <div class="landing-canvas-content"> + <LandingCanvasMessage/> + <Grid columns="1" columnsSm="2" class="landing-principles"> + <LandingPrinciple r-for="principle in canvas.principles" :principle="principle"/> + </Grid> + </div> + <Flex justify="between" align="center" class="landing-canvas-status"> + <span><Icon name="lucide:check" aria-hidden="true"/> A thoughtful starting point</span> + <span>01 / YOUR SITE</span> + </Flex> + </Panel> +</div>` + +export function defineLandingHeroCanvasComponent() { + return defineComponent<LandingHeroCanvas>(canvasTemplate, { + context: (head) => ({ + canvas: head.requireContext(LandingPage).content.canvas, + }), + }) +} diff --git a/templates/starter/template/content/modules/components/landing/hero/hero.ts b/templates/starter/template/content/modules/components/landing/hero/hero.ts new file mode 100644 index 00000000..4812f283 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/hero/hero.ts @@ -0,0 +1,17 @@ +import { defineComponent, html } from 'regor' + +export interface LandingHero { + headingId: string +} + +const heroTemplate = html`<Grid container="section" columns="1" columnsLg="minmax(0, 1.08fr) minmax(0, 0.92fr)" + alignItems="center" class="landing-hero" :aria-labelledby="headingId"> + <LandingHeroIntro :headingId="headingId"/> + <LandingHeroCanvas/> +</Grid>` + +export function defineLandingHeroComponent() { + return defineComponent<LandingHero>(heroTemplate, { + context: () => ({ headingId: 'home-title' }), + }) +} diff --git a/templates/starter/template/content/modules/components/landing/hero/intro.ts b/templates/starter/template/content/modules/components/landing/hero/intro.ts new file mode 100644 index 00000000..1bb5d031 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/hero/intro.ts @@ -0,0 +1,32 @@ +import { defineComponent, html, type RefOrValue } from 'regor' +import type { landingContent } from '../../../content/landing' +import { LandingPage } from '../context' + +export interface LandingHeroIntro { + headingId: RefOrValue<string> + hero: typeof landingContent.hero +} + +const introTemplate = html`<div class="landing-hero-copy"> + <p class="landing-eyebrow landing-eyebrow--accent">{{ hero.eyebrow }}</p> + <h1 :id="headingId" class="landing-hero-title"> + <span>{{ hero.title }}</span> + <span>{{ hero.continuation }} <span class="landing-accent">{{ hero.accent }}</span></span> + </h1> + <p class="landing-hero-description">{{ hero.description }}</p> + <Flex wrap="true" class="landing-actions"> + <BtnLink :href="hero.primary.href" tone="accent" icon="lucide:arrow-up-right" iconPosition="end" class="landing-button">{{ hero.primary.label }}</BtnLink> + <BtnLink :href="hero.secondary.href" variant="link" icon="lucide:arrow-right" iconPosition="end" class="landing-button">{{ hero.secondary.label }}</BtnLink> + </Flex> + <p class="landing-note">{{ hero.note }}</p> +</div>` + +export function defineLandingHeroIntroComponent() { + return defineComponent<LandingHeroIntro>(introTemplate, { + props: ['headingId'], + context: (head) => ({ + ...head.props, + hero: head.requireContext(LandingPage).content.hero, + }), + }) +} diff --git a/templates/starter/template/content/modules/components/landing/hero/message.ts b/templates/starter/template/content/modules/components/landing/hero/message.ts new file mode 100644 index 00000000..fd0357c1 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/hero/message.ts @@ -0,0 +1,21 @@ +import { defineComponent, html } from 'regor' +import type { landingContent } from '../../../content/landing' +import { LandingPage } from '../context' + +export interface LandingCanvasMessage { + canvas: typeof landingContent.canvas +} + +const messageTemplate = html`<div class="landing-canvas-message"> + <IconFrame name="lucide:sparkles" tone="accent" variant="surface"/> + <h2 class="landing-canvas-title"><span>{{ canvas.firstLine }}</span> <span>{{ canvas.secondLine }}</span></h2> + <p class="landing-body">{{ canvas.description }}</p> +</div>` + +export function defineLandingCanvasMessageComponent() { + return defineComponent<LandingCanvasMessage>(messageTemplate, { + context: (head) => ({ + canvas: head.requireContext(LandingPage).content.canvas, + }), + }) +} diff --git a/templates/starter/template/content/modules/components/landing/hero/principle.ts b/templates/starter/template/content/modules/components/landing/hero/principle.ts new file mode 100644 index 00000000..30b8678a --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/hero/principle.ts @@ -0,0 +1,18 @@ +import { defineComponent, html, type RefOrValue } from 'regor' +import type { LandingPrinciple } from '../../../content/landing' + +export interface LandingPrincipleCard { + principle: RefOrValue<LandingPrinciple> +} + +const principleTemplate = html`<Panel variant="surfaceAlt" class="landing-principle p-0" bodyClass="p-4"> + <Icon :name="principle.icon" class="landing-accent" aria-hidden="true"/> + <h3>{{ principle.title }}</h3> + <p>{{ principle.description }}</p> +</Panel>` + +export function defineLandingPrincipleComponent() { + return defineComponent<LandingPrincipleCard>(principleTemplate, { + props: ['principle'], + }) +} diff --git a/templates/starter/template/content/modules/components/landing/index.ts b/templates/starter/template/content/modules/components/landing/index.ts new file mode 100644 index 00000000..01230d61 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/index.ts @@ -0,0 +1,23 @@ +import { defineLandingFeatureCardComponent } from './foundation/feature-card' +import { defineLandingFoundationComponent } from './foundation/foundation' +import { defineLandingHeroCanvasComponent } from './hero/canvas' +import { defineLandingHeroComponent } from './hero/hero' +import { defineLandingHeroIntroComponent } from './hero/intro' +import { defineLandingCanvasMessageComponent } from './hero/message' +import { defineLandingPrincipleComponent } from './hero/principle' +import { defineLandingNextStepComponent } from './next-step' +import { defineLandingPageComponent } from './page' + +export function defineLandingComponents() { + return { + landingPage: defineLandingPageComponent(), + landingHero: defineLandingHeroComponent(), + landingHeroIntro: defineLandingHeroIntroComponent(), + landingHeroCanvas: defineLandingHeroCanvasComponent(), + landingCanvasMessage: defineLandingCanvasMessageComponent(), + landingPrinciple: defineLandingPrincipleComponent(), + landingFoundation: defineLandingFoundationComponent(), + landingFeatureCard: defineLandingFeatureCardComponent(), + landingNextStep: defineLandingNextStepComponent(), + } +} diff --git a/templates/starter/template/content/modules/components/landing/next-step.ts b/templates/starter/template/content/modules/components/landing/next-step.ts new file mode 100644 index 00000000..4ff258f9 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/next-step.ts @@ -0,0 +1,26 @@ +import { defineComponent, html } from 'regor' +import type { landingContent } from '../../content/landing' +import { LandingPage } from './context' + +export interface LandingNextStep { + next: typeof landingContent.next +} + +const nextTemplate = html`<Panel variant="spotlight" class="landing-next p-0 spotlight-from-top-right" bodyClass="landing-next-body"> + <Grid columns="1" columnsLg="minmax(0, 1fr) auto" alignItems="center" class="landing-next-layout"> + <div> + <p class="landing-eyebrow">{{ next.eyebrow }}</p> + <h2 class="landing-next-title">{{ next.title }}</h2> + <p class="landing-body">{{ next.description }}</p> + </div> + <BtnLink :href="next.link.href" tone="accent" icon="lucide:arrow-right" iconPosition="end" class="landing-button">{{ next.link.label }}</BtnLink> + </Grid> +</Panel>` + +export function defineLandingNextStepComponent() { + return defineComponent<LandingNextStep>(nextTemplate, { + context: (head) => ({ + next: head.requireContext(LandingPage).content.next, + }), + }) +} diff --git a/templates/starter/template/content/modules/components/landing/page.ts b/templates/starter/template/content/modules/components/landing/page.ts new file mode 100644 index 00000000..00ddd4d2 --- /dev/null +++ b/templates/starter/template/content/modules/components/landing/page.ts @@ -0,0 +1,14 @@ +import { defineComponent, html } from 'regor' +import { LandingPage } from './context' + +const pageTemplate = html`<div class="landing-page"> + <LandingHero/> + <LandingFoundation/> + <LandingNextStep/> +</div>` + +export function defineLandingPageComponent() { + return defineComponent<LandingPage>(pageTemplate, { + context: () => new LandingPage(), + }) +} diff --git a/templates/starter/template/content/modules/components/shell/footer/brand.ts b/templates/starter/template/content/modules/components/shell/footer/brand.ts new file mode 100644 index 00000000..1209428e --- /dev/null +++ b/templates/starter/template/content/modules/components/shell/footer/brand.ts @@ -0,0 +1,22 @@ +import type { SiteConfig } from 'purestack' +import { defineComponent, html } from 'regor' + +export interface FooterBrand { + description: string + logo: SiteConfig['logo'] +} + +const brandTemplate = html`<div class="footer-brand"> + <SiteLogo :config="logo"/> + <p class="footer-description">{{ description }}</p> + <Flex align="center" class="footer-theme-row"><span>Find your light.</span><ThemeToggle/></Flex> +</div>` + +export function defineFooterBrandComponent(logo: SiteConfig['logo']) { + return defineComponent<FooterBrand>(brandTemplate, { + context: () => ({ + logo, + description: 'Your words, your direction, and a little room to grow.', + }), + }) +} diff --git a/templates/starter/template/content/modules/components/shell/footer/footer.ts b/templates/starter/template/content/modules/components/shell/footer/footer.ts new file mode 100644 index 00000000..139cbbcf --- /dev/null +++ b/templates/starter/template/content/modules/components/shell/footer/footer.ts @@ -0,0 +1,33 @@ +import { defineComponent, html } from 'regor' + +export class StarterFooter { + readonly groups = [ + { + title: 'Explore', + links: [ + { label: 'Home', href: '/' }, + { label: 'Our story', href: '/about/' }, + ], + }, + { + title: 'Make it yours', + links: [ + { label: 'The guide', href: '/guide/' }, + { label: 'Launch checklist', href: '/guide/checklist/' }, + ], + }, + ] +} + +const footerTemplate = html`<Grid columns="1" columnsLg="minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1.1fr)" + alignItems="start" class="footer-layout"> + <FooterBrand/> + <FooterNavigation/> + <FooterStartCard/> +</Grid>` + +export function defineStarterFooterComponent() { + return defineComponent<StarterFooter>(footerTemplate, { + context: () => new StarterFooter(), + }) +} diff --git a/templates/starter/template/content/modules/components/shell/footer/index.ts b/templates/starter/template/content/modules/components/shell/footer/index.ts new file mode 100644 index 00000000..4d931b73 --- /dev/null +++ b/templates/starter/template/content/modules/components/shell/footer/index.ts @@ -0,0 +1,14 @@ +import type { SiteConfig } from 'purestack' +import { defineFooterBrandComponent } from './brand' +import { defineStarterFooterComponent } from './footer' +import { defineFooterNavigationComponent } from './navigation' +import { defineFooterStartCardComponent } from './start-card' + +export function defineFooterComponents(config: SiteConfig) { + return { + starterFooter: defineStarterFooterComponent(), + footerBrand: defineFooterBrandComponent(config.logo), + footerNavigation: defineFooterNavigationComponent(), + footerStartCard: defineFooterStartCardComponent(), + } +} diff --git a/templates/starter/template/content/modules/components/shell/footer/navigation.ts b/templates/starter/template/content/modules/components/shell/footer/navigation.ts new file mode 100644 index 00000000..ebae5b97 --- /dev/null +++ b/templates/starter/template/content/modules/components/shell/footer/navigation.ts @@ -0,0 +1,19 @@ +import { defineComponent, html } from 'regor' +import { StarterFooter } from './footer' + +export interface FooterNavigation { + footer: StarterFooter +} + +const navigationTemplate = html`<Grid container="nav" columns="2" class="footer-navigation" aria-label="Footer navigation"> + <div r-for="group in footer.groups" class="footer-link-group"> + <h2>{{ group.title }}</h2> + <a r-for="link in group.links" :href="link.href">{{ link.label }}</a> + </div> +</Grid>` + +export function defineFooterNavigationComponent() { + return defineComponent<FooterNavigation>(navigationTemplate, { + context: (head) => ({ footer: head.requireContext(StarterFooter) }), + }) +} diff --git a/templates/starter/template/content/modules/components/shell/footer/start-card.ts b/templates/starter/template/content/modules/components/shell/footer/start-card.ts new file mode 100644 index 00000000..02a52f7f --- /dev/null +++ b/templates/starter/template/content/modules/components/shell/footer/start-card.ts @@ -0,0 +1,21 @@ +import { defineComponent, html } from 'regor' + +export interface FooterStartCard { + title: string +} + +const startTemplate = html`<Panel variant="surface" class="footer-start p-0" bodyClass="footer-start-body"> + <Flex justify="between" align="center" class="footer-start-heading"> + <IconFrame name="lucide:book-open" tone="accent" variant="surface"/> + <span class="footer-kicker">A clear place to start</span> + </Flex> + <h2>{{ title }}</h2> + <p>Start with your message. The guide shows you where each piece lives.</p> + <BtnLink href="/guide/" tone="accent" variant="link" icon="lucide:arrow-up-right" iconPosition="end" class="footer-start-link">Open the guide</BtnLink> +</Panel>` + +export function defineFooterStartCardComponent() { + return defineComponent<FooterStartCard>(startTemplate, { + context: () => ({ title: 'Make this space your own.' }), + }) +} diff --git a/templates/starter/template/content/modules/content/landing.ts b/templates/starter/template/content/modules/content/landing.ts new file mode 100644 index 00000000..0a4163a2 --- /dev/null +++ b/templates/starter/template/content/modules/content/landing.ts @@ -0,0 +1,90 @@ +export interface LandingLink { + label: string + href: string +} + +export interface LandingPrinciple { + icon: string + title: string + description: string +} + +export interface LandingFeature extends LandingPrinciple { + number: string + link: LandingLink +} + +/** Edit the message here; the landing components own its presentation. */ +export const landingContent = { + hero: { + eyebrow: 'A little space. A lot of possibility.', + title: 'A home for', + continuation: 'your', + accent: 'next idea.', + description: + 'Introduce your work, share what matters, and give people a reason to stay. A thoughtful starting point for a business, a project, or something entirely your own.', + primary: { label: 'Make it yours', href: '/guide/' }, + secondary: { label: 'Tell your story', href: '/about/' }, + note: 'Your content. Your design. Your code.', + }, + canvas: { + label: 'The shape of a good beginning', + firstLine: 'One clear idea.', + secondLine: 'Room to grow.', + description: 'Start with what you want to say. Build the rest around it.', + principles: [ + { + icon: 'lucide:file-text', + title: 'Words with purpose', + description: 'A useful introduction. A story worth sharing.', + }, + { + icon: 'lucide:palette', + title: 'A feel of your own', + description: 'Thoughtful details. Light and dark included.', + }, + ] satisfies LandingPrinciple[], + }, + foundation: { + eyebrow: 'A considered foundation', + title: 'Small enough to understand. Ready to make your own.', + description: + 'A few useful pages, a consistent design, and clear places to start changing things.', + cards: [ + { + number: '01', + icon: 'lucide:file-text', + title: 'A story to tell', + description: + 'Introduce who you are, what you do, and why it matters. Your About page gives visitors the context to care.', + link: { label: 'Shape your story', href: '/about/' }, + }, + { + number: '02', + icon: 'lucide:sliders-horizontal', + title: 'A clear way to customize', + description: + 'Change the words, adjust the palette, and add the pages you need. The guide shows you where each piece lives.', + link: { label: 'Follow the guide', href: '/guide/' }, + }, + { + number: '03', + icon: 'lucide:check-check', + title: 'A confident first launch', + description: + 'Work through a short, interactive checklist before you share your site. Keep the parts that help; adapt the rest.', + link: { label: 'Open the checklist', href: '/guide/checklist/' }, + }, + ] satisfies LandingFeature[], + }, + next: { + eyebrow: 'Your next step', + title: 'Make the first sentence yours.', + description: + 'Say what you do, who it helps, and what to do next. The rest can grow from there.', + link: { + label: 'Start customizing', + href: '/guide/#start-with-your-message', + }, + }, +} diff --git a/templates/starter/template/content/modules/starter-plugin.ts b/templates/starter/template/content/modules/starter-plugin.ts new file mode 100644 index 00000000..c286917b --- /dev/null +++ b/templates/starter/template/content/modules/starter-plugin.ts @@ -0,0 +1,15 @@ +import { definePlugin } from 'purestack' +import { defineLandingComponents } from './components/landing' +import { defineFooterComponents } from './components/shell/footer' +import { registerStarterStyles } from './styles' +import { createStarterPalettes } from './theme/palette' + +export const starterPlugin = definePlugin({ + name: 'starter-site', + skins: { starter: { create: createStarterPalettes } }, + components: (config) => ({ + ...defineLandingComponents(), + ...defineFooterComponents(config), + }), + hooks: { onConfigResolved: () => registerStarterStyles() }, +}) diff --git a/templates/starter/template/content/modules/styles/footer.ts b/templates/starter/template/content/modules/styles/footer.ts new file mode 100644 index 00000000..5291dc59 --- /dev/null +++ b/templates/starter/template/content/modules/styles/footer.ts @@ -0,0 +1,125 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { StarterStyle } from './index' + +export function registerFooterStyles( + root: StarterStyle, + palette: ThemePalette, + gutter: string, +) { + root + .select('.starter-footer') + .borderTop(`1px solid ${palette.current.border.subtle}`) + .backgroundColor(palette.current.surfaceAlt.rest.bgcolor) + .padding(`3.5rem ${gutter} 1.5rem`) + root + .select( + '.starter-footer > .site-footer__inner, .starter-footer > .site-footer__bottom', + ) + .maxWidth('76rem') + .margin('0 auto') + root + .select('.footer-layout') + .gap('clamp(2rem, 4vw, 3rem)') + .paddingBottom('3rem') + root + .select('.footer-brand') + .display('grid') + .gap('1.25rem') + .justifyItems('start') + root + .select('.footer-description') + .margin('0') + .maxWidth('28ch') + .fontSize('0.94rem') + .fontWeight('400') + .lineHeight('1.8') + .letterSpacing('0') + .color(palette.current.text.subtle) + root + .select('.footer-theme-row') + .gap('0.75rem') + .fontSize('0.8rem') + .fontWeight('400') + .lineHeight('1.5') + .letterSpacing('0') + .color(palette.current.text.subtle) + root.select('.footer-navigation').gap('1.5rem') + root + .select('.footer-link-group') + .display('flex') + .flexDirection('column') + .gap('0.9rem') + root + .select('.footer-link-group h2') + .margin('0 0 0.35rem') + .fontSize('0.75rem') + .fontWeight('600') + .lineHeight('1.5') + .letterSpacing('0.12em') + .textTransform('uppercase') + .color(palette.current.text.subtle) + root + .select('.footer-link-group a') + .fontSize('0.94rem') + .fontWeight('500') + .lineHeight('1.6') + .letterSpacing('0') + .color(palette.current.text.default) + .textDecoration('none') + root + .select('.footer-link-group a:hover, .footer-link-group a:focus-visible') + .color(palette.semanticTone.accent.text.default) + .textDecoration('underline') + root.select('.footer-start').boxShadow('none').borderRadius(palette.radii.md) + root + .select('.footer-start-body') + .padding('1.5rem') + .display('grid') + .gap('1.15rem') + root.select('.footer-start-heading').gap('1rem') + root + .select('.footer-kicker') + .maxWidth('16ch') + .fontSize('0.65rem') + .fontWeight('600') + .lineHeight('1.6') + .letterSpacing('0.1em') + .textTransform('uppercase') + .color(palette.current.text.subtle) + root + .select('.footer-start h2') + .margin('0') + .fontSize('1.35rem') + .fontWeight('600') + .lineHeight('1.5') + .letterSpacing('-0.02em') + root + .select('.footer-start p') + .margin('0') + .fontSize('0.9rem') + .fontWeight('400') + .lineHeight('1.75') + .letterSpacing('0') + .color(palette.current.text.subtle) + root + .select('.footer-start .footer-start-link') + .padding('0') + .justifySelf('start') + .fontSize('0.9rem') + .fontWeight('600') + .lineHeight('1.5') + .letterSpacing('0') + root + .select('.starter-footer .site-footer__bottom') + .borderTop(`1px solid ${palette.current.border.subtle}`) + .padding('1.25rem 0 0') + .fontSize('0.8rem') + .fontWeight('400') + .lineHeight('1.6') + .letterSpacing('0') + .alignItems('flex-start') + .rowGap('0') + root + .select('.starter-footer .site-footer__copyright') + .justifyContent('flex-start') +} diff --git a/templates/starter/template/content/modules/styles/home.ts b/templates/starter/template/content/modules/styles/home.ts new file mode 100644 index 00000000..7032ec2e --- /dev/null +++ b/templates/starter/template/content/modules/styles/home.ts @@ -0,0 +1,59 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { StarterStyle } from './index' +import { registerLandingCanvasStyles } from './landing/canvas' +import { registerLandingFoundationStyles } from './landing/foundation' +import { registerLandingHeroStyles } from './landing/hero' +import { registerLandingNextStepStyles } from './landing/next-step' + +export function registerHomeStyles(root: StarterStyle, palette: ThemePalette) { + registerLandingBaseStyles(root, palette) + registerLandingHeroStyles(root) + registerLandingCanvasStyles(root, palette) + registerLandingFoundationStyles(root, palette) + registerLandingNextStepStyles(root, palette) +} + +function registerLandingBaseStyles(root: StarterStyle, palette: ThemePalette) { + root.select('.landing-page').display('grid').gap('clamp(4rem, 8vw, 6rem)') + root.select('.landing-page :where(h1, h2, h3, p)').margin('0') + root + .select('.landing-eyebrow') + .fontSize('0.75rem') + .fontWeight('600') + .lineHeight('1.6') + .letterSpacing('0.15em') + .textTransform('uppercase') + .color(palette.current.text.subtle) + root + .select('.landing-accent, .landing-eyebrow--accent') + .color(palette.semanticTone.accent.text.default) + root + .select('.landing-body') + .fontSize('1rem') + .fontWeight('400') + .lineHeight('1.75') + .letterSpacing('0') + .color(palette.current.text.subtle) + root + .select('.landing-note') + .fontSize('0.8rem') + .fontWeight('400') + .lineHeight('1.6') + .letterSpacing('0.015em') + .color(palette.current.text.subtle) + root.select('.landing-hero-description').color(palette.current.text.subtle) + root + .select('.landing-page .landing-button') + .padding('0.85rem 1.1rem') + .fontSize('1rem') + .fontWeight('600') + .lineHeight('1.5') + .letterSpacing('0') + root + .select('.landing-page .landing-card-link') + .padding('0') + .fontSize('0.94rem') + .fontWeight('600') + .lineHeight('1.5') + .letterSpacing('0') +} diff --git a/templates/starter/template/content/modules/styles/index.ts b/templates/starter/template/content/modules/styles/index.ts new file mode 100644 index 00000000..77a11bc4 --- /dev/null +++ b/templates/starter/template/content/modules/styles/index.ts @@ -0,0 +1,13 @@ +import { styleBuilder, themes } from '@purestack/ts-style' +import { registerHomeStyles } from './home' +import { registerShellStyles } from './shell' + +export type StarterStyle = ReturnType<typeof styleBuilder.get> + +export function registerStarterStyles() { + themes.forEach((theme, palette) => { + const root = styleBuilder.get(theme) + registerShellStyles(root, palette) + registerHomeStyles(root, palette) + }) +} diff --git a/templates/starter/template/content/modules/styles/landing/canvas.ts b/templates/starter/template/content/modules/styles/landing/canvas.ts new file mode 100644 index 00000000..d6006032 --- /dev/null +++ b/templates/starter/template/content/modules/styles/landing/canvas.ts @@ -0,0 +1,99 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { StarterStyle } from '../index' + +export function registerLandingCanvasStyles( + root: StarterStyle, + palette: ThemePalette, +) { + root + .select('.landing-canvas-scene') + .position('relative') + .padding('0 0 1.25rem 1.25rem') + root + .select('.landing-canvas-backdrop') + .position('absolute') + .inset('2rem 1.25rem 0 0') + .borderRadius(palette.radii.lg) + .backgroundColor(palette.semanticTone.accent.surface.rest.bgcolor) + .border(`1px solid ${palette.semanticTone.accent.surface.rest.border}`) + root + .select('.landing-canvas') + .position('relative') + .borderRadius(palette.radii.lg) + .boxShadow(palette.effect.panelShadow) + root + .select('.landing-canvas-toolbar') + .padding('1rem 1.25rem') + .gap('0.75rem') + .borderBottom(`1px solid ${palette.current.border.subtle}`) + .color(palette.current.text.subtle) + root.select('.landing-window-dots').gap('0.3rem') + root + .select('.landing-window-dots span') + .width('0.4rem') + .height('0.4rem') + .borderRadius('50%') + .backgroundColor(palette.current.border.default) + root + .select('.landing-window-dots span:first-child') + .backgroundColor(palette.semanticTone.accent.tone) + root + .select('.landing-canvas-label') + .maxWidth('22ch') + .fontSize('0.65rem') + .fontWeight('600') + .lineHeight('1.6') + .letterSpacing('0.08em') + .textTransform('uppercase') + root + .select('.landing-canvas-content') + .padding('1.5rem') + .display('grid') + .gap('1.5rem') + root.select('.landing-canvas-message').display('grid').gap('1.25rem') + root.select('.landing-canvas-message > .icon-frame').justifySelf('start') + root + .select('.landing-canvas-title') + .display('grid') + .gap('0.25rem') + .fontSize('clamp(1.6rem, 2.5vw, 2.1rem)') + .fontWeight('600') + .lineHeight('1.35') + .letterSpacing('-0.025em') + root.select('.landing-principles').gap('0.75rem') + root.select('.landing-principle').boxShadow('none') + root.select('.landing-principle .panel__body').display('grid').gap('0.65rem') + root + .select('.landing-principle h3') + .fontSize('0.94rem') + .fontWeight('600') + .lineHeight('1.5') + .letterSpacing('0') + root + .select('.landing-principle p') + .fontSize('0.85rem') + .fontWeight('400') + .lineHeight('1.7') + .letterSpacing('0') + .color(palette.current.text.subtle) + root + .select('.landing-canvas-status') + .padding('0.85rem 1.25rem') + .gap('0.75rem') + .borderTop(`1px solid ${palette.current.border.subtle}`) + .fontSize('0.65rem') + .fontWeight('400') + .lineHeight('1.6') + .letterSpacing('0.02em') + .color(palette.current.text.subtle) + root + .select('.landing-canvas-status > span:first-child') + .display('flex') + .alignItems('center') + .gap('0.35rem') + root + .select('.landing-canvas-status .icon') + .width('0.8rem') + .height('0.8rem') + .color(palette.semanticTone.accent.text.default) +} diff --git a/templates/starter/template/content/modules/styles/landing/foundation.ts b/templates/starter/template/content/modules/styles/landing/foundation.ts new file mode 100644 index 00000000..a86a8c57 --- /dev/null +++ b/templates/starter/template/content/modules/styles/landing/foundation.ts @@ -0,0 +1,45 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { StarterStyle } from '../index' + +export function registerLandingFoundationStyles( + root: StarterStyle, + palette: ThemePalette, +) { + root.select('.landing-section-heading').gap('2rem').marginBottom('2rem') + root.select('.landing-section-heading > div').display('grid').gap('1.25rem') + root + .select('.landing-section-title') + .maxWidth('24ch') + .fontSize('clamp(1.85rem, 3vw, 2.6rem)') + .fontWeight('650') + .lineHeight('1.3') + .letterSpacing('-0.035em') + root.select('.landing-features').gap('1.5rem') + root.select('.landing-feature-card').height('100%') + root + .select('.landing-feature-card:hover') + .borderColor(palette.semanticTone.accent.border.default) + root + .select('.landing-feature-body') + .padding('1.5rem') + .height('100%') + .display('flex') + .flexDirection('column') + .gap('1.25rem') + root.select('.landing-feature-heading').gap('1rem') + root + .select('.landing-feature-number') + .fontSize('2.25rem') + .fontWeight('300') + .lineHeight('1') + .letterSpacing('-0.05em') + .color(palette.current.text.subtle) + root + .select('.landing-feature-body h3') + .fontSize('1.25rem') + .fontWeight('600') + .lineHeight('1.4') + .letterSpacing('-0.015em') + root.select('.landing-feature-body > p').flex('1') + root.select('.landing-card-link').alignSelf('flex-start') +} diff --git a/templates/starter/template/content/modules/styles/landing/hero.ts b/templates/starter/template/content/modules/styles/landing/hero.ts new file mode 100644 index 00000000..1191dece --- /dev/null +++ b/templates/starter/template/content/modules/styles/landing/hero.ts @@ -0,0 +1,23 @@ +import type { StarterStyle } from '../index' + +export function registerLandingHeroStyles(root: StarterStyle) { + root.select('.landing-hero').gap('clamp(2.5rem, 5vw, 4rem)') + root.select('.landing-hero-copy').display('grid').gap('1.5rem') + root + .select('.landing-hero-title') + .display('flex') + .flexDirection('column') + .gap('0.3rem') + .fontSize('clamp(3.2rem, 6.2vw, 5.8rem)') + .fontWeight('650') + .lineHeight('1.14') + .letterSpacing('-0.055em') + root + .select('.landing-hero-description') + .maxWidth('36rem') + .fontSize('clamp(1.05rem, 1.4vw, 1.15rem)') + .fontWeight('400') + .lineHeight('1.8') + .letterSpacing('0') + root.select('.landing-actions').gap('0.75rem') +} diff --git a/templates/starter/template/content/modules/styles/landing/next-step.ts b/templates/starter/template/content/modules/styles/landing/next-step.ts new file mode 100644 index 00000000..71c40ef4 --- /dev/null +++ b/templates/starter/template/content/modules/styles/landing/next-step.ts @@ -0,0 +1,20 @@ +import type { ThemePalette } from '@purestack/ts-style' +import type { StarterStyle } from '../index' + +export function registerLandingNextStepStyles( + root: StarterStyle, + palette: ThemePalette, +) { + root.select('.landing-next').borderRadius(palette.radii.lg) + root.select('.landing-next-body').padding('clamp(1.5rem, 3vw, 2.5rem)') + root.select('.landing-next-layout').gap('2rem') + root.select('.landing-next-layout > div').display('grid').gap('1rem') + root + .select('.landing-next-title') + .fontSize('clamp(1.6rem, 2.5vw, 2rem)') + .fontWeight('600') + .lineHeight('1.35') + .letterSpacing('-0.025em') + root.select('.landing-next .landing-body').maxWidth('60ch') + root.select('.landing-next .landing-button').justifySelf('start') +} diff --git a/templates/starter/template/content/modules/styles/shell.ts b/templates/starter/template/content/modules/styles/shell.ts new file mode 100644 index 00000000..27e51331 --- /dev/null +++ b/templates/starter/template/content/modules/styles/shell.ts @@ -0,0 +1,117 @@ +import { BREAKPOINTS, mediaBelow, type ThemePalette } from '@purestack/ts-style' +import { registerFooterStyles } from './footer' +import type { StarterStyle } from './index' + +export function registerShellStyles(root: StarterStyle, palette: ThemePalette) { + // Match the content edges across the header, page, and footer. + const gutter = 'clamp(1.25rem, 4vw, 3rem)' + registerLayoutStyles(root, palette, gutter) + registerHeaderStyles(root, palette, gutter) + registerPageStyles(root) + registerFooterStyles(root, palette, gutter) + registerChecklistStyles(root, palette) + registerResponsiveStyles(root) +} + +function registerLayoutStyles( + root: StarterStyle, + palette: ThemePalette, + gutter: string, +) { + root.select('.template-doc').fontFamily(palette.font.family.base) + root + .select('.template-doc .doc-shell') + .maxWidth('82rem') + .margin('0 auto') + .padding(`clamp(2.5rem, 5vw, 4rem) ${gutter} clamp(3rem, 6vw, 5rem)`) +} + +function registerHeaderStyles( + root: StarterStyle, + palette: ThemePalette, + gutter: string, +) { + root + .select('.starter-topbar') + .borderBottom(`1px solid ${palette.current.border.subtle}`) + .padding(`1rem ${gutter}`) + root.select('.starter-topbar > .flex').maxWidth('76rem').margin('0 auto') + root.select('.starter-top-links').marginRight('1rem') + root + .select('.starter-top-links a') + .color(palette.current.text.subtle) + .fontSize('0.85rem') + .fontWeight('600') + .textDecoration('none') + .whiteSpace('nowrap') + root + .select('.starter-top-links a:hover') + .color(palette.semanticTone.accent.text.default) +} + +function registerPageStyles(root: StarterStyle) { + root + .select('.starter-page-title') + .fontSize('clamp(2.4rem, 5vw, 4.3rem)') + .letterSpacing('-0.055em') + .lineHeight('1.08') + .maxWidth('16ch') + root + .select('.doc-main > .doc-content > .section-header') + .maxWidth('48rem') + .marginBottom('3rem') + root + .select('.doc-main .section-header') + .display('flex') + .flexDirection('column') + .gap('1rem') + root + .select( + '.doc-main .section-header > :where(.text-eyebrow, .text-title, .text-tagline)', + ) + .margin('0') + root + .select('.doc-main .section-header > .text-tagline') + .maxWidth('42rem') + .lineHeight('1.75') + root + .select('.doc-main > .doc-content > h2') + .marginTop('3rem') + .marginBottom('1rem') + root.select('.doc-main > .doc-content > .section-header + h2').marginTop('0') +} + +function registerChecklistStyles(root: StarterStyle, palette: ThemePalette) { + root + .select('.starter-checklist-progress') + .display('block') + .width('100%') + .height('0.55rem') + .accentColor(palette.semanticTone.accent.tone) + root + .select('.starter-checklist-input') + .width('1.15rem') + .height('1.15rem') + .accentColor(palette.semanticTone.accent.tone) + .flexShrink('0') + .marginTop('0.2rem') + root + .select('.starter-checklist-row') + .display('flex') + .alignItems('flex-start') + .gap('0.75rem') + .padding('1rem 0') + .borderBottom(`1px solid ${palette.current.border.subtle}`) + .cursor('pointer') + root + .select('.starter-checklist-row:has(input:checked) strong') + .textDecoration('line-through') + .color(palette.current.text.subtle) +} + +function registerResponsiveStyles(root: StarterStyle) { + root + .media(mediaBelow(BREAKPOINTS.md)) + .select('.starter-top-links') + .display('none') +} diff --git a/templates/starter/template/content/modules/theme/palette.ts b/templates/starter/template/content/modules/theme/palette.ts new file mode 100644 index 00000000..225b5ff8 --- /dev/null +++ b/templates/starter/template/content/modules/theme/palette.ts @@ -0,0 +1,106 @@ +import { + type SkinPresetList, + type ThemePalette, + themeSkins, +} from '@purestack/ts-style' + +/** Keep the wizard's palette choice, then give both modes the same design language. */ +export function createStarterPalettes(presets?: SkinPresetList) { + const selected = presets?.length ? presets : ['orange'] + const palettes = themeSkins.standard.create(selected) + refinePalette(palettes.light, { + canvas: '#fafaf9', + surface: '#ffffff', + alternate: '#f1f1ef', + text: '#252523', + subtle: '#62625d', + border: '#ddddd8', + shadow: '0 14px 45px rgba(25, 25, 20, 0.05)', + }) + refinePalette(palettes.dark, { + canvas: '#141414', + surface: '#1c1c1c', + alternate: '#242423', + text: '#f1f1ed', + subtle: '#b5b5af', + border: '#3a3a37', + shadow: '0 14px 45px rgba(0, 0, 0, 0.18)', + }) + if (selected.includes('orange')) { + refineOrange(palettes.light, '#ae3b12', '#fff0e7') + refineOrange(palettes.dark, '#ffab80', '#33231d') + } + return palettes +} + +interface SurfaceColors { + canvas: string + surface: string + alternate: string + text: string + subtle: string + border: string + shadow: string +} + +function refinePalette(palette: ThemePalette, colors: SurfaceColors) { + palette.font.family.base = "'Segoe UI', ui-sans-serif, system-ui, sans-serif" + palette.radii.sm = '0.45rem' + palette.radii.md = '0.85rem' + palette.radii.lg = '1.35rem' + palette.effect.panelShadow = colors.shadow + palette.effect.softShadow = colors.shadow + const neutral = palette.semanticTone.neutral + neutral.canvas = colors.canvas + neutral.canvascolor = colors.text + neutral.tone = colors.text + neutral.spotlight.field = colors.surface + neutral.spotlight.light = `color-mix(in srgb, ${palette.semanticTone.accent.tone} 12%, transparent)` + for (const tokens of [neutral.text, neutral.root.text]) { + tokens.default = colors.text + tokens.subtle = colors.subtle + } + for (const tokens of [neutral.border, neutral.root.border]) { + tokens.default = colors.border + tokens.subtle = colors.border + } + for (const { surface, background } of [ + { surface: neutral.surface, background: colors.surface }, + { surface: neutral.surfaceAlt, background: colors.alternate }, + ]) { + for (const state of [surface.rest, surface.hover, surface.active]) { + state.background = background + state.bgcolor = background + state.text = colors.text + state.border = colors.border + } + } +} + +function refineOrange(palette: ThemePalette, text: string, surface: string) { + const accent = palette.semanticTone.accent + accent.tone = text + accent.text.default = text + accent.text.subtle = text + accent.root.text.default = text + for (const state of [ + accent.surface.rest, + accent.surface.hover, + accent.surface.active, + ]) { + state.background = surface + state.bgcolor = surface + state.text = text + state.border = `color-mix(in srgb, ${text} 25%, transparent)` + } + for (const { state, background } of [ + { state: accent.button.rest, background: '#ff8a50' }, + { state: accent.button.hover, background: '#ff9c6b' }, + { state: accent.button.active, background: '#f5783c' }, + ]) { + state.background = background + state.bgcolor = background + state.border = background + state.text = '#24150e' + } +} diff --git a/templates/starter/template/content/purestack.config.ts b/templates/starter/template/content/purestack.config.ts index bcbcf59c..3b0493d9 100644 --- a/templates/starter/template/content/purestack.config.ts +++ b/templates/starter/template/content/purestack.config.ts @@ -1,3 +1,4 @@ import { defineConfig } from 'purestack' +import { starterPlugin } from './modules/starter-plugin' -export default defineConfig({ plugins: [] }) +export default defineConfig({ plugins: [starterPlugin] }) diff --git a/templates/starter/template/content/siteConfig.json b/templates/starter/template/content/siteConfig.json index a09af98a..4fbe07f0 100644 --- a/templates/starter/template/content/siteConfig.json +++ b/templates/starter/template/content/siteConfig.json @@ -1,14 +1,42 @@ { "siteTitle": "My Site", + "favicon": "lucide:sparkles", "logo": { "brand": "My Site", - "href": "/" + "href": "/", + "icon": "lucide:sparkles", + "markStyle": "soft" }, "style": { + "themes": [ + "light", + "dark" + ], "theme": { - "skin": "standard" + "skin": "starter", + "presets": [ + "orange" + ] } }, + "navigation": { + "mode": "auto", + "maxDepth": 3, + "sortBy": "order", + "pageLinks": false + }, + "pagefind": { + "enabled": true + }, + "auth": { + "enabled": false + }, + "consent": { + "enabled": false + }, + "sitemap": { + "enabled": false + }, "outDir": "../dist/dev", "publishDir": "../dist/publish" } diff --git a/templates/starter/template/package.json b/templates/starter/template/package.json index fb0c9288..b06d5e4a 100644 --- a/templates/starter/template/package.json +++ b/templates/starter/template/package.json @@ -15,7 +15,9 @@ "check:fix": "biome check --write ." }, "dependencies": { - "purestack": "{{purestackVersion}}" + "purestack": "{{purestackVersion}}", + "@purestack/ts-style": "{{purestackVersion}}", + "regor": "^1.7.6" }, "devDependencies": { "@types/node": "^26.6.5", diff --git a/templates/starter/template/tests/checklist.test.ts b/templates/starter/template/tests/checklist.test.ts new file mode 100644 index 00000000..370113ce --- /dev/null +++ b/templates/starter/template/tests/checklist.test.ts @@ -0,0 +1,44 @@ +import { runInDom } from '@purestack/ts-minidom' +import { createApp, html } from 'regor' +import { describe, expect, it } from 'vitest' +import { defineLaunchChecklistComponent } from '../content/guide/checklist/checklist-component' + +describe('launch checklist', () => { + it('updates progress, announces completion, and resets every item', () => + runInDom('<div id="checklist"></div>', (document) => { + const app = createApp( + { components: { launchChecklist: defineLaunchChecklistComponent() } }, + { selector: '#checklist', template: html`<LaunchChecklist/>` }, + ) + try { + const inputs = document.querySelectorAll<HTMLInputElement>( + 'input[type="checkbox"]', + ) + const status = () => + document.querySelector('[role="status"]')?.textContent + expect(inputs.length).toBe(6) + expect(status()).toContain('0 of 6 complete') + inputs[0].checked = true + inputs[0].dispatchEvent(new Event('change', { bubbles: true })) + expect(status()).toContain('1 of 6 complete') + expect( + Number( + document.querySelector<HTMLProgressElement>('progress')?.value, + ), + ).toBe(1) + for (const input of Array.from(inputs).slice(1)) { + input.checked = true + input.dispatchEvent(new Event('change', { bubbles: true })) + } + expect(status()).toContain('6 of 6 complete') + expect( + document.querySelector('[aria-live="polite"]')?.textContent, + ).toContain('All checks complete') + document.querySelector<HTMLButtonElement>('button')?.click() + expect(status()).toContain('0 of 6 complete') + expect(Array.from(inputs).every((input) => !input.checked)).toBe(true) + } finally { + app.unbind() + } + })) +}) diff --git a/templates/starter/template/tests/hello.test.ts b/templates/starter/template/tests/hello.test.ts deleted file mode 100644 index 818683bf..00000000 --- a/templates/starter/template/tests/hello.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { runInDom } from '@purestack/ts-minidom' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -beforeEach(() => vi.resetModules()) - -describe('home page greeting', () => { - it('announces the greeting when the visitor clicks the button', () => - runInDom( - '<button id="hello-button" type="button">Say hello</button><output id="hello-output" aria-live="polite"></output>', - async (document) => { - await import('../content/hello') - const button = - document.querySelector<HTMLButtonElement>('#hello-button') - const output = - document.querySelector<HTMLOutputElement>('#hello-output') - - expect(output?.textContent).toBe('') - button?.click() - expect(output?.textContent).toBe('Hello from your new PureStack site!') - }, - )) - - it('can load on a page without the greeting controls', () => - runInDom('<main>Another page</main>', async () => { - await expect(import('../content/hello')).resolves.toBeDefined() - })) -}) From 3fb53d3a5df7ff9b81b9cbf3471d3a4fd6f9c5ce Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu <koculu@gmail.com> Date: Thu, 22 Oct 2026 07:12:28 +0200 Subject: [PATCH 5/6] improve package publisher. --- scripts/publish-new-packages.test.ts | 155 +++++++++++++++++++++++++++ scripts/publish-new-packages.ts | 126 +++++++++++++++------- vite.config.ts | 1 + 3 files changed, 245 insertions(+), 37 deletions(-) create mode 100644 scripts/publish-new-packages.test.ts diff --git a/scripts/publish-new-packages.test.ts b/scripts/publish-new-packages.test.ts new file mode 100644 index 00000000..9d6b93fe --- /dev/null +++ b/scripts/publish-new-packages.test.ts @@ -0,0 +1,155 @@ +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + npm: vi.fn< + ( + command: string, + args: string[], + options: unknown, + ) => Promise<{ stdout: string; stderr: string }> + >(), + checkFailures: new Map<string, string>(), + publishFailures: new Map<string, string>(), + existing: new Set<string>(), + missingArchives: new Set<string>(), +})) + +vi.mock('node:util', () => ({ promisify: () => mocks.npm })) +vi.mock('node:fs', () => ({ + existsSync: (file: string) => + !mocks.missingArchives.has( + file.replaceAll('\\', '/').split('/').at(-2) ?? '', + ), + readFileSync: (file: string) => { + const name = file.replaceAll('\\', '/').split('/').at(-2) + return JSON.stringify({ + name: `@purestack/${name}`, + version: '1.0.0', + exports: { '.': './dist/index.js' }, + }) + }, +})) +vi.mock('./workspaces', () => ({ + workspaceManifestPaths: () => + ['first', 'second', 'third'].map((name) => `packages/${name}/package.json`), +})) + +import { publishNewPackages } from './publish-new-packages' + +function npmError(message: string) { + return Object.assign(new Error('npm failed'), { stdout: '', stderr: message }) +} + +function publishAttempts() { + return mocks.npm.mock.calls + .map(([, args]) => args[args.indexOf('publish') + 1]) + .filter((arg) => arg?.endsWith('package.tgz')) + .map((file) => path.basename(path.dirname(file))) +} + +beforeEach(() => { + mocks.npm.mockReset() + mocks.checkFailures.clear() + mocks.publishFailures.clear() + mocks.existing.clear() + mocks.missingArchives.clear() + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) + mocks.npm.mockImplementation(async (_command, args) => { + const view = args.indexOf('view') + if (view !== -1) { + const name = args[view + 1] + const failure = mocks.checkFailures.get(name) + if (failure) throw npmError(failure) + if (!mocks.existing.has(name)) throw npmError('npm error code E404') + } + const publish = args.indexOf('publish') + if (publish !== -1) { + const name = path.basename(path.dirname(args[publish + 1])) + const failure = mocks.publishFailures.get(name) + if (failure) throw npmError(failure) + } + return { stdout: '"1.0.0"', stderr: '' } + }) +}) + +afterEach(() => vi.restoreAllMocks()) + +describe('publishing missing packages', () => { + it('continues publishing after an error and reports the failed package last', async () => { + mocks.publishFailures.set('first', 'npm error E403: permission denied') + + expect(await publishNewPackages(['--publish'])).toBe(1) + + expect(publishAttempts()).toEqual(['first', 'second', 'third']) + expect(console.log).toHaveBeenLastCalledWith('Published 2 new package(s).') + expect(console.error).toHaveBeenCalledWith('\nFailed 1 package(s):') + expect(console.error).toHaveBeenLastCalledWith( + '\n- @purestack/first@1.0.0 (publish)\nnpm error E403: permission denied', + ) + }) + + it('collects multiple publish failures without stopping later packages', async () => { + mocks.publishFailures.set('first', 'Authentication failed') + mocks.publishFailures.set('second', 'Upload failed') + + expect(await publishNewPackages(['--publish'])).toBe(1) + + expect(publishAttempts()).toEqual(['first', 'second', 'third']) + expect(console.log).toHaveBeenLastCalledWith('Published 1 new package(s).') + expect(console.error).toHaveBeenCalledWith('\nFailed 2 package(s):') + expect(console.error).toHaveBeenCalledWith( + '\n- @purestack/first@1.0.0 (publish)\nAuthentication failed', + ) + expect(console.error).toHaveBeenLastCalledWith( + '\n- @purestack/second@1.0.0 (publish)\nUpload failed', + ) + }) + + it('also continues after registry-check errors and missing archives', async () => { + mocks.checkFailures.set('@purestack/first', 'Registry unavailable') + mocks.missingArchives.add('second') + + expect(await publishNewPackages(['--publish'])).toBe(1) + + expect(publishAttempts()).toEqual(['third']) + expect(console.log).toHaveBeenLastCalledWith('Published 1 new package(s).') + expect(console.error).toHaveBeenCalledWith('\nFailed 2 package(s):') + expect(console.error).toHaveBeenCalledWith( + '\n- @purestack/first@1.0.0 (registry check)\nRegistry unavailable', + ) + expect(console.error).toHaveBeenLastCalledWith( + expect.stringContaining('@purestack/second@1.0.0 (publish)'), + ) + }) + + it('reports dry-run check failures without attempting to publish', async () => { + mocks.checkFailures.set('@purestack/first', 'Registry unavailable') + + expect(await publishNewPackages([])).toBe(1) + + expect(publishAttempts()).toEqual([]) + expect(console.log).toHaveBeenLastCalledWith( + 'Dry run complete. 2 package version(s) would be published.', + ) + expect(console.error).toHaveBeenLastCalledWith( + '\n- @purestack/first@1.0.0 (registry check)\nRegistry unavailable', + ) + }) + + it('keeps already-published versions as successful skips', async () => { + mocks.existing.add('@purestack/second') + mocks.existing.add('@purestack/second@1.0.0') + mocks.publishFailures.set( + 'first', + 'cannot publish over the previously published versions', + ) + + expect(await publishNewPackages(['--publish'])).toBe(0) + + expect(publishAttempts()).toEqual(['first', 'third']) + expect(console.log).toHaveBeenLastCalledWith('Published 1 new package(s).') + expect(console.error).not.toHaveBeenCalled() + }) +}) diff --git a/scripts/publish-new-packages.ts b/scripts/publish-new-packages.ts index c53d6312..acf2ced9 100644 --- a/scripts/publish-new-packages.ts +++ b/scripts/publish-new-packages.ts @@ -1,6 +1,7 @@ import { execFile } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { workspaceManifestPaths } from './workspaces' @@ -48,10 +49,11 @@ interface PublishOptions { provenance: boolean } -const options = parseArgs(process.argv.slice(2)) -const publishablePackages = sortByWorkspaceDependencies( - discoverPublishablePackages(), -) +interface PackageFailure { + id: string + stage: 'registry check' | 'publish' + error: unknown +} async function npmJson(args: string[]) { return execFileAsync( @@ -75,7 +77,7 @@ async function npm(args: string[]) { ) } -async function packageExists(packageName: string) { +async function packageExists(packageName: string, registry: string) { try { await npmJson([ 'view', @@ -83,7 +85,7 @@ async function packageExists(packageName: string) { 'version', '--prefer-online', '--registry', - options.registry, + registry, ]) return true } catch (error) { @@ -92,7 +94,11 @@ async function packageExists(packageName: string) { } } -async function packageVersionExists(packageName: string, version: string) { +async function packageVersionExists( + packageName: string, + version: string, + registry: string, +) { try { await npmJson([ 'view', @@ -100,7 +106,7 @@ async function packageVersionExists(packageName: string, version: string) { 'version', '--prefer-online', '--registry', - options.registry, + registry, ]) return true } catch (error) { @@ -127,7 +133,9 @@ function isExecError(error: unknown): error is { typeof error === 'object' && error !== null && 'stdout' in error && - 'stderr' in error + typeof error.stdout === 'string' && + 'stderr' in error && + typeof error.stderr === 'string' ) } @@ -256,7 +264,7 @@ Options: `) } -async function publishPackage(pkg: PackageInfo) { +async function publishPackage(pkg: PackageInfo, options: PublishOptions) { if (!existsSync(pkg.packageTarballPath)) { throw new Error( `${pkg.packageJson.name} is missing ${path.relative(projectRoot, pkg.packageTarballPath)}. Run yarn package first.`, @@ -294,7 +302,26 @@ function isAlreadyPublished(error: unknown) { ) } -async function main() { +function reportFailures(failures: PackageFailure[]) { + if (!failures.length) return 0 + console.error(`\nFailed ${failures.length} package(s):`) + for (const { id, stage, error } of failures) { + const message = + isExecError(error) && error.stderr.trim() + ? error.stderr.trim() + : error instanceof Error + ? error.message + : String(error) + console.error(`\n- ${id} (${stage})\n${message}`) + } + return 1 +} + +export async function publishNewPackages(args = process.argv.slice(2)) { + const options = parseArgs(args) + const publishablePackages = sortByWorkspaceDependencies( + discoverPublishablePackages(), + ) if (publishablePackages.length === 0) { throw new Error('No publishable PureStack packages found.') } @@ -304,52 +331,77 @@ async function main() { ) const missingPackages: PackageInfo[] = [] + const failures: PackageFailure[] = [] for (const pkg of publishablePackages) { - if (!pkg.packageJson.version) { - throw new Error(`${pkg.packageJson.name} is missing version.`) - } - - const nameExists = await packageExists(pkg.packageJson.name) - const versionExists = - nameExists && - (await packageVersionExists( + const id = pkg.packageJson.version + ? `${pkg.packageJson.name}@${pkg.packageJson.version}` + : pkg.packageJson.name + try { + if (!pkg.packageJson.version) + throw new Error(`${pkg.packageJson.name} is missing version.`) + + const nameExists = await packageExists( pkg.packageJson.name, - pkg.packageJson.version, - )) - if (versionExists) { - console.log( - `skip existing ${pkg.packageJson.name}@${pkg.packageJson.version}`, + options.registry, ) - continue - } + const versionExists = + nameExists && + (await packageVersionExists( + pkg.packageJson.name, + pkg.packageJson.version, + options.registry, + )) + if (versionExists) { + console.log(`skip existing ${id}`) + continue + } - missingPackages.push(pkg) - console.log(`missing ${pkg.packageJson.name}@${pkg.packageJson.version}`) + missingPackages.push(pkg) + console.log(`missing ${id}`) + } catch (error) { + failures.push({ id, stage: 'registry check', error }) + console.error(`Registry check failed for ${id}; continuing.`) + } } if (!options.publish) { console.log( `Dry run complete. ${missingPackages.length} package version(s) would be published.`, ) - return + return reportFailures(failures) } let publishedCount = 0 for (const pkg of missingPackages) { const id = `${pkg.packageJson.name}@${pkg.packageJson.version}` console.log(`publishing ${id}`) - if (await publishPackage(pkg)) { - publishedCount += 1 - continue + try { + if (await publishPackage(pkg, options)) { + publishedCount += 1 + continue + } + console.log(`skip already published ${id}`) + } catch (error) { + failures.push({ id, stage: 'publish', error }) + console.error(`Publish failed for ${id}; continuing.`) } - console.log(`skip already published ${id}`) } console.log(`Published ${publishedCount} new package(s).`) + return reportFailures(failures) } -main().catch((error) => { - console.error('Publish-new-packages failed:', error) - process.exit(1) -}) +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + publishNewPackages() + .then((status) => { + process.exitCode = status + }) + .catch((error: unknown) => { + console.error('Publish-new-packages failed:', error) + process.exitCode = 1 + }) +} diff --git a/vite.config.ts b/vite.config.ts index b6a6b6d6..508e5fa9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ include: [ 'packages/*/src/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}', 'frontend/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}', + 'scripts/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}', ], exclude: [ 'packages/_ts-ssg-vscode/src/**/*.{test,spec}.{js,cjs,mjs,ts,cts,mts,jsx,tsx}', From e8e360b34bd891dd0298e5f798ffe4e308e00c41 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu <koculu@gmail.com> Date: Thu, 22 Oct 2026 07:38:11 +0200 Subject: [PATCH 6/6] Bump version --- docs/template-scaffolding.md | 12 +++--- package.json | 2 +- packages/purestack/package.json | 2 +- .../src/cli/terminal/creation-view.ts | 4 +- packages/purestack/src/index.ts | 2 +- packages/purestack/src/init/init.test.ts | 39 +++++++++++++++++-- packages/purestack/src/init/template.ts | 26 +++---------- packages/ts-common/package.json | 2 +- packages/ts-components/package.json | 2 +- packages/ts-css/package.json | 2 +- packages/ts-highlight/package.json | 2 +- packages/ts-html/package.json | 2 +- packages/ts-images/package.json | 2 +- packages/ts-minidom/package.json | 2 +- packages/ts-page-scripts/package.json | 2 +- packages/ts-pagefind/package.json | 2 +- packages/ts-render/package.json | 2 +- packages/ts-seo/package.json | 2 +- packages/ts-ssg-vscode/package.json | 2 +- packages/ts-ssg/package.json | 2 +- packages/ts-ssg/src/config/head.ts | 2 +- packages/ts-style/package.json | 2 +- packages/ts-svg-icons/package.json | 2 +- packages/ts-theme/package.json | 2 +- packages/ts-util-node/package.json | 2 +- packages/ts-util/package.json | 2 +- scripts/tsconfig.json | 11 +++--- scripts/verify-templates.ts | 2 +- templates/starter/README.md | 3 +- templates/starter/package.json | 2 +- templates/starter/template/AGENTS.md | 2 + templates/starter/template/README.md | 3 ++ .../starter/template/content/siteConfig.json | 4 +- templates/starter/template/package.json | 9 +++-- 34 files changed, 94 insertions(+), 67 deletions(-) diff --git a/docs/template-scaffolding.md b/docs/template-scaffolding.md index ded04b63..d284c1f2 100644 --- a/docs/template-scaffolding.md +++ b/docs/template-scaffolding.md @@ -18,7 +18,7 @@ template-defined categories, configuration bindings, and extension points. - Git initialization is opt-in with `--git`; never create an initial commit. - Accept explicit npm package versions and local `.tgz` files or package folders. - Require an absent or empty destination. Stage and validate before writing. -- Check compatibility against the running CLI and pin that exact PureStack version. +- Check template compatibility against the running CLI. Keep the template's dependency selectors; the starter uses `latest` for PureStack packages. - Preserve generated files if installation or Git initialization fails, with retry instructions and a failing exit status. @@ -44,12 +44,14 @@ project, including `package.json`, `README.md`, `AGENTS.md`, and Project dependencies must use registry versions, ranges, tags, or `npm:` aliases. Workspace references, local paths, Git sources, and tarball URLs are rejected. -The generated package name, `purestack` dependency, site title, and logo brand +The generated package name, site title, and logo brand are customized. Template-defined option bindings can configure appearance, features, publishing, and plugin settings. Unspecified values use the template's -declared defaults. The `purestack` dependency uses the literal -`{{purestackVersion}}` in the source template. Other `@purestack/*` dependencies -can use the same token to share the CLI's product version, including test helpers. +declared defaults. The starter declares `purestack` and its `@purestack/*` +dependencies as `latest`. The package manager resolves their current published +releases during installation and records the exact versions in the project's +lockfile. The CLI preserves template dependency selectors instead of pinning them +to its own version. Templates can also declare published versions, ranges, and tags. Keep `.gitignore` and `.yarnrc.yml` under their real names and explicitly include `template/.gitignore` and `template/.yarnrc.yml` in the package's `files` list. `yarn package` and `yarn publish-package` use npm to pack templates, preserving diff --git a/package.json b/package.json index 1721bce0..9baf0087 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "purestack-repo", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.18.1", "type": "module", "sideEffects": false, diff --git a/packages/purestack/package.json b/packages/purestack/package.json index ea177537..d009e2fe 100644 --- a/packages/purestack/package.json +++ b/packages/purestack/package.json @@ -1,6 +1,6 @@ { "name": "purestack", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/purestack.mjs", "types": "./dist/purestack.d.mts", diff --git a/packages/purestack/src/cli/terminal/creation-view.ts b/packages/purestack/src/cli/terminal/creation-view.ts index 07e9fae7..3759c9a0 100644 --- a/packages/purestack/src/cli/terminal/creation-view.ts +++ b/packages/purestack/src/cli/terminal/creation-view.ts @@ -60,7 +60,7 @@ export class CreationView { start(title: string, directory: string, version: string) { return `\n${this.frame( '◈ PURESTACK · CREATE SITE', - [title, '', `Directory ${directory}`, `PureStack ${version}`], + [title, '', `Directory ${directory}`, `PureStack CLI ${version}`], 'accent', )}\n\n` } @@ -101,7 +101,7 @@ export class CreationView { [ `Directory ${result.directory}`, `Template ${result.template}`, - `PureStack ${result.version}`, + `PureStack CLI ${result.version}`, ], tone, ) diff --git a/packages/purestack/src/index.ts b/packages/purestack/src/index.ts index 6e739fc6..beb8b910 100644 --- a/packages/purestack/src/index.ts +++ b/packages/purestack/src/index.ts @@ -1,3 +1,3 @@ -export const version: string = '1.1.19' +export const version: string = '1.1.20' export * from '@purestack/ts-ssg' diff --git a/packages/purestack/src/init/init.test.ts b/packages/purestack/src/init/init.test.ts index eb97b60e..38772305 100644 --- a/packages/purestack/src/init/init.test.ts +++ b/packages/purestack/src/init/init.test.ts @@ -203,12 +203,12 @@ describe('project generation', () => { expect(project).toMatchObject({ name: 'my-docs', private: true, - dependencies: { purestack: cliVersion }, + dependencies: { purestack: 'latest' }, }) expect(prepared.packageManager).toBe('yarn') expect(await readFile(path.join(destination, 'yarn.lock'), 'utf8')).toBe('') expect(project.packageManager).toBe('yarn@4.18.1') - expect(project.devDependencies['@purestack/ts-minidom']).toBe(cliVersion) + expect(project.devDependencies['@purestack/ts-minidom']).toBe('latest') const yarnConfig = await readFile( path.join(destination, '.yarnrc.yml'), 'utf8', @@ -216,8 +216,8 @@ describe('project generation', () => { expect(yarnConfig).toContain('nodeLinker: node-modules') expect(yarnConfig).toContain('npmMinimalAgeGate: 0') expect(project.dependencies).toMatchObject({ - '@purestack/ts-style': cliVersion, - regor: '^1.7.6', + '@purestack/ts-style': 'latest', + regor: 'latest', }) expect( JSON.parse( @@ -241,6 +241,36 @@ describe('project generation', () => { ).toContain('yarn build') }) + it('preserves template dependency selectors instead of replacing them with the CLI version', async () => { + const unpacked = await fixture() + const packageFile = path.join(unpacked, 'template/package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + project.dependencies.purestack = '1.2.3' + project.dependencies['@purestack/ts-style'] = '^1.2.0' + await writeFile(packageFile, JSON.stringify(project)) + + await prepareProject(unpacked, path.join(temporary, 'project'), cliVersion) + + expect( + JSON.parse(await readFile(packageFile, 'utf8')).dependencies, + ).toEqual(project.dependencies) + }) + + it.each([undefined, 42])( + 'rejects a missing or invalid PureStack dependency (%s)', + async (dependency) => { + const unpacked = await fixture() + const packageFile = path.join(unpacked, 'template/package.json') + const project = JSON.parse(await readFile(packageFile, 'utf8')) + project.dependencies.purestack = dependency + await writeFile(packageFile, JSON.stringify(project)) + + await expect( + prepareProject(unpacked, path.join(temporary, 'project'), cliVersion), + ).rejects.toThrow(/must declare dependencies.purestack/) + }, + ) + it('rejects incompatible templates without creating a destination', async () => { const unpacked = await fixture() const destination = path.join(temporary, 'project') @@ -390,6 +420,7 @@ describe('project generation', () => { ' package.tgz ', '', '{{unknown}}', + '{{purestackVersion}}', ])('rejects nonportable dependency %s', async (version) => { const unpacked = await fixture() const packageFile = path.join(unpacked, 'template/package.json') diff --git a/packages/purestack/src/init/template.ts b/packages/purestack/src/init/template.ts index ba94096d..3c2ee954 100644 --- a/packages/purestack/src/init/template.ts +++ b/packages/purestack/src/init/template.ts @@ -259,10 +259,10 @@ function isRegistryDependency(version: string) { export async function prepareProject( unpacked: string, directory: string, - purestackVersion: string, + cliVersion: string, title = path.basename(directory), ) { - const { pkg } = await inspectTemplate(unpacked, purestackVersion) + const { pkg } = await inspectTemplate(unpacked, cliVersion) const source = path.join(unpacked, 'template') const files = await listProjectFiles(source) for (const file of [ @@ -289,12 +289,9 @@ export async function prepareProject( if (match[1] === 'yarn') packageManager = 'yarn' } const dependencies = project.dependencies - if ( - !isRecord(dependencies) || - dependencies.purestack !== '{{purestackVersion}}' - ) + if (!isRecord(dependencies) || typeof dependencies.purestack !== 'string') throw new Error( - 'Template must declare dependencies.purestack as {{purestackVersion}}.', + 'Template must declare dependencies.purestack as a published dependency version, range, or tag.', ) for (const field of [ 'dependencies', @@ -305,21 +302,11 @@ export async function prepareProject( const entries = project[field] if (entries === undefined) continue if (!isRecord(entries)) throw new Error(`Invalid project ${field}.`) - for (const [dependency, version] of Object.entries(entries)) { - if ( - typeof version !== 'string' || - (!isRegistryDependency(version) && - !( - (dependency === 'purestack' || - dependency.startsWith('@purestack/')) && - version === '{{purestackVersion}}' - )) - ) + for (const version of Object.values(entries)) { + if (typeof version !== 'string' || !isRegistryDependency(version)) throw new Error( `Project ${field} must use published dependency versions.`, ) - if (version === '{{purestackVersion}}') - entries[dependency] = purestackVersion } } const scripts = project.scripts @@ -345,7 +332,6 @@ export async function prepareProject( } project.name = projectName(directory) project.private = true - dependencies.purestack = purestackVersion await writeJson(path.join(source, 'package.json'), project) const configFile = path.join(source, 'content', 'siteConfig.json') const config = await readJson(configFile) diff --git a/packages/ts-common/package.json b/packages/ts-common/package.json index 6ac05a57..c88b98e7 100644 --- a/packages/ts-common/package.json +++ b/packages/ts-common/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-common", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-common.mjs", "types": "./dist/ts-common.d.mts", diff --git a/packages/ts-components/package.json b/packages/ts-components/package.json index 95ab2f27..b76611b4 100644 --- a/packages/ts-components/package.json +++ b/packages/ts-components/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-components", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-components.mjs", "types": "./dist/ts-components.d.mts", diff --git a/packages/ts-css/package.json b/packages/ts-css/package.json index 1c9e1000..cc3d8da6 100644 --- a/packages/ts-css/package.json +++ b/packages/ts-css/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-css", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-css.mjs", "types": "./dist/ts-css.d.mts", diff --git a/packages/ts-highlight/package.json b/packages/ts-highlight/package.json index 9482aa4b..09363775 100644 --- a/packages/ts-highlight/package.json +++ b/packages/ts-highlight/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-highlight", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-highlight.mjs", "types": "./dist/ts-highlight.d.mts", diff --git a/packages/ts-html/package.json b/packages/ts-html/package.json index 0e5934b6..09d5074f 100644 --- a/packages/ts-html/package.json +++ b/packages/ts-html/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-html", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-html.mjs", "types": "./dist/ts-html.d.mts", diff --git a/packages/ts-images/package.json b/packages/ts-images/package.json index 77ab6ac5..ea5c29d9 100644 --- a/packages/ts-images/package.json +++ b/packages/ts-images/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-images", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-images.mjs", "types": "./dist/ts-images.d.mts", diff --git a/packages/ts-minidom/package.json b/packages/ts-minidom/package.json index 936ff89e..afaa6793 100644 --- a/packages/ts-minidom/package.json +++ b/packages/ts-minidom/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-minidom", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-minidom.mjs", "types": "./dist/ts-minidom.d.mts", diff --git a/packages/ts-page-scripts/package.json b/packages/ts-page-scripts/package.json index 933f98de..11bb8756 100644 --- a/packages/ts-page-scripts/package.json +++ b/packages/ts-page-scripts/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-page-scripts", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-page-scripts.mjs", "types": "./dist/ts-page-scripts.d.mts", diff --git a/packages/ts-pagefind/package.json b/packages/ts-pagefind/package.json index 01f14e29..0be3a6f4 100644 --- a/packages/ts-pagefind/package.json +++ b/packages/ts-pagefind/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-pagefind", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-pagefind.mjs", "types": "./dist/ts-pagefind.d.mts", diff --git a/packages/ts-render/package.json b/packages/ts-render/package.json index 639d3a5d..beb3f03b 100644 --- a/packages/ts-render/package.json +++ b/packages/ts-render/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-render", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-render.mjs", "types": "./dist/ts-render.d.mts", diff --git a/packages/ts-seo/package.json b/packages/ts-seo/package.json index 4d37b1af..26ec3e21 100644 --- a/packages/ts-seo/package.json +++ b/packages/ts-seo/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-seo", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-seo.mjs", "types": "./dist/ts-seo.d.mts", diff --git a/packages/ts-ssg-vscode/package.json b/packages/ts-ssg-vscode/package.json index 2cf0c178..55d5f536 100644 --- a/packages/ts-ssg-vscode/package.json +++ b/packages/ts-ssg-vscode/package.json @@ -2,7 +2,7 @@ "name": "purestack-component-navigation", "displayName": "PureStack Component Tools", "description": "Component navigation, prop IntelliSense, lit-html syntax highlighting, and template formatting for Markdown, Regor MDX, and TypeScript templates.", - "version": "1.1.19", + "version": "1.1.20", "private": true, "publisher": "purestack", "license": "MIT", diff --git a/packages/ts-ssg/package.json b/packages/ts-ssg/package.json index 5ac61142..7e575b6a 100644 --- a/packages/ts-ssg/package.json +++ b/packages/ts-ssg/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-ssg", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-ssg.mjs", "types": "./dist/ts-ssg.d.mts", diff --git a/packages/ts-ssg/src/config/head.ts b/packages/ts-ssg/src/config/head.ts index fed8dee9..5a47f0e6 100644 --- a/packages/ts-ssg/src/config/head.ts +++ b/packages/ts-ssg/src/config/head.ts @@ -24,7 +24,7 @@ export function getHead(config?: BasicHeadConfig) { return createHead(getHeadConfig(merge(DEFAULTS, config))).push( h('meta').attr({ name: 'generator', - content: 'PureStack v1.1.19', + content: 'PureStack v1.1.20', }), ) } diff --git a/packages/ts-style/package.json b/packages/ts-style/package.json index 6ae3e8ab..71b36a97 100644 --- a/packages/ts-style/package.json +++ b/packages/ts-style/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-style", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-style.mjs", "types": "./dist/ts-style.d.mts", diff --git a/packages/ts-svg-icons/package.json b/packages/ts-svg-icons/package.json index 9d2949e2..25595acb 100644 --- a/packages/ts-svg-icons/package.json +++ b/packages/ts-svg-icons/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-svg-icons", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-svg-icons.mjs", "types": "./dist/ts-svg-icons.d.mts", diff --git a/packages/ts-theme/package.json b/packages/ts-theme/package.json index 039db712..ab127bae 100644 --- a/packages/ts-theme/package.json +++ b/packages/ts-theme/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-theme", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-theme.mjs", "types": "./dist/ts-theme.d.mts", diff --git a/packages/ts-util-node/package.json b/packages/ts-util-node/package.json index 61ec0096..099a0724 100644 --- a/packages/ts-util-node/package.json +++ b/packages/ts-util-node/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util-node", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util-node.mjs", "types": "./dist/ts-util-node.d.mts", diff --git a/packages/ts-util/package.json b/packages/ts-util/package.json index 742b27b8..431800f2 100644 --- a/packages/ts-util/package.json +++ b/packages/ts-util/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/ts-util", - "version": "1.1.19", + "version": "1.1.20", "packageManager": "yarn@4.9.2", "module": "./dist/ts-util.mjs", "types": "./dist/ts-util.d.mts", diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index db260f0f..d2e4c263 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -1,9 +1,8 @@ { "extends": "../tsconfig.json", - "include": [ - "./*.ts", - "./fixtures/*.ts", - "../packages/purestack/src/init/*.ts" - ], - "exclude": ["../packages/purestack/src/init/*.test.ts"] + "compilerOptions": { + "composite": false, + "incremental": false + }, + "include": ["./**/*.ts"] } diff --git a/scripts/verify-templates.ts b/scripts/verify-templates.ts index 7945c89e..20a46bc1 100644 --- a/scripts/verify-templates.ts +++ b/scripts/verify-templates.ts @@ -81,7 +81,7 @@ async function main() { ) const packageFile = path.join(directory, 'package.json') const project = JSON.parse(await readFile(packageFile, 'utf8')) - assert.equal(project.dependencies.purestack, framework.manifest.version) + assert.equal(project.dependencies.purestack, 'latest') assert( !Object.keys(project.dependencies).some((name) => name.includes('template-'), diff --git a/templates/starter/README.md b/templates/starter/README.md index 9f3c53b7..f6962a0d 100644 --- a/templates/starter/README.md +++ b/templates/starter/README.md @@ -11,7 +11,8 @@ the wizard's color choices. Starter copy explains what to replace; it makes no fictional business claims. Generated projects use pinned Yarn 4, Vite-powered Vitest app tests, and Biome -for linting, formatting, and import organization. See the generated +for linting, formatting, and import organization. PureStack dependencies use +`latest`, with exact installed versions recorded in the project's lockfile. See the generated [README](template/README.md) for commands and requirements. To test unpublished changes, pass this folder or its packed `.tgz` to diff --git a/templates/starter/package.json b/templates/starter/package.json index e7e1d72b..18a46dc6 100644 --- a/templates/starter/package.json +++ b/templates/starter/package.json @@ -1,6 +1,6 @@ { "name": "@purestack/template-starter", - "version": "1.1.19", + "version": "1.1.20", "description": "A polished general-purpose PureStack site with clear customization guidance.", "purestackTemplate": true, "license": "MIT", diff --git a/templates/starter/template/AGENTS.md b/templates/starter/template/AGENTS.md index d4cb7bd0..88bcbc9f 100644 --- a/templates/starter/template/AGENTS.md +++ b/templates/starter/template/AGENTS.md @@ -32,6 +32,8 @@ - Other files in `content/`, including images, are copied as public assets. - `dist/` and `node_modules/` are generated. Change source instead of output. - Commit `yarn.lock` after installing. Keep Yarn's `node-modules` linker enabled. +- PureStack dependencies use `latest`; installation resolves current releases and + the lockfile records their exact versions. Preserve the lockfile for repeatable installs. ## Customization rules diff --git a/templates/starter/template/README.md b/templates/starter/template/README.md index 21e31fe8..8c0ebb22 100644 --- a/templates/starter/template/README.md +++ b/templates/starter/template/README.md @@ -65,5 +65,8 @@ inside another Yarn workspace. `yarn install` fills it with dependency resolutio commit the resulting lockfile. The project uses Yarn's `node-modules` linker. Biome checks supported code and JSON; edit MDX content separately. +PureStack dependencies use `latest` to fetch current releases during installation. +The lockfile records the exact versions used by your project. + Read `AGENTS.md` before making automated changes. Framework documentation: <https://purestack.studio/guides/>. diff --git a/templates/starter/template/content/siteConfig.json b/templates/starter/template/content/siteConfig.json index 4fbe07f0..879b21fe 100644 --- a/templates/starter/template/content/siteConfig.json +++ b/templates/starter/template/content/siteConfig.json @@ -5,7 +5,9 @@ "brand": "My Site", "href": "/", "icon": "lucide:sparkles", - "markStyle": "soft" + "markStyle": "plain", + "ariaLabel": "My Site home", + "size": "md" }, "style": { "themes": [ diff --git a/templates/starter/template/package.json b/templates/starter/template/package.json index b06d5e4a..c1ce4010 100644 --- a/templates/starter/template/package.json +++ b/templates/starter/template/package.json @@ -15,14 +15,15 @@ "check:fix": "biome check --write ." }, "dependencies": { - "purestack": "{{purestackVersion}}", - "@purestack/ts-style": "{{purestackVersion}}", - "regor": "^1.7.6" + "purestack": "latest", + "@purestack/ts-components": "latest", + "@purestack/ts-style": "latest", + "regor": "latest" }, "devDependencies": { "@types/node": "^26.6.5", "@biomejs/biome": "^2.6.2", - "@purestack/ts-minidom": "{{purestackVersion}}", + "@purestack/ts-minidom": "latest", "vite": "^8.3.5", "vitest": "^5.0.5" }