From 99f5862d55c38318ba265c9e55d801a887f1ad5e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 03:35:24 +0200 Subject: [PATCH 01/56] Create new-publish-command-spec.md --- new-publish-command-spec.md | 386 ++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 new-publish-command-spec.md diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md new file mode 100644 index 00000000000..306e43b00b2 --- /dev/null +++ b/new-publish-command-spec.md @@ -0,0 +1,386 @@ +# HydePHP v3 — `publish` Command Specification + +Status: implementation-ready +Scope: reimplement publishing from scratch for v3 +Owner: Emma / HydePHP core + +--- + +## 1. Goals + +Collapse the current four-command publishing surface into a small, Laravel-shaped set: + +- `php hyde publish` — a **flag-driven, views-centric** command for Hyde Blade + customizations, with an optional `--page` side path for starter pages. +- `php hyde vendor:publish` — unchanged Laravel path for providers/tags/packages. + **Config files live here** (see §6). + +Design constraints (HydePHP philosophy doc): +- Simplicity first — one entry point for the common case (views). +- Feel Laravel-y — optional flags, no positional sub-arguments. +- Curated over generic — `publish` never exposes tag/provider/path publishing. +- Power still available — advanced users drop to `vendor:publish`. +- Safe by default — never destroy user-modified files silently. + +--- + +## 2. Command surface + +``` +php hyde publish [--layouts] [--components] [--all] [--page[=NAME]] [--to=PATH] [--force] +``` + +All flags are **optional**. With no flags, `publish` runs an interactive wizard. +Each flag simply **skips a wizard step**. + +| Flag | Meaning | +|----------------|--------------------------------------------------------------| +| `--layouts` | Scope to layout views (skip the "what?" step) | +| `--components` | Scope to component views | +| `--all` | Publish **all views**, skip the picker | +| `--page` | Skip to the starter-page picker | +| `--page=NAME` | Publish a specific starter page directly | +| `--to=PATH` | Destination for a published page (**pages only**) | +| `--force` | Overwrite user-modified files | + +**Hard guardrails** + +- `publish` MUST reject `--tag`, `--provider`, and arbitrary source paths: + - `php hyde publish --tag=foo` → `Use php hyde vendor:publish --tag=foo for tag/provider publishing.` +- `--layouts` and `--components` are mutually exclusive → fail with guidance. +- `--to` is valid only with `--page` → else `--to is only valid when publishing a page.` +- `--all` means **all views**; it does not apply to pages. +- Config is not a `publish` concept → `php hyde publish --config` fails and points + to `vendor:publish --tag=hyde-config`. + +--- + +## 3. Interactive flow (no flags) + +Step 1 — what to publish: + +``` +What do you want to publish? + + › Views Customize Hyde Blade layouts/components + › A starter page Copy a homepage, 404, or other default page + › Cancel +``` + +- Choosing **Views** → the views multi-select picker (§4). +- Choosing **A starter page** → the page flow (§5). + +Flags skip Step 1 (and sometimes Step 2): + +- `--layouts` / `--components` → jump to the views picker, prefiltered. +- `--all` → publish all views, no picker. +- `--page` → jump to the page picker. +- `--page=NAME` → publish that page, no picker. + +Non-interactive with no flags → fail helpfully: + +``` +Nothing to publish. Try: + php hyde publish --all + php hyde publish --layouts + php hyde publish --page=welcome +``` + +--- + +## 4. Views + +Publishes Hyde Blade overrides to `resources/views/vendor/hyde/`. +Two declared groups (fixed 1:1 file lists — **no per-item class**): + +- `layouts` → `resources/views/vendor/hyde/layouts/*` +- `components` → `resources/views/vendor/hyde/components/*` + +### Behavior + +``` +php hyde publish # wizard → Views → multi-select picker +php hyde publish --layouts # picker prefiltered to layouts +php hyde publish --components # picker prefiltered to components +php hyde publish --layouts --all # all layouts, no picker +php hyde publish --all # all views, no picker +php hyde publish --force # overwrite modified +``` + +### Multi-select picker (one grouped picker — no required narrowing step) + +``` +Select Hyde views to publish + + [ ] All views + Layouts + [ ] layouts/app.blade.php + [ ] layouts/page.blade.php + [ ] layouts/post.blade.php + + Components + [ ] components/markdown-heading.blade.php + [ ] components/docs/sidebar.blade.php +``` + +`--layouts` / `--components` only prefilter the list. The user may select one, +many, or all files. + +### Output (cardinality-aware) + +``` +Published 1 view to resources/views/vendor/hyde/components/markdown-heading.blade.php +Published 3 views to resources/views/vendor/hyde/layouts +Published all 42 views to resources/views/vendor/hyde +``` + +--- + +## 5. Pages (the `--page` side path) + +Publishes starter/default page templates into `_pages/`. +Pages stay in `publish` (not `vendor:publish`) because a template can have +**multiple valid destinations** and carries **display metadata** — neither of +which `vendor:publish` can express. + +### 5.1 `PublishablePage` value object + registry + +```php +final class PublishablePage +{ + public function __construct( + public string $key, // 'posts' + public string $label, // 'Posts feed' + public string $description, // short help text + public string $source, // stub path within the framework + public string $defaultTarget, // '_pages/posts.blade.php' + /** @var array path => human label */ + public array $alternativeTargets = [], + public bool $allowCustomTarget = true, + ) {} +} + +final class PublishablePages +{ + /** @return array */ + public static function all(): array; + public static function get(string $key): ?PublishablePage; + public static function register(PublishablePage $page): void; // extension point +} +``` + +Only pages get a class. Views are fixed 1:1 file lists (a declared map suffices); +pages need branching destinations + metadata, and the registry lets Hyde Cloud / +plugins register their own publishable pages. + +### 5.2 Initial catalog (illustrative — final set TBD by v3 page work) + +| key | default target | alternatives | custom? | +|-----------|--------------------------|---------------------------------------|---------| +| `welcome` | `_pages/index.blade.php` | — | yes | +| `posts` | `_pages/posts.blade.php` | `_pages/index.blade.php` (as homepage)| yes | +| `blank` | `_pages/index.blade.php` | — | yes | +| `404` | `_pages/404.blade.php` | — | **no** | + +No dedicated "homepage" concept: setting a homepage = publishing a +homepage-capable page to `_pages/index.blade.php`. + +### 5.3 Behavior + +``` +php hyde publish --page # page picker +php hyde publish --page=welcome # publish welcome, resolve destination +php hyde publish --page=posts --to=_pages/index.blade.php +php hyde publish --page=welcome --force +``` + +### 5.4 Destination resolution (per selected page) + +1. `--to=PATH` → use it. Must resolve under `_pages/` and end in `.blade.php`, else fail. +2. Non-interactive, no `--to` → use `defaultTarget`. +3. Interactive AND (`alternativeTargets` non-empty OR `allowCustomTarget`) → prompt: + + ``` + Where should "Posts feed" be published? + + › _pages/posts.blade.php (default — served at /posts) + › _pages/index.blade.php (use as your site homepage) + › Custom path… + ``` +4. Otherwise → `defaultTarget`. + +### 5.5 Interactive page flow (select → resolve → confirm) + +``` +Select pages to publish + + [ ] Welcome page → _pages/index.blade.php + [ ] Posts feed → _pages/posts.blade.php + [ ] 404 page → _pages/404.blade.php +``` + +Resolve ambiguous destinations (§5.4), then confirm: + +``` +Ready to publish: + Welcome page → _pages/index.blade.php + 404 page → _pages/404.blade.php + +Proceed? [yes] +``` + +### 5.6 Destination conflict detection + +If two selected pages resolve to the same target: + +``` +Welcome page and Blank page both target _pages/index.blade.php. +Pick one, or set --to for each. +``` + +### 5.7 Optional rebuild (interactive only) + +After a successful page publish in interactive mode: + +``` +Rebuild the site now? [no] +``` + +Non-interactive mode NEVER rebuilds automatically. + +--- + +## 6. Config — moved to `vendor:publish` + +Config publishing is rare and has fixed destinations, so it becomes a plain tag: + +``` +php hyde vendor:publish --tag=hyde-config +``` + +`hyde-config` publishes the Hyde-owned config files: +`hyde.php`, `docs.php`, `markdown.php`, `view.php`, `cache.php`, `commands.php`. + +- Torchlight is **not** included — it is obtained via Torchlight's own package tag. +- Granular tags (e.g. per-file) may still be registered for power users, but the + single `hyde-config` tag is the documented path. +- `publish` has no config concept. `php hyde publish --config` fails and points here. + +> (Exact tag name `hyde-config` is bikesheddable; keep it singular and Hyde-scoped.) + +--- + +## 7. Overwrite policy (unified across views + pages) + +Identical rule everywhere. **No historical-checksum manifest.** + +| Destination state | Action | +|------------------------------------|------------------------------------------| +| Missing | copy | +| Byte-identical to current source | skip (`already current`) | +| Exists and differs (user-modified) | require interactive confirm OR `--force` | + +Interactive conflict prompt: + +``` +2 selected files already exist and appear modified. + + › Skip modified files + › Overwrite modified files + › Cancel +``` + +Non-interactive conflict: + +``` +Cannot overwrite modified files without --force: + resources/views/vendor/hyde/layouts/app.blade.php + +Run again with --force to overwrite. +``` + +--- + +## 8. Deprecated aliases (kept for v3, removed from primary docs) + +| Old command | Maps to | +|-------------------------------|-------------------------------------------| +| `publish:views [group]` | `publish --layouts` / `--components` | +| `publish:configs` | `vendor:publish --tag=hyde-config` | +| `publish:homepage [template]` | `publish --page=[template]` | + +Each prints a one-line deprecation notice, e.g.: + +``` +publish:configs is deprecated. Use php hyde vendor:publish --tag=hyde-config instead. +``` + +Aliases keep working through v3; target removal in v4. + +--- + +## 9. Errors & guardrails (summary) + +- `publish --tag=…` / `--provider=…` / raw path → redirect to `vendor:publish`. +- `publish --config` → redirect to `vendor:publish --tag=hyde-config`. +- `--layouts` + `--components` together → mutually exclusive error. +- `--to` without `--page` → `--to is only valid when publishing a page.` +- `--to` path outside `_pages/` or wrong extension → fail with a valid example. +- Non-interactive with no actionable flags → fail with usage examples. + +--- + +## 10. Architecture summary — what to build + +1. `PublishCommand` — single flag-driven command; routes to views or page flow. +2. `ViewsPublisher` — reads declared `layouts`/`components` groups. +3. `PagesPublisher` — reads `PublishablePages` registry, resolves destinations, + detects conflicts. +4. `PublishablePage` value object + `PublishablePages` registry (extension point). +5. Shared `OverwritePolicy` service (missing / identical / modified). +6. Shared interactive multi-select + confirmation UI component. +7. Deprecated alias commands delegating to `PublishCommand` / `vendor:publish`. +8. Register the `hyde-config` publish tag on the relevant service provider. + +Views stay declarative file-group lists (no per-item class); only pages use the +value-object + registry model. + +--- + +## 11. Acceptance criteria + +1. `php hyde publish` is flag-driven with no positional sub-arguments. +2. With no flags, `publish` runs the wizard (Views / A starter page). +3. `--layouts`, `--components`, `--all`, `--page`, `--page=NAME`, `--to`, `--force` + each behave per §2 and skip the appropriate wizard step. +4. `--layouts` + `--components` together is rejected. +5. `publish` never exposes provider/tag/path publishing; `--tag`/`--provider` + fail and point to `vendor:publish`. +6. `publish` has no config path; `--config` redirects to `vendor:publish --tag=hyde-config`. +7. `vendor:publish --tag=hyde-config` publishes the six Hyde-owned configs and + NOT `torchlight.php`. +8. Views use one grouped multi-select picker; one/many/all all work; output is + cardinality-aware. +9. Pages are backed by the `PublishablePages` registry. +10. A page with multiple valid targets resolves via `--to`, default, or an + interactive destination prompt (custom paths allowed where declared). +11. `--to` is valid only with `--page`; `--all` applies only to views. +12. Two selected pages resolving to the same destination are detected before any write. +13. Overwrite policy is identical for views and pages: missing→copy, + identical→skip, modified→confirm-or-`--force`. No checksum manifest. +14. Modified files are never overwritten without interactive confirm or `--force`. +15. Non-interactive mode never prompts and fails helpfully on ambiguity. +16. `publish:views`, `publish:configs`, `publish:homepage` still work, print a + deprecation notice, and are absent from primary docs. + +--- + +## 12. Docs cleanup + +- Remove the reference to `php hyde publish:components` in + `docs/digging-deeper/advanced-markdown.md` (command does not exist). + Replace with `php hyde publish --components`. +- Rewrite the publishing docs around `php hyde publish` (views + `--page`) as the + primary command, and `php hyde vendor:publish --tag=hyde-config` for config. + Document `vendor:publish` as the advanced Laravel path; list deprecated aliases + in a migration note only. From 83d38f55af55da495d2caf347e0f165a274088de Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 03:35:26 +0200 Subject: [PATCH 02/56] Create new-publish-command-implementation-plan.md --- new-publish-command-implementation-plan.md | 186 +++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 new-publish-command-implementation-plan.md diff --git a/new-publish-command-implementation-plan.md b/new-publish-command-implementation-plan.md new file mode 100644 index 00000000000..dc442c1d3ff --- /dev/null +++ b/new-publish-command-implementation-plan.md @@ -0,0 +1,186 @@ +# HydePHP v3 — `publish` Implementation Plan + +Companion to `hyde-v3-publish-spec.md`. Drive this **one step at a time**. +Hand the agent a single step + the spec — never the whole plan. + +--- + +## Rules of engagement (paste as standing instructions to the agent) + +1. **One step only.** You are working on the single step I give you. Do not start, + scaffold, or "prepare for" any other step. +2. **Plan before code.** First restate the step's scope in your own words and list + the exact files you will create or edit. Wait for my approval before writing code. +3. **Stay in the box.** Touch only the files listed for this step. No unrelated + refactors, renames, reformatting, or "while I'm here" changes. If you believe + another file must change, STOP and ask — don't do it. +4. **Implement to the spec, not to invention.** Follow the referenced spec section + exactly. Do not add flags, commands, names, options, or behaviors that aren't in + the spec. If the spec is ambiguous or seems wrong, STOP and ask — don't guess. +5. **Green means done.** The step is complete only when: (a) tests pinning this + step's acceptance checks pass, and (b) the existing suite still passes. Use the + project's existing test framework and conventions. +6. **One commit per step**, message referencing the step number. I review the diff + before we move on. +7. If I say "out of scope for this step," drop it without argument. + +### Per-step prompt template + +> Implement **Step N** from the implementation plan (pasted below), following the +> spec section it references (pasted below). Obey the rules of engagement. +> Start by restating the scope and listing the files you'll touch, then wait for me. +> +> [paste the step block] +> [paste the referenced spec section] + +--- + +## Step 1 — `OverwritePolicy` service + +- **Goal:** the shared, pure decision logic for whether to copy/skip/protect a file. +- **In scope:** a service that, given a source path and destination path, returns + one of `copy` (missing), `skip` (byte-identical), `blocked` (exists & differs). + No console UI. No knowledge of views/pages. +- **Out of scope:** any picker, any command wiring, any checksum manifest / historical + version detection (explicitly excluded by the spec). +- **Files:** the new policy service class + its unit test. Nothing else. +- **Depends on:** nothing. +- **Done when:** unit tests cover all three states (missing / identical / modified), + and the suite is green. +- **Spec ref:** §7. + +## Step 2 — `PublishablePage` value object + `PublishablePages` registry + +- **Goal:** the data model for starter pages. +- **In scope:** the immutable `PublishablePage` value object (§5.1 shape), the + `PublishablePages` registry (`all()`, `get()`, `register()`), and the initial + catalog from §5.2 (welcome / posts / blank / 404) registered as defaults. +- **Out of scope:** destination resolution logic, any command or picker, any writing + of files. This step is data + registry only. +- **Files:** the value object, the registry, the catalog registration, and their tests. +- **Depends on:** nothing. +- **Done when:** tests assert the catalog contents (keys, default targets, + alternatives, `allowCustomTarget`) and that `register()` adds a page. Suite green. +- **Spec ref:** §5.1, §5.2. + +## Step 3 — `PublishCommand` spine (flags, guardrails, wizard routing to stubs) + +- **Goal:** the command shell with the full flag surface, all error/guardrail + behavior, and the interactive wizard step 1 — routing to **stub** handlers. +- **In scope:** register `php hyde publish`; define `--layouts --components --all + --page[=NAME] --to=PATH --force`; implement §2 guardrails (reject `--tag`/`--provider` + with a redirect message; `--layouts` + `--components` mutually exclusive; `--to` + only with `--page`; `--config` → redirect to `vendor:publish --tag=hyde-config`; + non-interactive with no actionable flags → usage error); implement the §3 wizard + step-1 menu routing to two stub methods (`publishViews()`, `publishPage()`) that + currently just print "not yet implemented". +- **Out of scope:** real views logic, real pages logic, real config tag. Handlers are + stubs. Do not implement OverwritePolicy calls yet. +- **Files:** the `PublishCommand` class, its service-provider registration, and the + command's guardrail/routing tests. +- **Depends on:** nothing (stubs stand in for Steps 4–5). +- **Done when:** tests cover every guardrail/redirect in §9 and the wizard routing, + against the stubbed handlers. Suite green. +- **Spec ref:** §2, §3, §9. + +## Step 4 — Views flow + +- **Goal:** replace the `publishViews()` stub with the real views publisher. +- **In scope:** `ViewsPublisher` reading the declared `layouts` / `components` groups; + the single grouped multi-select picker (§4) with an "All views" option; + `--layouts` / `--components` prefiltering; `--all` skipping the picker; + cardinality-aware output; wiring `OverwritePolicy` + `--force` and the interactive + conflict prompt / non-interactive `--force` error from §7. +- **Out of scope:** anything pages, config, or deprecation-related. Don't touch the + page stub. +- **Files:** `ViewsPublisher` (+ any small multiselect helper), the group declarations, + edits to `PublishCommand`'s `publishViews()` only, and views-flow tests. +- **Depends on:** Steps 1, 3. +- **Done when:** tests cover single/many/all selection, both group prefilters, + cardinality-aware output strings, and the overwrite/force behavior. Suite green. +- **Spec ref:** §4, §7. + +## Step 5 — Pages flow + +- **Goal:** replace the `publishPage()` stub with the real pages publisher. +- **In scope:** `PagesPublisher` using the registry; destination resolution precedence + (§5.4: `--to` → non-interactive default → interactive prompt → default); + `--page` (picker) and `--page=NAME` (direct); the interactive select→resolve→confirm + flow (§5.5); conflict detection for two pages resolving to the same target (§5.6); + optional post-publish rebuild in interactive mode only (§5.7); `OverwritePolicy` + + `--force`; `--to` validation (under `_pages/`, `.blade.php`). +- **Out of scope:** views, config, deprecation. Reuse the multiselect helper from + Step 4 — do not fork or rewrite it. +- **Files:** `PagesPublisher`, edits to `PublishCommand`'s `publishPage()` only, and + pages-flow tests. +- **Depends on:** Steps 1, 2, 3 (and the helper from 4). +- **Done when:** tests cover each resolution branch, `--to` validation failures, + conflict detection, and that rebuild only offers in interactive mode. Suite green. +- **Spec ref:** §5. + +## Step 6 — `hyde-config` publish tag + +- **Goal:** move config publishing to `vendor:publish` as a single tag. +- **In scope:** register a `hyde-config` publish tag on the relevant service provider + that publishes exactly the six Hyde-owned configs (hyde, docs, markdown, view, cache, + commands) and **not** `torchlight.php`; confirm the `--config` redirect message from + Step 3 names this tag. +- **Out of scope:** touching the `publish` command's views/pages logic; adding config + back into `publish`; anything about Torchlight's own tag. +- **Files:** the service-provider tag registration + a test asserting the tag's file set + (and excluding torchlight). +- **Depends on:** Step 3 (for the redirect message target). +- **Done when:** a test asserts `vendor:publish --tag=hyde-config` maps to exactly the + six files. Suite green. +- **Spec ref:** §6. + +## Step 7 — Deprecated aliases + +- **Goal:** keep the old commands working as thin, notice-printing delegators. +- **In scope:** convert `publish:views`, `publish:configs`, `publish:homepage` into + aliases that print a one-line deprecation notice and delegate per §8 + (`publish:views [group]` → `publish --layouts`/`--components`; `publish:configs` → + `vendor:publish --tag=hyde-config`; `publish:homepage [template]` → `publish --page=[template]`). +- **Out of scope:** deleting the old commands (they stay through v3); changing new + command behavior; docs. +- **Files:** the three old command classes (now delegating) + alias tests. +- **Depends on:** Steps 4, 5, 6. +- **Done when:** tests assert each alias prints its notice and routes to the new path. + Suite green. +- **Spec ref:** §8. + +## Step 8 — Docs cleanup + +- **Goal:** align the docs with the new command surface. +- **In scope:** fix the nonexistent `php hyde publish:components` reference in + `docs/digging-deeper/advanced-markdown.md` (→ `php hyde publish --components`); + rewrite the publishing docs around `php hyde publish` (views + `--page`) and + `php hyde vendor:publish --tag=hyde-config`; add a short migration note listing the + deprecated aliases. +- **Out of scope:** any code changes. Docs only. +- **Files:** the affected docs pages only. +- **Depends on:** Steps 4–7. +- **Done when:** no doc references a nonexistent command; the deprecated aliases appear + only in the migration note, not the primary flow. +- **Spec ref:** §12, §8. + +## Step 9 (optional) — Acceptance sweep + +- **Goal:** verify nothing drifted across steps. +- **In scope:** walk §11 criteria 1–16 one by one; for any not already covered by an + existing test, add a focused test or fix the gap. No new behavior. +- **Out of scope:** new features, refactors, scope beyond §11. +- **Files:** test files (+ minimal fixes if a criterion fails). +- **Depends on:** Steps 1–8. +- **Done when:** every §11 criterion has a passing test or a demonstrated pass. +- **Spec ref:** §11. + +--- + +## Suggested review checklist (you, between steps) + +- Does the diff touch only the files the step named? +- Did it add any flag / command / behavior not in the spec? (If yes → revert it.) +- Do the new tests actually assert the spec's wording (output strings, error text)? +- Does the full suite still pass? +- One commit, referencing the step number? From a3961d975c2a4083f691b5dd71d9257a1e1376e8 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 03:47:45 +0200 Subject: [PATCH 03/56] Update new-publish-command-spec.md --- new-publish-command-spec.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index 306e43b00b2..56570a5ab44 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -274,11 +274,16 @@ php hyde vendor:publish --tag=hyde-config Identical rule everywhere. **No historical-checksum manifest.** -| Destination state | Action | -|------------------------------------|------------------------------------------| -| Missing | copy | -| Byte-identical to current source | skip (`already current`) | -| Exists and differs (user-modified) | require interactive confirm OR `--force` | +"Identical" is compared **EOL-agnostically** (line endings normalized before +comparison), reusing Hyde's existing `unixsum` checksum. A file that differs from +the source only by line endings counts as unchanged (`skip`), not modified — CRLF/LF +differences from git autocrlf, editors, or OS are noise, never a user modification. + +| Destination state | Action | +|-----------------------------------------|------------------------------------------| +| Missing | copy | +| Identical to source (EOL-normalized) | skip (`already current`) | +| Exists and differs (user-modified) | require interactive confirm OR `--force` | Interactive conflict prompt: From 4e8c1bb81bda20a82cfb983a3a022ae2bd7f6427 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 03:51:51 +0200 Subject: [PATCH 04/56] Step 1: Add OverwritePolicy service and OverwriteAction enum Introduces the shared, pure decision primitive for the new publish command: given a source and destination path, it returns copy (missing), skip (unchanged), or blocked (user-modified). Comparison is EOL-agnostic via unixsum so line-ending-only differences (e.g. CRLF checkouts) count as unchanged rather than modified. No console/view/page knowledge; no checksum manifest. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../framework/src/Enums/OverwriteAction.php | 23 ++++++ .../Framework/Services/OverwritePolicy.php | 46 +++++++++++ .../tests/Unit/OverwritePolicyTest.php | 82 +++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 packages/framework/src/Enums/OverwriteAction.php create mode 100644 packages/framework/src/Framework/Services/OverwritePolicy.php create mode 100644 packages/framework/tests/Unit/OverwritePolicyTest.php diff --git a/packages/framework/src/Enums/OverwriteAction.php b/packages/framework/src/Enums/OverwriteAction.php new file mode 100644 index 00000000000..bf3914bb737 --- /dev/null +++ b/packages/framework/src/Enums/OverwriteAction.php @@ -0,0 +1,23 @@ +source = 'overwrite-policy-source-'.uniqid().'.tmp'; + $this->destination = 'overwrite-policy-destination-'.uniqid().'.tmp'; + } + + protected function tearDown(): void + { + foreach ([$this->source, $this->destination] as $path) { + if (is_file(Hyde::path($path))) { + unlink(Hyde::path($path)); + } + } + } + + public function testDecidesToCopyWhenDestinationIsMissing() + { + $this->putSource('Hello world'); + + $this->assertSame(OverwriteAction::Copy, OverwritePolicy::decide($this->source, $this->destination)); + } + + public function testDecidesToSkipWhenDestinationIsIdenticalToSource() + { + $this->putSource('Hello world'); + $this->putDestination('Hello world'); + + $this->assertSame(OverwriteAction::Skip, OverwritePolicy::decide($this->source, $this->destination)); + } + + public function testDecidesToBlockWhenDestinationDiffersFromSource() + { + $this->putSource('Hello world'); + $this->putDestination('Hello world, but modified by the user'); + + $this->assertSame(OverwriteAction::Blocked, OverwritePolicy::decide($this->source, $this->destination)); + } + + public function testDecidesToSkipWhenFilesDifferOnlyByLineEndings() + { + $this->putSource("Hello\nworld\n"); + $this->putDestination("Hello\r\nworld\r\n"); + + $this->assertSame(OverwriteAction::Skip, OverwritePolicy::decide($this->source, $this->destination)); + } + + protected function putSource(string $contents): void + { + file_put_contents(Hyde::path($this->source), $contents); + } + + protected function putDestination(string $contents): void + { + file_put_contents(Hyde::path($this->destination), $contents); + } +} From 1da3b1556b0bf1269886f9c38a306d5e407913ca Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 04:36:31 +0200 Subject: [PATCH 05/56] Step 2: Add publishable page registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the data model for starter pages used by the new publish command: an immutable PublishablePage value object (key, label, description, source, defaultTarget, alternativeTargets, allowCustomTarget) and the PublishablePages registry (all/get/register) seeded with the default catalog — welcome, posts, blank, and 404. Pages get a value object + registry (unlike the fixed view file-maps) because a page can have multiple valid destinations, carries display metadata, and the registry is an extension point for Hyde Cloud and plugins. Source paths are stored framework-relative for resolution via Hyde::vendorPath() at publish time; destination resolution and publishing land in a later step. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../src/Console/Helpers/PublishablePage.php | 36 +++++ .../src/Console/Helpers/PublishablePages.php | 93 +++++++++++++ .../tests/Unit/PublishablePageTest.php | 59 +++++++++ .../tests/Unit/PublishablePagesTest.php | 125 ++++++++++++++++++ 4 files changed, 313 insertions(+) create mode 100644 packages/framework/src/Console/Helpers/PublishablePage.php create mode 100644 packages/framework/src/Console/Helpers/PublishablePages.php create mode 100644 packages/framework/tests/Unit/PublishablePageTest.php create mode 100644 packages/framework/tests/Unit/PublishablePagesTest.php diff --git a/packages/framework/src/Console/Helpers/PublishablePage.php b/packages/framework/src/Console/Helpers/PublishablePage.php new file mode 100644 index 00000000000..d11838b7c64 --- /dev/null +++ b/packages/framework/src/Console/Helpers/PublishablePage.php @@ -0,0 +1,36 @@ + $alternativeTargets Additional valid destinations, mapping a project-relative path to a human label. + * @param bool $allowCustomTarget Whether the user may publish this page to a custom path. + */ + public function __construct( + public readonly string $key, + public readonly string $label, + public readonly string $description, + public readonly string $source, + public readonly string $defaultTarget, + public readonly array $alternativeTargets = [], + public readonly bool $allowCustomTarget = true, + ) { + } +} diff --git a/packages/framework/src/Console/Helpers/PublishablePages.php b/packages/framework/src/Console/Helpers/PublishablePages.php new file mode 100644 index 00000000000..3506e19fcfe --- /dev/null +++ b/packages/framework/src/Console/Helpers/PublishablePages.php @@ -0,0 +1,93 @@ +|null */ + protected static ?array $pages = null; + + /** @return array */ + public static function all(): array + { + return static::$pages ??= static::getDefaultPages(); + } + + public static function get(string $key): ?PublishablePage + { + return static::all()[$key] ?? null; + } + + /** Register a publishable page, making it available to the publish command. Overrides any page sharing its key. */ + public static function register(PublishablePage $page): void + { + static::$pages = static::all(); + static::$pages[$page->key] = $page; + } + + /** + * Reset the registry back to its default catalog. + * + * @internal Primarily used to restore state between tests. + */ + public static function clear(): void + { + static::$pages = null; + } + + /** @return array */ + protected static function getDefaultPages(): array + { + return static::keyed([ + new PublishablePage( + key: 'welcome', + label: 'Welcome page', + description: 'The default Hyde welcome page.', + source: 'resources/views/homepages/welcome.blade.php', + defaultTarget: '_pages/index.blade.php', + ), + new PublishablePage( + key: 'posts', + label: 'Posts feed', + description: 'A feed of your latest posts. Perfect for a blog site!', + source: 'resources/views/homepages/post-feed.blade.php', + defaultTarget: '_pages/posts.blade.php', + alternativeTargets: ['_pages/index.blade.php' => 'Use as your site homepage'], + ), + new PublishablePage( + key: 'blank', + label: 'Blank page', + description: 'A blank Blade template with just the base layout.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/index.blade.php', + ), + new PublishablePage( + key: '404', + label: '404 page', + description: 'A custom 404 error page.', + source: 'resources/views/pages/404.blade.php', + defaultTarget: '_pages/404.blade.php', + allowCustomTarget: false, + ), + ]); + } + + /** + * @param array $pages + * @return array + */ + protected static function keyed(array $pages): array + { + return collect($pages)->keyBy(fn (PublishablePage $page): string => $page->key)->all(); + } +} diff --git a/packages/framework/tests/Unit/PublishablePageTest.php b/packages/framework/tests/Unit/PublishablePageTest.php new file mode 100644 index 00000000000..6a6c524dc2d --- /dev/null +++ b/packages/framework/tests/Unit/PublishablePageTest.php @@ -0,0 +1,59 @@ + 'Use as your site homepage'], + allowCustomTarget: false, + ); + + $this->assertSame('posts', $page->key); + $this->assertSame('Posts feed', $page->label); + $this->assertSame('A feed of your latest posts.', $page->description); + $this->assertSame('resources/views/homepages/post-feed.blade.php', $page->source); + $this->assertSame('_pages/posts.blade.php', $page->defaultTarget); + $this->assertSame(['_pages/index.blade.php' => 'Use as your site homepage'], $page->alternativeTargets); + $this->assertFalse($page->allowCustomTarget); + } + + public function testOptionalPropertiesDefaultToNoAlternativesAndCustomTargetAllowed() + { + $page = new PublishablePage( + key: 'welcome', + label: 'Welcome page', + description: 'The default welcome page.', + source: 'resources/views/homepages/welcome.blade.php', + defaultTarget: '_pages/index.blade.php', + ); + + $this->assertSame([], $page->alternativeTargets); + $this->assertTrue($page->allowCustomTarget); + } + + public function testValueObjectIsImmutable() + { + $reflection = new ReflectionClass(PublishablePage::class); + + $this->assertTrue($reflection->isFinal()); + + foreach ($reflection->getProperties() as $property) { + $this->assertTrue($property->isReadOnly(), "Property {$property->getName()} should be readonly."); + } + } +} diff --git a/packages/framework/tests/Unit/PublishablePagesTest.php b/packages/framework/tests/Unit/PublishablePagesTest.php new file mode 100644 index 00000000000..6521bb200df --- /dev/null +++ b/packages/framework/tests/Unit/PublishablePagesTest.php @@ -0,0 +1,125 @@ +key property since PHP coerces the numeric '404' array key to an integer. + $this->assertSame(['welcome', 'posts', 'blank', '404'], $this->catalogKeys()); + } + + public function testAllReturnsPublishablePageInstancesRetrievableByTheirKey() + { + foreach (PublishablePages::all() as $page) { + $this->assertInstanceOf(PublishablePage::class, $page); + $this->assertSame($page, PublishablePages::get($page->key)); + } + } + + public function testDefaultCatalogTargets() + { + $pages = PublishablePages::all(); + + $this->assertSame('_pages/index.blade.php', $pages['welcome']->defaultTarget); + $this->assertSame('_pages/posts.blade.php', $pages['posts']->defaultTarget); + $this->assertSame('_pages/index.blade.php', $pages['blank']->defaultTarget); + $this->assertSame('_pages/404.blade.php', $pages['404']->defaultTarget); + } + + public function testDefaultCatalogSources() + { + $pages = PublishablePages::all(); + + $this->assertSame('resources/views/homepages/welcome.blade.php', $pages['welcome']->source); + $this->assertSame('resources/views/homepages/post-feed.blade.php', $pages['posts']->source); + $this->assertSame('resources/views/homepages/blank.blade.php', $pages['blank']->source); + $this->assertSame('resources/views/pages/404.blade.php', $pages['404']->source); + } + + public function testOnlyPostsDeclaresAnAlternativeHomepageTarget() + { + $pages = PublishablePages::all(); + + $this->assertSame(['_pages/index.blade.php' => 'Use as your site homepage'], $pages['posts']->alternativeTargets); + $this->assertSame([], $pages['welcome']->alternativeTargets); + $this->assertSame([], $pages['blank']->alternativeTargets); + $this->assertSame([], $pages['404']->alternativeTargets); + } + + public function testOnlyThe404PageForbidsCustomTargets() + { + $pages = PublishablePages::all(); + + $this->assertTrue($pages['welcome']->allowCustomTarget); + $this->assertTrue($pages['posts']->allowCustomTarget); + $this->assertTrue($pages['blank']->allowCustomTarget); + $this->assertFalse($pages['404']->allowCustomTarget); + } + + public function testGetReturnsPageByKey() + { + $this->assertSame('welcome', PublishablePages::get('welcome')->key); + } + + public function testGetReturnsNullForUnknownKey() + { + $this->assertNull(PublishablePages::get('does-not-exist')); + } + + public function testRegisterAddsANewPage() + { + PublishablePages::register(new PublishablePage( + key: 'changelog', + label: 'Changelog page', + description: 'A changelog for your site.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/changelog.blade.php', + )); + + $this->assertArrayHasKey('changelog', PublishablePages::all()); + $this->assertSame('changelog', PublishablePages::get('changelog')->key); + $this->assertSame(['welcome', 'posts', 'blank', '404', 'changelog'], $this->catalogKeys()); + } + + public function testRegisterOverridesAPageSharingItsKey() + { + PublishablePages::register(new PublishablePage( + key: 'welcome', + label: 'Custom welcome page', + description: 'An overridden welcome page.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/index.blade.php', + )); + + $this->assertSame('Custom welcome page', PublishablePages::get('welcome')->label); + $this->assertCount(4, PublishablePages::all()); + } + + /** @return array The page keys read from the ->key property (immune to PHP numeric-key coercion). */ + protected function catalogKeys(): array + { + return array_values(array_map(fn (PublishablePage $page): string => $page->key, PublishablePages::all())); + } +} From 30ad0429806b8718f1bd8463bdfedf44782bc642 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 04:50:35 +0200 Subject: [PATCH 06/56] Step 2: Remove blank starter default target Per review decision: `blank` is a general-purpose empty starter you drop anywhere rather than a third homepage variant, so it declares no default target. Makes PublishablePage::$defaultTarget nullable and sets blank's to null; destination resolution (Step 5) will always prompt interactively and require --to non-interactively. This also removes the welcome/blank collision on _pages/index.blade.php that the default catalog would otherwise create. Updates spec 5.1/5.2/5.4 to match so the acceptance sweep stays consistent. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- new-publish-command-spec.md | 13 +++++++++---- .../src/Console/Helpers/PublishablePage.php | 4 ++-- .../src/Console/Helpers/PublishablePages.php | 2 +- .../framework/tests/Unit/PublishablePageTest.php | 13 +++++++++++++ .../framework/tests/Unit/PublishablePagesTest.php | 2 +- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index 56570a5ab44..bac7e3b73d6 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -153,7 +153,7 @@ final class PublishablePage public string $label, // 'Posts feed' public string $description, // short help text public string $source, // stub path within the framework - public string $defaultTarget, // '_pages/posts.blade.php' + public ?string $defaultTarget, // '_pages/posts.blade.php', or null when the page has no default (always prompt / require --to) /** @var array path => human label */ public array $alternativeTargets = [], public bool $allowCustomTarget = true, @@ -179,12 +179,16 @@ plugins register their own publishable pages. |-----------|--------------------------|---------------------------------------|---------| | `welcome` | `_pages/index.blade.php` | — | yes | | `posts` | `_pages/posts.blade.php` | `_pages/index.blade.php` (as homepage)| yes | -| `blank` | `_pages/index.blade.php` | — | yes | +| `blank` | *(none — always prompt)* | — | yes | | `404` | `_pages/404.blade.php` | — | **no** | No dedicated "homepage" concept: setting a homepage = publishing a homepage-capable page to `_pages/index.blade.php`. +`blank` is a general-purpose empty starter you drop anywhere, so it declares no +default target: in interactive mode its destination is always prompted for, and +non-interactive use must supply `--to` (see §5.4). + ### 5.3 Behavior ``` @@ -197,8 +201,9 @@ php hyde publish --page=welcome --force ### 5.4 Destination resolution (per selected page) 1. `--to=PATH` → use it. Must resolve under `_pages/` and end in `.blade.php`, else fail. -2. Non-interactive, no `--to` → use `defaultTarget`. -3. Interactive AND (`alternativeTargets` non-empty OR `allowCustomTarget`) → prompt: +2. Non-interactive, no `--to` → use `defaultTarget`. If `defaultTarget` is null + (e.g. `blank`), there is nothing to fall back to → fail helpfully, pointing to `--to`. +3. Interactive AND (`alternativeTargets` non-empty OR `allowCustomTarget` OR `defaultTarget` is null) → prompt: ``` Where should "Posts feed" be published? diff --git a/packages/framework/src/Console/Helpers/PublishablePage.php b/packages/framework/src/Console/Helpers/PublishablePage.php index d11838b7c64..baaaf020985 100644 --- a/packages/framework/src/Console/Helpers/PublishablePage.php +++ b/packages/framework/src/Console/Helpers/PublishablePage.php @@ -19,7 +19,7 @@ final class PublishablePage * @param string $label The human-readable name shown in pickers (e.g. 'Posts feed'). * @param string $description A short help text describing the page. * @param string $source The framework-relative path to the stub file, resolved via Hyde::vendorPath() when published. - * @param string $defaultTarget The default project-relative destination (e.g. '_pages/posts.blade.php'). + * @param string|null $defaultTarget The default project-relative destination (e.g. '_pages/posts.blade.php'), or null when the page has no default and its destination must be resolved interactively or via --to. * @param array $alternativeTargets Additional valid destinations, mapping a project-relative path to a human label. * @param bool $allowCustomTarget Whether the user may publish this page to a custom path. */ @@ -28,7 +28,7 @@ public function __construct( public readonly string $label, public readonly string $description, public readonly string $source, - public readonly string $defaultTarget, + public readonly ?string $defaultTarget, public readonly array $alternativeTargets = [], public readonly bool $allowCustomTarget = true, ) { diff --git a/packages/framework/src/Console/Helpers/PublishablePages.php b/packages/framework/src/Console/Helpers/PublishablePages.php index 3506e19fcfe..6b1b31126d8 100644 --- a/packages/framework/src/Console/Helpers/PublishablePages.php +++ b/packages/framework/src/Console/Helpers/PublishablePages.php @@ -69,7 +69,7 @@ protected static function getDefaultPages(): array label: 'Blank page', description: 'A blank Blade template with just the base layout.', source: 'resources/views/homepages/blank.blade.php', - defaultTarget: '_pages/index.blade.php', + defaultTarget: null, // An empty starter you drop anywhere: no default, so its destination is always prompted for (or set via --to). ), new PublishablePage( key: '404', diff --git a/packages/framework/tests/Unit/PublishablePageTest.php b/packages/framework/tests/Unit/PublishablePageTest.php index 6a6c524dc2d..31b4a743d5c 100644 --- a/packages/framework/tests/Unit/PublishablePageTest.php +++ b/packages/framework/tests/Unit/PublishablePageTest.php @@ -46,6 +46,19 @@ public function testOptionalPropertiesDefaultToNoAlternativesAndCustomTargetAllo $this->assertTrue($page->allowCustomTarget); } + public function testDefaultTargetMayBeNullForPagesWithoutADefaultDestination() + { + $page = new PublishablePage( + key: 'blank', + label: 'Blank page', + description: 'A blank Blade template with just the base layout.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: null, + ); + + $this->assertNull($page->defaultTarget); + } + public function testValueObjectIsImmutable() { $reflection = new ReflectionClass(PublishablePage::class); diff --git a/packages/framework/tests/Unit/PublishablePagesTest.php b/packages/framework/tests/Unit/PublishablePagesTest.php index 6521bb200df..9d52572ddef 100644 --- a/packages/framework/tests/Unit/PublishablePagesTest.php +++ b/packages/framework/tests/Unit/PublishablePagesTest.php @@ -44,7 +44,7 @@ public function testDefaultCatalogTargets() $this->assertSame('_pages/index.blade.php', $pages['welcome']->defaultTarget); $this->assertSame('_pages/posts.blade.php', $pages['posts']->defaultTarget); - $this->assertSame('_pages/index.blade.php', $pages['blank']->defaultTarget); + $this->assertNull($pages['blank']->defaultTarget, 'blank has no default target; its destination is always prompted for or set via --to.'); $this->assertSame('_pages/404.blade.php', $pages['404']->defaultTarget); } From 2653a35ee3332db0283aac4f5b742558497f3fe6 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 16:19:27 +0200 Subject: [PATCH 07/56] Step 3: Add PublishCommand guardrails Introduces the flag-driven publish command shell that routes to the views and pages flows (both stubbed here, filled in by Steps 4-5). It owns the full flag surface (--layouts --components --all --page[=NAME] --to=PATH --force) and all guardrails: - Raw tag/provider/config publishing is redirected to vendor:publish. These are deliberately not declared as options (which would advertise them in --help, the exact raw-publishing surface this command exists to hide); instead run() intercepts the raw input before Symfony's strict bind. Only those three tokens are short-circuited, so a genuine typo like --layout still hits Symfony's native "unknown option" error rather than being swallowed. - --layouts and --components are mutually exclusive. - --to is only valid alongside --page. - Non-interactive with no actionable flag fails with a usage hint before any prompt is attempted; the interactive wizard (Views / A starter page / Cancel) routes to the stub handlers, with Cancel exiting cleanly. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../src/Console/Commands/PublishCommand.php | 162 +++++++++++++++++ .../src/Console/ConsoleServiceProvider.php | 1 + .../Feature/Commands/PublishCommandTest.php | 172 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 packages/framework/src/Console/Commands/PublishCommand.php create mode 100644 packages/framework/tests/Feature/Commands/PublishCommandTest.php diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php new file mode 100644 index 00000000000..0bee9fb7b2f --- /dev/null +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -0,0 +1,162 @@ +hasParameterOption(['--tag', '--provider', '--config'], true)) { + $this->output = $output instanceof OutputStyle ? $output : $this->laravel->make( + OutputStyle::class, ['input' => $input, 'output' => $output] + ); + + return $this->redirectRawPublishingFlags($input); + } + + return parent::run($input, $output); + } + + protected function safeHandle(): int + { + if ($this->option('layouts') && $this->option('components')) { + $this->error('The --layouts and --components options are mutually exclusive. Use --all to publish both.'); + + return Command::FAILURE; + } + + if ($this->option('to') !== null && ! $this->wantsToPublishPage()) { + $this->error('--to is only valid when publishing a page.'); + + return Command::FAILURE; + } + + if ($this->wantsToPublishPage()) { + return $this->publishPage(); + } + + if ($this->wantsToPublishViews()) { + return $this->publishViews(); + } + + // No actionable flags were supplied. We must decide before attempting any prompt: without + // an interactive terminal there is no wizard to run, so we fail with usage guidance instead. + if (! $this->input->isInteractive()) { + return $this->failWithUsageHint(); + } + + return $this->runWizard(); + } + + /** Interactive step 1 (§3): route to the views or pages flow, or cancel out. */ + protected function runWizard(): int + { + $choice = select('What do you want to publish?', [ + 'views' => 'Views — customize Hyde Blade layouts and components', + 'page' => 'A starter page — copy a homepage, 404, or other default page', + 'cancel' => 'Cancel', + ], 'views'); + + return match ($choice) { + 'views' => $this->publishViews(), + 'page' => $this->publishPage(), + default => Command::SUCCESS, // Cancelling is a clean exit, not an error. + }; + } + + /** @todo Replaced with the real views publisher in Step 4. */ + protected function publishViews(): int + { + $this->infoComment('Publishing views is not yet implemented.'); + + return Command::SUCCESS; + } + + /** @todo Replaced with the real pages publisher in Step 5. */ + protected function publishPage(): int + { + $this->infoComment('Publishing pages is not yet implemented.'); + + return Command::SUCCESS; + } + + protected function wantsToPublishViews(): bool + { + return $this->option('layouts') || $this->option('components') || $this->option('all'); + } + + /** + * The --page flag is value-optional, so a bare --page and an absent --page both read as null + * via option(). We check the raw input for its presence to tell the two apart. + */ + protected function wantsToPublishPage(): bool + { + return $this->input->hasParameterOption('--page') || $this->option('page') !== null; + } + + protected function failWithUsageHint(): int + { + $this->error('Nothing to publish. Try:'); + $this->line(' php hyde publish --all'); + $this->line(' php hyde publish --layouts'); + $this->line(' php hyde publish --page=welcome'); + + return Command::FAILURE; + } + + protected function redirectRawPublishingFlags(InputInterface $input): int + { + if ($input->hasParameterOption('--config', true)) { + $this->error('Config is not published through this command. Use php hyde vendor:publish --tag=hyde-config instead.'); + + return Command::FAILURE; + } + + $flag = $input->hasParameterOption('--tag', true) ? 'tag' : 'provider'; + $value = $input->getParameterOption("--$flag", null, true); + $hint = is_string($value) && $value !== '' ? "--$flag=$value" : "--$flag"; + + $this->error("Use php hyde vendor:publish $hint for tag/provider publishing."); + + return Command::FAILURE; + } +} diff --git a/packages/framework/src/Console/ConsoleServiceProvider.php b/packages/framework/src/Console/ConsoleServiceProvider.php index 5d50838deb2..d774fe78406 100644 --- a/packages/framework/src/Console/ConsoleServiceProvider.php +++ b/packages/framework/src/Console/ConsoleServiceProvider.php @@ -26,6 +26,7 @@ public function register(): void Commands\MakePostCommand::class, Commands\VendorPublishCommand::class, + Commands\PublishCommand::class, Commands\PublishConfigsCommand::class, Commands\PublishHomepageCommand::class, Commands\PublishViewsCommand::class, diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php new file mode 100644 index 00000000000..1f09870d80a --- /dev/null +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -0,0 +1,172 @@ +artisan('publish --tag=foo') + ->expectsOutputToContain('Use php hyde vendor:publish --tag=foo for tag/provider publishing.') + ->assertExitCode(1); + } + + public function testBareTagFlagIsRedirectedToVendorPublish() + { + $this->artisan('publish --tag') + ->expectsOutputToContain('Use php hyde vendor:publish --tag for tag/provider publishing.') + ->assertExitCode(1); + } + + public function testProviderFlagIsRedirectedToVendorPublish() + { + $this->artisan('publish --provider=FooServiceProvider') + ->expectsOutputToContain('Use php hyde vendor:publish --provider=FooServiceProvider for tag/provider publishing.') + ->assertExitCode(1); + } + + public function testConfigFlagIsRedirectedToVendorPublish() + { + $this->artisan('publish --config') + ->expectsOutputToContain('Config is not published through this command. Use php hyde vendor:publish --tag=hyde-config instead.') + ->assertExitCode(1); + } + + // Guardrails: the command's own flag combinations (§9). + + public function testLayoutsAndComponentsAreMutuallyExclusive() + { + $this->artisan('publish --layouts --components') + ->expectsOutputToContain('The --layouts and --components options are mutually exclusive. Use --all to publish both.') + ->assertExitCode(1); + } + + public function testToOptionRequiresThePageFlag() + { + $this->artisan('publish --to=_pages/index.blade.php') + ->expectsOutputToContain('--to is only valid when publishing a page.') + ->assertExitCode(1); + } + + public function testToOptionIsAllowedAlongsideThePageFlag() + { + $this->artisan('publish --page --to=_pages/index.blade.php') + ->expectsOutputToContain($this->pagesStub) + ->assertExitCode(0); + } + + public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() + { + $this->artisan('publish --no-interaction') + ->expectsOutput('Nothing to publish. Try:') + ->expectsOutput(' php hyde publish --all') + ->expectsOutput(' php hyde publish --layouts') + ->expectsOutput(' php hyde publish --page=welcome') + ->assertExitCode(1); + } + + // Flag routing to the (stubbed) handlers. + + public function testLayoutsFlagRoutesToViews() + { + $this->artisan('publish --layouts') + ->expectsOutputToContain($this->viewsStub) + ->assertExitCode(0); + } + + public function testComponentsFlagRoutesToViews() + { + $this->artisan('publish --components') + ->expectsOutputToContain($this->viewsStub) + ->assertExitCode(0); + } + + public function testAllFlagRoutesToViews() + { + $this->artisan('publish --all') + ->expectsOutputToContain($this->viewsStub) + ->assertExitCode(0); + } + + public function testBarePageFlagRoutesToPages() + { + $this->artisan('publish --page') + ->expectsOutputToContain($this->pagesStub) + ->assertExitCode(0); + } + + public function testPageFlagWithNameRoutesToPages() + { + $this->artisan('publish --page=welcome') + ->expectsOutputToContain($this->pagesStub) + ->assertExitCode(0); + } + + // Interactive wizard routing (§3). + + public function testWizardRoutesToViews() + { + $this->artisan('publish') + ->expectsQuestion('What do you want to publish?', 'views') + ->expectsOutputToContain($this->viewsStub) + ->assertExitCode(0); + } + + public function testWizardRoutesToPages() + { + $this->artisan('publish') + ->expectsQuestion('What do you want to publish?', 'page') + ->expectsOutputToContain($this->pagesStub) + ->assertExitCode(0); + } + + public function testWizardCancelExitsCleanlyWithoutPublishing() + { + $this->artisan('publish') + ->expectsQuestion('What do you want to publish?', 'cancel') + ->doesntExpectOutputToContain($this->viewsStub) + ->doesntExpectOutputToContain($this->pagesStub) + ->assertExitCode(0); + } + + // Approach 1 must not swallow genuine mistakes: unknown options and stray arguments + // still hit Symfony's native errors rather than our redirect or a stub handler. + + public function testUnknownOptionIsNotSwallowed() + { + // A typo for --layouts must surface Symfony's native error, not be eaten by our + // raw-flag interception (which only short-circuits --tag/--provider/--config). + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The "--layout" option does not exist.'); + + $this->artisan('publish --layout')->run(); + } + + public function testArbitrarySourcePathArgumentIsRejected() + { + // The command declares no arguments, so a stray source path is rejected outright + // rather than being interpreted as a publishable target. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No arguments expected for "publish" command, got "resources/views/foo.blade.php".'); + + $this->artisan('publish resources/views/foo.blade.php')->run(); + } +} From bcbc0d50ff1855f7a41252a96ffc52a4a690bcea Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 16:48:25 +0200 Subject: [PATCH 08/56] Step 4: Implement the views publishing flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the publishViews() stub with a real ViewsPublisher (§4, §7): - Reads the two declared groups (layouts, components) via ViewPublishGroup; --layouts/--components prefilter the offered set, --all skips the picker, and a non-interactive scoped run behaves exactly like adding --all. - Adds a minimal shared InteractiveMultiselect helper: a grouped multi-select with an "All views" sentinel (selecting it means everything regardless of other checkbox state) and group-prefixed labels (layouts/app.blade.php). - Decides every file's outcome first (OverwritePolicy: copy/skip/blocked), resolves modified-file conflicts second (interactive Skip/Overwrite/Cancel or --force; non-interactive without --force is a hard §7 error), and writes last, so Cancel never leaves a half-published tree. - Cardinality-aware output that reports the real breakdown: "Published all N" prints only when the entire offered set was genuinely copied; mixed runs report copied / already-current / left-modified (with a --force hint). Re-points the four Step 3 views-routing tests off the removed stub string to assert real views behavior, adds a dedicated views-flow test suite, and notes the mixed-run reporting rule in §4 of the spec. Additional commits: - Make the publish multiselect "All" sentinel row optional Add a nullable $allLabel to InteractiveMultiselect::select() so callers can omit the "select all" row entirely. The views picker keeps its "All views" affordance; the pages picker (Step 5) passes no label, since "publish all starter pages at once" is never a sensible selection and would only trip destination resolution and conflict detection. This is a backward-compatible parameter on the shared helper, not a fork — both callers depend on the same picker. - Simplify publish multiselect all sentinel Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- new-publish-command-spec.md | 4 + .../src/Console/Commands/PublishCommand.php | 6 +- .../Helpers/InteractiveMultiselect.php | 52 ++++ .../src/Console/Helpers/ViewsPublisher.php | 278 ++++++++++++++++++ .../Feature/Commands/PublishCommandTest.php | 35 ++- .../Commands/PublishCommandViewsTest.php | 273 +++++++++++++++++ 6 files changed, 634 insertions(+), 14 deletions(-) create mode 100644 packages/framework/src/Console/Helpers/InteractiveMultiselect.php create mode 100644 packages/framework/src/Console/Helpers/ViewsPublisher.php create mode 100644 packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index bac7e3b73d6..94dca0f1822 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -134,6 +134,10 @@ Published 3 views to resources/views/vendor/hyde/layouts Published all 42 views to resources/views/vendor/hyde ``` +A mixed run reports the breakdown — how many views were copied, how many were skipped +because already current, and which were left unchanged because modified (with a `--force` +hint); `Published all N views` prints only when the entire offered set was genuinely copied. + --- ## 5. Pages (the `--page` side path) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 0bee9fb7b2f..32e1b9e9ff7 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -5,6 +5,7 @@ namespace Hyde\Console\Commands; use Hyde\Console\Concerns\Command; +use Hyde\Console\Helpers\ViewsPublisher; use Illuminate\Console\OutputStyle; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -103,12 +104,9 @@ protected function runWizard(): int }; } - /** @todo Replaced with the real views publisher in Step 4. */ protected function publishViews(): int { - $this->infoComment('Publishing views is not yet implemented.'); - - return Command::SUCCESS; + return (new ViewsPublisher($this, $this->input))->publish(); } /** @todo Replaced with the real pages publisher in Step 5. */ diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php new file mode 100644 index 00000000000..282cd5a16aa --- /dev/null +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -0,0 +1,52 @@ + label map (for views these are group-prefixed paths), and gets back the selected option + * keys with the sentinel resolved away. + * + * @internal This helper is scoped to the publish command flows and should not be used elsewhere. + */ +class InteractiveMultiselect +{ + /** The sentinel key for the "All" row; option keys are file paths, so this never collides. */ + protected const ALL = '__hyde_select_all__'; + + /** + * @param array $options Map of option key => display label. + * @param string|null $allLabel Label for the "select all" row, or null to omit it entirely. + * @return array The selected option keys (never includes the sentinel). + */ + public static function select(string $label, array $options, ?string $allLabel = null): array + { + $choices = $allLabel !== null ? array_merge([self::ALL => $allLabel], $options) : $options; + + $prompt = new MultiSelectPrompt($label, $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.'); + + $selected = (array) $prompt->prompt(); + + // Selecting the sentinel means "everything", regardless of which other rows were checked. + if (in_array(self::ALL, $selected, true)) { + return array_keys($options); + } + + return array_values(array_filter($selected, fn (string $key): bool => $key !== self::ALL)); + } +} diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php new file mode 100644 index 00000000000..e8d5191a894 --- /dev/null +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -0,0 +1,278 @@ +collectOfferedFiles(); + + $selected = Arr::only($offered, $this->selectFiles($offered, $labels)); + + if ($selected === []) { + $this->command->infoComment('No views selected; nothing to publish.'); + + return Command::SUCCESS; + } + + [$copy, $current, $blocked] = $this->decide($selected); + + $overwrite = $this->resolveBlocked($blocked); + + // A null resolution means the run stopped after the decision but before any write: a non-interactive + // blocked run without --force is a hard failure, while an interactive Cancel is a clean exit. + if ($overwrite === null) { + return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; + } + + $published = array_merge($copy, $overwrite); + + foreach ($published as $source => $target) { + Filesystem::ensureParentDirectoryExists($target); + Filesystem::copy($source, $target); + } + + return $this->report($published, $current, $overwrite === [] ? $blocked : [], count($offered)); + } + + /** + * @return array{0: array, 1: array} + * A tuple of [source => target] and [source => group-prefixed label] for the offered files. + */ + protected function collectOfferedFiles(): array + { + $offered = []; + $labels = []; + + foreach ($this->groups() as $key => $group) { + foreach ($group->publishableFilesMap() as $source => $target) { + $offered[$source] = $target; + $labels[$source] = $key.'/'.Str::after($source, $group->source.'/'); + } + } + + return [$offered, $labels]; + } + + /** @return array The offered groups, keyed by short name and filtered by --layouts/--components. */ + protected function groups(): array + { + $groups = [ + 'layouts' => ViewPublishGroup::fromGroup('hyde-layouts'), + 'components' => ViewPublishGroup::fromGroup('hyde-components'), + ]; + + if ($this->command->option('layouts')) { + return Arr::only($groups, ['layouts']); + } + + if ($this->command->option('components')) { + return Arr::only($groups, ['components']); + } + + return $groups; + } + + /** + * @param array $offered + * @param array $labels + * @return array The selected source keys. + */ + protected function selectFiles(array $offered, array $labels): array + { + // --all skips the picker; so does a non-interactive run, where publishing a scoped group is + // exactly equivalent to adding --all: one predictable rule, with OverwritePolicy still protecting + // any modified files. + if ($this->command->option('all') || ! $this->canPrompt()) { + return array_keys($offered); + } + + return InteractiveMultiselect::select('Select Hyde views to publish', $labels, 'All views'); + } + + /** + * Decide every selected file's outcome up front, before anything is written. + * + * @param array $selected + * @return array{0: array, 1: array, 2: array} + * A tuple of [copy, already-current, blocked] maps, each source => target. + */ + protected function decide(array $selected): array + { + $copy = []; + $current = []; + $blocked = []; + + foreach ($selected as $source => $target) { + match (OverwritePolicy::decide($source, $target)) { + OverwriteAction::Copy => $copy[$source] = $target, + OverwriteAction::Skip => $current[$source] = $target, + OverwriteAction::Blocked => $blocked[$source] = $target, + }; + } + + return [$copy, $current, $blocked]; + } + + /** + * Resolve what to do with modified (blocked) files, after the full outcome is known but before any write. + * + * @param array $blocked + * @return array|null The blocked files to overwrite, or null when the run should stop + * (cancelled interactively, or blocked non-interactively without --force). + */ + protected function resolveBlocked(array $blocked): ?array + { + if ($blocked === []) { + return []; + } + + if ($this->command->option('force')) { + return $blocked; + } + + if (! $this->canPrompt()) { + $this->command->error('Cannot overwrite modified files without --force:'); + + foreach ($blocked as $target) { + $this->command->line(' '.$target); + } + + $this->command->newLine(); + $this->command->line('Run again with --force to overwrite.'); + + return null; + } + + $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ + 'skip' => 'Skip modified files', + 'overwrite' => 'Overwrite modified files', + 'cancel' => 'Cancel', + ], 'skip'); + + return match ($choice) { + 'overwrite' => $blocked, + 'skip' => [], + default => $this->cancel(), + }; + } + + protected function cancel(): ?array + { + $this->command->infoComment('Cancelled. No views were published.'); + + return null; + } + + /** + * @param array $published The files actually written (source => target). + * @param array $current The files skipped because already up to date. + * @param array $blocked The modified files left unchanged (interactive skip). + */ + protected function report(array $published, array $current, array $blocked, int $offeredTotal): int + { + if ($published === [] && $blocked === [] && $current !== []) { + $this->command->infoComment('All selected views are already up to date.'); + + return Command::SUCCESS; + } + + if ($published !== []) { + $this->command->infoComment($this->publishedLine($published, $offeredTotal)); + } + + if ($current !== []) { + $this->command->infoComment(sprintf('%s already up to date and skipped.', $this->viewCount(count($current)))); + } + + if ($blocked !== []) { + $this->command->newLine(); + $this->command->warn(sprintf('%s left unchanged because they were modified:', $this->viewCount(count($blocked)))); + + foreach ($blocked as $target) { + $this->command->line(' '.$target); + } + + $this->command->line('Run again with --force to overwrite.'); + } + + return Command::SUCCESS; + } + + /** @param array $published */ + protected function publishedLine(array $published, int $offeredTotal): string + { + $count = count($published); + + if ($count === 1) { + return sprintf('Published 1 view to [%s]', reset($published)); + } + + // "all N" is reserved for when the entire offered set was genuinely copied; any file that was + // already current or a blocked modification drops the count below the offered total. + $base = $this->baseDirectory($published); + + return $count === $offeredTotal + ? sprintf('Published all %d views to [%s]', $count, $base) + : sprintf('Published %d views to [%s]', $count, $base); + } + + protected function viewCount(int $count): string + { + return $count === 1 ? '1 view' : "$count views"; + } + + /** Find the most specific common parent directory shared by the given files' target paths. */ + protected function baseDirectory(array $files): string + { + $partsMap = collect($files)->map(fn (string $file): array => explode('/', $file)); + + $commonParts = $partsMap->reduce(function (array $carry, array $parts): array { + return array_intersect($carry, $parts); + }, $partsMap->first()); + + return implode('/', $commonParts); + } + + protected function canPrompt(): bool + { + return ConsoleHelper::canUseLaravelPrompts($this->input); + } +} diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 1f09870d80a..b807caa8b58 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -5,7 +5,9 @@ namespace Hyde\Framework\Testing\Feature\Commands; use Hyde\Console\Commands\PublishCommand; +use Hyde\Hyde; use Hyde\Testing\TestCase; +use Illuminate\Support\Facades\File; use PHPUnit\Framework\Attributes\CoversClass; use Symfony\Component\Console\Exception\RuntimeException; @@ -17,9 +19,18 @@ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { - protected string $viewsStub = 'Publishing views is not yet implemented.'; protected string $pagesStub = 'Publishing pages is not yet implemented.'; + protected function tearDown(): void + { + // The views-routing tests below publish real files; remove them so the tree stays clean. + if (File::isDirectory(Hyde::path('resources/views/vendor'))) { + File::deleteDirectory(Hyde::path('resources/views/vendor')); + } + + parent::tearDown(); + } + // Guardrails: raw tag/provider/config publishing is redirected to vendor:publish (§9). public function testTagFlagIsRedirectedToVendorPublish() @@ -83,26 +94,27 @@ public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() ->assertExitCode(1); } - // Flag routing to the (stubbed) handlers. + // Flag routing to the views handler. The full views behavior is covered in PublishCommandViewsTest; + // here we assert only that each flag actually reaches the real views publisher (routing coverage). public function testLayoutsFlagRoutesToViews() { - $this->artisan('publish --layouts') - ->expectsOutputToContain($this->viewsStub) + $this->artisan('publish --layouts --no-interaction') + ->expectsOutputToContain('views to [resources/views/vendor/hyde/layouts]') ->assertExitCode(0); } public function testComponentsFlagRoutesToViews() { - $this->artisan('publish --components') - ->expectsOutputToContain($this->viewsStub) + $this->artisan('publish --components --no-interaction') + ->expectsOutputToContain('views to [resources/views/vendor/hyde/components]') ->assertExitCode(0); } public function testAllFlagRoutesToViews() { - $this->artisan('publish --all') - ->expectsOutputToContain($this->viewsStub) + $this->artisan('publish --all --no-interaction') + ->expectsOutputToContain('Published all') ->assertExitCode(0); } @@ -124,9 +136,12 @@ public function testPageFlagWithNameRoutesToPages() public function testWizardRoutesToViews() { + $appLayout = (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde').'/framework/resources/views/layouts/app.blade.php'; + $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'views') - ->expectsOutputToContain($this->viewsStub) + ->expectsQuestion('Select Hyde views to publish', [$appLayout]) + ->expectsOutputToContain('Published 1 view') ->assertExitCode(0); } @@ -142,7 +157,7 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() { $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'cancel') - ->doesntExpectOutputToContain($this->viewsStub) + ->doesntExpectOutputToContain('Published') ->doesntExpectOutputToContain($this->pagesStub) ->assertExitCode(0); } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php new file mode 100644 index 00000000000..fd168266b40 --- /dev/null +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -0,0 +1,273 @@ +viewCount('layouts') + $this->viewCount('components'); + + $this->artisan('publish --all --no-interaction') + ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde]") + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); + } + + public function testLayoutsPublishesOnlyLayoutsNonInteractively() + { + $count = $this->viewCount('layouts'); + + $this->artisan('publish --layouts --no-interaction') + ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde/layouts]") + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); + $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); + } + + public function testComponentsPublishesOnlyComponentsNonInteractively() + { + $count = $this->viewCount('components'); + + $this->artisan('publish --components --no-interaction') + ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde/components]") + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); + $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); + } + + // Interactive picker selection (single / many / cross-group), with cardinality-aware output. + + public function testPickerCanPublishASingleView() + { + $this->artisan('publish --layouts') + ->expectsQuestion('Select Hyde views to publish', [$this->source('layouts', 'app.blade.php')]) + ->expectsOutputToContain('Published 1 view to [resources/views/vendor/hyde/layouts/app.blade.php]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); + $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php')); + } + + public function testPickerCanPublishManyViewsFromOneGroup() + { + $this->artisan('publish --layouts') + ->expectsQuestion('Select Hyde views to publish', [ + $this->source('layouts', 'app.blade.php'), + $this->source('layouts', 'page.blade.php'), + $this->source('layouts', 'post.blade.php'), + ]) + ->expectsOutputToContain('Published 3 views to [resources/views/vendor/hyde/layouts]') + ->assertExitCode(0); + } + + public function testPickerBaseDirectorySpansGroupsWhenBothAreSelected() + { + $this->artisan('publish') + ->expectsQuestion('What do you want to publish?', 'views') + ->expectsQuestion('Select Hyde views to publish', [ + $this->source('layouts', 'app.blade.php'), + $this->source('components', 'article-excerpt.blade.php'), + ]) + ->expectsOutputToContain('Published 2 views to [resources/views/vendor/hyde]') + ->assertExitCode(0); + } + + // The picker is prefiltered by the scope flag and uses group-prefixed labels with an "All views" row. + + public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() + { + $output = $this->runViewsPicker(['--layouts' => true], [Key::SPACE, Key::ENTER]); + + Prompt::assertOutputContains('Select Hyde views to publish'); + Prompt::assertOutputContains('All views'); + Prompt::assertOutputContains('layouts/app.blade.php'); + Prompt::assertOutputDoesntContain('components/'); + + // Checking the "All views" sentinel selects every offered (layouts) view. + $this->assertStringContainsString('Published all', $output->fetch()); + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); + $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); + } + + public function testComponentsPickerIsPrefiltered() + { + $this->runViewsPicker(['--components' => true], [Key::SPACE, Key::ENTER]); + + Prompt::assertOutputContains('components/article-excerpt.blade.php'); + Prompt::assertOutputDoesntContain('layouts/'); + } + + // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. + + public function testIdenticalViewsAreSkippedAsAlreadyCurrent() + { + $this->seedAllViews(); + + $this->artisan('publish --all --no-interaction') + ->expectsOutputToContain('All selected views are already up to date.') + ->assertExitCode(0); + } + + public function testModifiedViewsCannotBeOverwrittenNonInteractivelyWithoutForce() + { + $this->seedAllViews(); + $target = $this->modifyPublishedView(); + + $this->artisan('publish --all --no-interaction') + ->expectsOutput('Cannot overwrite modified files without --force:') + ->expectsOutputToContain('resources/views/vendor/hyde/layouts/app.blade.php') + ->expectsOutput('Run again with --force to overwrite.') + ->assertExitCode(1); + + // Hard stop: the modified file is left untouched and nothing else is written either. + $this->assertSame('MODIFIED BY USER', File::get($target)); + } + + public function testForceOverwritesModifiedViews() + { + $this->seedAllViews(); + $target = $this->modifyPublishedView(); + + $this->artisan('publish --all --force --no-interaction') + ->assertExitCode(0); + + $this->assertNotSame('MODIFIED BY USER', File::get($target)); + $this->assertSame(File::get(Hyde::path($this->source('layouts', 'app.blade.php'))), File::get($target)); + } + + public function testInteractiveConflictPromptCanOverwrite() + { + $this->seedAllViews(); + $target = $this->modifyPublishedView(); + + $this->artisan('publish --all') + ->expectsQuestion('1 selected files already exist and appear modified.', 'overwrite') + ->expectsOutputToContain('Published 1 view to [resources/views/vendor/hyde/layouts/app.blade.php]') + ->assertExitCode(0); + + $this->assertNotSame('MODIFIED BY USER', File::get($target)); + } + + public function testInteractiveConflictPromptCanSkip() + { + $this->seedAllViews(); + $target = $this->modifyPublishedView(); + + $this->artisan('publish --all') + ->expectsQuestion('1 selected files already exist and appear modified.', 'skip') + ->expectsOutputToContain('left unchanged because they were modified') + ->assertExitCode(0); + + $this->assertSame('MODIFIED BY USER', File::get($target)); + } + + public function testInteractiveConflictPromptCanCancel() + { + $this->seedAllViews(); + $target = $this->modifyPublishedView(); + + $this->artisan('publish --all') + ->expectsQuestion('1 selected files already exist and appear modified.', 'cancel') + ->expectsOutputToContain('Cancelled. No views were published.') + ->assertExitCode(0); + + $this->assertSame('MODIFIED BY USER', File::get($target)); + } + + protected function viewCount(string $group): int + { + return Filesystem::findFiles("packages/framework/resources/views/$group", '.blade.php', true)->count(); + } + + protected function source(string $group, string $file): string + { + return (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde')."/framework/resources/views/$group/$file"; + } + + /** Publish every view so subsequent runs see identical (already current) destinations. */ + protected function seedAllViews(): void + { + $this->artisan('publish --all --no-interaction')->assertExitCode(0); + } + + /** Modify one already-published view so it is seen as user-modified, and return its target path. */ + protected function modifyPublishedView(): string + { + $target = Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php'); + File::put($target, 'MODIFIED BY USER'); + + return $target; + } + + /** Drive the interactive picker with faked keystrokes and return the buffered output. */ + protected function runViewsPicker(array $parameters, array $keys): BufferedOutput + { + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + Prompt::fake($keys); + + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput($parameters, $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + $command->handle(); + + return $output; + } + + protected function tearDown(): void + { + ConsoleHelper::clearMocks(); + ViewsPromptsReset::resetFallbacks(); + + if (File::isDirectory(Hyde::path('resources/views/vendor'))) { + File::deleteDirectory(Hyde::path('resources/views/vendor')); + } + + parent::tearDown(); + } +} + +abstract class ViewsPromptsReset extends Prompt +{ + // Workaround for https://github.com/laravel/prompts/issues/158 + public static function resetFallbacks(): void + { + static::$shouldFallback = false; + } +} From 72eb12541a4b9628273c068eeb6ba8aa911da20e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 17:33:46 +0200 Subject: [PATCH 09/56] Step 5: Implement the pages publishing flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the publishPage() stub with a real PagesPublisher backed by the PublishablePages registry (§5): - Selection: --page=NAME publishes one page directly; a bare --page or the wizard opens the interactive multi-select picker. - Destination resolution (§5.4): --to → non-interactive default → interactive prompt (default / alternative / custom path) → default. A page with no default (blank) fails helpfully non-interactively, pointing to --to. - --to validation: must resolve under _pages/ and end in .blade.php. - --to is only valid for a single named page; a bare --page (multi-select) with --to is rejected. Documented as a new line in spec §5.4. - Conflict detection (§5.6): two pages resolving to the same target fail before any write. - Interactive confirm (§5.5): select → resolve → "Ready to publish… Proceed?". - Overwrite policy (§7): reuses OverwritePolicy exactly as the views flow does (missing→copy, identical→skip, modified→confirm-or-force). - Optional rebuild (§5.7): offered interactively only, defaulting to NO. Implemented inline rather than via AsksToRebuildSite, which defaults to YES — a why-comment guards against a future consolidation reintroducing the yes-default. Registry lookups compare ->key (never array access) so the string '404' key survives PHP's numeric-key coercion. The Step 3 routing tests that asserted the old stub are updated to assert the real, non-destructive routing behavior. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- new-publish-command-spec.md | 4 + .../src/Console/Commands/PublishCommand.php | 6 +- .../src/Console/Helpers/PagesPublisher.php | 475 ++++++++++++++++++ .../Commands/PublishCommandPagesTest.php | 275 ++++++++++ .../Feature/Commands/PublishCommandTest.php | 32 +- 5 files changed, 775 insertions(+), 17 deletions(-) create mode 100644 packages/framework/src/Console/Helpers/PagesPublisher.php create mode 100644 packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index 94dca0f1822..e1efc6a2f35 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -204,6 +204,10 @@ php hyde publish --page=welcome --force ### 5.4 Destination resolution (per selected page) +`--to` names a single destination, so it is only valid when publishing a **single named page** +(`--page=NAME --to=PATH`). A bare `--page` (multi-select) combined with `--to` is rejected, since +one path cannot stand in as the destination for several pages. + 1. `--to=PATH` → use it. Must resolve under `_pages/` and end in `.blade.php`, else fail. 2. Non-interactive, no `--to` → use `defaultTarget`. If `defaultTarget` is null (e.g. `blank`), there is nothing to fall back to → fail helpfully, pointing to `--to`. diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 32e1b9e9ff7..f196088ac15 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -5,6 +5,7 @@ namespace Hyde\Console\Commands; use Hyde\Console\Concerns\Command; +use Hyde\Console\Helpers\PagesPublisher; use Hyde\Console\Helpers\ViewsPublisher; use Illuminate\Console\OutputStyle; use Symfony\Component\Console\Input\InputInterface; @@ -109,12 +110,9 @@ protected function publishViews(): int return (new ViewsPublisher($this, $this->input))->publish(); } - /** @todo Replaced with the real pages publisher in Step 5. */ protected function publishPage(): int { - $this->infoComment('Publishing pages is not yet implemented.'); - - return Command::SUCCESS; + return (new PagesPublisher($this, $this->input))->publish(); } protected function wantsToPublishViews(): bool diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php new file mode 100644 index 00000000000..1d4a3476936 --- /dev/null +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -0,0 +1,475 @@ + Pages skipped because the destination is already up to date. */ + protected array $current = []; + + /** @var array Modified destinations left unchanged (interactive skip). */ + protected array $leftModified = []; + + /** Whether the pages were chosen through the interactive picker (which adds the §5.5 confirmation step). */ + protected bool $usedPicker = false; + + public function __construct(protected Command $command, protected InputInterface $input) + { + } + + public function publish(): int + { + // §5.4: --to names a single destination, so it is only meaningful for a single named page. A bare --page + // (multi-select) with --to would have one path stand in for several pages; reject it rather than guess. + if ($this->command->option('to') !== null && ! $this->hasNamedPage()) { + $this->command->error('--to is only valid when publishing a single page. Use --page=NAME with --to.'); + + return Command::FAILURE; + } + + $pages = $this->selectPages(); + + if ($pages === null) { + return Command::FAILURE; // A guidance message was already printed. + } + + if ($pages === []) { + $this->command->infoComment('No pages selected; nothing to publish.'); + + return Command::SUCCESS; + } + + $resolved = $this->resolveDestinations($pages); + + if ($resolved === null) { + // A destination could not be resolved (invalid --to, an invalid custom path, or a page with no + // default in non-interactive mode). A guidance message was already printed; this is always a failure. + return Command::FAILURE; + } + + if (! $this->assertNoDestinationConflicts($resolved)) { + return Command::FAILURE; + } + + if ($this->usedPicker && ! $this->confirmProceed($resolved)) { + $this->command->infoComment('Cancelled. No pages were published.'); + + return Command::SUCCESS; + } + + $written = $this->write($resolved); + + // A null result means the run stopped after the decision but before any write: a non-interactive blocked + // run without --force is a hard failure, while an interactive Cancel is a clean exit. + if ($written === null) { + return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; + } + + $this->report($written); + + if ($written !== []) { + $this->maybeRebuild(); + } + + return Command::SUCCESS; + } + + /** + * Determine which pages to publish: a named page directly, or the interactive picker. + * + * @return array|null The selected pages, or null when the run should fail (message printed). + */ + protected function selectPages(): ?array + { + if ($this->hasNamedPage()) { + $name = (string) $this->command->option('page'); + $page = $this->findPage($name); + + if ($page === null) { + $this->command->error("The page [$name] does not exist."); + $this->command->line('Available pages: '.implode(', ', array_map(fn (PublishablePage $page): string => $page->key, PublishablePages::all()))); + + return null; + } + + return [$page]; + } + + // A bare --page (or the wizard) needs the picker, which requires an interactive terminal. + if (! $this->canPrompt()) { + $this->command->error('No page specified for publishing. Provide one, for example --page=welcome.'); + + return null; + } + + $this->usedPicker = true; + + return $this->promptForPages(); + } + + /** @return array */ + protected function promptForPages(): array + { + $options = []; + + foreach (PublishablePages::all() as $page) { + $options[$page->key] = $this->pickerLabel($page); + } + + $selected = InteractiveMultiselect::select('Select pages to publish', $options); + + // Cast defends against PHP coercing a numeric key such as '404' to an int on the way back through the prompt. + return array_map(fn ($key): PublishablePage => $this->findPage((string) $key), $selected); + } + + protected function pickerLabel(PublishablePage $page): string + { + return $page->defaultTarget !== null + ? sprintf('%s → %s', $page->label, $page->defaultTarget) + : $page->label; + } + + /** + * Resolve the destination for each selected page, in registry order. + * + * @param array $pages + * @return array|null Null when resolution failed or was cancelled. + */ + protected function resolveDestinations(array $pages): ?array + { + $resolved = []; + + foreach ($pages as $page) { + $target = $this->resolveTarget($page); + + if ($target === null) { + return null; + } + + $resolved[] = ['page' => $page, 'target' => $target]; + } + + return $resolved; + } + + /** Resolve one page's destination per the §5.4 precedence. Returns null when it cannot be resolved. */ + protected function resolveTarget(PublishablePage $page): ?string + { + // 1. An explicit --to wins (validated against _pages/ and the .blade.php extension). + if ($this->command->option('to') !== null) { + return $this->validateCustomTarget((string) $this->command->option('to')); + } + + // 2. Non-interactive falls back to the default; a page without one (e.g. blank) cannot be resolved. + if (! $this->canPrompt()) { + if ($page->defaultTarget === null) { + $this->command->error(sprintf('The [%s] page has no default destination. Provide one with --to.', $page->key)); + + return null; + } + + return $page->defaultTarget; + } + + // 3. Interactively, prompt whenever there is a real choice to make (alternatives, custom paths, or no default). + if ($page->alternativeTargets !== [] || $page->allowCustomTarget || $page->defaultTarget === null) { + return $this->promptForTarget($page); + } + + // 4. Otherwise the default is the only valid destination. + return $page->defaultTarget; + } + + protected function promptForTarget(PublishablePage $page): ?string + { + $options = []; + + if ($page->defaultTarget !== null) { + $options[$page->defaultTarget] = sprintf('%s (default)', $page->defaultTarget); + } + + foreach ($page->alternativeTargets as $path => $label) { + $options[$path] = sprintf('%s (%s)', $path, $label); + } + + if ($page->allowCustomTarget) { + $options[self::CUSTOM] = 'Custom path…'; + } + + $choice = (string) select(sprintf('Where should "%s" be published?', $page->label), $options); + + return $choice === self::CUSTOM ? $this->promptForCustomTarget() : $choice; + } + + protected function promptForCustomTarget(): ?string + { + $path = text('Enter a path within _pages/', placeholder: '_pages/example.blade.php', required: true); + + return $this->validateCustomTarget($path); + } + + /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ + protected function validateCustomTarget(string $path): ?string + { + $normalized = Str::replace('\\', '/', $path); + + $valid = Str::startsWith($normalized, '_pages/') + && ! Str::contains($normalized, '..') + && Str::endsWith($normalized, '.blade.php'); + + if (! $valid) { + $this->command->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); + + return null; + } + + return $normalized; + } + + /** + * Reject the run when two selected pages resolve to the same destination (§5.6), before anything is written. + * + * @param array $resolved + */ + protected function assertNoDestinationConflicts(array $resolved): bool + { + $labelsByTarget = []; + + foreach ($resolved as $entry) { + $labelsByTarget[$entry['target']][] = $entry['page']->label; + } + + foreach ($labelsByTarget as $target => $labels) { + if (count($labels) > 1) { + $this->command->error(sprintf('%s both target %s.', $this->joinLabels($labels), $target)); + $this->command->line('Pick one, or set --to for each.'); + + return false; + } + } + + return true; + } + + /** @param array $resolved */ + protected function confirmProceed(array $resolved): bool + { + $this->command->line('Ready to publish:'); + + foreach ($resolved as $entry) { + $this->command->line(sprintf(' %s → %s', $entry['page']->label, $entry['target'])); + } + + $this->command->newLine(); + + return confirm('Proceed?', true); + } + + /** + * Apply the shared overwrite policy and copy the resolved pages into place. + * + * @param array $resolved + * @return array|null + * The pages actually written, or null when the run should stop (cancelled, or blocked without --force). + */ + protected function write(array $resolved): ?array + { + $copy = []; + $blocked = []; + + foreach ($resolved as $entry) { + $record = [ + 'page' => $entry['page'], + 'target' => $entry['target'], + 'source' => Hyde::vendorPath($entry['page']->source), + 'absolute' => Hyde::path($entry['target']), + ]; + + match (OverwritePolicy::decide($record['source'], $record['absolute'])) { + OverwriteAction::Copy => $copy[] = $record, + OverwriteAction::Skip => $this->current[] = $entry, + OverwriteAction::Blocked => $blocked[] = $record, + }; + } + + $overwrite = $this->resolveBlocked($blocked); + + if ($overwrite === null) { + return null; + } + + $this->leftModified = $overwrite === [] ? array_map(fn (array $record): array => ['page' => $record['page'], 'target' => $record['target']], $blocked) : []; + + $written = [...$copy, ...$overwrite]; + + foreach ($written as $record) { + Filesystem::ensureParentDirectoryExists($record['absolute']); + Filesystem::copy($record['source'], $record['absolute']); + } + + return $written; + } + + /** + * Resolve what to do with modified (blocked) destinations, mirroring the views flow (§7). + * + * @param array $blocked + * @return array|null + */ + protected function resolveBlocked(array $blocked): ?array + { + if ($blocked === []) { + return []; + } + + if ($this->command->option('force')) { + return $blocked; + } + + if (! $this->canPrompt()) { + $this->command->error('Cannot overwrite modified files without --force:'); + + foreach ($blocked as $record) { + $this->command->line(' '.$record['target']); + } + + $this->command->newLine(); + $this->command->line('Run again with --force to overwrite.'); + + return null; + } + + $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ + 'skip' => 'Skip modified files', + 'overwrite' => 'Overwrite modified files', + 'cancel' => 'Cancel', + ], 'skip'); + + return match ($choice) { + 'overwrite' => $blocked, + 'skip' => [], + default => null, + }; + } + + /** @param array $written */ + protected function report(array $written): void + { + if ($written === [] && $this->leftModified === [] && $this->current !== []) { + $this->command->infoComment('All selected pages are already up to date.'); + + return; + } + + foreach ($written as $record) { + $this->command->infoComment(sprintf('Published [%s] to [%s]', $record['page']->key, $record['target'])); + } + + if ($this->current !== []) { + $this->command->infoComment(sprintf('%s already up to date and skipped.', $this->pageCount(count($this->current)))); + } + + if ($this->leftModified !== []) { + $this->command->newLine(); + $this->command->warn(sprintf('%s left unchanged because they were modified:', $this->pageCount(count($this->leftModified)))); + + foreach ($this->leftModified as $entry) { + $this->command->line(' '.$entry['target']); + } + + $this->command->line('Run again with --force to overwrite.'); + } + } + + /** + * Offer to rebuild the site after a successful publish (§5.7). + * + * Interactive only, and deliberately defaulting to NO. We do not reuse the shared AsksToRebuildSite trait + * here: it prompts with different wording and defaults to YES, which would auto-rebuild the entire site after + * a single page publish. Keep this inline so the no-default is not "helpfully" consolidated back to a yes-default. + */ + protected function maybeRebuild(): void + { + if (! $this->canPrompt()) { + return; + } + + if (confirm('Rebuild the site now?', false)) { + Artisan::call('build', [], $this->command->getOutput()); + } + } + + /** Whether a specific page name was supplied via --page=NAME (as opposed to a bare --page or the wizard). */ + protected function hasNamedPage(): bool + { + $name = $this->command->option('page'); + + return $name !== null && $name !== ''; + } + + /** Find a page by key, comparing the ->key property so a numeric key such as '404' is never lost to array coercion. */ + protected function findPage(string $name): ?PublishablePage + { + foreach (PublishablePages::all() as $page) { + if ($page->key === $name) { + return $page; + } + } + + return null; + } + + /** @param array $labels */ + protected function joinLabels(array $labels): string + { + if (count($labels) < 2) { + return implode('', $labels); + } + + return implode(', ', array_slice($labels, 0, -1)).' and '.$labels[count($labels) - 1]; + } + + protected function pageCount(int $count): string + { + return $count === 1 ? '1 page' : "$count pages"; + } + + protected function canPrompt(): bool + { + return ConsoleHelper::canUseLaravelPrompts($this->input); + } +} diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php new file mode 100644 index 00000000000..e8804e7fffa --- /dev/null +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -0,0 +1,275 @@ +withoutDefaultPages(); + } + + protected function tearDown(): void + { + ConsoleHelper::clearMocks(); + PublishablePages::clear(); + + // Remove anything a test published, then restore the two committed default pages so the tree stays clean. + foreach (glob(Hyde::path('_pages/*.blade.php')) as $file) { + File::delete($file); + } + + $this->restoreDefaultPages(); + + parent::tearDown(); + } + + // Named-page publishing (--page=NAME) with non-interactive destination resolution (§5.4 step 2). + + public function testNamedPagePublishesToItsDefaultTargetNonInteractively() + { + $this->artisan('publish --page=welcome --no-interaction') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + $this->assertSame( + File::get(Hyde::vendorPath('resources/views/homepages/welcome.blade.php')), + File::get(Hyde::path('_pages/index.blade.php')) + ); + } + + public function testUnknownPageNameFailsHelpfully() + { + $this->artisan('publish --page=nope --no-interaction') + ->expectsOutputToContain('The page [nope] does not exist.') + ->expectsOutputToContain('Available pages: welcome, posts, blank, 404') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + + // The '404' key is a string on the value object but coerces to an int array key: lookup must compare ->key (§5.1). + + public function testNumericPageKeyIsResolvedByItsStringKey() + { + $this->artisan('publish --page=404 --no-interaction') + ->expectsOutputToContain('Published [404] to [_pages/404.blade.php]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/404.blade.php')); + } + + // Destination resolution: --to wins over the default (§5.4 step 1). + + public function testToOverridesTheDefaultTarget() + { + $this->artisan('publish --page=posts --to=_pages/index.blade.php --no-interaction') + ->expectsOutputToContain('Published [posts] to [_pages/index.blade.php]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); + } + + // A page with no default target (blank) cannot be resolved non-interactively without --to (§5.4 step 2). + + public function testPageWithoutDefaultTargetFailsNonInteractivelyWithoutTo() + { + $this->artisan('publish --page=blank --no-interaction') + ->expectsOutputToContain('The [blank] page has no default destination. Provide one with --to.') + ->assertExitCode(1); + } + + public function testPageWithoutDefaultTargetPublishesWithTo() + { + $this->artisan('publish --page=blank --to=_pages/about.blade.php --no-interaction') + ->expectsOutputToContain('Published [blank] to [_pages/about.blade.php]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/about.blade.php')); + } + + // --to validation: must live under _pages/ and end in .blade.php (§5.4 step 1, §9). + + public function testToPathOutsidePagesDirectoryIsRejected() + { + $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') + ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') + ->assertExitCode(1); + } + + public function testToPathWithWrongExtensionIsRejected() + { + $this->artisan('publish --page=welcome --to=_pages/index.md --no-interaction') + ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') + ->assertExitCode(1); + } + + // Overwrite policy (§7): identical -> skip, modified -> fail without --force, --force overwrites. + + public function testIdenticalPageIsSkippedAsAlreadyCurrent() + { + $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); + + $this->artisan('publish --page=welcome --no-interaction') + ->expectsOutputToContain('All selected pages are already up to date.') + ->assertExitCode(0); + } + + public function testModifiedPageCannotBeOverwrittenNonInteractivelyWithoutForce() + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); + + $this->artisan('publish --page=welcome --no-interaction') + ->expectsOutput('Cannot overwrite modified files without --force:') + ->expectsOutputToContain('_pages/index.blade.php') + ->expectsOutput('Run again with --force to overwrite.') + ->assertExitCode(1); + + $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); + } + + public function testForceOverwritesModifiedPage() + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); + + $this->artisan('publish --page=welcome --force --no-interaction') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->assertExitCode(0); + + $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); + } + + // Interactive destination prompt (§5.4 step 3): default / alternative / custom path. + + public function testInteractiveResolutionCanChooseAnAlternativeTarget() + { + $this->artisan('publish --page=posts') + ->expectsQuestion('Where should "Posts feed" be published?', '_pages/index.blade.php') + ->expectsOutputToContain('Published [posts] to [_pages/index.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); + } + + public function testInteractiveResolutionCanChooseACustomPath() + { + $this->artisan('publish --page=blank') + ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') + ->expectsQuestion('Enter a path within _pages/', '_pages/custom.blade.php') + ->expectsOutputToContain('Published [blank] to [_pages/custom.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/custom.blade.php')); + } + + public function testCustomPathFromPromptIsValidated() + { + $this->artisan('publish --page=blank') + ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') + ->expectsQuestion('Enter a path within _pages/', 'somewhere/else.blade.php') + ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') + ->assertExitCode(1); + } + + // Interactive picker flow (§5.5): select -> resolve -> confirm. + + public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() + { + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome']) + ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') + ->expectsOutput('Ready to publish:') + ->expectsOutputToContain('Welcome page → _pages/index.blade.php') + ->expectsConfirmation('Proceed?', 'yes') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + } + + public function testInteractivePickerCanBeDeclinedAtConfirmation() + { + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome']) + ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') + ->expectsConfirmation('Proceed?', 'no') + ->expectsOutputToContain('Cancelled. No pages were published.') + ->assertExitCode(0); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + + // Destination-conflict detection before any write (§5.6). + + public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() + { + // Register a second page whose default collides with welcome's default so the picker offers both. + PublishablePages::register(new PublishablePage( + key: 'clash', + label: 'Clashing page', + description: 'A page that targets the homepage too.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/index.blade.php', + allowCustomTarget: false, + )); + + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome', 'clash']) + ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') + ->expectsOutputToContain('Welcome page and Clashing page both target _pages/index.blade.php.') + ->expectsOutputToContain('Pick one, or set --to for each.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + + // Optional rebuild (§5.7): offered interactively, never non-interactively. + + public function testRebuildIsOfferedInteractivelyAfterPublishing() + { + $this->artisan('publish --page=welcome') + ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + } + + public function testRebuildIsNeverOfferedNonInteractively() + { + $this->artisan('publish --page=welcome --no-interaction') + ->doesntExpectOutputToContain('Rebuild the site now?') + ->assertExitCode(0); + } +} diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index b807caa8b58..3722c4f060e 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -19,8 +19,6 @@ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { - protected string $pagesStub = 'Publishing pages is not yet implemented.'; - protected function tearDown(): void { // The views-routing tests below publish real files; remove them so the tree stays clean. @@ -77,11 +75,13 @@ public function testToOptionRequiresThePageFlag() ->assertExitCode(1); } - public function testToOptionIsAllowedAlongsideThePageFlag() + public function testToOptionRequiresANamedPageNotABarePageFlag() { + // --to names one destination, so it is only valid with a single named page (§5.4); a bare --page + // (multi-select) with --to is rejected rather than letting one path stand in for several pages. $this->artisan('publish --page --to=_pages/index.blade.php') - ->expectsOutputToContain($this->pagesStub) - ->assertExitCode(0); + ->expectsOutputToContain('--to is only valid when publishing a single page. Use --page=NAME with --to.') + ->assertExitCode(1); } public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() @@ -120,16 +120,18 @@ public function testAllFlagRoutesToViews() public function testBarePageFlagRoutesToPages() { - $this->artisan('publish --page') - ->expectsOutputToContain($this->pagesStub) - ->assertExitCode(0); + // A bare --page needs the interactive picker; non-interactively it reaches the pages flow and fails there. + $this->artisan('publish --page --no-interaction') + ->expectsOutputToContain('No page specified for publishing. Provide one, for example --page=welcome.') + ->assertExitCode(1); } public function testPageFlagWithNameRoutesToPages() { - $this->artisan('publish --page=welcome') - ->expectsOutputToContain($this->pagesStub) - ->assertExitCode(0); + // An unknown name proves the flag routes into the pages flow and its registry lookup, without writing anything. + $this->artisan('publish --page=nonexistent --no-interaction') + ->expectsOutputToContain('The page [nonexistent] does not exist.') + ->assertExitCode(1); } // Interactive wizard routing (§3). @@ -147,9 +149,14 @@ public function testWizardRoutesToViews() public function testWizardRoutesToPages() { + // Route through the wizard into the real pages flow. Welcome resolves to the default _pages/index.blade.php, + // which ships identical to the source, so this is a non-destructive "already up to date" skip. $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'page') - ->expectsOutputToContain($this->pagesStub) + ->expectsQuestion('Select pages to publish', ['welcome']) + ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') + ->expectsConfirmation('Proceed?', 'yes') + ->expectsOutputToContain('All selected pages are already up to date.') ->assertExitCode(0); } @@ -158,7 +165,6 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'cancel') ->doesntExpectOutputToContain('Published') - ->doesntExpectOutputToContain($this->pagesStub) ->assertExitCode(0); } From 95bcd5d35b72377f0404eebc88afdd1cdb0cb44a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 17:49:41 +0200 Subject: [PATCH 10/56] Step 5: Streamline page destination prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Destination resolution UX (§5.4): only prompt interactively when a page is genuinely ambiguous — it has alternative targets or no default. A page whose default is its one sensible destination (welcome, 404) publishes in a single frictionless step instead of asking "Where should this go?" for a near-certain answer. allowCustomTarget now governs only whether a "Custom path…" entry is offered and whether --to is accepted, not whether the prompt appears; custom placement of an otherwise-unambiguous page is reached via --to. Consequently --to is rejected for a page that disallows custom targets (404). Polish: - The overwrite-conflict "Cancel" choice now prints "Cancelled. No pages were published." instead of exiting silently, matching the views flow and the "Proceed? no" path. - The destination-conflict message uses "both target" for a pair and "all target" for three or more colliding pages. - Added tests: --to rejected for 404, the picker round-tripping the numeric '404' key (the coerced-int-key path the named lookup can't reach), and the pages picker omitting the "All" row that the views picker offers. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- new-publish-command-spec.md | 12 ++- .../src/Console/Helpers/PagesPublisher.php | 40 ++++++--- .../Commands/PublishCommandPagesTest.php | 90 ++++++++++++++++++- .../Feature/Commands/PublishCommandTest.php | 1 - 4 files changed, 125 insertions(+), 18 deletions(-) diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index e1efc6a2f35..373debd2170 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -206,12 +206,18 @@ php hyde publish --page=welcome --force `--to` names a single destination, so it is only valid when publishing a **single named page** (`--page=NAME --to=PATH`). A bare `--page` (multi-select) combined with `--to` is rejected, since -one path cannot stand in as the destination for several pages. +one path cannot stand in as the destination for several pages. `--to` is also rejected for a page +that declares `allowCustomTarget = false` (e.g. `404`), which has one fixed destination. -1. `--to=PATH` → use it. Must resolve under `_pages/` and end in `.blade.php`, else fail. +1. `--to=PATH` → use it. Rejected if the page disallows custom targets; otherwise must resolve + under `_pages/` and end in `.blade.php`, else fail. 2. Non-interactive, no `--to` → use `defaultTarget`. If `defaultTarget` is null (e.g. `blank`), there is nothing to fall back to → fail helpfully, pointing to `--to`. -3. Interactive AND (`alternativeTargets` non-empty OR `allowCustomTarget` OR `defaultTarget` is null) → prompt: +3. Interactive AND (`alternativeTargets` non-empty OR `defaultTarget` is null) → prompt. `allowCustomTarget` + governs only whether a "Custom path…" entry is offered here (and whether `--to` is accepted), **not** + whether the prompt appears — a page whose default is its one sensible destination (e.g. `welcome`, `404`) + is not prompted for, keeping the common case a single, frictionless step. Its custom placement, when + allowed, is reached through `--to`. ``` Where should "Posts feed" be published? diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 1d4a3476936..d3dde9b3735 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -189,8 +189,15 @@ protected function resolveDestinations(array $pages): ?array /** Resolve one page's destination per the §5.4 precedence. Returns null when it cannot be resolved. */ protected function resolveTarget(PublishablePage $page): ?string { - // 1. An explicit --to wins (validated against _pages/ and the .blade.php extension). + // 1. An explicit --to wins, but only for pages that allow a custom destination (e.g. not 404), and it is + // validated against _pages/ and the .blade.php extension. if ($this->command->option('to') !== null) { + if (! $page->allowCustomTarget) { + $this->command->error(sprintf('The [%s] page cannot be published to a custom path; omit --to to use its default (%s).', $page->key, $page->defaultTarget)); + + return null; + } + return $this->validateCustomTarget((string) $this->command->option('to')); } @@ -205,12 +212,15 @@ protected function resolveTarget(PublishablePage $page): ?string return $page->defaultTarget; } - // 3. Interactively, prompt whenever there is a real choice to make (alternatives, custom paths, or no default). - if ($page->alternativeTargets !== [] || $page->allowCustomTarget || $page->defaultTarget === null) { + // 3. Interactively, prompt only when the destination is genuinely ambiguous: the page offers alternative + // targets, or it has no default at all. A page whose default is the one sensible destination (welcome, 404) + // is not prompted for — its custom placement, if allowed, is reached through --to instead. This keeps the + // common "publish the welcome homepage" case a single, frictionless step. + if ($page->alternativeTargets !== [] || $page->defaultTarget === null) { return $this->promptForTarget($page); } - // 4. Otherwise the default is the only valid destination. + // 4. Otherwise the default is the only offered destination. return $page->defaultTarget; } @@ -275,7 +285,9 @@ protected function assertNoDestinationConflicts(array $resolved): bool foreach ($labelsByTarget as $target => $labels) { if (count($labels) > 1) { - $this->command->error(sprintf('%s both target %s.', $this->joinLabels($labels), $target)); + // "both" reads correctly for a pair; three or more colliding pages need "all". + $verb = count($labels) === 2 ? 'both target' : 'all target'; + $this->command->error(sprintf('%s %s %s.', $this->joinLabels($labels), $verb, $target)); $this->command->line('Pick one, or set --to for each.'); return false; @@ -379,11 +391,19 @@ protected function resolveBlocked(array $blocked): ?array 'cancel' => 'Cancel', ], 'skip'); - return match ($choice) { - 'overwrite' => $blocked, - 'skip' => [], - default => null, - }; + if ($choice === 'overwrite') { + return $blocked; + } + + if ($choice === 'skip') { + return []; + } + + // Cancelling the overwrite prompt aborts the whole run; announce it as the views flow and the + // "Proceed? no" path both do, so the exit is never silent. This branch is only reached interactively. + $this->command->infoComment('Cancelled. No pages were published.'); + + return null; } /** @param array $written */ diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index e8804e7fffa..b0c66a95a28 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -12,8 +12,13 @@ use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Testing\TestCase; +use Illuminate\Console\OutputStyle; use Illuminate\Support\Facades\File; +use Laravel\Prompts\Key; +use Laravel\Prompts\Prompt; use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; use function glob; @@ -38,6 +43,7 @@ protected function setUp(): void protected function tearDown(): void { ConsoleHelper::clearMocks(); + PagesPromptsReset::resetFallbacks(); PublishablePages::clear(); // Remove anything a test published, then restore the two committed default pages so the tree stays clean. @@ -132,6 +138,17 @@ public function testToPathWithWrongExtensionIsRejected() ->assertExitCode(1); } + // A page that disallows custom targets (404) rejects --to and keeps its fixed default. + + public function testToIsRejectedForAPageThatDisallowsCustomTargets() + { + $this->artisan('publish --page=404 --to=_pages/error.blade.php --no-interaction') + ->expectsOutputToContain('The [404] page cannot be published to a custom path; omit --to to use its default (_pages/404.blade.php).') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/error.blade.php')); + } + // Overwrite policy (§7): identical -> skip, modified -> fail without --force, --force overwrites. public function testIdenticalPageIsSkippedAsAlreadyCurrent() @@ -206,9 +223,9 @@ public function testCustomPathFromPromptIsValidated() public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() { + // Welcome has a single sensible destination, so it is not prompted for; it resolves to its default. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome']) - ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') ->expectsOutput('Ready to publish:') ->expectsOutputToContain('Welcome page → _pages/index.blade.php') ->expectsConfirmation('Proceed?', 'yes') @@ -223,7 +240,6 @@ public function testInteractivePickerCanBeDeclinedAtConfirmation() { $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome']) - ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') ->expectsConfirmation('Proceed?', 'no') ->expectsOutputToContain('Cancelled. No pages were published.') ->assertExitCode(0); @@ -245,9 +261,10 @@ public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() allowCustomTarget: false, )); + // Neither page is prompted for (welcome and clash each resolve straight to their default), so the + // collision is caught purely from the picker selection, before any destination prompt or write. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', 'clash']) - ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') ->expectsOutputToContain('Welcome page and Clashing page both target _pages/index.blade.php.') ->expectsOutputToContain('Pick one, or set --to for each.') ->assertExitCode(1); @@ -259,8 +276,8 @@ public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() public function testRebuildIsOfferedInteractivelyAfterPublishing() { + // Welcome resolves to its default without a destination prompt, so the only interaction is the rebuild offer. $this->artisan('publish --page=welcome') - ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') ->expectsConfirmation('Rebuild the site now?', 'no') ->assertExitCode(0); @@ -272,4 +289,69 @@ public function testRebuildIsNeverOfferedNonInteractively() ->doesntExpectOutputToContain('Rebuild the site now?') ->assertExitCode(0); } + + // The picker round-trips the numeric '404' key: PHP coerces it to an int option key, and it must cast + // back to the string key to resolve. This covers the sneakier path the named --page=404 test cannot reach. + + public function testPickerCanSelectTheNumericKeyedPage() + { + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['404']) + ->expectsConfirmation('Proceed?', 'yes') + ->expectsOutputToContain('Published [404] to [_pages/404.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/404.blade.php')); + } + + // Option 2's whole point: the pages picker must NOT offer an "All" row (unlike the views picker). + + public function testPickerDoesNotOfferAnAllRow() + { + // Space+enter selects the first row (welcome); the next enter accepts "Proceed?" (default yes), the + // last accepts "Rebuild the site now?" (default no) — so the run completes without leftover prompts. + $output = $this->runPagesPicker([Key::SPACE, Key::ENTER, Key::ENTER, Key::ENTER]); + + Prompt::assertOutputContains('Select pages to publish'); + Prompt::assertOutputContains('Welcome page'); + Prompt::assertOutputDoesntContain('All pages'); + Prompt::assertOutputDoesntContain('All views'); + + // The first offered row is a real page (welcome), not a select-all sentinel, so a single space+enter publishes it. + $this->assertStringContainsString('Published [welcome]', $output->fetch()); + } + + /** Drive the interactive pages picker with faked keystrokes and return the buffered output. */ + protected function runPagesPicker(array $keys): BufferedOutput + { + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + // Earlier --no-interaction runs in this class leave Prompt::$shouldFallback stuck true, which would + // route the picker through the (unrendered) fallback path; reset it so the prompt renders to the fake buffer. + PagesPromptsReset::resetFallbacks(); + + Prompt::fake($keys); + + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput(['--page' => null], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + $command->handle(); + + return $output; + } +} + +abstract class PagesPromptsReset extends Prompt +{ + // Workaround for https://github.com/laravel/prompts/issues/158 + public static function resetFallbacks(): void + { + static::$shouldFallback = false; + } } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 3722c4f060e..387da2c4a38 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -154,7 +154,6 @@ public function testWizardRoutesToPages() $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'page') ->expectsQuestion('Select pages to publish', ['welcome']) - ->expectsQuestion('Where should "Welcome page" be published?', '_pages/index.blade.php') ->expectsConfirmation('Proceed?', 'yes') ->expectsOutputToContain('All selected pages are already up to date.') ->assertExitCode(0); From bf3ab26e4b48ad267f9bdfc36e15ff32d9cc395c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 17:59:18 +0200 Subject: [PATCH 11/56] Step 5: Pin multi-select --to guard precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a test proving the two --to rejections cannot disagree. Bare --page + --to is caught by the top-level "one path can't serve several pages" guard (message A) before selectPages() runs, so the per-page allowCustomTarget rejection (message B) can never pre-empt it. The test runs interactively — it would hang on an unanswered picker prompt if the picker were reached — and asserts message A wins and message B is absent. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../Feature/Commands/PublishCommandPagesTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index b0c66a95a28..d54c5dc1abe 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -149,6 +149,19 @@ public function testToIsRejectedForAPageThatDisallowsCustomTargets() $this->assertFileDoesNotExist(Hyde::path('_pages/error.blade.php')); } + // The two --to rejections must never disagree. Bare --page + --to is a multi-select context with a single + // destination: the "one path can't serve several pages" guard (message A) must win over any per-page reason + // (message B, e.g. 404's custom-path rejection) AND fire before the picker — so this interactive run, which + // would hang on an unanswered picker prompt if the picker were reached, asks nothing and exits on message A. + + public function testBarePageWithToIsRejectedBeforeThePickerAndBeatsThePerPageReason() + { + $this->artisan('publish --page --to=_pages/error.blade.php') + ->expectsOutputToContain('--to is only valid when publishing a single page. Use --page=NAME with --to.') + ->doesntExpectOutputToContain('cannot be published to a custom path') + ->assertExitCode(1); + } + // Overwrite policy (§7): identical -> skip, modified -> fail without --force, --force overwrites. public function testIdenticalPageIsSkippedAsAlreadyCurrent() From ab376c330245aa884ad9c321a7f4ac63517ee742 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 18:23:54 +0200 Subject: [PATCH 12/56] Step 6: Register the hyde-config publish tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a single `hyde-config` publish group on ConfigurationServiceProvider that publishes exactly the six Hyde-owned config files (hyde, docs, markdown, view, cache, commands) and not torchlight.php, per spec §6. The legacy `configs` / `hyde-configs` / `support-configs` tags are left in place; the deprecated publish:configs command still relies on them until Step 7. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../ConfigurationServiceProvider.php | 9 +++++++ .../tests/Feature/HydeServiceProviderTest.php | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php b/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php index d5af0e0952d..708376a13ec 100644 --- a/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php +++ b/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php @@ -29,6 +29,15 @@ public function boot(): void __DIR__.'/../../../config/markdown.php' => config_path('markdown.php'), ], 'hyde-configs'); + $this->publishes([ + __DIR__.'/../../../config/hyde.php' => config_path('hyde.php'), + __DIR__.'/../../../config/docs.php' => config_path('docs.php'), + __DIR__.'/../../../config/markdown.php' => config_path('markdown.php'), + __DIR__.'/../../../config/view.php' => config_path('view.php'), + __DIR__.'/../../../config/cache.php' => config_path('cache.php'), + __DIR__.'/../../../config/commands.php' => config_path('commands.php'), + ], 'hyde-config'); + $this->publishes([ __DIR__.'/../../../config/view.php' => config_path('view.php'), __DIR__.'/../../../config/cache.php' => config_path('cache.php'), diff --git a/packages/framework/tests/Feature/HydeServiceProviderTest.php b/packages/framework/tests/Feature/HydeServiceProviderTest.php index faad0cb0fdc..2270cc3c113 100644 --- a/packages/framework/tests/Feature/HydeServiceProviderTest.php +++ b/packages/framework/tests/Feature/HydeServiceProviderTest.php @@ -9,6 +9,7 @@ use Hyde\Framework\Features\Navigation\DocumentationSidebar; use Illuminate\Contracts\Container\BindingResolutionException; use Hyde\Console\ConsoleServiceProvider; +use Hyde\Foundation\Providers\ConfigurationServiceProvider; use Hyde\Framework\HydeServiceProvider; use Hyde\Framework\Services\BuildTaskService; use Hyde\Foundation\HydeCoreExtension; @@ -20,6 +21,7 @@ use Hyde\Pages\MarkdownPost; use Hyde\Testing\TestCase; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\ServiceProvider; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\HydeServiceProvider::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Concerns\RegistersFileLocations::class)] @@ -371,4 +373,26 @@ public function testCanGetDocumentationSidebarFromContainerUsingShorthand() $this->assertSame(app('navigation.sidebar'), DocumentationSidebar::get()); } + + public function testHydeConfigPublishTagPublishesExactlyTheSixHydeOwnedConfigFiles() + { + $paths = ServiceProvider::pathsToPublish(ConfigurationServiceProvider::class, 'hyde-config'); + + $sources = array_map('basename', array_keys($paths)); + $destinations = array_map('basename', array_values($paths)); + + $expected = ['hyde.php', 'docs.php', 'markdown.php', 'view.php', 'cache.php', 'commands.php']; + + $this->assertSame($expected, $sources); + $this->assertSame($expected, $destinations); + } + + public function testHydeConfigPublishTagDoesNotPublishTorchlightConfig() + { + $paths = ServiceProvider::pathsToPublish(ConfigurationServiceProvider::class, 'hyde-config'); + + $files = array_map('basename', array_keys($paths)); + + $this->assertNotContains('torchlight.php', $files); + } } From 67cab3e4dbb6c2425b16c0e5c8a8a8cbe05a94d9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 18:46:59 +0200 Subject: [PATCH 13/56] Step 7: Convert the old publish commands into deprecated aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec §8, publish:views, publish:configs, and publish:homepage become thin delegators that print a one-line deprecation notice and forward to the new surface: - publish:views [group] -> publish --layouts / --components (no group -> --all, keeping legacy non-interactive scripts non-interactive) - publish:configs -> vendor:publish --tag=hyde-config - publish:homepage [template] -> publish --page=[template], forwarding --force The old command classes stay registered and keep working through v3; target removal in v4. Their tests are rewritten to assert the notice and delegation. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../Commands/PublishConfigsCommand.php | 38 +-- .../Commands/PublishHomepageCommand.php | 112 ++------- .../Console/Commands/PublishViewsCommand.php | 150 ++--------- .../Commands/PublishConfigsCommandTest.php | 57 ++--- .../Commands/PublishHomepageCommandTest.php | 101 +++----- .../Commands/PublishViewsCommandTest.php | 237 ++---------------- 6 files changed, 122 insertions(+), 573 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishConfigsCommand.php b/packages/framework/src/Console/Commands/PublishConfigsCommand.php index 496d88ec3fd..45e39e9f6b3 100644 --- a/packages/framework/src/Console/Commands/PublishConfigsCommand.php +++ b/packages/framework/src/Console/Commands/PublishConfigsCommand.php @@ -4,15 +4,16 @@ namespace Hyde\Console\Commands; -use Hyde\Hyde; use Hyde\Console\Concerns\Command; -use Illuminate\Support\Facades\Artisan; - -use function array_search; -use function sprintf; /** - * Publish the Hyde config files. + * Deprecated alias for publishing the Hyde config files. + * + * Kept working through v3 as a thin delegator: it prints a one-line deprecation + * notice and forwards to `php hyde vendor:publish --tag=hyde-config`. Target + * removal in v4. + * + * @see \Hyde\Console\Commands\VendorPublishCommand */ class PublishConfigsCommand extends Command { @@ -24,29 +25,8 @@ class PublishConfigsCommand extends Command public function handle(): int { - $options = [ - 'All configs', - 'hyde-configs: Main configuration files', - 'support-configs: Laravel and package configuration files', - ]; - $selection = $this->choice('Which configuration files do you want to publish?', $options, 'All configs'); - - $tag = $this->parseTagFromSelection($selection, $options); - - Artisan::call('vendor:publish', [ - '--tag' => $tag, - '--force' => true, - ], $this->output); - - $this->infoComment(sprintf('Published config files to [%s]', Hyde::path('config'))); - - return Command::SUCCESS; - } - - protected function parseTagFromSelection(string $selection, array $options): string - { - $tags = ['configs', 'hyde-configs', 'support-configs']; + $this->warn('publish:configs is deprecated. Use php hyde vendor:publish --tag=hyde-config instead.'); - return $tags[array_search($selection, $options)]; + return $this->call('vendor:publish', ['--tag' => 'hyde-config']); } } diff --git a/packages/framework/src/Console/Commands/PublishHomepageCommand.php b/packages/framework/src/Console/Commands/PublishHomepageCommand.php index 61f49beb9c4..71e670f230c 100644 --- a/packages/framework/src/Console/Commands/PublishHomepageCommand.php +++ b/packages/framework/src/Console/Commands/PublishHomepageCommand.php @@ -4,26 +4,21 @@ namespace Hyde\Console\Commands; -use Hyde\Pages\BladePage; use Hyde\Console\Concerns\Command; -use Hyde\Console\Concerns\AsksToRebuildSite; -use Hyde\Framework\Services\ViewDiffService; -use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Collection; -use function Hyde\unixsum_file; -use function array_key_exists; -use function file_exists; -use function str_replace; -use function strstr; +use function is_string; /** - * Publish one of the default homepages to index.blade.php. + * Deprecated alias for the page flow of the `publish` command. + * + * Kept working through v3 as a thin delegator: it prints a one-line deprecation + * notice and forwards to `php hyde publish --page`, mapping the optional template + * name and the --force flag. Target removal in v4. + * + * @see \Hyde\Console\Commands\PublishCommand */ class PublishHomepageCommand extends Command { - use AsksToRebuildSite; - /** @var string */ protected $signature = 'publish:homepage {homepage? : The name of the page to publish} {--force : Overwrite any existing files}'; @@ -31,98 +26,21 @@ class PublishHomepageCommand extends Command /** @var string */ protected $description = 'Publish one of the default homepages to index.blade.php'; - /** @var array */ - protected array $options = [ - 'welcome' => [ - 'name' => 'Welcome', - 'description' => 'The default welcome page.', - 'group' => 'hyde-welcome-page', - ], - 'posts' => [ - 'name' => 'Posts Feed', - 'description' => 'A feed of your latest posts. Perfect for a blog site!', - 'group' => 'hyde-posts-page', - ], - 'blank' => [ - 'name' => 'Blank Starter', - 'description' => 'A blank Blade template with just the base layout.', - 'group' => 'hyde-blank-page', - ], - ]; - public function handle(): int { - $selected = $this->parseSelection(); - - if (! $this->canExistingFileBeOverwritten()) { - $this->error('A modified index.blade.php file already exists. Use --force to overwrite.'); - - return 409; - } - - $tagExists = array_key_exists($selected, $this->options); - - Artisan::call('vendor:publish', [ - '--tag' => $this->options[$selected]['group'] ?? $selected, - '--force' => true, - ], $tagExists ? null : $this->output); // If the tag doesn't exist, we pass the output to use the called command's error output. - - if ($tagExists) { - $this->infoComment("Published page [$selected]"); - - $this->askToRebuildSite(); - } - - return $tagExists ? Command::SUCCESS : 404; - } - - protected function parseSelection(): string - { - return $this->argument('homepage') ?? $this->parseChoiceIntoKey($this->promptForHomepage()); - } - - protected function promptForHomepage(): string - { - return $this->choice( - 'Which homepage do you want to publish?', - $this->formatPublishableChoices(), - 0 - ); - } + $homepage = $this->argument('homepage'); + $name = is_string($homepage) ? $homepage : null; - protected function formatPublishableChoices(): array - { - return $this->getTemplateOptions()->map(function (array $option, string $key): string { - return "$key: {$option['description']}"; - })->values()->toArray(); - } + $hint = $name !== null ? "--page=$name" : '--page'; - /** @return Collection */ - protected function getTemplateOptions(): Collection - { - return new Collection($this->options); - } + $this->warn("publish:homepage is deprecated. Use php hyde publish $hint instead."); - protected function parseChoiceIntoKey(string $choice): string - { - return strstr(str_replace(['', ''], '', $choice), ':', true); - } + $parameters = ['--page' => $name]; - protected function canExistingFileBeOverwritten(): bool - { if ($this->option('force')) { - return true; - } - - if (! file_exists(BladePage::path('index.blade.php'))) { - return true; + $parameters['--force'] = true; } - return $this->isTheExistingFileADefaultOne(); - } - - protected function isTheExistingFileADefaultOne(): bool - { - return ViewDiffService::checksumMatchesAny(unixsum_file(BladePage::path('index.blade.php'))); + return $this->call('publish', $parameters); } } diff --git a/packages/framework/src/Console/Commands/PublishViewsCommand.php b/packages/framework/src/Console/Commands/PublishViewsCommand.php index 96e47972f80..44e94a21b1a 100644 --- a/packages/framework/src/Console/Commands/PublishViewsCommand.php +++ b/packages/framework/src/Console/Commands/PublishViewsCommand.php @@ -4,23 +4,16 @@ namespace Hyde\Console\Commands; -use Closure; use Hyde\Console\Concerns\Command; -use Hyde\Console\Helpers\ConsoleHelper; -use Hyde\Console\Helpers\InteractivePublishCommandHelper; -use Hyde\Console\Helpers\ViewPublishGroup; -use Illuminate\Support\Str; -use Laravel\Prompts\Key; -use Laravel\Prompts\MultiSelectPrompt; -use Laravel\Prompts\SelectPrompt; - -use function Laravel\Prompts\select; -use function str_replace; -use function sprintf; -use function strstr; /** - * Publish the Hyde Blade views. + * Deprecated alias for the views flow of the `publish` command. + * + * Kept working through v3 as a thin delegator: it prints a one-line deprecation + * notice and forwards to `php hyde publish` with the mapped scope flag. Target + * removal in v4. + * + * @see \Hyde\Console\Commands\PublishCommand */ class PublishViewsCommand extends Command { @@ -30,129 +23,18 @@ class PublishViewsCommand extends Command /** @var string */ protected $description = 'Publish the Hyde components for customization. Note that existing files will be overwritten'; - /** @var array */ - protected array $options; - public function handle(): int { - $this->options = static::mapToKeys([ - ViewPublishGroup::fromGroup('hyde-layouts', 'Blade Layouts', 'Shared layout views, such as the app layout, navigation menu, and Markdown page templates'), - ViewPublishGroup::fromGroup('hyde-components', 'Blade Components', 'More or less self contained components, extracted for customizability and DRY code'), - ]); - - $selected = ($this->argument('group') ?? $this->promptForGroup()) ?: 'all'; - - if ($selected !== 'all' && (bool) $this->argument('group') === false && ConsoleHelper::canUseLaravelPrompts($this->input)) { - $this->infoComment(sprintf('Selected group [%s]', $selected)); - } - - if (! in_array($selected, $allowed = array_merge(['all'], array_keys($this->options)), true)) { - $this->error("Invalid selection: '$selected'"); - $this->infoComment('Allowed values are: ['.implode(', ', $allowed).']'); - - return Command::FAILURE; - } - - $files = $selected === 'all' - ? collect($this->options)->flatMap(fn (ViewPublishGroup $option): array => $option->publishableFilesMap())->all() - : $this->options[$selected]->publishableFilesMap(); - - $publisher = $this->publishSelectedFiles($files, $selected === 'all'); - - $this->infoComment($publisher->formatOutput($selected)); - - return Command::SUCCESS; - } - - protected function promptForGroup(): string - { - SelectPrompt::fallbackUsing(function (SelectPrompt $prompt): string { - return $this->choice($prompt->label, $prompt->options, $prompt->default); - }); - - return $this->parseChoiceIntoKey( - select('Which group do you want to publish?', $this->formatPublishableChoices(), 0) ?: 'all' - ); - } - - protected function formatPublishableChoices(): array - { - return collect($this->options) - ->map(fn (ViewPublishGroup $option, string $key): string => sprintf('%s: %s', $key, $option->description)) - ->prepend('Publish all groups listed below') - ->values() - ->all(); - } - - protected function parseChoiceIntoKey(string $choice): string - { - return strstr(str_replace(['', ''], '', $choice), ':', true) ?: ''; - } - - /** - * @param array $groups - * @return array - */ - protected static function mapToKeys(array $groups): array - { - return collect($groups)->mapWithKeys(function (ViewPublishGroup $group): array { - return [Str::after($group->group, 'hyde-') => $group]; - })->all(); - } - - /** @param array $files */ - protected function publishSelectedFiles(array $files, bool $isPublishingAll): InteractivePublishCommandHelper - { - $publisher = new InteractivePublishCommandHelper($files); - - if (! $isPublishingAll && ConsoleHelper::canUseLaravelPrompts($this->input)) { - $publisher->only($this->promptUserForWhichFilesToPublish($publisher->getFileChoices())); - } - - $publisher->publishFiles(); - - return $publisher; - } - - /** - * @param array $files - * @return array - */ - protected function promptUserForWhichFilesToPublish(array $files): array - { - $choices = array_merge(['all' => 'All files'], $files); - - $prompt = new MultiSelectPrompt('Select the files you want to publish', $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.'); - - $prompt->on('key', static::supportTogglingAll($prompt)); - - return (array) $prompt->prompt(); - } - - protected static function supportTogglingAll(MultiSelectPrompt $prompt): Closure - { - return function (string $key) use ($prompt): void { - static $isToggled = false; - - if ($prompt->isHighlighted('all')) { - if ($key === Key::SPACE) { - $prompt->emit('key', Key::CTRL_A); + // A bare invocation (no group) historically published every group, so it maps to + // --all rather than the interactive wizard, keeping legacy scripts non-interactive. + $flag = match ($this->argument('group')) { + 'layouts' => '--layouts', + 'components' => '--components', + default => '--all', + }; - if ($isToggled) { - // We need to emit CTRL+A twice to deselect all for some reason - $prompt->emit('key', Key::CTRL_A); - $isToggled = false; - } else { - $isToggled = true; - } - } elseif ($key === Key::ENTER) { - if (! $isToggled) { - $prompt->emit('key', Key::CTRL_A); - } + $this->warn("publish:views is deprecated. Use php hyde publish $flag instead."); - $prompt->state = 'submit'; - } - } - }; + return $this->call('publish', [$flag => true]); } } diff --git a/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php b/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php index 3a9efbca14c..d82153c8573 100644 --- a/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php @@ -7,9 +7,14 @@ use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Testing\TestCase; -use Illuminate\Support\Facades\File; - -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\PublishConfigsCommand::class)] +use PHPUnit\Framework\Attributes\CoversClass; + +/** + * Step 7 (§8): publish:configs is now a thin, deprecated delegator. It prints a one-line + * deprecation notice and forwards to `php hyde vendor:publish --tag=hyde-config`, which + * publishes exactly the six Hyde-owned config files (asserted by the Step 6 provider test). + */ +#[CoversClass(\Hyde\Console\Commands\PublishConfigsCommand::class)] class PublishConfigsCommandTest extends TestCase { public function setUp(): void @@ -28,45 +33,23 @@ public function tearDown(): void parent::tearDown(); } - public function testCommandHasExpectedOutput() - { - $this->artisan('publish:configs') - ->expectsChoice('Which configuration files do you want to publish?', 'All configs', $this->expectedOptions()) - ->expectsOutput(sprintf('Published config files to [%s]', Hyde::path('config'))) - ->assertExitCode(0); - } - - public function testConfigFilesArePublished() + public function testPrintsNoticeAndDelegatesToVendorPublishHydeConfigTag() { $this->assertDirectoryDoesNotExist(Hyde::path('config')); - $this->artisan('publish:configs') - ->expectsChoice('Which configuration files do you want to publish?', 'All configs', $this->expectedOptions()) - ->assertExitCode(0); - - $this->assertFileEquals(Hyde::vendorPath('config/hyde.php'), Hyde::path('config/hyde.php')); - - $this->assertDirectoryExists(Hyde::path('config')); - } - - public function testCommandOverwritesExistingFiles() - { - File::makeDirectory(Hyde::path('config')); - File::put(Hyde::path('config/hyde.php'), 'foo'); - - $this->artisan('publish:configs') - ->expectsChoice('Which configuration files do you want to publish?', 'All configs', $this->expectedOptions()) + $this->artisan('publish:configs --no-interaction') + ->expectsOutputToContain('publish:configs is deprecated. Use php hyde vendor:publish --tag=hyde-config instead.') ->assertExitCode(0); - $this->assertNotSame('foo', File::get(Hyde::path('config/hyde.php'))); - } + // The hyde-config tag publishes exactly the six Hyde-owned configs. + $this->assertFileExists(Hyde::path('config/hyde.php')); + $this->assertFileExists(Hyde::path('config/docs.php')); + $this->assertFileExists(Hyde::path('config/markdown.php')); + $this->assertFileExists(Hyde::path('config/view.php')); + $this->assertFileExists(Hyde::path('config/cache.php')); + $this->assertFileExists(Hyde::path('config/commands.php')); - protected function expectedOptions(): array - { - return [ - 'All configs', - 'hyde-configs: Main configuration files', - 'support-configs: Laravel and package configuration files', - ]; + // Torchlight is obtained via its own package tag, never through hyde-config. + $this->assertFileDoesNotExist(Hyde::path('config/torchlight.php')); } } diff --git a/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php b/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php index e5fad845ea0..8fbb0edc169 100644 --- a/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php @@ -7,9 +7,17 @@ use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Testing\TestCase; - -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\PublishHomepageCommand::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Concerns\AsksToRebuildSite::class)] +use Illuminate\Support\Facades\File; +use PHPUnit\Framework\Attributes\CoversClass; + +/** + * Step 7 (§8): publish:homepage is now a thin, deprecated delegator to `php hyde publish --page`. + * It prints a one-line deprecation notice, forwards the optional template name to --page=NAME + * (a bare invocation maps to --page, i.e. the picker), and forwards the --force flag. + * + * @see \Hyde\Framework\Testing\Feature\Commands\PublishCommandPagesTest for the real pages flow. + */ +#[CoversClass(\Hyde\Console\Commands\PublishHomepageCommand::class)] class PublishHomepageCommandTest extends TestCase { protected function setUp(): void @@ -30,95 +38,58 @@ protected function tearDown(): void parent::tearDown(); } - public function testThereAreNoDefaultPages() - { - $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); - } - - public function testCommandReturnsExpectedOutput() + public function testNamedTemplatePrintsNoticeAndDelegatesToPageFlag() { - $this->artisan('publish:homepage welcome') - ->expectsConfirmation('Would you like to rebuild the site?') + $this->artisan('publish:homepage welcome --no-interaction') + ->expectsOutputToContain('publish:homepage is deprecated. Use php hyde publish --page=welcome instead.') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') ->assertExitCode(0); $this->assertFileExists(Hyde::path('_pages/index.blade.php')); } - public function testCommandReturnsExpectedOutputWithRebuild() + public function testUnknownTemplateDelegatesAndFailsHelpfully() { - $this->artisan('publish:homepage welcome') - ->expectsConfirmation('Would you like to rebuild the site?', 'yes') - ->expectsOutput('Okay, building site!') - ->expectsOutput('Site is built!') - ->assertExitCode(0); + $this->artisan('publish:homepage nope --no-interaction') + ->expectsOutputToContain('publish:homepage is deprecated. Use php hyde publish --page=nope instead.') + ->expectsOutputToContain('The page [nope] does not exist.') + ->assertExitCode(1); - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); - $this->resetSite(); + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - public function testCommandPromptsForOutput() + public function testBareInvocationPrintsNoticeAndDelegatesToThePagePicker() { $this->artisan('publish:homepage') - ->expectsQuestion( - 'Which homepage do you want to publish?', - 'welcome: The default welcome page.' - ) - ->expectsOutput('Published page [welcome]') - ->expectsConfirmation('Would you like to rebuild the site?') - ->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); - } - - public function testCommandShowsFeedbackOutputWhenSupplyingAHomepageName() - { - $this->artisan('publish:homepage welcome') - ->expectsOutput('Published page [welcome]') - ->expectsConfirmation('Would you like to rebuild the site?', false) + ->expectsOutputToContain('publish:homepage is deprecated. Use php hyde publish --page instead.') + ->expectsQuestion('Select pages to publish', ['welcome']) + ->expectsConfirmation('Proceed?', 'yes') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') ->assertExitCode(0); $this->assertFileExists(Hyde::path('_pages/index.blade.php')); } - public function testCommandHandlesErrorCode404() + public function testForceFlagIsForwardedToOverwriteModifiedFiles() { - $this->artisan('publish:homepage invalid-page') - ->assertExitCode(404); - - $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); - } - - public function testCommandDoesNotOverwriteModifiedFilesWithoutForceFlag() - { - file_put_contents(Hyde::path('_pages/index.blade.php'), 'foo'); - - $this->artisan('publish:homepage welcome') - ->assertExitCode(409); - - $this->assertSame('foo', file_get_contents(Hyde::path('_pages/index.blade.php'))); - - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); - } - - public function testCommandOverwritesModifiedFilesIfForceFlagIsSet() - { - file_put_contents(Hyde::path('_pages/index.blade.php'), 'foo'); + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); $this->artisan('publish:homepage welcome --force --no-interaction') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') ->assertExitCode(0); - $this->assertNotSame('foo', file_get_contents(Hyde::path('_pages/index.blade.php'))); - - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } - public function testCommandDoesNotReturn409IfTheCurrentFileIsADefaultFile() + public function testWithoutForceModifiedFilesAreProtected() { - copy(Hyde::vendorPath('resources/views/layouts/app.blade.php'), Hyde::path('_pages/index.blade.php')); + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); $this->artisan('publish:homepage welcome --no-interaction') - ->assertExitCode(0); + ->expectsOutput('Cannot overwrite modified files without --force:') + ->assertExitCode(1); - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } } diff --git a/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php b/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php index 7ba3109fcca..c848615287d 100644 --- a/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php @@ -4,244 +4,68 @@ namespace Hyde\Framework\Testing\Feature\Commands; -use Hyde\Console\Commands\PublishViewsCommand; -use Hyde\Console\Helpers\ConsoleHelper; use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Testing\TestCase; -use Illuminate\Console\OutputStyle; use Illuminate\Support\Facades\File; -use Laravel\Prompts\Key; -use Laravel\Prompts\Prompt; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; +use PHPUnit\Framework\Attributes\CoversClass; /** - * @see \Hyde\Framework\Testing\Unit\InteractivePublishCommandHelperTest + * Step 7 (§8): publish:views is now a thin, deprecated delegator to `php hyde publish`. + * It prints a one-line deprecation notice and forwards the group to the matching scope flag + * (layouts → --layouts, components → --components, no group → --all). + * + * @see \Hyde\Framework\Testing\Feature\Commands\PublishCommandViewsTest for the real views flow. */ -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Commands\PublishViewsCommand::class)] -#[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Console\Helpers\InteractivePublishCommandHelper::class)] +#[CoversClass(\Hyde\Console\Commands\PublishViewsCommand::class)] class PublishViewsCommandTest extends TestCase { - public function testCommandPublishesViews() + public function testWithoutGroupPrintsNoticeAndDelegatesToPublishAll() { - $count = Filesystem::findFiles('vendor/hyde/framework/resources/views/components', '.blade.php', true)->count() - + Filesystem::findFiles('vendor/hyde/framework/resources/views/layouts', '.blade.php', true)->count(); + $count = $this->viewCount('layouts') + $this->viewCount('components'); - $this->artisan('publish:views') - ->expectsQuestion('Which group do you want to publish?', 'all') - ->doesntExpectOutputToContain('Selected group') - ->expectsOutput("Published all $count files to [resources/views/vendor/hyde]") + $this->artisan('publish:views --no-interaction') + ->expectsOutputToContain('publish:views is deprecated. Use php hyde publish --all instead.') + ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde]") ->assertExitCode(0); - // Assert all groups were published - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde')); - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde/layouts')); - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde/components')); - - // Assert files were published $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php')); $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); - - // Assert subdirectories were published with files - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde/components/docs')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/docs/documentation-article.blade.php')); - } - - public function testCanSelectGroupWithArgument() - { - ConsoleHelper::disableLaravelPrompts(); - - $this->artisan('publish:views layouts') - ->expectsOutput('Published all [layout] files to [resources/views/vendor/hyde/layouts]') - ->assertExitCode(0); - - // Assert selected group was published - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde')); - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde/layouts')); - - // Assert files were published - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php')); - - // Assert not selected group was not published - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); } - public function testCanSelectGroupWithQuestion() + public function testLayoutsGroupPrintsNoticeAndDelegatesToLayoutsFlag() { - ConsoleHelper::disableLaravelPrompts(); + $count = $this->viewCount('layouts'); - $this->artisan('publish:views') - ->expectsQuestion('Which group do you want to publish?', 'layouts: Shared layout views, such as the app layout, navigation menu, and Markdown page templates') - ->expectsOutput('Published all [layout] files to [resources/views/vendor/hyde/layouts]') + $this->artisan('publish:views layouts --no-interaction') + ->expectsOutputToContain('publish:views is deprecated. Use php hyde publish --layouts instead.') + ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde/layouts]") ->assertExitCode(0); - // Assert selected group was published - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde')); - $this->assertDirectoryExists(Hyde::path('resources/views/vendor/hyde/layouts')); - - // Assert files were published - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php')); - - // Assert not selected group was not published - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); - } - - public function testWithInvalidSuppliedTag() - { - $this->artisan('publish:views invalid') - ->expectsOutput("Invalid selection: 'invalid'") - ->expectsOutput('Allowed values are: [all, layouts, components]') - ->assertExitCode(1); - } - - public function testInteractiveSelectionOnWindowsSystemsSkipsInteractiveness() - { - ConsoleHelper::mockWindowsOs(true); - - $this->artisan('publish:views components') - ->expectsOutput('Published all [component] files to [resources/views/vendor/hyde/components]') - ->assertExitCode(0); - } - - public function testInteractiveSelectionOnUnixSystems() - { - if (windows_os()) { - $this->markTestSkipped('Test is not applicable on Windows systems.'); - } - - Prompt::fake([ - Key::DOWN, Key::SPACE, - Key::DOWN, Key::SPACE, - Key::DOWN, Key::SPACE, - Key::ENTER, - ]); - - $output = $this->executePublishViewsCommand(); - - Prompt::assertOutputContains('Select the files you want to publish'); - Prompt::assertOutputContains('All files'); - Prompt::assertOutputContains('app.blade.php'); - Prompt::assertOutputContains('docs.blade.php'); - Prompt::assertOutputContains('footer.blade.php'); - - $this->assertSame("Published selected [layout] files to [resources/views/vendor/hyde/layouts]\n", $output->fetch()); - - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/docs.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/footer.blade.php')); - - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); - } - - public function testInteractiveSelectionWithHittingEnterRightAway() - { - if (windows_os()) { - $this->markTestSkipped('Test is not applicable on Windows systems.'); - } - - Prompt::fake([ - Key::ENTER, - ]); - - $output = $this->executePublishViewsCommand(); - - Prompt::assertOutputContains('Select the files you want to publish'); - Prompt::assertOutputContains('All files'); - Prompt::assertOutputContains('app.blade.php'); - Prompt::assertOutputContains('docs.blade.php'); - - $this->assertSame("Published all [layout] files to [resources/views/vendor/hyde/layouts]\n", $output->fetch()); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/docs.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/footer.blade.php')); - - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); } - public function testInteractiveSelectionWithComplexToggles() + public function testComponentsGroupPrintsNoticeAndDelegatesToComponentsFlag() { - if (windows_os()) { - $this->markTestSkipped('Test is not applicable on Windows systems.'); - } - - Prompt::fake([ - // Select "all files" - Key::SPACE, - // Unselect next file - Key::DOWN, Key::SPACE, - // Go back up and deselect the all files option - Key::UP, Key::SPACE, - // Select the next three files - Key::DOWN, Key::SPACE, Key::DOWN, Key::SPACE, Key::DOWN, Key::SPACE, - // De-select the last file - Key::SPACE, - // Confirm selection - Key::ENTER, - ]); - - $output = $this->executePublishViewsCommand(); - - Prompt::assertOutputContains('Select the files you want to publish'); - Prompt::assertOutputContains('All files'); - Prompt::assertOutputContains('app.blade.php'); - Prompt::assertOutputContains('docs.blade.php'); - - $this->assertSame("Published selected [layout] files to [resources/views/vendor/hyde/layouts]\n", $output->fetch()); - - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/docs.blade.php')); - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/footer.blade.php')); + $count = $this->viewCount('components'); - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); - } - - public function testCanSelectGroupWithQuestionAndPrompts() - { - if (windows_os()) { - $this->markTestSkipped('Test is not applicable on Windows systems.'); - } - - $this->artisan('publish:views') - ->expectsQuestion('Which group do you want to publish?', 'layouts: Shared layout views, such as the app layout, navigation menu, and Markdown page templates') - ->expectsOutput('Selected group [layouts]') - ->expectsQuestion('Select the files you want to publish', [(is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde').'/framework/resources/views/layouts/app.blade.php']) - ->expectsOutput('Published selected file to [resources/views/vendor/hyde/layouts/app.blade.php]') + $this->artisan('publish:views components --no-interaction') + ->expectsOutputToContain('publish:views is deprecated. Use php hyde publish --components instead.') + ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde/components]") ->assertExitCode(0); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php')); - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); - $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); + $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); } - protected function executePublishViewsCommand(): BufferedOutput + protected function viewCount(string $group): int { - $command = (new PublishViewsCommand()); - $input = new ArrayInput(['group' => 'layouts'], $command->getDefinition()); - $output = new BufferedOutput(); - $command->setInput($input); - $command->setOutput(new OutputStyle($input, $output)); - $command->handle(); - - return $output; + return Filesystem::findFiles("packages/framework/resources/views/$group", '.blade.php', true)->count(); } protected function tearDown(): void { - ConsoleHelper::clearMocks(); - PromptsReset::resetFallbacks(); - if (File::isDirectory(Hyde::path('resources/views/vendor'))) { File::deleteDirectory(Hyde::path('resources/views/vendor')); } @@ -249,12 +73,3 @@ protected function tearDown(): void parent::tearDown(); } } - -abstract class PromptsReset extends Prompt -{ - // Workaround for https://github.com/laravel/prompts/issues/158 - public static function resetFallbacks(): void - { - static::$shouldFallback = false; - } -} From cee367018f18b4fc5183a37384f1e6ea3b83de4c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 19:08:58 +0200 Subject: [PATCH 14/56] Step 8: Align docs with the new publish command surface Rewrite the publishing docs around php hyde publish (views + --page) and php hyde vendor:publish --tag=hyde-config for config. Fix the nonexistent publish:components reference in advanced-markdown.md, and update remaining publish:views/publish:configs/publish:homepage references throughout the primary docs. The deprecated aliases now appear only in a migration note in the console commands reference (historical release notes left as-is). Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- docs/creating-content/managing-assets.md | 2 +- docs/digging-deeper/advanced-markdown.md | 4 +- docs/digging-deeper/customization.md | 11 ++-- docs/digging-deeper/helpers.md | 4 +- docs/digging-deeper/troubleshooting.md | 4 +- docs/digging-deeper/updating-hyde.md | 6 +- docs/getting-started/console-commands.md | 77 ++++++++++++------------ 7 files changed, 57 insertions(+), 51 deletions(-) diff --git a/docs/creating-content/managing-assets.md b/docs/creating-content/managing-assets.md index 97444410c16..3848770d86b 100644 --- a/docs/creating-content/managing-assets.md +++ b/docs/creating-content/managing-assets.md @@ -130,7 +130,7 @@ To make it really easy to customize asset loading, the styles and scripts are lo To customize them, run the following command: ```bash -php hyde publish:views layouts +php hyde publish --layouts ``` Then edit the files found in `resources/views/vendor/hyde/layouts` directory of your project. diff --git a/docs/digging-deeper/advanced-markdown.md b/docs/digging-deeper/advanced-markdown.md index d74b2842093..c034418a3ef 100644 --- a/docs/digging-deeper/advanced-markdown.md +++ b/docs/digging-deeper/advanced-markdown.md @@ -70,7 +70,7 @@ coloured blockquotes. Simply append the desired colour after the initial `>` cha You can easily customize these styles by publishing and editing the `markdown-blockquote.blade.php` file. ```bash -php hyde publish:views components +php hyde publish --components ``` ### Markdown usage @@ -166,7 +166,7 @@ You can enable it for other page types by adding the page class to the `permalin Under the hood, Hyde uses a custom Blade-based heading renderer when converting Markdown to HTML. This allows for more flexibility and customization compared to standard Markdown parsers. You can also publish and customize the Blade component used to render the headings: ```bash -php hyde publish:components +php hyde publish --components ``` This will copy the `markdown-heading.blade.php` component to your views directory where you can modify its markup and behavior. diff --git a/docs/digging-deeper/customization.md b/docs/digging-deeper/customization.md index d52350726af..0c6f81e0bdf 100644 --- a/docs/digging-deeper/customization.md +++ b/docs/digging-deeper/customization.md @@ -81,7 +81,7 @@ The main configuration file, `hyde.php`, is used for things ranging from site na Since HydePHP is based on Laravel we also have a few configuration files related to them. As you most often don't need to edit any of these, unless you want to make changes to the underlying application, they are not present in the -base HydePHP installation. However, you can publish them to your project by running `php hyde publish:configs`. +base HydePHP installation. However, you can publish them to your project by running `php hyde vendor:publish --tag=hyde-config`. | Config File | Description | |------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------| @@ -92,7 +92,7 @@ base HydePHP installation. However, you can publish them to your project by runn {.align-top} -If any of these files are missing, you can run `php hyde publish:configs` to copy the default files to your project. +If any of these files are missing, you can run `php hyde vendor:publish --tag=hyde-config` to copy the default files to your project. ## Configuration Options @@ -371,12 +371,15 @@ instead the appropriate color scheme will be automatically applied based on the Hyde uses the Laravel Blade templating engine. Most parts of the included templates have been extracted into components to be customized easily. Before editing the views you should familiarize yourself with the [Laravel Blade Documentation](https://laravel.com/docs/10.x/blade). -To edit a default Hyde component you need to publish them first using the `hyde publish:views` command. +To edit a default Hyde component you need to publish them first using the `hyde publish` command. ```bash -php hyde publish:views +php hyde publish --all ``` +You can also run `php hyde publish` without any flags to interactively pick which views to publish, +or scope the command to just `--layouts` or `--components`. + The files will then be available in the `resources/views/vendor/hyde` directory. >info **Tip:** If you use Linux/macOS or Windows with WSL you will be able to interactively select individual files to publish. diff --git a/docs/digging-deeper/helpers.md b/docs/digging-deeper/helpers.md index 5ebdfde700a..9c375272d51 100644 --- a/docs/digging-deeper/helpers.md +++ b/docs/digging-deeper/helpers.md @@ -333,8 +333,8 @@ HydePHP comes with a started homepage called 'posts'. This includes a component #### Creating our posts page -Now, let's paginate this feed! For this example, we will assume that you ran `php hyde publish:homepage posts` -and renamed the resulting `index.blade.php` file to `posts.blade.php`. We will also assume that you have a few blog posts set up. +Now, let's paginate this feed! For this example, we will assume that you ran `php hyde publish --page=posts` +to publish the posts feed page to `_pages/posts.blade.php`. We will also assume that you have a few blog posts set up. The blog post feed component is a simple component that looks like this: diff --git a/docs/digging-deeper/troubleshooting.md b/docs/digging-deeper/troubleshooting.md index a999358651d..273957124c1 100644 --- a/docs/digging-deeper/troubleshooting.md +++ b/docs/digging-deeper/troubleshooting.md @@ -76,13 +76,13 @@ You can read more about some of these in the [Core Concepts](core-concepts#paths | Wrong layout used | Hyde determines the layout template to use depending on the directory of the source file | Ensure your source file is in the right directory. | | Invalid/no permalinks or post URIs | You may be missing or have an invalid site URL | Set the site URL in the `.env` file | | No styles in custom Blade pages | When using custom blade pages need to add the styles yourself. You can do this by extending the default layout | Use the app layout, or by include the Blade components directly. | -| Overriding Hyde views is not working | Ensure the Blade views are in the correct directory. | Rerun `php hyde publish:views`. | +| Overriding Hyde views is not working | Ensure the Blade views are in the correct directory. | Rerun `php hyde publish --all`. | | Styles not updating when deploying site | It could be a caching issue. To be honest, when dealing with styles, it's always a caching issue. | Clear your cache, and optionally complain to your site host | | Documentation sidebar items are in the wrong order | Double check the config, make sure the route keys are written correctly. Check that you are not overriding with front matter. | Check config for typos and front matter | | Issues with date in blog post front matter | The date is parsed by the PHP `strtotime()` function. The date may be in an invalid format, or the front matter is invalid | Ensure the date is in a format that `strtotime()` can parse. Wrap the front matter value in quotes. | | RSS feed not being generated | The RSS feed requires that you have set a site URL in the Hyde config or the `.env` file. Also check that you have blog posts, and that they are enabled. | Check your configuration files. | | | Sitemap not being generated | The sitemap requires that you have set a site URL in the Hyde config or the `.env` file. | Check your configuration files. | | -| Unable to do literally anything | If everything is broken, you may be missing a Composer package or your configuration files could be messed up. | Run `composer install` and/or `composer update`. If you can run HydeCLI commands, update your configs with `php hyde publish:configs`, or copy them manually from GitHub or the vendor directory. | +| Unable to do literally anything | If everything is broken, you may be missing a Composer package or your configuration files could be messed up. | Run `composer install` and/or `composer update`. If you can run HydeCLI commands, update your configs with `php hyde vendor:publish --tag=hyde-config`, or copy them manually from GitHub or the vendor directory. | | Namespaced Yaml config (`hyde.yml`) not working | When using namespaced Yaml configuration, you must begin the file with `hyde:`, even if you just want to use another file for example `docs:`. | Make sure the file starts with `hyde:` (You don't need to specify any options, as long as it's present). See [`#1475`](https://github.com/hydephp/develop/issues/1475) | ### Extra troubleshooting information diff --git a/docs/digging-deeper/updating-hyde.md b/docs/digging-deeper/updating-hyde.md index 603f3368a21..113ba3e3d28 100644 --- a/docs/digging-deeper/updating-hyde.md +++ b/docs/digging-deeper/updating-hyde.md @@ -89,14 +89,14 @@ composer update Then, update your config files. This is the hardest part, as you may need to manually copy in your own changes. ```bash -php hyde publish:configs +php hyde vendor:publish --tag=hyde-config ``` If you have published any of the included Blade components you will need to re-publish them. ```bash -php hyde publish:views layouts -php hyde publish:views components +php hyde publish --layouts +php hyde publish --components ``` You may also want to download any resources that have been updated. You download these from the Zip file of the latest release on GitHub. diff --git a/docs/getting-started/console-commands.md b/docs/getting-started/console-commands.md index bcaf5ad599f..e09d07ed252 100644 --- a/docs/getting-started/console-commands.md +++ b/docs/getting-started/console-commands.md @@ -52,10 +52,8 @@ Here is a quick reference of all the available commands. You can also run `php h | [`build:sitemap`](#build-sitemap) | Generate the `sitemap.xml` file | | [`make:page`](#make-page) | Scaffold a new Markdown, Blade, or documentation page file | | [`make:post`](#make-post) | Scaffold a new Markdown blog post file | -| [`publish:configs`](#publish-configs) | Publish the default configuration files | -| [`publish:homepage`](#publish-homepage) | Publish one of the default homepages as `index.blade.php` | -| [`publish:views`](#publish-views) | Publish the hyde components for customization. Note that existing files will be overwritten | -| [`vendor:publish`](#vendor-publish) | Publish any publishable assets from vendor packages | +| [`publish`](#publish) | Publish Hyde views and starter pages for customization | +| [`vendor:publish`](#vendor-publish) | Publish any publishable assets from vendor packages (including the Hyde config files) | | [`route:list`](#route-list) | Display all registered routes | | [`validate`](#validate) | Run a series of tests to validate your setup and help you optimize your site | | [`list`](#available-commands) | List all available commands | @@ -172,48 +170,35 @@ Scaffold a new Markdown blog post file | `title` | The title for the Post. Will also be used to generate the filename | | `--force` | Should the generated file overwrite existing posts with the same filename? | -## Publish the Default Configuration Files +## Publish Hyde views and starter pages for customization - + ```bash -php hyde publish:configs +php hyde publish [--layouts] [--components] [--all] [--page[=NAME]] [--to=PATH] [--force] ``` -Publish the default configuration files +Publish Hyde views and starter pages for customization. -## Publish one of the default homepages as `index.blade.php`. +With no flags, `publish` runs an interactive wizard that lets you choose between publishing +**views** (Hyde's Blade layouts and components) or **a starter page** (such as a homepage or 404 page). +Each flag simply skips a step of the wizard. Existing files that you have modified are never overwritten +without your confirmation or the `--force` flag. - - -```bash -php hyde publish:homepage [--force] [--] [] -``` - -Publish one of the default homepages as `index.blade.php`. - -#### Arguments & Options - -| | | -|------------|---------------------------------| -| `homepage` | The name of the page to publish | -| `--force` | Overwrite any existing files | - -## Publish the hyde components for customization - - - -```bash -php hyde publish:views [] -``` +#### Options -Publish the hyde components for customization. Note that existing files will be overwritten. +| Option | Description | +|----------------|--------------------------------------------------------------| +| `--layouts` | Scope publishing to the Hyde layout views | +| `--components` | Scope publishing to the Hyde component views | +| `--all` | Publish all Hyde views without the picker | +| `--page[=NAME]`| Publish a starter page, optionally by name (e.g. `--page=welcome`) | +| `--to=PATH` | Destination path for a published page (pages only) | +| `--force` | Overwrite files that you have modified | -#### Arguments +Published views land in `resources/views/vendor/hyde`, and published pages land in `_pages`. -| | | -|----------|-------------------------| -| `group` | The group to publish | +>info **Tip:** To publish the Hyde configuration files, use `php hyde vendor:publish --tag=hyde-config`. See the [`vendor:publish`](#vendor-publish) command below. ## Display All Registered Routes. @@ -233,7 +218,14 @@ Display all registered routes. php hyde vendor:publish [--existing] [--force] [--all] [--provider [PROVIDER]] [--tag [TAG]] ``` -Publish any publishable assets from vendor packages +Publish any publishable assets from vendor packages. This is the advanced Laravel publishing path, +and is also where the Hyde configuration files are published from, using the `hyde-config` tag: + +```bash +php hyde vendor:publish --tag=hyde-config +``` + +This publishes the Hyde-owned config files (`hyde.php`, `docs.php`, `markdown.php`, `view.php`, `cache.php`, and `commands.php`) to your project's `config` directory. #### Options @@ -244,3 +236,14 @@ Publish any publishable assets from vendor packages | `--all` | Publish assets for all service providers without prompt | | `--provider=` | The service provider that has assets you want to publish | | `--tag=` | One or many tags that have assets you want to publish \n- Is multiple: yes | + +## Deprecated publishing commands + +The following commands from earlier versions of Hyde still work but are deprecated, and print a notice +pointing to their replacement. They will be removed in a future major version, so prefer the new commands. + +| Deprecated command | Use instead | +|-------------------------------|----------------------------------------------------| +| `publish:views [group]` | `publish --layouts` / `publish --components` | +| `publish:configs` | `vendor:publish --tag=hyde-config` | +| `publish:homepage [template]` | `publish --page=[template]` | From 7e105388020dcef4ec808452734735938f2adb7d Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 19:31:32 +0200 Subject: [PATCH 15/56] =?UTF-8?q?Step=209:=20Acceptance=20sweep=20?= =?UTF-8?q?=E2=80=94=20pin=20--all-is-views-only=20(=C2=A711)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Co-authored-by: Gemini <218195315+gemini-cli@users.noreply.github.com> --- .../Feature/Commands/PublishCommandPagesTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index d54c5dc1abe..5f5115bf16a 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -71,6 +71,19 @@ public function testNamedPagePublishesToItsDefaultTargetNonInteractively() ); } + // §2/§11: --all means "all views" and does not apply to pages. Combined with --page, the page + // flow wins (page-intent is resolved before view-intent) and --all is inert — it must not divert + // into the views "Published all" path. PagesPublisher never reads the --all option. + public function testAllFlagDoesNotApplyToPagesWhenCombinedWithPage() + { + $this->artisan('publish --page=welcome --all --no-interaction') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->doesntExpectOutputToContain('Published all') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/index.blade.php')); + } + public function testUnknownPageNameFailsHelpfully() { $this->artisan('publish --page=nope --no-interaction') From 57a56f55f01e8ecfb67dc486ae3e31ab58022683 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sat, 4 Jul 2026 17:32:32 +0000 Subject: [PATCH 16/56] Apply fixes from StyleCI --- .../Console/Helpers/InteractiveMultiselect.php | 2 +- .../src/Console/Helpers/PagesPublisher.php | 7 +++---- .../src/Console/Helpers/ViewsPublisher.php | 15 +++++++-------- .../Feature/Commands/PublishCommandPagesTest.php | 1 - 4 files changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index 282cd5a16aa..31299bf7755 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -32,7 +32,7 @@ class InteractiveMultiselect /** * @param array $options Map of option key => display label. * @param string|null $allLabel Label for the "select all" row, or null to omit it entirely. - * @return array The selected option keys (never includes the sentinel). + * @return array The selected option keys (never includes the sentinel). */ public static function select(string $label, array $options, ?string $allLabel = null): array { diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index d3dde9b3735..a44c1c4919b 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -18,7 +18,6 @@ use function count; use function implode; use function sprintf; - use function Laravel\Prompts\confirm; use function Laravel\Prompts\select; use function Laravel\Prompts\text; @@ -111,7 +110,7 @@ public function publish(): int /** * Determine which pages to publish: a named page directly, or the interactive picker. * - * @return array|null The selected pages, or null when the run should fail (message printed). + * @return array|null The selected pages, or null when the run should fail (message printed). */ protected function selectPages(): ?array { @@ -167,7 +166,7 @@ protected function pickerLabel(PublishablePage $page): string * Resolve the destination for each selected page, in registry order. * * @param array $pages - * @return array|null Null when resolution failed or was cancelled. + * @return array|null Null when resolution failed or was cancelled. */ protected function resolveDestinations(array $pages): ?array { @@ -316,7 +315,7 @@ protected function confirmProceed(array $resolved): bool * * @param array $resolved * @return array|null - * The pages actually written, or null when the run should stop (cancelled, or blocked without --force). + * The pages actually written, or null when the run should stop (cancelled, or blocked without --force). */ protected function write(array $resolved): ?array { diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index e8d5191a894..7c8bb174e4e 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -20,7 +20,6 @@ use function implode; use function reset; use function sprintf; - use function Laravel\Prompts\select; /** @@ -74,7 +73,7 @@ public function publish(): int /** * @return array{0: array, 1: array} - * A tuple of [source => target] and [source => group-prefixed label] for the offered files. + * A tuple of [source => target] and [source => group-prefixed label] for the offered files. */ protected function collectOfferedFiles(): array { @@ -113,7 +112,7 @@ protected function groups(): array /** * @param array $offered * @param array $labels - * @return array The selected source keys. + * @return array The selected source keys. */ protected function selectFiles(array $offered, array $labels): array { @@ -132,7 +131,7 @@ protected function selectFiles(array $offered, array $labels): array * * @param array $selected * @return array{0: array, 1: array, 2: array} - * A tuple of [copy, already-current, blocked] maps, each source => target. + * A tuple of [copy, already-current, blocked] maps, each source => target. */ protected function decide(array $selected): array { @@ -155,8 +154,8 @@ protected function decide(array $selected): array * Resolve what to do with modified (blocked) files, after the full outcome is known but before any write. * * @param array $blocked - * @return array|null The blocked files to overwrite, or null when the run should stop - * (cancelled interactively, or blocked non-interactively without --force). + * @return array|null The blocked files to overwrite, or null when the run should stop + * (cancelled interactively, or blocked non-interactively without --force). */ protected function resolveBlocked(array $blocked): ?array { @@ -203,8 +202,8 @@ protected function cancel(): ?array /** * @param array $published The files actually written (source => target). - * @param array $current The files skipped because already up to date. - * @param array $blocked The modified files left unchanged (interactive skip). + * @param array $current The files skipped because already up to date. + * @param array $blocked The modified files left unchanged (interactive skip). */ protected function report(array $published, array $current, array $blocked, int $offeredTotal): int { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 5f5115bf16a..7ab492953fe 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -9,7 +9,6 @@ use Hyde\Console\Helpers\PagesPublisher; use Hyde\Console\Helpers\PublishablePage; use Hyde\Console\Helpers\PublishablePages; -use Hyde\Facades\Filesystem; use Hyde\Hyde; use Hyde\Testing\TestCase; use Illuminate\Console\OutputStyle; From eff67865b68a22680a4e4a259fcc15969e838b09 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 19:42:42 +0200 Subject: [PATCH 17/56] Fix publish views invalid group handling Co-Authored-By: Codex --- .../src/Console/Commands/PublishViewsCommand.php | 14 ++++++++++++-- .../Feature/Commands/PublishViewsCommandTest.php | 10 ++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishViewsCommand.php b/packages/framework/src/Console/Commands/PublishViewsCommand.php index 44e94a21b1a..52301a7d5e6 100644 --- a/packages/framework/src/Console/Commands/PublishViewsCommand.php +++ b/packages/framework/src/Console/Commands/PublishViewsCommand.php @@ -25,14 +25,24 @@ class PublishViewsCommand extends Command public function handle(): int { + $group = $this->argument('group'); + // A bare invocation (no group) historically published every group, so it maps to // --all rather than the interactive wizard, keeping legacy scripts non-interactive. - $flag = match ($this->argument('group')) { + $flag = match ($group) { 'layouts' => '--layouts', 'components' => '--components', - default => '--all', + null => '--all', + default => null, }; + if ($flag === null) { + $this->error("Invalid selection: '$group'"); + $this->infoComment('Allowed values are: [layouts, components]'); + + return Command::FAILURE; + } + $this->warn("publish:views is deprecated. Use php hyde publish $flag instead."); return $this->call('publish', [$flag => true]); diff --git a/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php b/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php index c848615287d..15da49a3897 100644 --- a/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php @@ -59,6 +59,16 @@ public function testComponentsGroupPrintsNoticeAndDelegatesToComponentsFlag() $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); } + public function testInvalidGroupFailsWithoutPublishingViews() + { + $this->artisan('publish:views typo_group --no-interaction') + ->expectsOutputToContain("Invalid selection: 'typo_group'") + ->expectsOutputToContain('Allowed values are: [layouts, components]') + ->assertExitCode(1); + + $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor')); + } + protected function viewCount(string $group): int { return Filesystem::findFiles("packages/framework/resources/views/$group", '.blade.php', true)->count(); From 952c83b860d78a07cd546b96acfee6bc1bc49005 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 19:57:34 +0200 Subject: [PATCH 18/56] Cover remaining publish feature paths in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the coverage gaps flagged on PagesPublisher and ViewsPublisher by testing the feature paths they exercise, not lines for their own sake: Pages (§5, §7): - interactive overwrite-conflict prompt: overwrite / skip / cancel (the §7 interactive flow was tested for views but not for pages) - mixed run reporting published pages alongside already-current ones, with pluralized cardinality - three-or-more pages colliding on one target ("all target" wording) - accepting the §5.7 rebuild offer actually runs the build - --to path traversal (..) rejected Views (§4): - mixed run reporting copied views alongside already-current ones The only lines left uncovered are unreachable defensive guards behind `required` multi-selects (empty-selection is rejected by the prompt before the guard) and joinLabels' <2 branch — not feature paths. Co-Authored-By: Claude Opus 4.8 --- .../Commands/PublishCommandPagesTest.php | 149 ++++++++++++++++++ .../Commands/PublishCommandViewsTest.php | 18 +++ 2 files changed, 167 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 7ab492953fe..15c85ac77ba 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -12,6 +12,7 @@ use Hyde\Hyde; use Hyde\Testing\TestCase; use Illuminate\Console\OutputStyle; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use Laravel\Prompts\Key; use Laravel\Prompts\Prompt; @@ -347,6 +348,154 @@ public function testPickerDoesNotOfferAnAllRow() $this->assertStringContainsString('Published [welcome]', $output->fetch()); } + // A bare --page (no name) needs the picker, which needs an interactive terminal, so non-interactively it + // fails in the pages flow (§3/§5). Exercised here through PagesPublisher so the guidance path is covered there. + + public function testBarePageWithoutInteractionFailsHelpfully() + { + $this->artisan('publish --page --no-interaction') + ->expectsOutputToContain('No page specified for publishing. Provide one, for example --page=welcome.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + + // §9: a --to path may not escape _pages/ via traversal, even though it starts with _pages/ and ends in .blade.php. + + public function testToPathWithParentTraversalIsRejected() + { + $this->artisan('publish --page=welcome --to=_pages/../secret.blade.php --no-interaction') + ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('secret.blade.php')); + } + + // §5.6: three or more pages colliding on one target switch the message from "both target" to "all target". + + public function testThreePagesResolvingToTheSameTargetReportAllTarget() + { + foreach (['clash-one' => 'Clash One', 'clash-two' => 'Clash Two'] as $key => $label) { + PublishablePages::register(new PublishablePage( + key: $key, + label: $label, + description: 'A page that targets the homepage too.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/index.blade.php', + allowCustomTarget: false, + )); + } + + // None of the three is prompted for (each resolves straight to _pages/index.blade.php), so the collision is + // caught from the picker selection alone, before any confirmation or write. + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome', 'clash-one', 'clash-two']) + ->expectsOutputToContain('Welcome page, Clash One and Clash Two all target _pages/index.blade.php.') + ->expectsOutputToContain('Pick one, or set --to for each.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + + // §7 interactive conflict prompt applied to pages: overwrite / skip / cancel, mirroring the views flow. + + public function testInteractiveConflictPromptCanOverwriteAPage() + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); + + $this->artisan('publish --page=welcome') + ->expectsQuestion('1 selected files already exist and appear modified.', 'overwrite') + ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + + $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); + } + + public function testInteractiveConflictPromptCanSkipAModifiedPage() + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); + + $this->artisan('publish --page=welcome') + ->expectsQuestion('1 selected files already exist and appear modified.', 'skip') + ->expectsOutputToContain('1 page left unchanged because they were modified:') + ->expectsOutputToContain('_pages/index.blade.php') + ->expectsOutputToContain('Run again with --force to overwrite.') + ->assertExitCode(0); + + // Skipping leaves the file as the user had it, and (nothing was written) never offers a rebuild. + $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); + } + + public function testInteractiveConflictPromptCanCancelForPages() + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); + + $this->artisan('publish --page=welcome') + ->expectsQuestion('1 selected files already exist and appear modified.', 'cancel') + ->expectsOutputToContain('Cancelled. No pages were published.') + ->assertExitCode(0); + + $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); + } + + // §4/§5 cardinality-aware output: a mixed run reports what was published alongside what was already current + // (pluralized), without collapsing to the "all up to date" shortcut. + + public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() + { + // Seed two pages so they are already current, then register a third new page and publish all three. + $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); + $this->artisan('publish --page=404 --no-interaction')->assertExitCode(0); + + PublishablePages::register(new PublishablePage( + key: 'about', + label: 'About page', + description: 'A simple about page.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/about.blade.php', + )); + + // welcome and 404 are already current; only about is copied — so the run reports both sides. + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome', '404', 'about']) + ->expectsConfirmation('Proceed?', 'yes') + ->expectsOutputToContain('Published [about] to [_pages/about.blade.php]') + ->expectsOutputToContain('2 pages already up to date and skipped.') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('_pages/about.blade.php')); + } + + // §5.7: accepting the interactive rebuild offer runs the build command; declining is covered elsewhere. + // The command is driven directly (not through the console kernel) so that mocking the Artisan facade + // intercepts only maybeRebuild's own build call, rather than the runner's call that dispatches the command. + + public function testAcceptingTheRebuildOfferRunsTheBuild() + { + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + PagesPromptsReset::resetFallbacks(); + + Artisan::shouldReceive('call')->once()->with('build', [], \Mockery::any())->andReturn(0); + + // 'y' + enter answers the "Rebuild the site now?" confirm (which defaults to no) with yes. + Prompt::fake(['y', Key::ENTER]); + + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput(['--page' => 'welcome'], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $this->assertSame(0, $command->handle()); + $this->assertStringContainsString('Published [welcome]', $output->fetch()); + } + /** Drive the interactive pages picker with faked keystrokes and return the buffered output. */ protected function runPagesPicker(array $keys): BufferedOutput { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index fd168266b40..ecb4375d7ad 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -205,6 +205,24 @@ public function testInteractiveConflictPromptCanCancel() $this->assertSame('MODIFIED BY USER', File::get($target)); } + // §4 cardinality-aware output: a mixed run reports what was copied alongside what was already current, + // instead of collapsing to either the "Published all" or the "all up to date" shortcut. + + public function testMixedRunReportsPublishedAlongsideAlreadyCurrentViews() + { + // Seed only the layouts so they are already current, then publish everything: components copy, layouts skip. + $this->artisan('publish --layouts --no-interaction')->assertExitCode(0); + + $components = $this->viewCount('components'); + $layouts = $this->viewCount('layouts'); + + $this->artisan('publish --all --no-interaction') + ->expectsOutputToContain("Published $components views to [resources/views/vendor/hyde/components]") + ->expectsOutputToContain("$layouts views already up to date and skipped.") + ->doesntExpectOutputToContain('Published all') + ->assertExitCode(0); + } + protected function viewCount(string $group): int { return Filesystem::findFiles("packages/framework/resources/views/$group", '.blade.php', true)->count(); From 2222aa0107a83f73367d17bf1b4aa9c89c7ed48a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:15:44 +0200 Subject: [PATCH 19/56] Step A: Remove the three legacy publish commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete publish:views, publish:configs, and publish:homepage entirely — classes, ConsoleServiceProvider registrations, and their test files. The behavior they covered lives in the new command's suites. Add one pin test asserting publish:views now raises the native CommandNotFoundException, proving the command is gone and no shim intercepts it. Co-Authored-By: Claude Opus 4.8 --- .../Commands/PublishConfigsCommand.php | 32 ------- .../Commands/PublishHomepageCommand.php | 46 --------- .../Console/Commands/PublishViewsCommand.php | 50 ---------- .../src/Console/ConsoleServiceProvider.php | 3 - .../Feature/Commands/PublishCommandTest.php | 12 +++ .../Commands/PublishConfigsCommandTest.php | 55 ----------- .../Commands/PublishHomepageCommandTest.php | 95 ------------------- .../Commands/PublishViewsCommandTest.php | 85 ----------------- 8 files changed, 12 insertions(+), 366 deletions(-) delete mode 100644 packages/framework/src/Console/Commands/PublishConfigsCommand.php delete mode 100644 packages/framework/src/Console/Commands/PublishHomepageCommand.php delete mode 100644 packages/framework/src/Console/Commands/PublishViewsCommand.php delete mode 100644 packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php delete mode 100644 packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php delete mode 100644 packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php diff --git a/packages/framework/src/Console/Commands/PublishConfigsCommand.php b/packages/framework/src/Console/Commands/PublishConfigsCommand.php deleted file mode 100644 index 45e39e9f6b3..00000000000 --- a/packages/framework/src/Console/Commands/PublishConfigsCommand.php +++ /dev/null @@ -1,32 +0,0 @@ -warn('publish:configs is deprecated. Use php hyde vendor:publish --tag=hyde-config instead.'); - - return $this->call('vendor:publish', ['--tag' => 'hyde-config']); - } -} diff --git a/packages/framework/src/Console/Commands/PublishHomepageCommand.php b/packages/framework/src/Console/Commands/PublishHomepageCommand.php deleted file mode 100644 index 71e670f230c..00000000000 --- a/packages/framework/src/Console/Commands/PublishHomepageCommand.php +++ /dev/null @@ -1,46 +0,0 @@ -argument('homepage'); - $name = is_string($homepage) ? $homepage : null; - - $hint = $name !== null ? "--page=$name" : '--page'; - - $this->warn("publish:homepage is deprecated. Use php hyde publish $hint instead."); - - $parameters = ['--page' => $name]; - - if ($this->option('force')) { - $parameters['--force'] = true; - } - - return $this->call('publish', $parameters); - } -} diff --git a/packages/framework/src/Console/Commands/PublishViewsCommand.php b/packages/framework/src/Console/Commands/PublishViewsCommand.php deleted file mode 100644 index 52301a7d5e6..00000000000 --- a/packages/framework/src/Console/Commands/PublishViewsCommand.php +++ /dev/null @@ -1,50 +0,0 @@ -argument('group'); - - // A bare invocation (no group) historically published every group, so it maps to - // --all rather than the interactive wizard, keeping legacy scripts non-interactive. - $flag = match ($group) { - 'layouts' => '--layouts', - 'components' => '--components', - null => '--all', - default => null, - }; - - if ($flag === null) { - $this->error("Invalid selection: '$group'"); - $this->infoComment('Allowed values are: [layouts, components]'); - - return Command::FAILURE; - } - - $this->warn("publish:views is deprecated. Use php hyde publish $flag instead."); - - return $this->call('publish', [$flag => true]); - } -} diff --git a/packages/framework/src/Console/ConsoleServiceProvider.php b/packages/framework/src/Console/ConsoleServiceProvider.php index d774fe78406..15174a9a998 100644 --- a/packages/framework/src/Console/ConsoleServiceProvider.php +++ b/packages/framework/src/Console/ConsoleServiceProvider.php @@ -27,9 +27,6 @@ public function register(): void Commands\VendorPublishCommand::class, Commands\PublishCommand::class, - Commands\PublishConfigsCommand::class, - Commands\PublishHomepageCommand::class, - Commands\PublishViewsCommand::class, Commands\PackageDiscoverCommand::class, Commands\RouteListCommand::class, diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 387da2c4a38..77c79cd503f 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -9,6 +9,7 @@ use Hyde\Testing\TestCase; use Illuminate\Support\Facades\File; use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; /** @@ -189,4 +190,15 @@ public function testArbitrarySourcePathArgumentIsRejected() $this->artisan('publish resources/views/foo.blade.php')->run(); } + + // The legacy publish commands are removed in v3, not aliased. Invoking one must raise Symfony's + // native command-not-found error, proving the command is gone and that no shim intercepts it. + + public function testRemovedLegacyPublishViewsCommandRaisesCommandNotFound() + { + $this->expectException(CommandNotFoundException::class); + $this->expectExceptionMessage('The command "publish:views" does not exist.'); + + $this->artisan('publish:views')->run(); + } } diff --git a/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php b/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php deleted file mode 100644 index d82153c8573..00000000000 --- a/packages/framework/tests/Feature/Commands/PublishConfigsCommandTest.php +++ /dev/null @@ -1,55 +0,0 @@ -assertDirectoryDoesNotExist(Hyde::path('config')); - - $this->artisan('publish:configs --no-interaction') - ->expectsOutputToContain('publish:configs is deprecated. Use php hyde vendor:publish --tag=hyde-config instead.') - ->assertExitCode(0); - - // The hyde-config tag publishes exactly the six Hyde-owned configs. - $this->assertFileExists(Hyde::path('config/hyde.php')); - $this->assertFileExists(Hyde::path('config/docs.php')); - $this->assertFileExists(Hyde::path('config/markdown.php')); - $this->assertFileExists(Hyde::path('config/view.php')); - $this->assertFileExists(Hyde::path('config/cache.php')); - $this->assertFileExists(Hyde::path('config/commands.php')); - - // Torchlight is obtained via its own package tag, never through hyde-config. - $this->assertFileDoesNotExist(Hyde::path('config/torchlight.php')); - } -} diff --git a/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php b/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php deleted file mode 100644 index 8fbb0edc169..00000000000 --- a/packages/framework/tests/Feature/Commands/PublishHomepageCommandTest.php +++ /dev/null @@ -1,95 +0,0 @@ -withoutDefaultPages(); - } - - protected function tearDown(): void - { - if (Filesystem::exists('_pages/index.blade.php')) { - Filesystem::unlink('_pages/index.blade.php'); - } - - $this->restoreDefaultPages(); - - parent::tearDown(); - } - - public function testNamedTemplatePrintsNoticeAndDelegatesToPageFlag() - { - $this->artisan('publish:homepage welcome --no-interaction') - ->expectsOutputToContain('publish:homepage is deprecated. Use php hyde publish --page=welcome instead.') - ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') - ->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); - } - - public function testUnknownTemplateDelegatesAndFailsHelpfully() - { - $this->artisan('publish:homepage nope --no-interaction') - ->expectsOutputToContain('publish:homepage is deprecated. Use php hyde publish --page=nope instead.') - ->expectsOutputToContain('The page [nope] does not exist.') - ->assertExitCode(1); - - $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); - } - - public function testBareInvocationPrintsNoticeAndDelegatesToThePagePicker() - { - $this->artisan('publish:homepage') - ->expectsOutputToContain('publish:homepage is deprecated. Use php hyde publish --page instead.') - ->expectsQuestion('Select pages to publish', ['welcome']) - ->expectsConfirmation('Proceed?', 'yes') - ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') - ->expectsConfirmation('Rebuild the site now?', 'no') - ->assertExitCode(0); - - $this->assertFileExists(Hyde::path('_pages/index.blade.php')); - } - - public function testForceFlagIsForwardedToOverwriteModifiedFiles() - { - File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); - - $this->artisan('publish:homepage welcome --force --no-interaction') - ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') - ->assertExitCode(0); - - $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); - } - - public function testWithoutForceModifiedFilesAreProtected() - { - File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); - - $this->artisan('publish:homepage welcome --no-interaction') - ->expectsOutput('Cannot overwrite modified files without --force:') - ->assertExitCode(1); - - $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); - } -} diff --git a/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php b/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php deleted file mode 100644 index 15da49a3897..00000000000 --- a/packages/framework/tests/Feature/Commands/PublishViewsCommandTest.php +++ /dev/null @@ -1,85 +0,0 @@ -viewCount('layouts') + $this->viewCount('components'); - - $this->artisan('publish:views --no-interaction') - ->expectsOutputToContain('publish:views is deprecated. Use php hyde publish --all instead.') - ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde]") - ->assertExitCode(0); - - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); - } - - public function testLayoutsGroupPrintsNoticeAndDelegatesToLayoutsFlag() - { - $count = $this->viewCount('layouts'); - - $this->artisan('publish:views layouts --no-interaction') - ->expectsOutputToContain('publish:views is deprecated. Use php hyde publish --layouts instead.') - ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde/layouts]") - ->assertExitCode(0); - - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); - } - - public function testComponentsGroupPrintsNoticeAndDelegatesToComponentsFlag() - { - $count = $this->viewCount('components'); - - $this->artisan('publish:views components --no-interaction') - ->expectsOutputToContain('publish:views is deprecated. Use php hyde publish --components instead.') - ->expectsOutputToContain("Published all $count views to [resources/views/vendor/hyde/components]") - ->assertExitCode(0); - - $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/components/article-excerpt.blade.php')); - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); - } - - public function testInvalidGroupFailsWithoutPublishingViews() - { - $this->artisan('publish:views typo_group --no-interaction') - ->expectsOutputToContain("Invalid selection: 'typo_group'") - ->expectsOutputToContain('Allowed values are: [layouts, components]') - ->assertExitCode(1); - - $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor')); - } - - protected function viewCount(string $group): int - { - return Filesystem::findFiles("packages/framework/resources/views/$group", '.blade.php', true)->count(); - } - - protected function tearDown(): void - { - if (File::isDirectory(Hyde::path('resources/views/vendor'))) { - File::deleteDirectory(Hyde::path('resources/views/vendor')); - } - - parent::tearDown(); - } -} From 785dbcb3a5847fb98a715764844499b7ea3c4e21 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:17:56 +0200 Subject: [PATCH 20/56] Step B: Dead-code sweep after removing legacy publish commands Verified each candidate repo-wide before deleting. Removed with zero remaining consumers: - InteractivePublishCommandHelper (+ its unit test) - ViewDiffService / checksumMatchesAny (+ its unit test); unixsum helpers stay (OverwritePolicy and GenerateBuildManifest still use unixsum_file) - AsksToRebuildSite trait (no use/method consumer; reworded the PagesPublisher comment that named it) - The hyde-welcome-page / hyde-posts-page / hyde-blank-page publish tags (the new PublishablePages resolves homepages by direct source path) Kept: hyde-page-404 (general 404 publish surface, not a homepage-command tag). Co-Authored-By: Claude Opus 4.8 --- .../Console/Concerns/AsksToRebuildSite.php | 25 --- .../InteractivePublishCommandHelper.php | 87 -------- .../src/Console/Helpers/PagesPublisher.php | 6 +- .../Providers/ViewServiceProvider.php | 12 -- .../Framework/Services/ViewDiffService.php | 57 ----- .../InteractivePublishCommandHelperTest.php | 202 ------------------ .../tests/Unit/ViewDiffServiceTest.php | 48 ----- 7 files changed, 3 insertions(+), 434 deletions(-) delete mode 100644 packages/framework/src/Console/Concerns/AsksToRebuildSite.php delete mode 100644 packages/framework/src/Console/Helpers/InteractivePublishCommandHelper.php delete mode 100644 packages/framework/src/Framework/Services/ViewDiffService.php delete mode 100644 packages/framework/tests/Unit/InteractivePublishCommandHelperTest.php delete mode 100644 packages/framework/tests/Unit/ViewDiffServiceTest.php diff --git a/packages/framework/src/Console/Concerns/AsksToRebuildSite.php b/packages/framework/src/Console/Concerns/AsksToRebuildSite.php deleted file mode 100644 index 02ef6c9c0b0..00000000000 --- a/packages/framework/src/Console/Concerns/AsksToRebuildSite.php +++ /dev/null @@ -1,25 +0,0 @@ -option('no-interaction')) { - return; - } - - if ($this->confirm('Would you like to rebuild the site?', true)) { - $this->line('Okay, building site!'); - Artisan::call('build'); - $this->info('Site is built!'); - } else { - $this->line('Okay, you can always run the build later!'); - } - } -} diff --git a/packages/framework/src/Console/Helpers/InteractivePublishCommandHelper.php b/packages/framework/src/Console/Helpers/InteractivePublishCommandHelper.php deleted file mode 100644 index 97f0871292c..00000000000 --- a/packages/framework/src/Console/Helpers/InteractivePublishCommandHelper.php +++ /dev/null @@ -1,87 +0,0 @@ - Map of source files to target files */ - protected array $publishableFilesMap; - - protected readonly int $originalFileCount; - - /** @param array $publishableFilesMap */ - public function __construct(array $publishableFilesMap) - { - $this->publishableFilesMap = $publishableFilesMap; - $this->originalFileCount = count($publishableFilesMap); - } - - /** @return array */ - public function getFileChoices(): array - { - return Arr::mapWithKeys($this->publishableFilesMap, /** @return array */ function (string $target, string $source): array { - return [$source => $this->pathRelativeToDirectory($source, $this->getBaseDirectory())]; - }); - } - - /** - * Only publish the selected files. - * - * @param array $selectedFiles Array of selected file paths, matching the keys of the publishableFilesMap. - */ - public function only(array $selectedFiles): void - { - $this->publishableFilesMap = Arr::only($this->publishableFilesMap, $selectedFiles); - } - - /** Find the most specific common parent directory path for the files, trimming as much as possible whilst keeping specificity and uniqueness. */ - public function getBaseDirectory(): string - { - $partsMap = collect($this->publishableFilesMap)->map(function (string $file): array { - return explode('/', $file); - }); - - $commonParts = $partsMap->reduce(function (array $carry, array $parts): array { - return array_intersect($carry, $parts); - }, $partsMap->first()); - - return implode('/', $commonParts); - } - - public function publishFiles(): void - { - foreach ($this->publishableFilesMap as $source => $target) { - Filesystem::ensureParentDirectoryExists($target); - Filesystem::copy($source, $target); - } - } - - public function formatOutput(string $group): string - { - $fileCount = count($this->publishableFilesMap); - $publishedOneFile = $fileCount === 1; - $publishedAllGroups = $group === 'all'; - $publishedAllFiles = $fileCount === $this->originalFileCount; - $selectedFilesModifier = $publishedAllFiles ? 'all' : 'selected'; - - return match (true) { - $publishedAllGroups => sprintf('Published all %d files to [%s]', $fileCount, $this->getBaseDirectory()), - $publishedOneFile => sprintf('Published selected file to [%s]', reset($this->publishableFilesMap)), - default => sprintf('Published %s [%s] files to [%s]', $selectedFilesModifier, Str::singular($group), $this->getBaseDirectory()) - }; - } - - protected function pathRelativeToDirectory(string $source, string $directory): string - { - return Str::after($source, basename($directory).'/'); - } -} diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index a44c1c4919b..d7783d6c42c 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -437,9 +437,9 @@ protected function report(array $written): void /** * Offer to rebuild the site after a successful publish (§5.7). * - * Interactive only, and deliberately defaulting to NO. We do not reuse the shared AsksToRebuildSite trait - * here: it prompts with different wording and defaults to YES, which would auto-rebuild the entire site after - * a single page publish. Keep this inline so the no-default is not "helpfully" consolidated back to a yes-default. + * Interactive only, and deliberately defaulting to NO. A single page publish should not auto-rebuild the + * entire site, so the prompt defaults to NO — keep this check here rather than consolidating it into a + * shared rebuild helper, which would tempt a yes-default back in. */ protected function maybeRebuild(): void { diff --git a/packages/framework/src/Foundation/Providers/ViewServiceProvider.php b/packages/framework/src/Foundation/Providers/ViewServiceProvider.php index 19cccadef69..8b24df23850 100644 --- a/packages/framework/src/Foundation/Providers/ViewServiceProvider.php +++ b/packages/framework/src/Foundation/Providers/ViewServiceProvider.php @@ -38,18 +38,6 @@ public function boot(): void Hyde::vendorPath('resources/views/pages/404.blade.php') => Hyde::path('_pages/404.blade.php'), ], 'hyde-page-404'); - $this->publishes([ - Hyde::vendorPath('resources/views/homepages/welcome.blade.php') => Hyde::path('_pages/index.blade.php'), - ], 'hyde-welcome-page'); - - $this->publishes([ - Hyde::vendorPath('resources/views/homepages/post-feed.blade.php') => Hyde::path('_pages/index.blade.php'), - ], 'hyde-posts-page'); - - $this->publishes([ - Hyde::vendorPath('resources/views/homepages/blank.blade.php') => Hyde::path('_pages/index.blade.php'), - ], 'hyde-blank-page'); - Blade::component('link', LinkComponent::class); Blade::component('hyde::breadcrumbs', BreadcrumbsComponent::class); } diff --git a/packages/framework/src/Framework/Services/ViewDiffService.php b/packages/framework/src/Framework/Services/ViewDiffService.php deleted file mode 100644 index 9ee1aa0f514..00000000000 --- a/packages/framework/src/Framework/Services/ViewDiffService.php +++ /dev/null @@ -1,57 +0,0 @@ - */ - public static function getViewFileHashIndex(): array - { - $filecache = []; - - foreach (glob(Hyde::vendorPath('resources/views/**/*.blade.php')) as $file) { - $filecache[unslash(str_replace(Hyde::vendorPath(), '', (string) $file))] = [ - 'unixsum' => unixsum_file($file), - ]; - } - - return $filecache; - } - - /** @return array */ - public static function getChecksums(): array - { - $checksums = []; - - foreach (static::getViewFileHashIndex() as $file) { - $checksums[] = $file['unixsum']; - } - - return $checksums; - } - - public static function checksumMatchesAny(string $checksum): bool - { - return in_array($checksum, static::getChecksums()); - } -} diff --git a/packages/framework/tests/Unit/InteractivePublishCommandHelperTest.php b/packages/framework/tests/Unit/InteractivePublishCommandHelperTest.php deleted file mode 100644 index 22198a4a607..00000000000 --- a/packages/framework/tests/Unit/InteractivePublishCommandHelperTest.php +++ /dev/null @@ -1,202 +0,0 @@ -filesystem = $this->mockFilesystemStrict(); - - app()->instance(Filesystem::class, $this->filesystem); - } - - protected function tearDown(): void - { - $this->verifyMockeryExpectations(); - - app()->forgetInstance(Filesystem::class); - } - - public function testGetFileChoices(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - ]); - - $this->assertSame([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'post.blade.php', - ], $helper->getFileChoices()); - } - - public function testOnlyFiltersPublishableFiles(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - ]); - - $helper->only([ - 'packages/framework/resources/views/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php', - ]); - - $this->assertSame([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'app.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'post.blade.php', - ], $helper->getFileChoices()); - } - - public function testPublishFiles(): void - { - $this->filesystem->shouldReceive('dirname')->times(3)->andReturn(Hyde::path('resources/views/vendor/hyde/layouts')); - $this->filesystem->shouldReceive('ensureDirectoryExists')->times(3); - $this->filesystem->shouldReceive('copy')->times(3); - - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - ]); - - $helper->publishFiles(); - - $this->filesystem->shouldHaveReceived('dirname')->times(3); - $this->filesystem->shouldHaveReceived('ensureDirectoryExists')->with(Hyde::path('resources/views/vendor/hyde/layouts'), 0755, true)->times(3); - - $this->filesystem->shouldHaveReceived('copy')->with( - Hyde::path('packages/framework/resources/views/layouts/app.blade.php'), - Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php') - )->once(); - - $this->filesystem->shouldHaveReceived('copy')->with( - Hyde::path('packages/framework/resources/views/layouts/page.blade.php'), - Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php') - )->once(); - - $this->filesystem->shouldHaveReceived('copy')->with( - Hyde::path('packages/framework/resources/views/layouts/post.blade.php'), - Hyde::path('resources/views/vendor/hyde/layouts/post.blade.php') - )->once(); - } - - public function testFormatOutputForSingleFile(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - ]); - - $this->assertSame( - 'Published selected file to [resources/views/vendor/hyde/layouts/app.blade.php]', - $helper->formatOutput('layouts') - ); - } - - public function testFormatOutputForMultipleFiles(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - ]); - - $this->assertSame( - 'Published all 2 files to [resources/views/vendor/hyde/layouts]', - $helper->formatOutput('all') - ); - } - - public function testFormatOutputForSingleChosenFile(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - ]); - - $helper->only([ - 'packages/framework/resources/views/layouts/app.blade.php', - ]); - - $this->assertSame( - 'Published selected file to [resources/views/vendor/hyde/layouts/app.blade.php]', - $helper->formatOutput('layouts') - ); - } - - public function testFormatOutputForMultipleChosenFiles(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - ]); - - $helper->only([ - 'packages/framework/resources/views/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php', - ]); - - $this->assertSame( - 'Published selected [layout] files to [resources/views/vendor/hyde/layouts]', - $helper->formatOutput('layouts') - ); - } - - public function testGetBaseDirectoryWithOneSet(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/page.blade.php' => 'resources/views/vendor/hyde/layouts/page.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - ]); - - $this->assertSame( - 'resources/views/vendor/hyde/layouts', - $helper->getBaseDirectory() - ); - } - - public function testGetBaseDirectoryWithMultipleSets(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - 'packages/framework/resources/views/layouts/post.blade.php' => 'resources/views/vendor/hyde/layouts/post.blade.php', - 'packages/framework/resources/views/components/page.blade.php' => 'resources/views/vendor/hyde/components/page.blade.php', - ]); - - $this->assertSame( - 'resources/views/vendor/hyde', - $helper->getBaseDirectory() - ); - } - - public function testGetBaseDirectoryWithSinglePath(): void - { - $helper = new InteractivePublishCommandHelper([ - 'packages/framework/resources/views/layouts/app.blade.php' => 'resources/views/vendor/hyde/layouts/app.blade.php', - ]); - - $this->assertSame( - 'resources/views/vendor/hyde/layouts/app.blade.php', - $helper->getBaseDirectory() - ); - } -} diff --git a/packages/framework/tests/Unit/ViewDiffServiceTest.php b/packages/framework/tests/Unit/ViewDiffServiceTest.php deleted file mode 100644 index d8f06f2fa61..00000000000 --- a/packages/framework/tests/Unit/ViewDiffServiceTest.php +++ /dev/null @@ -1,48 +0,0 @@ -assertIsArray($fileCache); - $this->assertArrayHasKey('resources/views/layouts/app.blade.php', $fileCache); - $this->assertArrayHasKey('unixsum', $fileCache['resources/views/layouts/app.blade.php']); - $this->assertSame(32, strlen($fileCache['resources/views/layouts/app.blade.php']['unixsum'])); - } - - public function testGetChecksums() - { - $checksums = ViewDiffService::getChecksums(); - - $this->assertIsArray($checksums); - $this->assertSame(32, strlen($checksums[0])); - } - - public function testChecksumMatchesAny() - { - $this->assertTrue(ViewDiffService::checksumMatchesAny( - unixsum_file(Hyde::vendorPath('resources/views/layouts/app.blade.php')) - )); - } - - public function testChecksumMatchesAnyFalse() - { - $this->assertFalse(ViewDiffService::checksumMatchesAny(unixsum('foo'))); - } -} From b40186d5d3cf9df6dc8a5466a1d90a53e9bd946a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:18:55 +0200 Subject: [PATCH 21/56] =?UTF-8?q?Step=20C:=20Config=20tags=20=E2=80=94=20h?= =?UTF-8?q?yde-config=20is=20the=20only=20tag=20in=20v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the configs, hyde-configs, and support-configs tag registrations from ConfigurationServiceProvider. With the alias command gone they are dead public surface, and v3 is the moment to drop them rather than carry them another major cycle. Keep hyde-config (the exact six-file set, torchlight excluded) and its two tests unchanged. Add a test pinning that the removed tags publish nothing. Co-Authored-By: Claude Opus 4.8 --- .../Providers/ConfigurationServiceProvider.php | 17 ----------------- .../tests/Feature/HydeServiceProviderTest.php | 8 ++++++++ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php b/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php index 708376a13ec..0b6d6262905 100644 --- a/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php +++ b/packages/framework/src/Foundation/Providers/ConfigurationServiceProvider.php @@ -19,16 +19,6 @@ public function register(): void public function boot(): void { - $this->publishes([ - __DIR__.'/../../../config' => config_path(), - ], 'configs'); - - $this->publishes([ - __DIR__.'/../../../config/hyde.php' => config_path('hyde.php'), - __DIR__.'/../../../config/docs.php' => config_path('docs.php'), - __DIR__.'/../../../config/markdown.php' => config_path('markdown.php'), - ], 'hyde-configs'); - $this->publishes([ __DIR__.'/../../../config/hyde.php' => config_path('hyde.php'), __DIR__.'/../../../config/docs.php' => config_path('docs.php'), @@ -37,12 +27,5 @@ public function boot(): void __DIR__.'/../../../config/cache.php' => config_path('cache.php'), __DIR__.'/../../../config/commands.php' => config_path('commands.php'), ], 'hyde-config'); - - $this->publishes([ - __DIR__.'/../../../config/view.php' => config_path('view.php'), - __DIR__.'/../../../config/cache.php' => config_path('cache.php'), - __DIR__.'/../../../config/commands.php' => config_path('commands.php'), - __DIR__.'/../../../config/torchlight.php' => config_path('torchlight.php'), - ], 'support-configs'); } } diff --git a/packages/framework/tests/Feature/HydeServiceProviderTest.php b/packages/framework/tests/Feature/HydeServiceProviderTest.php index 2270cc3c113..f571de61f47 100644 --- a/packages/framework/tests/Feature/HydeServiceProviderTest.php +++ b/packages/framework/tests/Feature/HydeServiceProviderTest.php @@ -395,4 +395,12 @@ public function testHydeConfigPublishTagDoesNotPublishTorchlightConfig() $this->assertNotContains('torchlight.php', $files); } + + public function testLegacyConfigPublishTagsAreRemovedAndPublishNothing() + { + // hyde-config is the only Hyde config publish tag in v3; the legacy tags publish nothing. + foreach (['configs', 'hyde-configs', 'support-configs'] as $tag) { + $this->assertSame([], ServiceProvider::pathsToPublish(ConfigurationServiceProvider::class, $tag)); + } + } } From e9be2b9ea7403e298094ef0c84a7dda58ddcce99 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:22:02 +0200 Subject: [PATCH 22/56] =?UTF-8?q?Step=20D:=20Documentation=20=E2=80=94=20t?= =?UTF-8?q?he=20upgrade=20guide=20carries=20the=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add a "Removed Publishing Commands" section to the v3 upgrade guide (and UPGRADE.md) with the full replacement table, including the posts/blank --to mappings that don't map 1:1, the config-tag consolidation, and the never-overwrite-without-force behavioral note. - Fix the config-update instructions in updating-hyde.md and troubleshooting.md to use --force, noting existing files are skipped without it. - Replace the "Deprecated publishing commands" table in console-commands.md with a single line linking to the upgrade guide's removed-commands section. Co-Authored-By: Claude Opus 4.8 --- UPGRADE.md | 20 ++++++++++++++++++ docs/digging-deeper/troubleshooting.md | 2 +- docs/digging-deeper/updating-hyde.md | 4 +++- docs/getting-started/console-commands.md | 11 ++-------- docs/getting-started/upgrade-guide.md | 26 ++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 11 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 544a723c88a..a79ee9e5e10 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -74,11 +74,31 @@ StaticPageBuilder::handle(Pages::getPage('_posts/hello-world.md')); Note that this only produces a correct `_site` when the page is self-contained. For anything that touches aggregate outputs, run `php hyde build` to rebuild the whole site instead. +## Step 3: Replace the Removed Publishing Commands + +The three legacy publishing commands (`publish:views`, `publish:configs`, and `publish:homepage`) were removed in v3 and replaced by the unified `publish` command (and, for configuration files, the standard `vendor:publish` path). They are not aliased — invoking one now raises the native "command not found" error, which already suggests `publish` as an alternative. + +| Removed in v3 | Use instead | +|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `publish:views` | `php hyde publish --all` (or `--layouts` / `--components`, or bare `publish` for the interactive picker) | +| `publish:views layouts` | `php hyde publish --layouts` | +| `publish:views components` | `php hyde publish --components` | +| `publish:configs` | `php hyde vendor:publish --tag=hyde-config --force` | +| `publish:homepage` | `php hyde publish --page` | +| `publish:homepage welcome` | `php hyde publish --page=welcome` | +| `publish:homepage posts` | `php hyde publish --page=posts --to=_pages/index.blade.php` (the old command always published to the index; the new default is `_pages/posts.blade.php`) | +| `publish:homepage blank` | `php hyde publish --page=blank --to=_pages/index.blade.php` (blank now has no default destination) | + +The config publish tags were consolidated too: `hyde-configs`, `support-configs`, and `configs` are removed, and `hyde-config` is now the only Hyde config publish tag. + +Note that the new `publish` command never overwrites files you have modified without confirmation or `--force`, where the old commands overwrote silently. This is why the `publish:configs` replacement passes `--force` — existing files are skipped without it. + ## Migration Checklist Use this checklist to track your upgrade progress: - [ ] Replaced any `php hyde rebuild ` usage with `StaticPageBuilder::handle()` or a full `php hyde build` +- [ ] Replaced any `publish:views`, `publish:configs`, or `publish:homepage` usage with the new `publish` / `vendor:publish` commands (see the table above; note the `posts`/`blank` `--to` mappings) ## Troubleshooting diff --git a/docs/digging-deeper/troubleshooting.md b/docs/digging-deeper/troubleshooting.md index 273957124c1..03a63daeea1 100644 --- a/docs/digging-deeper/troubleshooting.md +++ b/docs/digging-deeper/troubleshooting.md @@ -82,7 +82,7 @@ You can read more about some of these in the [Core Concepts](core-concepts#paths | Issues with date in blog post front matter | The date is parsed by the PHP `strtotime()` function. The date may be in an invalid format, or the front matter is invalid | Ensure the date is in a format that `strtotime()` can parse. Wrap the front matter value in quotes. | | RSS feed not being generated | The RSS feed requires that you have set a site URL in the Hyde config or the `.env` file. Also check that you have blog posts, and that they are enabled. | Check your configuration files. | | | Sitemap not being generated | The sitemap requires that you have set a site URL in the Hyde config or the `.env` file. | Check your configuration files. | | -| Unable to do literally anything | If everything is broken, you may be missing a Composer package or your configuration files could be messed up. | Run `composer install` and/or `composer update`. If you can run HydeCLI commands, update your configs with `php hyde vendor:publish --tag=hyde-config`, or copy them manually from GitHub or the vendor directory. | +| Unable to do literally anything | If everything is broken, you may be missing a Composer package or your configuration files could be messed up. | Run `composer install` and/or `composer update`. If you can run HydeCLI commands, update your configs with `php hyde vendor:publish --tag=hyde-config --force` (existing files are skipped without `--force`), or copy them manually from GitHub or the vendor directory. | | Namespaced Yaml config (`hyde.yml`) not working | When using namespaced Yaml configuration, you must begin the file with `hyde:`, even if you just want to use another file for example `docs:`. | Make sure the file starts with `hyde:` (You don't need to specify any options, as long as it's present). See [`#1475`](https://github.com/hydephp/develop/issues/1475) | ### Extra troubleshooting information diff --git a/docs/digging-deeper/updating-hyde.md b/docs/digging-deeper/updating-hyde.md index 113ba3e3d28..ba27ba45c90 100644 --- a/docs/digging-deeper/updating-hyde.md +++ b/docs/digging-deeper/updating-hyde.md @@ -89,9 +89,11 @@ composer update Then, update your config files. This is the hardest part, as you may need to manually copy in your own changes. ```bash -php hyde vendor:publish --tag=hyde-config +php hyde vendor:publish --tag=hyde-config --force ``` +Note that existing files are skipped without `--force`, so it is required here to overwrite your current config files. + If you have published any of the included Blade components you will need to re-publish them. ```bash diff --git a/docs/getting-started/console-commands.md b/docs/getting-started/console-commands.md index e09d07ed252..2a1b66fbdfb 100644 --- a/docs/getting-started/console-commands.md +++ b/docs/getting-started/console-commands.md @@ -237,13 +237,6 @@ This publishes the Hyde-owned config files (`hyde.php`, `docs.php`, `markdown.ph | `--provider=` | The service provider that has assets you want to publish | | `--tag=` | One or many tags that have assets you want to publish \n- Is multiple: yes | -## Deprecated publishing commands +## Removed publishing commands -The following commands from earlier versions of Hyde still work but are deprecated, and print a notice -pointing to their replacement. They will be removed in a future major version, so prefer the new commands. - -| Deprecated command | Use instead | -|-------------------------------|----------------------------------------------------| -| `publish:views [group]` | `publish --layouts` / `publish --components` | -| `publish:configs` | `vendor:publish --tag=hyde-config` | -| `publish:homepage [template]` | `publish --page=[template]` | +The legacy `publish:*` publishing commands from earlier versions of Hyde were removed in v3. See the [Removed Publishing Commands](https://hydephp.com/docs/3.x/upgrade-guide#removed-publishing-commands) section of the upgrade guide for the replacement for each. diff --git a/docs/getting-started/upgrade-guide.md b/docs/getting-started/upgrade-guide.md index 3dca3642972..34432bb27a1 100644 --- a/docs/getting-started/upgrade-guide.md +++ b/docs/getting-started/upgrade-guide.md @@ -5,3 +5,29 @@ navigation: --- Will be filled in from UPGRADE.md before release. + +## Removed Publishing Commands + +The three legacy publishing commands were removed in v3 and replaced by the unified `publish` command +(and, for configuration files, the standard `vendor:publish` path). They are not aliased — invoking one +now raises the native "command not found" error, which already suggests `publish` as an alternative. + + + +| Removed in v3 | Use instead | +|---------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| `publish:views` | `php hyde publish --all` (or `--layouts` / `--components`, or bare `publish` for the interactive picker) | +| `publish:views layouts` | `php hyde publish --layouts` | +| `publish:views components`| `php hyde publish --components` | +| `publish:configs` | `php hyde vendor:publish --tag=hyde-config --force` | +| `publish:homepage` | `php hyde publish --page` | +| `publish:homepage welcome`| `php hyde publish --page=welcome` | +| `publish:homepage posts` | `php hyde publish --page=posts --to=_pages/index.blade.php` (the old command always published to the index; the new default is `_pages/posts.blade.php`) | +| `publish:homepage blank` | `php hyde publish --page=blank --to=_pages/index.blade.php` (blank now has no default destination) | + +The config publish tags were consolidated too: `hyde-configs`, `support-configs`, and `configs` are removed, +and `hyde-config` is now the only Hyde config publish tag. + +>info **Behavioral note:** The new `publish` command never overwrites files you have modified without confirmation +> or `--force`, where the old commands overwrote silently. This is the improvement the break buys — and it is why +> the `publish:configs` replacement above passes `--force` (existing files are skipped without it). From 45aa7729bd2c4df2c5fc2d6df0991f21e6596e2e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:24:26 +0200 Subject: [PATCH 23/56] Step E: Sync spec with reality and repair mangled docblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite spec §8 from "Deprecated aliases (kept for v3)" to "Removed commands (v3)": removal, native command-not-found behavior, and pointer to the upgrade guide. Drop the notice-printing requirement. - Update §11 criterion 16 to the removal + native-error + documented-replacement wording (including the posts/blank --to mappings). - Update §6 to note the legacy config tags are removed, hyde-config is the only tag, and updating existing files requires --force. Fix the incidental stale alias references in §10 and §12. - Repair the StyleCI-mangled multi-line @return docblocks in ViewsPublisher and PagesPublisher by putting each description back on the tag line. No behavior change. Co-Authored-By: Claude Opus 4.8 --- new-publish-command-spec.md | 44 +++++++++++-------- .../src/Console/Helpers/PagesPublisher.php | 3 +- .../src/Console/Helpers/ViewsPublisher.php | 9 ++-- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index 373debd2170..af6c428e0b0 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -281,8 +281,10 @@ php hyde vendor:publish --tag=hyde-config `hyde.php`, `docs.php`, `markdown.php`, `view.php`, `cache.php`, `commands.php`. - Torchlight is **not** included — it is obtained via Torchlight's own package tag. -- Granular tags (e.g. per-file) may still be registered for power users, but the - single `hyde-config` tag is the documented path. +- `hyde-config` is the **only** Hyde config publish tag in v3. The legacy tags + (`configs`, `hyde-configs`, `support-configs`) are removed — they publish nothing. +- Updating existing config files requires `--force`: `vendor:publish` skips files + that already exist without it. - `publish` has no config concept. `php hyde publish --config` fails and points here. > (Exact tag name `hyde-config` is bikesheddable; keep it singular and Hyde-scoped.) @@ -325,21 +327,23 @@ Run again with --force to overwrite. --- -## 8. Deprecated aliases (kept for v3, removed from primary docs) +## 8. Removed commands (v3) -| Old command | Maps to | +v3 is a major release, so the three legacy publish commands are **removed, not +aliased**. There is no compat shim and no deprecation-notice interceptor: invoking +a removed command produces Symfony's native "command not found" error (which already +suggests `publish` as an alternative). The upgrade guide is the migration hint. + +| Removed command | Use instead | |-------------------------------|-------------------------------------------| -| `publish:views [group]` | `publish --layouts` / `--components` | -| `publish:configs` | `vendor:publish --tag=hyde-config` | +| `publish:views [group]` | `publish --all` / `--layouts` / `--components` | +| `publish:configs` | `vendor:publish --tag=hyde-config --force` | | `publish:homepage [template]` | `publish --page=[template]` | -Each prints a one-line deprecation notice, e.g.: - -``` -publish:configs is deprecated. Use php hyde vendor:publish --tag=hyde-config instead. -``` - -Aliases keep working through v3; target removal in v4. +The full replacement table — including the `publish:homepage posts` / `blank` +mappings that do **not** map 1:1 (they need `--to=_pages/index.blade.php`) — lives in +the v3 upgrade guide's "Removed Publishing Commands" section, which is the canonical +migration reference. --- @@ -363,7 +367,8 @@ Aliases keep working through v3; target removal in v4. 4. `PublishablePage` value object + `PublishablePages` registry (extension point). 5. Shared `OverwritePolicy` service (missing / identical / modified). 6. Shared interactive multi-select + confirmation UI component. -7. Deprecated alias commands delegating to `PublishCommand` / `vendor:publish`. + +(No alias/compat commands: the legacy `publish:*` commands are removed outright — see §8.) 8. Register the `hyde-config` publish tag on the relevant service provider. Views stay declarative file-group lists (no per-item class); only pages use the @@ -394,8 +399,10 @@ value-object + registry model. identical→skip, modified→confirm-or-`--force`. No checksum manifest. 14. Modified files are never overwritten without interactive confirm or `--force`. 15. Non-interactive mode never prompts and fails helpfully on ambiguity. -16. `publish:views`, `publish:configs`, `publish:homepage` still work, print a - deprecation notice, and are absent from primary docs. +16. The legacy commands (`publish:views`, `publish:configs`, `publish:homepage`) + are removed; invoking them raises the native command-not-found error, and the + upgrade guide documents each replacement (including the `posts`/`blank` `--to` + mappings). --- @@ -406,5 +413,6 @@ value-object + registry model. Replace with `php hyde publish --components`. - Rewrite the publishing docs around `php hyde publish` (views + `--page`) as the primary command, and `php hyde vendor:publish --tag=hyde-config` for config. - Document `vendor:publish` as the advanced Laravel path; list deprecated aliases - in a migration note only. + Document `vendor:publish` as the advanced Laravel path; the removed legacy + commands are covered only by the upgrade guide's "Removed Publishing Commands" + migration table. diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index d7783d6c42c..f1104db4d89 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -314,8 +314,7 @@ protected function confirmProceed(array $resolved): bool * Apply the shared overwrite policy and copy the resolved pages into place. * * @param array $resolved - * @return array|null - * The pages actually written, or null when the run should stop (cancelled, or blocked without --force). + * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). */ protected function write(array $resolved): ?array { diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 7c8bb174e4e..4b9f18a1ee7 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -72,8 +72,7 @@ public function publish(): int } /** - * @return array{0: array, 1: array} - * A tuple of [source => target] and [source => group-prefixed label] for the offered files. + * @return array{0: array, 1: array} A tuple of [source => target] and [source => group-prefixed label] for the offered files. */ protected function collectOfferedFiles(): array { @@ -130,8 +129,7 @@ protected function selectFiles(array $offered, array $labels): array * Decide every selected file's outcome up front, before anything is written. * * @param array $selected - * @return array{0: array, 1: array, 2: array} - * A tuple of [copy, already-current, blocked] maps, each source => target. + * @return array{0: array, 1: array, 2: array} A tuple of [copy, already-current, blocked] maps, each source => target. */ protected function decide(array $selected): array { @@ -154,8 +152,7 @@ protected function decide(array $selected): array * Resolve what to do with modified (blocked) files, after the full outcome is known but before any write. * * @param array $blocked - * @return array|null The blocked files to overwrite, or null when the run should stop - * (cancelled interactively, or blocked non-interactively without --force). + * @return array|null The blocked files to overwrite, or null when the run should stop (cancelled interactively, or blocked non-interactively without --force). */ protected function resolveBlocked(array $blocked): ?array { From af392e0e10f131f78d067aea2c290a412ecf9a24 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:27:06 +0200 Subject: [PATCH 24/56] =?UTF-8?q?Step=20F:=20Acceptance=20sweep=20?= =?UTF-8?q?=E2=80=94=20purge=20live=20references=20to=20removed=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final sweep of packages/ surfaced three shipped, user-facing references to the now-removed commands (criterion 1 requires only historical notes to remain): - The default welcome homepage told every new user to run `publish:homepage`; now points to `publish --page` (and the text-representation meta test's expected string tracks the blade file). - ValidationService's missing-404 and missing-index tips pointed to `publish:views` / `publish:homepage`; now `publish --page=404` / `publish --page`. packages/ is clean of live references (only CHANGELOG history and the pin test remain); `hyde list` shows only publish and vendor:publish; suite green (the sole failure is the pre-existing, unrelated FeaturedImageUnitTest PHP 8.5 issue). Co-Authored-By: Claude Opus 4.8 --- .../framework/resources/views/homepages/welcome.blade.php | 2 +- .../framework/src/Framework/Services/ValidationService.php | 4 ++-- packages/framework/tests/Unit/HtmlTestingSupportMetaTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/framework/resources/views/homepages/welcome.blade.php b/packages/framework/resources/views/homepages/welcome.blade.php index ed3a8c6175a..dfbdf321fad 100644 --- a/packages/framework/resources/views/homepages/welcome.blade.php +++ b/packages/framework/resources/views/homepages/welcome.blade.php @@ -52,7 +52,7 @@ class="relative mt-2 text-transparent bg-clip-text bg-linear-to-br logo-gradient This is the default homepage stored as index.blade.php, however you can publish any of the built-in views using the following command: -
php hyde publish:homepage
+
php hyde publish --page

diff --git a/packages/framework/src/Framework/Services/ValidationService.php b/packages/framework/src/Framework/Services/ValidationService.php index 052486d4041..4ba85e76d76 100644 --- a/packages/framework/src/Framework/Services/ValidationService.php +++ b/packages/framework/src/Framework/Services/ValidationService.php @@ -62,7 +62,7 @@ public function check_site_has_a_404_page(Result $result): Result } return $result->fail('Could not find an 404.md or 404.blade.php file!') - ->withTip('You can publish the default one using `php hyde publish:views`'); + ->withTip('You can publish the default one using `php hyde publish --page=404`'); } public function check_site_has_an_index_page(Result $result): Result @@ -74,7 +74,7 @@ public function check_site_has_an_index_page(Result $result): Result } return $result->fail('Could not find an index.md or index.blade.php file!') - ->withTip('You can publish the one of the built in templates using `php hyde publish:homepage`'); + ->withTip('You can publish the one of the built in templates using `php hyde publish --page`'); } public function check_documentation_site_has_an_index_page(Result $result): Result diff --git a/packages/framework/tests/Unit/HtmlTestingSupportMetaTest.php b/packages/framework/tests/Unit/HtmlTestingSupportMetaTest.php index bdec65b7734..e35afd8cfe9 100644 --- a/packages/framework/tests/Unit/HtmlTestingSupportMetaTest.php +++ b/packages/framework/tests/Unit/HtmlTestingSupportMetaTest.php @@ -281,7 +281,7 @@ public function testComplexTextRepresentationParsing() Leap into the future of static HTML blogs and documentation with the tools you already know and love. Made with Tailwind, Laravel, and Coffee. This is the default homepage stored as index.blade.php, however you can publish any of the built-in views using the following command: -php hyde php hyde php hyde publish:homepage +php hyde php hyde php hyde publish --page Resources for getting started Documentation Getting Started From c0ba9eef0bed46091b6a636574c6e83eca9b2fbc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:41:39 +0200 Subject: [PATCH 25/56] Fix view publish base directory prefix Co-Authored-By: Codex --- .../src/Console/Helpers/ViewsPublisher.php | 14 ++++++++++---- .../Commands/PublishCommandViewsTest.php | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 4b9f18a1ee7..f69d70e7fc8 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -12,7 +12,6 @@ use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputInterface; -use function array_intersect; use function array_keys; use function array_merge; use function count; @@ -259,10 +258,17 @@ protected function viewCount(int $count): string protected function baseDirectory(array $files): string { $partsMap = collect($files)->map(fn (string $file): array => explode('/', $file)); + $commonParts = []; - $commonParts = $partsMap->reduce(function (array $carry, array $parts): array { - return array_intersect($carry, $parts); - }, $partsMap->first()); + foreach ($partsMap->first() as $index => $part) { + foreach ($partsMap as $parts) { + if (! isset($parts[$index]) || $parts[$index] !== $part) { + break 2; + } + } + + $commonParts[] = $part; + } return implode('/', $commonParts); } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index ecb4375d7ad..ce79f309826 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -103,6 +103,24 @@ public function testPickerBaseDirectorySpansGroupsWhenBothAreSelected() ->assertExitCode(0); } + public function testBaseDirectoryIgnoresSharedSegmentsAfterPathsDiverge() + { + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $publisher = new class($command, $input) extends ViewsPublisher + { + public function exposeBaseDirectory(array $files): string + { + return $this->baseDirectory($files); + } + }; + + $this->assertSame('resources/views/vendor/hyde', $publisher->exposeBaseDirectory([ + 'resources/views/vendor/hyde/layouts/page.blade.php', + 'resources/views/vendor/hyde/components/page.blade.php', + ])); + } + // The picker is prefiltered by the scope flag and uses group-prefixed labels with an "All views" row. public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() From b2001e05adc0482e1ea899e7958364dcbc5c99fb Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:45:40 +0200 Subject: [PATCH 26/56] Add publish empty selection coverage Co-Authored-By: Codex --- .../Commands/PublishCommandPagesTest.php | 28 +++++++++++++++++++ .../Commands/PublishCommandViewsTest.php | 26 +++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 15c85ac77ba..e2e65ce2568 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -273,6 +273,34 @@ public function testInteractivePickerCanBeDeclinedAtConfirmation() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + public function testEmptyPageSelectionExitsWithoutPublishing() + { + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $publisher = new class($command, $input) extends PagesPublisher + { + protected function selectPages(): ?array + { + return []; + } + }; + + $this->assertSame(0, $publisher->publish()); + + $contents = $output->fetch(); + $this->assertStringContainsString('No pages selected; nothing to publish.', $contents); + $this->assertStringNotContainsString('Ready to publish:', $contents); + $this->assertStringNotContainsString('Published', $contents); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + $this->assertFileDoesNotExist(Hyde::path('_pages/404.blade.php')); + } + // Destination-conflict detection before any write (§5.6). public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index ce79f309826..cf4fd8c82f5 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -79,6 +79,32 @@ public function testPickerCanPublishASingleView() $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/page.blade.php')); } + public function testEmptyViewSelectionExitsWithoutPublishing() + { + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $publisher = new class($command, $input) extends ViewsPublisher + { + protected function selectFiles(array $offered, array $labels): array + { + return []; + } + }; + + $this->assertSame(0, $publisher->publish()); + + $contents = $output->fetch(); + $this->assertStringContainsString('No views selected; nothing to publish.', $contents); + $this->assertStringNotContainsString('Published', $contents); + + $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde')); + } + public function testPickerCanPublishManyViewsFromOneGroup() { $this->artisan('publish --layouts') From a96cad6051321ebe9a63028d123c492469a2a324 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sat, 4 Jul 2026 23:56:13 +0200 Subject: [PATCH 27/56] Simplify publish conflict label formatting Co-Authored-By: Codex --- packages/framework/src/Console/Helpers/PagesPublisher.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index f1104db4d89..ee4e6a1c4b0 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -10,11 +10,11 @@ use Hyde\Framework\Services\OverwritePolicy; use Hyde\Hyde; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Symfony\Component\Console\Input\InputInterface; use function array_map; -use function array_slice; use function count; use function implode; use function sprintf; @@ -474,11 +474,7 @@ protected function findPage(string $name): ?PublishablePage /** @param array $labels */ protected function joinLabels(array $labels): string { - if (count($labels) < 2) { - return implode('', $labels); - } - - return implode(', ', array_slice($labels, 0, -1)).' and '.$labels[count($labels) - 1]; + return Arr::join($labels, ', ', ' and '); } protected function pageCount(int $count): string From 9a90264daf3fb27cf762720caac84fda3856936b Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:00:14 +0200 Subject: [PATCH 28/56] Preserve multiselect option keys Co-Authored-By: Codex --- .../src/Console/Helpers/InteractiveMultiselect.php | 3 +-- .../Feature/Commands/PublishCommandViewsTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index 31299bf7755..186337e3c1c 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -8,7 +8,6 @@ use function array_filter; use function array_keys; -use function array_merge; use function array_values; use function in_array; @@ -36,7 +35,7 @@ class InteractiveMultiselect */ public static function select(string $label, array $options, ?string $allLabel = null): array { - $choices = $allLabel !== null ? array_merge([self::ALL => $allLabel], $options) : $options; + $choices = $allLabel !== null ? [self::ALL => $allLabel] + $options : $options; $prompt = new MultiSelectPrompt($label, $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.'); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index cf4fd8c82f5..c244c9392b3 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -6,6 +6,7 @@ use Hyde\Console\Commands\PublishCommand; use Hyde\Console\Helpers\ConsoleHelper; +use Hyde\Console\Helpers\InteractiveMultiselect; use Hyde\Console\Helpers\ViewsPublisher; use Hyde\Facades\Filesystem; use Hyde\Hyde; @@ -172,6 +173,17 @@ public function testComponentsPickerIsPrefiltered() Prompt::assertOutputDoesntContain('layouts/'); } + public function testAllSentinelPreservesNumericOptionKeys() + { + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + Prompt::fake([Key::DOWN, Key::SPACE, Key::ENTER]); + + $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); + } + // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. public function testIdenticalViewsAreSkippedAsAlreadyCurrent() From f7265014270f71dc1e78fc4810d9dded4925fb58 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:01:06 +0200 Subject: [PATCH 29/56] Fail cleanly for missing publish sources Co-Authored-By: Codex --- .../src/Framework/Services/OverwritePolicy.php | 5 +++++ packages/framework/tests/Unit/OverwritePolicyTest.php | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/framework/src/Framework/Services/OverwritePolicy.php b/packages/framework/src/Framework/Services/OverwritePolicy.php index 1545d5d0f59..5722faf3176 100644 --- a/packages/framework/src/Framework/Services/OverwritePolicy.php +++ b/packages/framework/src/Framework/Services/OverwritePolicy.php @@ -6,6 +6,7 @@ use Hyde\Enums\OverwriteAction; use Hyde\Facades\Filesystem; +use RuntimeException; use function Hyde\unixsum_file; @@ -27,6 +28,10 @@ class OverwritePolicy { public static function decide(string $source, string $destination): OverwriteAction { + if (! Filesystem::exists($source)) { + throw new RuntimeException("Cannot publish: source file [$source] does not exist."); + } + if (! Filesystem::exists($destination)) { return OverwriteAction::Copy; } diff --git a/packages/framework/tests/Unit/OverwritePolicyTest.php b/packages/framework/tests/Unit/OverwritePolicyTest.php index 66086c0a969..507b109aeb3 100644 --- a/packages/framework/tests/Unit/OverwritePolicyTest.php +++ b/packages/framework/tests/Unit/OverwritePolicyTest.php @@ -8,6 +8,7 @@ use Hyde\Testing\UnitTestCase; use Hyde\Enums\OverwriteAction; use Hyde\Framework\Services\OverwritePolicy; +use RuntimeException; use function file_put_contents; use function unlink; @@ -70,6 +71,16 @@ public function testDecidesToSkipWhenFilesDifferOnlyByLineEndings() $this->assertSame(OverwriteAction::Skip, OverwritePolicy::decide($this->source, $this->destination)); } + public function testThrowsCleanExceptionWhenSourceFileIsMissing() + { + $this->putDestination('Existing destination'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Cannot publish: source file [$this->source] does not exist."); + + OverwritePolicy::decide($this->source, $this->destination); + } + protected function putSource(string $contents): void { file_put_contents(Hyde::path($this->source), $contents); From 6aeae6c06f86e9e7f4d1dec3a6f6849b6b361eb2 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:03:12 +0200 Subject: [PATCH 30/56] Keep custom page target prompt active Co-Authored-By: Codex --- .../src/Console/Helpers/PagesPublisher.php | 28 ++++++++++---- .../Commands/PublishCommandPagesTest.php | 37 ++++++++++++++++--- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index ee4e6a1c4b0..4c91342571f 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -246,9 +246,16 @@ protected function promptForTarget(PublishablePage $page): ?string protected function promptForCustomTarget(): ?string { - $path = text('Enter a path within _pages/', placeholder: '_pages/example.blade.php', required: true); - - return $this->validateCustomTarget($path); + $path = text( + label: 'Enter a path within _pages/', + placeholder: '_pages/example.blade.php', + required: true, + validate: fn (string $value): ?string => $this->isValidCustomTarget($value) + ? null + : 'The path must be within _pages/ and end in .blade.php.' + ); + + return Str::replace('\\', '/', $path); } /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ @@ -256,11 +263,7 @@ protected function validateCustomTarget(string $path): ?string { $normalized = Str::replace('\\', '/', $path); - $valid = Str::startsWith($normalized, '_pages/') - && ! Str::contains($normalized, '..') - && Str::endsWith($normalized, '.blade.php'); - - if (! $valid) { + if (! $this->isValidCustomTarget($normalized)) { $this->command->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); return null; @@ -269,6 +272,15 @@ protected function validateCustomTarget(string $path): ?string return $normalized; } + protected function isValidCustomTarget(string $path): bool + { + $normalized = Str::replace('\\', '/', $path); + + return Str::startsWith($normalized, '_pages/') + && ! Str::contains($normalized, '..') + && Str::endsWith($normalized, '.blade.php'); + } + /** * Reject the run when two selected pages resolve to the same destination (§5.6), before anything is written. * diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index e2e65ce2568..7b36dbf8e9a 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -236,13 +236,38 @@ public function testInteractiveResolutionCanChooseACustomPath() $this->assertFileExists(Hyde::path('_pages/custom.blade.php')); } - public function testCustomPathFromPromptIsValidated() + public function testCustomPathFromPromptRepromptsUntilValid() { - $this->artisan('publish --page=blank') - ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') - ->expectsQuestion('Enter a path within _pages/', 'somewhere/else.blade.php') - ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') - ->assertExitCode(1); + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + PagesPromptsReset::resetFallbacks(); + + $invalidPath = 'somewhere/else.blade.php'; + + Prompt::fake([ + Key::ENTER, + $invalidPath, + Key::ENTER, + ...array_fill(0, strlen($invalidPath), Key::BACKSPACE), + '_pages/custom.blade.php', + Key::ENTER, + Key::ENTER, + ]); + + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput(['--page' => 'blank'], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $this->assertSame(0, $command->handle()); + + $this->assertFileExists(Hyde::path('_pages/custom.blade.php')); + $this->assertStringContainsString('Published [blank] to [_pages/custom.blade.php]', $output->fetch()); + Prompt::assertStrippedOutputContains('The path must be within _pages/ and end in .blade.php.'); } // Interactive picker flow (§5.5): select -> resolve -> confirm. From b41fef005b827fb2993cde9cb0037a503aa28c17 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:17:18 +0200 Subject: [PATCH 31/56] Handle publish copy failures Co-Authored-By: Codex --- .../src/Console/Helpers/PagesPublisher.php | 13 ++++++++++-- .../src/Console/Helpers/ViewsPublisher.php | 13 ++++++++++-- .../Framework/Services/OverwritePolicy.php | 8 ++++++++ .../Commands/PublishCommandPagesTest.php | 20 +++++++++++++++++++ .../Commands/PublishCommandViewsTest.php | 20 +++++++++++++++++++ .../tests/Unit/OverwritePolicyTest.php | 18 +++++++++++++++++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 4c91342571f..7f2cc58fd81 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use function array_map; @@ -359,13 +360,21 @@ protected function write(array $resolved): ?array $written = [...$copy, ...$overwrite]; foreach ($written as $record) { - Filesystem::ensureParentDirectoryExists($record['absolute']); - Filesystem::copy($record['source'], $record['absolute']); + $this->copy($record['source'], $record['absolute']); } return $written; } + protected function copy(string $source, string $target): void + { + Filesystem::ensureParentDirectoryExists($target); + + if (! Filesystem::copy($source, $target)) { + throw new RuntimeException("Failed to copy [$source] to [$target]."); + } + } + /** * Resolve what to do with modified (blocked) destinations, mirroring the views flow (§7). * diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index f69d70e7fc8..9faa9e88ccc 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -10,6 +10,7 @@ use Hyde\Framework\Services\OverwritePolicy; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use function array_keys; @@ -63,13 +64,21 @@ public function publish(): int $published = array_merge($copy, $overwrite); foreach ($published as $source => $target) { - Filesystem::ensureParentDirectoryExists($target); - Filesystem::copy($source, $target); + $this->copy($source, $target); } return $this->report($published, $current, $overwrite === [] ? $blocked : [], count($offered)); } + protected function copy(string $source, string $target): void + { + Filesystem::ensureParentDirectoryExists($target); + + if (! Filesystem::copy($source, $target)) { + throw new RuntimeException("Failed to copy [$source] to [$target]."); + } + } + /** * @return array{0: array, 1: array} A tuple of [source => target] and [source => group-prefixed label] for the offered files. */ diff --git a/packages/framework/src/Framework/Services/OverwritePolicy.php b/packages/framework/src/Framework/Services/OverwritePolicy.php index 5722faf3176..630caee4d44 100644 --- a/packages/framework/src/Framework/Services/OverwritePolicy.php +++ b/packages/framework/src/Framework/Services/OverwritePolicy.php @@ -32,10 +32,18 @@ public static function decide(string $source, string $destination): OverwriteAct throw new RuntimeException("Cannot publish: source file [$source] does not exist."); } + if (! Filesystem::isFile($source)) { + throw new RuntimeException("Cannot publish: source [$source] is not a file."); + } + if (! Filesystem::exists($destination)) { return OverwriteAction::Copy; } + if (Filesystem::isDirectory($destination)) { + throw new RuntimeException("Cannot publish: destination [$destination] is a directory."); + } + if (static::filesMatch($source, $destination)) { return OverwriteAction::Skip; } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 7b36dbf8e9a..d990a60ff7b 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -42,6 +42,8 @@ protected function setUp(): void protected function tearDown(): void { + app()->forgetInstance(\Illuminate\Filesystem\Filesystem::class); + ConsoleHelper::clearMocks(); PagesPromptsReset::resetFallbacks(); PublishablePages::clear(); @@ -210,6 +212,24 @@ public function testForceOverwritesModifiedPage() $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } + public function testCopyFailureFailsWithoutReportingSuccess() + { + app()->instance(\Illuminate\Filesystem\Filesystem::class, new class extends \Illuminate\Filesystem\Filesystem + { + public function copy($path, $target): bool + { + return false; + } + }); + + $this->artisan('publish --page=welcome --no-interaction') + ->expectsOutputToContain('Error: Failed to copy') + ->doesntExpectOutputToContain('Published') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + // Interactive destination prompt (§5.4 step 3): default / alternative / custom path. public function testInteractiveResolutionCanChooseAnAlternativeTarget() diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index c244c9392b3..63628640f25 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -222,6 +222,24 @@ public function testForceOverwritesModifiedViews() $this->assertSame(File::get(Hyde::path($this->source('layouts', 'app.blade.php'))), File::get($target)); } + public function testCopyFailureFailsWithoutReportingSuccess() + { + app()->instance(\Illuminate\Filesystem\Filesystem::class, new class extends \Illuminate\Filesystem\Filesystem + { + public function copy($path, $target): bool + { + return false; + } + }); + + $this->artisan('publish --layouts --no-interaction') + ->expectsOutputToContain('Error: Failed to copy') + ->doesntExpectOutputToContain('Published') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); + } + public function testInteractiveConflictPromptCanOverwrite() { $this->seedAllViews(); @@ -326,6 +344,8 @@ protected function runViewsPicker(array $parameters, array $keys): BufferedOutpu protected function tearDown(): void { + app()->forgetInstance(\Illuminate\Filesystem\Filesystem::class); + ConsoleHelper::clearMocks(); ViewsPromptsReset::resetFallbacks(); diff --git a/packages/framework/tests/Unit/OverwritePolicyTest.php b/packages/framework/tests/Unit/OverwritePolicyTest.php index 507b109aeb3..4bd4a376b6a 100644 --- a/packages/framework/tests/Unit/OverwritePolicyTest.php +++ b/packages/framework/tests/Unit/OverwritePolicyTest.php @@ -11,9 +11,12 @@ use RuntimeException; use function file_put_contents; +use function is_dir; use function unlink; use function uniqid; use function is_file; +use function mkdir; +use function rmdir; #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Framework\Services\OverwritePolicy::class)] #[\PHPUnit\Framework\Attributes\CoversClass(\Hyde\Enums\OverwriteAction::class)] @@ -37,6 +40,10 @@ protected function tearDown(): void if (is_file(Hyde::path($path))) { unlink(Hyde::path($path)); } + + if (is_dir(Hyde::path($path))) { + rmdir(Hyde::path($path)); + } } } @@ -81,6 +88,17 @@ public function testThrowsCleanExceptionWhenSourceFileIsMissing() OverwritePolicy::decide($this->source, $this->destination); } + public function testThrowsCleanExceptionWhenDestinationIsADirectory() + { + $this->putSource('Hello world'); + mkdir(Hyde::path($this->destination)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Cannot publish: destination [$this->destination] is a directory."); + + OverwritePolicy::decide($this->source, $this->destination); + } + protected function putSource(string $contents): void { file_put_contents(Hyde::path($this->source), $contents); From c0eb5a794a03d57a554411569c5e5936f9203f33 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:20:22 +0200 Subject: [PATCH 32/56] Recheck publish targets before copying Co-Authored-By: Codex --- .../src/Console/Helpers/PagesPublisher.php | 79 ++++++++++++++++--- .../src/Console/Helpers/ViewsPublisher.php | 69 ++++++++++++++-- .../Commands/PublishCommandPagesTest.php | 36 +++++++++ .../Commands/PublishCommandViewsTest.php | 49 ++++++++++++ 4 files changed, 217 insertions(+), 16 deletions(-) diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 7f2cc58fd81..298648302ac 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -17,6 +17,7 @@ use function array_map; use function count; +use function Hyde\unixsum_file; use function implode; use function sprintf; use function Laravel\Prompts\confirm; @@ -327,7 +328,7 @@ protected function confirmProceed(array $resolved): bool * Apply the shared overwrite policy and copy the resolved pages into place. * * @param array $resolved - * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). + * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). */ protected function write(array $resolved): ?array { @@ -342,11 +343,16 @@ protected function write(array $resolved): ?array 'absolute' => Hyde::path($entry['target']), ]; - match (OverwritePolicy::decide($record['source'], $record['absolute'])) { - OverwriteAction::Copy => $copy[] = $record, - OverwriteAction::Skip => $this->current[] = $entry, - OverwriteAction::Blocked => $blocked[] = $record, - }; + $action = OverwritePolicy::decide($record['source'], $record['absolute']); + + if ($action === OverwriteAction::Copy) { + $copy[] = $record; + } elseif ($action === OverwriteAction::Skip) { + $this->current[] = $entry; + } else { + $record['destinationChecksum'] = $this->destinationChecksum($record['absolute']); + $blocked[] = $record; + } } $overwrite = $this->resolveBlocked($blocked); @@ -357,7 +363,7 @@ protected function write(array $resolved): ?array $this->leftModified = $overwrite === [] ? array_map(fn (array $record): array => ['page' => $record['page'], 'target' => $record['target']], $blocked) : []; - $written = [...$copy, ...$overwrite]; + $written = $this->refreshApprovedWrites($copy, $overwrite); foreach ($written as $record) { $this->copy($record['source'], $record['absolute']); @@ -366,6 +372,54 @@ protected function write(array $resolved): ?array return $written; } + /** + * Re-check destinations immediately before copying so a file changed during an interactive prompt is not lost. + * + * @param array $copy + * @param array $overwrite + * @return array + */ + protected function refreshApprovedWrites(array $copy, array $overwrite): array + { + $written = []; + + foreach ($copy as $record) { + $this->refreshApprovedWrite($written, $record, false); + } + + foreach ($overwrite as $record) { + $this->refreshApprovedWrite($written, $record, true); + } + + return $written; + } + + /** + * @param array $written + * @param array{page: PublishablePage, target: string, source: string, absolute: string, destinationChecksum?: string} $record + */ + protected function refreshApprovedWrite(array &$written, array $record, bool $approvedOverwrite): void + { + match (OverwritePolicy::decide($record['source'], $record['absolute'])) { + OverwriteAction::Copy => $written[] = $record, + OverwriteAction::Skip => $this->current[] = ['page' => $record['page'], 'target' => $record['target']], + OverwriteAction::Blocked => $this->handleStillBlockedWrite($written, $record, $approvedOverwrite), + }; + } + + /** + * @param array $written + * @param array{page: PublishablePage, target: string, source: string, absolute: string, destinationChecksum?: string} $record + */ + protected function handleStillBlockedWrite(array &$written, array $record, bool $approvedOverwrite): void + { + if (! $approvedOverwrite || ($record['destinationChecksum'] ?? null) !== $this->destinationChecksum($record['absolute'])) { + throw new RuntimeException("Cannot publish: destination [{$record['target']}] changed after overwrite checks. Run the command again."); + } + + $written[] = $record; + } + protected function copy(string $source, string $target): void { Filesystem::ensureParentDirectoryExists($target); @@ -378,8 +432,8 @@ protected function copy(string $source, string $target): void /** * Resolve what to do with modified (blocked) destinations, mirroring the views flow (§7). * - * @param array $blocked - * @return array|null + * @param array $blocked + * @return array|null */ protected function resolveBlocked(array $blocked): ?array { @@ -425,7 +479,12 @@ protected function resolveBlocked(array $blocked): ?array return null; } - /** @param array $written */ + protected function destinationChecksum(string $target): string + { + return unixsum_file($target); + } + + /** @param array $written */ protected function report(array $written): void { if ($written === [] && $this->leftModified === [] && $this->current !== []) { diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 9faa9e88ccc..4dc4167e133 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -17,6 +17,7 @@ use function array_merge; use function count; use function explode; +use function Hyde\unixsum_file; use function implode; use function reset; use function sprintf; @@ -35,6 +36,9 @@ */ class ViewsPublisher { + /** @var array EOL-agnostic destination checksums captured when a file was first blocked. */ + protected array $blockedChecksums = []; + public function __construct(protected Command $command, protected InputInterface $input) { } @@ -61,7 +65,7 @@ public function publish(): int return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; } - $published = array_merge($copy, $overwrite); + [$published, $current] = $this->refreshApprovedWrites($copy, $current, $overwrite); foreach ($published as $source => $target) { $this->copy($source, $target); @@ -70,6 +74,49 @@ public function publish(): int return $this->report($published, $current, $overwrite === [] ? $blocked : [], count($offered)); } + /** + * Re-check destinations immediately before copying so a file changed during an interactive prompt is not lost. + * + * @param array $copy + * @param array $current + * @param array $overwrite + * @return array{0: array, 1: array} + */ + protected function refreshApprovedWrites(array $copy, array $current, array $overwrite): array + { + $published = []; + + foreach ($copy as $source => $target) { + $this->refreshApprovedWrite($published, $current, $source, $target, false); + } + + foreach ($overwrite as $source => $target) { + $this->refreshApprovedWrite($published, $current, $source, $target, true); + } + + return [$published, $current]; + } + + /** @param array $published @param array $current */ + protected function refreshApprovedWrite(array &$published, array &$current, string $source, string $target, bool $approvedOverwrite): void + { + match (OverwritePolicy::decide($source, $target)) { + OverwriteAction::Copy => $published[$source] = $target, + OverwriteAction::Skip => $current[$source] = $target, + OverwriteAction::Blocked => $this->handleStillBlockedWrite($published, $source, $target, $approvedOverwrite), + }; + } + + /** @param array $published */ + protected function handleStillBlockedWrite(array &$published, string $source, string $target, bool $approvedOverwrite): void + { + if (! $approvedOverwrite || ($this->blockedChecksums[$source] ?? null) !== $this->destinationChecksum($target)) { + throw new RuntimeException("Cannot publish: destination [$target] changed after overwrite checks. Run the command again."); + } + + $published[$source] = $target; + } + protected function copy(string $source, string $target): void { Filesystem::ensureParentDirectoryExists($target); @@ -146,16 +193,26 @@ protected function decide(array $selected): array $blocked = []; foreach ($selected as $source => $target) { - match (OverwritePolicy::decide($source, $target)) { - OverwriteAction::Copy => $copy[$source] = $target, - OverwriteAction::Skip => $current[$source] = $target, - OverwriteAction::Blocked => $blocked[$source] = $target, - }; + $action = OverwritePolicy::decide($source, $target); + + if ($action === OverwriteAction::Copy) { + $copy[$source] = $target; + } elseif ($action === OverwriteAction::Skip) { + $current[$source] = $target; + } else { + $this->blockedChecksums[$source] = $this->destinationChecksum($target); + $blocked[$source] = $target; + } } return [$copy, $current, $blocked]; } + protected function destinationChecksum(string $target): string + { + return unixsum_file($target); + } + /** * Resolve what to do with modified (blocked) files, after the full outcome is known but before any write. * diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index d990a60ff7b..fff0a6f2fa7 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -485,6 +485,42 @@ public function testInteractiveConflictPromptCanOverwriteAPage() $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } + public function testApprovedOverwriteAbortsWhenDestinationChangesAfterPrompt() + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BEFORE PROMPT'); + + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $publisher = new class($command, $input) extends PagesPublisher + { + protected function selectPages(): ?array + { + return [PublishablePages::get('welcome')]; + } + + protected function resolveBlocked(array $blocked): ?array + { + File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED AFTER PROMPT'); + + return $blocked; + } + }; + + try { + $publisher->publish(); + $this->fail('The publisher should abort when an approved destination changes before copy.'); + } catch (\RuntimeException $exception) { + $this->assertSame('Cannot publish: destination [_pages/index.blade.php] changed after overwrite checks. Run the command again.', $exception->getMessage()); + } + + $this->assertSame('MODIFIED AFTER PROMPT', File::get(Hyde::path('_pages/index.blade.php'))); + } + public function testInteractiveConflictPromptCanSkipAModifiedPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 63628640f25..d5dbf55e640 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -253,6 +253,55 @@ public function testInteractiveConflictPromptCanOverwrite() $this->assertNotSame('MODIFIED BY USER', File::get($target)); } + public function testApprovedOverwriteAbortsWhenDestinationChangesAfterPrompt() + { + $source = $this->source('layouts', 'app.blade.php'); + $target = 'resources/views/vendor/hyde/layouts/app.blade.php'; + File::ensureDirectoryExists(Hyde::path('resources/views/vendor/hyde/layouts')); + File::put(Hyde::path($target), 'MODIFIED BEFORE PROMPT'); + + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $publisher = new class($command, $input, $source, $target) extends ViewsPublisher + { + public function __construct($command, $input, protected string $source, protected string $target) + { + parent::__construct($command, $input); + } + + protected function collectOfferedFiles(): array + { + return [[$this->source => $this->target], [$this->source => 'layouts/app.blade.php']]; + } + + protected function selectFiles(array $offered, array $labels): array + { + return [$this->source]; + } + + protected function resolveBlocked(array $blocked): ?array + { + File::put(Hyde::path($this->target), 'MODIFIED AFTER PROMPT'); + + return $blocked; + } + }; + + try { + $publisher->publish(); + $this->fail('The publisher should abort when an approved destination changes before copy.'); + } catch (\RuntimeException $exception) { + $this->assertSame("Cannot publish: destination [$target] changed after overwrite checks. Run the command again.", $exception->getMessage()); + } + + $this->assertSame('MODIFIED AFTER PROMPT', File::get(Hyde::path($target))); + } + public function testInteractiveConflictPromptCanSkip() { $this->seedAllViews(); From ad080895b4abea383ff7c645cc58954e48a08a34 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:25:41 +0200 Subject: [PATCH 33/56] Normalize publish page targets Co-Authored-By: Codex --- .../src/Console/Commands/PublishCommand.php | 14 +++++++++++++- .../src/Console/Helpers/PagesPublisher.php | 14 ++++++++++---- .../Feature/Commands/PublishCommandPagesTest.php | 13 +++++++++++++ .../tests/Feature/Commands/PublishCommandTest.php | 14 +++++++++++--- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index f196088ac15..746f79d31a7 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -66,6 +66,12 @@ protected function safeHandle(): int return Command::FAILURE; } + if ($this->hasEmptyPageOption()) { + $this->error('The --page option cannot be empty. Use --page for the picker or --page=welcome.'); + + return Command::FAILURE; + } + if ($this->option('to') !== null && ! $this->wantsToPublishPage()) { $this->error('--to is only valid when publishing a page.'); @@ -126,7 +132,13 @@ protected function wantsToPublishViews(): bool */ protected function wantsToPublishPage(): bool { - return $this->input->hasParameterOption('--page') || $this->option('page') !== null; + return $this->input->hasParameterOption(['--page', '--page=']) || $this->option('page') !== null; + } + + protected function hasEmptyPageOption(): bool + { + return $this->input->hasParameterOption('--page=') + || ($this->input->hasParameterOption('--page') && $this->input->getParameterOption('--page', false) === ''); } protected function failWithUsageHint(): int diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 298648302ac..49b3a6bfff3 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -19,6 +19,7 @@ use function count; use function Hyde\unixsum_file; use function implode; +use function preg_replace; use function sprintf; use function Laravel\Prompts\confirm; use function Laravel\Prompts\select; @@ -181,7 +182,7 @@ protected function resolveDestinations(array $pages): ?array return null; } - $resolved[] = ['page' => $page, 'target' => $target]; + $resolved[] = ['page' => $page, 'target' => $this->normalizeTargetPath($target)]; } return $resolved; @@ -257,13 +258,13 @@ protected function promptForCustomTarget(): ?string : 'The path must be within _pages/ and end in .blade.php.' ); - return Str::replace('\\', '/', $path); + return $this->normalizeTargetPath($path); } /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ protected function validateCustomTarget(string $path): ?string { - $normalized = Str::replace('\\', '/', $path); + $normalized = $this->normalizeTargetPath($path); if (! $this->isValidCustomTarget($normalized)) { $this->command->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); @@ -276,13 +277,18 @@ protected function validateCustomTarget(string $path): ?string protected function isValidCustomTarget(string $path): bool { - $normalized = Str::replace('\\', '/', $path); + $normalized = $this->normalizeTargetPath($path); return Str::startsWith($normalized, '_pages/') && ! Str::contains($normalized, '..') && Str::endsWith($normalized, '.blade.php'); } + protected function normalizeTargetPath(string $path): string + { + return (string) preg_replace('#/+#', '/', Str::replace('\\', '/', $path)); + } + /** * Reject the run when two selected pages resolve to the same destination (§5.6), before anything is written. * diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index fff0a6f2fa7..5e70c2614a3 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -371,6 +371,19 @@ public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefaultTarget() + { + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome', 'blank']) + ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') + ->expectsQuestion('Enter a path within _pages/', '_pages//index.blade.php') + ->expectsOutputToContain('Welcome page and Blank page both target _pages/index.blade.php.') + ->expectsOutputToContain('Pick one, or set --to for each.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + // Optional rebuild (§5.7): offered interactively, never non-interactively. public function testRebuildIsOfferedInteractivelyAfterPublishing() diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 77c79cd503f..dc87bca13ea 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -127,6 +127,13 @@ public function testBarePageFlagRoutesToPages() ->assertExitCode(1); } + public function testPageFlagWithEmptyValueFailsBeforeTheWizard() + { + $this->artisan('publish --page= --no-interaction') + ->expectsOutputToContain('The --page option cannot be empty. Use --page for the picker or --page=welcome.') + ->assertExitCode(1); + } + public function testPageFlagWithNameRoutesToPages() { // An unknown name proves the flag routes into the pages flow and its registry lookup, without writing anything. @@ -150,13 +157,14 @@ public function testWizardRoutesToViews() public function testWizardRoutesToPages() { - // Route through the wizard into the real pages flow. Welcome resolves to the default _pages/index.blade.php, - // which ships identical to the source, so this is a non-destructive "already up to date" skip. + // Route through the wizard into the real pages flow. The tracked homepage fixture differs from the current + // bundled source, so the overwrite guard proves the wizard reached PagesPublisher without writing anything. $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'page') ->expectsQuestion('Select pages to publish', ['welcome']) ->expectsConfirmation('Proceed?', 'yes') - ->expectsOutputToContain('All selected pages are already up to date.') + ->expectsQuestion('1 selected files already exist and appear modified.', 'skip') + ->expectsOutputToContain('1 page left unchanged because they were modified:') ->assertExitCode(0); } From 46e72e7ef7a2768c6b665dbcb2773a09ee7975e2 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 00:33:10 +0200 Subject: [PATCH 34/56] Fix publish wizard page route test fixture Co-Authored-By: Codex --- .../Feature/Commands/PublishCommandTest.php | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index dc87bca13ea..a83edcfff6f 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -20,6 +20,10 @@ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { + protected bool $restoreIndexPage = false; + + protected ?string $originalIndexPage = null; + protected function tearDown(): void { // The views-routing tests below publish real files; remove them so the tree stays clean. @@ -27,6 +31,14 @@ protected function tearDown(): void File::deleteDirectory(Hyde::path('resources/views/vendor')); } + if ($this->restoreIndexPage) { + if ($this->originalIndexPage === null) { + File::delete(Hyde::path('_pages/index.blade.php')); + } else { + File::put(Hyde::path('_pages/index.blade.php'), $this->originalIndexPage); + } + } + parent::tearDown(); } @@ -157,8 +169,10 @@ public function testWizardRoutesToViews() public function testWizardRoutesToPages() { - // Route through the wizard into the real pages flow. The tracked homepage fixture differs from the current - // bundled source, so the overwrite guard proves the wizard reached PagesPublisher without writing anything. + // Route through the wizard into the real pages flow. A deliberately modified target makes the overwrite + // guard prove the wizard reached PagesPublisher without depending on the repository fixture contents. + $this->modifyDefaultHomePage(); + $this->artisan('publish') ->expectsQuestion('What do you want to publish?', 'page') ->expectsQuestion('Select pages to publish', ['welcome']) @@ -168,6 +182,16 @@ public function testWizardRoutesToPages() ->assertExitCode(0); } + protected function modifyDefaultHomePage(): void + { + $target = Hyde::path('_pages/index.blade.php'); + + $this->restoreIndexPage = true; + $this->originalIndexPage = File::exists($target) ? File::get($target) : null; + + File::put($target, 'MODIFIED BY USER'); + } + public function testWizardCancelExitsCleanlyWithoutPublishing() { $this->artisan('publish') From c1d35552bc2a79498935ba84f538884b0c34c4f9 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Sat, 4 Jul 2026 22:26:29 +0000 Subject: [PATCH 35/56] Apply fixes from StyleCI --- packages/framework/src/Console/Helpers/ViewsPublisher.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 4dc4167e133..3a8ceb8564b 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -14,7 +14,6 @@ use Symfony\Component\Console\Input\InputInterface; use function array_keys; -use function array_merge; use function count; use function explode; use function Hyde\unixsum_file; From dd0979de4d5ddde9dfb9771ac53ba6edf03622ad Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 01:07:09 +0200 Subject: [PATCH 36/56] Remove unnecessary code comments Co-Authored-By: Codex --- .../src/Console/Commands/PublishCommand.php | 13 ----- .../Helpers/InteractiveMultiselect.php | 14 +---- .../src/Console/Helpers/PagesPublisher.php | 30 ++--------- .../src/Console/Helpers/PublishablePage.php | 10 +--- .../src/Console/Helpers/PublishablePages.php | 7 +-- .../src/Console/Helpers/ViewsPublisher.php | 13 +---- .../framework/src/Enums/OverwriteAction.php | 9 ---- .../Commands/PublishCommandPagesTest.php | 51 ------------------- .../Feature/Commands/PublishCommandTest.php | 31 ----------- .../Commands/PublishCommandViewsTest.php | 22 -------- 10 files changed, 8 insertions(+), 192 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 746f79d31a7..98af2dc7895 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -14,17 +14,8 @@ use function is_string; use function Laravel\Prompts\select; -/** - * The flag-driven, views-centric publishing command for Hyde Blade customizations, - * with an optional side path for publishing starter pages. - * - * This is the command spine: it owns the full flag surface, all guardrails, and the - * interactive wizard routing. The actual views and pages publishing are delegated to - * handlers that are stubbed out in this step and filled in by later steps. - */ class PublishCommand extends Command { - /** @var string */ protected $signature = 'publish {--layouts : Scope publishing to the Hyde layout views} {--components : Scope publishing to the Hyde component views} @@ -33,7 +24,6 @@ class PublishCommand extends Command {--to= : Destination path for a published page (pages only)} {--force : Overwrite files that you have modified}'; - /** @var string */ protected $description = 'Publish Hyde views and starter pages for customization'; /** @@ -86,8 +76,6 @@ protected function safeHandle(): int return $this->publishViews(); } - // No actionable flags were supplied. We must decide before attempting any prompt: without - // an interactive terminal there is no wizard to run, so we fail with usage guidance instead. if (! $this->input->isInteractive()) { return $this->failWithUsageHint(); } @@ -95,7 +83,6 @@ protected function safeHandle(): int return $this->runWizard(); } - /** Interactive step 1 (§3): route to the views or pages flow, or cancel out. */ protected function runWizard(): int { $choice = select('What do you want to publish?', [ diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index 186337e3c1c..c4e551f5778 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -11,18 +11,7 @@ use function array_values; use function in_array; -/** - * A small interactive multi-select prompt with an optional "All" sentinel row. - * - * When an $allLabel is given, the list is prepended with a single "select all" option: checking - * that sentinel row means "everything" regardless of the other checkbox state. Callers that do not - * want a bulk affordance (e.g. the pages picker, where "all starter pages at once" is never a sensible - * selection) pass no $allLabel and the row is omitted. The caller supplies an already-labelled - * key => label map (for views these are group-prefixed paths), and gets back the selected option - * keys with the sentinel resolved away. - * - * @internal This helper is scoped to the publish command flows and should not be used elsewhere. - */ +/** @internal This helper is scoped to the publish command flows and should not be used elsewhere. */ class InteractiveMultiselect { /** The sentinel key for the "All" row; option keys are file paths, so this never collides. */ @@ -41,7 +30,6 @@ public static function select(string $label, array $options, ?string $allLabel = $selected = (array) $prompt->prompt(); - // Selecting the sentinel means "everything", regardless of which other rows were checked. if (in_array(self::ALL, $selected, true)) { return array_keys($options); } diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 49b3a6bfff3..caa2b8e29d2 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -25,16 +25,7 @@ use function Laravel\Prompts\select; use function Laravel\Prompts\text; -/** - * The starter-page publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. - * - * Publishes pages from the {@see PublishablePages} registry into the project's _pages directory. Unlike views, - * a page may have several valid destinations, so the flow is: select the pages, resolve each destination (§5.4: - * --to → non-interactive default → interactive prompt → default), detect any two pages colliding on one target - * (§5.6) before writing, confirm, then apply the shared {@see OverwritePolicy} exactly as the views flow does. - * - * @internal This helper is scoped to the publish command and should not be used elsewhere. - */ +/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ class PagesPublisher { /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are _pages/ paths, so it never collides. */ @@ -66,7 +57,7 @@ public function publish(): int $pages = $this->selectPages(); if ($pages === null) { - return Command::FAILURE; // A guidance message was already printed. + return Command::FAILURE; } if ($pages === []) { @@ -78,8 +69,6 @@ public function publish(): int $resolved = $this->resolveDestinations($pages); if ($resolved === null) { - // A destination could not be resolved (invalid --to, an invalid custom path, or a page with no - // default in non-interactive mode). A guidance message was already printed; this is always a failure. return Command::FAILURE; } @@ -110,11 +99,7 @@ public function publish(): int return Command::SUCCESS; } - /** - * Determine which pages to publish: a named page directly, or the interactive picker. - * - * @return array|null The selected pages, or null when the run should fail (message printed). - */ + /** @return array|null */ protected function selectPages(): ?array { if ($this->hasNamedPage()) { @@ -131,7 +116,6 @@ protected function selectPages(): ?array return [$page]; } - // A bare --page (or the wizard) needs the picker, which requires an interactive terminal. if (! $this->canPrompt()) { $this->command->error('No page specified for publishing. Provide one, for example --page=welcome.'); @@ -188,7 +172,6 @@ protected function resolveDestinations(array $pages): ?array return $resolved; } - /** Resolve one page's destination per the §5.4 precedence. Returns null when it cannot be resolved. */ protected function resolveTarget(PublishablePage $page): ?string { // 1. An explicit --to wins, but only for pages that allow a custom destination (e.g. not 404), and it is @@ -222,7 +205,6 @@ protected function resolveTarget(PublishablePage $page): ?string return $this->promptForTarget($page); } - // 4. Otherwise the default is the only offered destination. return $page->defaultTarget; } @@ -261,7 +243,6 @@ protected function promptForCustomTarget(): ?string return $this->normalizeTargetPath($path); } - /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ protected function validateCustomTarget(string $path): ?string { $normalized = $this->normalizeTargetPath($path); @@ -331,10 +312,8 @@ protected function confirmProceed(array $resolved): bool } /** - * Apply the shared overwrite policy and copy the resolved pages into place. - * * @param array $resolved - * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). + * @return array|null */ protected function write(array $resolved): ?array { @@ -537,7 +516,6 @@ protected function maybeRebuild(): void } } - /** Whether a specific page name was supplied via --page=NAME (as opposed to a bare --page or the wizard). */ protected function hasNamedPage(): bool { $name = $this->command->option('page'); diff --git a/packages/framework/src/Console/Helpers/PublishablePage.php b/packages/framework/src/Console/Helpers/PublishablePage.php index baaaf020985..fc718a89eb9 100644 --- a/packages/framework/src/Console/Helpers/PublishablePage.php +++ b/packages/framework/src/Console/Helpers/PublishablePage.php @@ -14,15 +14,7 @@ */ final class PublishablePage { - /** - * @param string $key The unique identifier for the page (e.g. 'posts'). - * @param string $label The human-readable name shown in pickers (e.g. 'Posts feed'). - * @param string $description A short help text describing the page. - * @param string $source The framework-relative path to the stub file, resolved via Hyde::vendorPath() when published. - * @param string|null $defaultTarget The default project-relative destination (e.g. '_pages/posts.blade.php'), or null when the page has no default and its destination must be resolved interactively or via --to. - * @param array $alternativeTargets Additional valid destinations, mapping a project-relative path to a human label. - * @param bool $allowCustomTarget Whether the user may publish this page to a custom path. - */ + /** @param array $alternativeTargets */ public function __construct( public readonly string $key, public readonly string $label, diff --git a/packages/framework/src/Console/Helpers/PublishablePages.php b/packages/framework/src/Console/Helpers/PublishablePages.php index 6b1b31126d8..c3716775567 100644 --- a/packages/framework/src/Console/Helpers/PublishablePages.php +++ b/packages/framework/src/Console/Helpers/PublishablePages.php @@ -28,18 +28,13 @@ public static function get(string $key): ?PublishablePage return static::all()[$key] ?? null; } - /** Register a publishable page, making it available to the publish command. Overrides any page sharing its key. */ public static function register(PublishablePage $page): void { static::$pages = static::all(); static::$pages[$page->key] = $page; } - /** - * Reset the registry back to its default catalog. - * - * @internal Primarily used to restore state between tests. - */ + /** @internal Primarily used to restore state between tests. */ public static function clear(): void { static::$pages = null; diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 3a8ceb8564b..0e6a9b6d1a5 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -22,17 +22,7 @@ use function sprintf; use function Laravel\Prompts\select; -/** - * The views publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. - * - * Publishes Hyde's Blade overrides from the two declared groups (layouts, components) into - * resources/views/vendor/hyde/. The flow is: decide every selected file's outcome first (via the shared - * {@see OverwritePolicy}), resolve any modified-file conflicts second (interactive prompt or --force), and - * only then write — so cancelling never leaves a half-published tree. Output is cardinality-aware and - * reports the full breakdown of what was copied, what was already current, and what was left modified. - * - * @internal This helper is scoped to the publish command and should not be used elsewhere. - */ +/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ class ViewsPublisher { /** @var array EOL-agnostic destination checksums captured when a file was first blocked. */ @@ -319,7 +309,6 @@ protected function viewCount(int $count): string return $count === 1 ? '1 view' : "$count views"; } - /** Find the most specific common parent directory shared by the given files' target paths. */ protected function baseDirectory(array $files): string { $partsMap = collect($files)->map(fn (string $file): array => explode('/', $file)); diff --git a/packages/framework/src/Enums/OverwriteAction.php b/packages/framework/src/Enums/OverwriteAction.php index bf3914bb737..f638c1acde2 100644 --- a/packages/framework/src/Enums/OverwriteAction.php +++ b/packages/framework/src/Enums/OverwriteAction.php @@ -4,20 +4,11 @@ namespace Hyde\Enums; -/** - * The action the {@see \Hyde\Framework\Services\OverwritePolicy} decides to take - * when a source file is about to be published to a destination path. - * - * @see \Hyde\Framework\Services\OverwritePolicy - */ enum OverwriteAction: string { - /** The destination does not exist yet, so the source can be copied freely. */ case Copy = 'copy'; - /** The destination already matches the source, so there is nothing to do. */ case Skip = 'skip'; - /** The destination exists and differs from the source (user-modified), so overwriting is blocked. */ case Blocked = 'blocked'; } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 5e70c2614a3..7eb18eafd2a 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -22,12 +22,6 @@ use function glob; -/** - * Covers the starter-page publishing flow (§5): named vs. picker selection, the §5.4 destination - * resolution precedence (--to → non-interactive default → interactive prompt → default), --to - * validation, destination-conflict detection (§5.6), the interactive confirm (§5.5), the shared - * overwrite policy (§7) applied to pages, and the interactive-only optional rebuild (§5.7). - */ #[CoversClass(PublishCommand::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase @@ -36,7 +30,6 @@ protected function setUp(): void { parent::setUp(); - // Start from a known-empty _pages so each test controls exactly which destinations exist. $this->withoutDefaultPages(); } @@ -48,7 +41,6 @@ protected function tearDown(): void PagesPromptsReset::resetFallbacks(); PublishablePages::clear(); - // Remove anything a test published, then restore the two committed default pages so the tree stays clean. foreach (glob(Hyde::path('_pages/*.blade.php')) as $file) { File::delete($file); } @@ -58,8 +50,6 @@ protected function tearDown(): void parent::tearDown(); } - // Named-page publishing (--page=NAME) with non-interactive destination resolution (§5.4 step 2). - public function testNamedPagePublishesToItsDefaultTargetNonInteractively() { $this->artisan('publish --page=welcome --no-interaction') @@ -107,8 +97,6 @@ public function testNumericPageKeyIsResolvedByItsStringKey() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } - // Destination resolution: --to wins over the default (§5.4 step 1). - public function testToOverridesTheDefaultTarget() { $this->artisan('publish --page=posts --to=_pages/index.blade.php --no-interaction') @@ -119,8 +107,6 @@ public function testToOverridesTheDefaultTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); } - // A page with no default target (blank) cannot be resolved non-interactively without --to (§5.4 step 2). - public function testPageWithoutDefaultTargetFailsNonInteractivelyWithoutTo() { $this->artisan('publish --page=blank --no-interaction') @@ -137,8 +123,6 @@ public function testPageWithoutDefaultTargetPublishesWithTo() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } - // --to validation: must live under _pages/ and end in .blade.php (§5.4 step 1, §9). - public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') @@ -153,8 +137,6 @@ public function testToPathWithWrongExtensionIsRejected() ->assertExitCode(1); } - // A page that disallows custom targets (404) rejects --to and keeps its fixed default. - public function testToIsRejectedForAPageThatDisallowsCustomTargets() { $this->artisan('publish --page=404 --to=_pages/error.blade.php --no-interaction') @@ -177,8 +159,6 @@ public function testBarePageWithToIsRejectedBeforeThePickerAndBeatsThePerPageRea ->assertExitCode(1); } - // Overwrite policy (§7): identical -> skip, modified -> fail without --force, --force overwrites. - public function testIdenticalPageIsSkippedAsAlreadyCurrent() { $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); @@ -230,8 +210,6 @@ public function copy($path, $target): bool $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // Interactive destination prompt (§5.4 step 3): default / alternative / custom path. - public function testInteractiveResolutionCanChooseAnAlternativeTarget() { $this->artisan('publish --page=posts') @@ -290,11 +268,8 @@ public function testCustomPathFromPromptRepromptsUntilValid() Prompt::assertStrippedOutputContains('The path must be within _pages/ and end in .blade.php.'); } - // Interactive picker flow (§5.5): select -> resolve -> confirm. - public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() { - // Welcome has a single sensible destination, so it is not prompted for; it resolves to its default. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome']) ->expectsOutput('Ready to publish:') @@ -346,11 +321,8 @@ protected function selectPages(): ?array $this->assertFileDoesNotExist(Hyde::path('_pages/404.blade.php')); } - // Destination-conflict detection before any write (§5.6). - public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() { - // Register a second page whose default collides with welcome's default so the picker offers both. PublishablePages::register(new PublishablePage( key: 'clash', label: 'Clashing page', @@ -360,8 +332,6 @@ public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() allowCustomTarget: false, )); - // Neither page is prompted for (welcome and clash each resolve straight to their default), so the - // collision is caught purely from the picker selection, before any destination prompt or write. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', 'clash']) ->expectsOutputToContain('Welcome page and Clashing page both target _pages/index.blade.php.') @@ -384,11 +354,8 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // Optional rebuild (§5.7): offered interactively, never non-interactively. - public function testRebuildIsOfferedInteractivelyAfterPublishing() { - // Welcome resolves to its default without a destination prompt, so the only interaction is the rebuild offer. $this->artisan('publish --page=welcome') ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') ->expectsConfirmation('Rebuild the site now?', 'no') @@ -417,12 +384,8 @@ public function testPickerCanSelectTheNumericKeyedPage() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } - // Option 2's whole point: the pages picker must NOT offer an "All" row (unlike the views picker). - public function testPickerDoesNotOfferAnAllRow() { - // Space+enter selects the first row (welcome); the next enter accepts "Proceed?" (default yes), the - // last accepts "Rebuild the site now?" (default no) — so the run completes without leftover prompts. $output = $this->runPagesPicker([Key::SPACE, Key::ENTER, Key::ENTER, Key::ENTER]); Prompt::assertOutputContains('Select pages to publish'); @@ -430,13 +393,9 @@ public function testPickerDoesNotOfferAnAllRow() Prompt::assertOutputDoesntContain('All pages'); Prompt::assertOutputDoesntContain('All views'); - // The first offered row is a real page (welcome), not a select-all sentinel, so a single space+enter publishes it. $this->assertStringContainsString('Published [welcome]', $output->fetch()); } - // A bare --page (no name) needs the picker, which needs an interactive terminal, so non-interactively it - // fails in the pages flow (§3/§5). Exercised here through PagesPublisher so the guidance path is covered there. - public function testBarePageWithoutInteractionFailsHelpfully() { $this->artisan('publish --page --no-interaction') @@ -483,8 +442,6 @@ public function testThreePagesResolvingToTheSameTargetReportAllTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // §7 interactive conflict prompt applied to pages: overwrite / skip / cancel, mirroring the views flow. - public function testInteractiveConflictPromptCanOverwriteAPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); @@ -545,7 +502,6 @@ public function testInteractiveConflictPromptCanSkipAModifiedPage() ->expectsOutputToContain('Run again with --force to overwrite.') ->assertExitCode(0); - // Skipping leaves the file as the user had it, and (nothing was written) never offers a rebuild. $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } @@ -561,12 +517,8 @@ public function testInteractiveConflictPromptCanCancelForPages() $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } - // §4/§5 cardinality-aware output: a mixed run reports what was published alongside what was already current - // (pluralized), without collapsing to the "all up to date" shortcut. - public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() { - // Seed two pages so they are already current, then register a third new page and publish all three. $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); $this->artisan('publish --page=404 --no-interaction')->assertExitCode(0); @@ -578,7 +530,6 @@ public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() defaultTarget: '_pages/about.blade.php', )); - // welcome and 404 are already current; only about is copied — so the run reports both sides. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', '404', 'about']) ->expectsConfirmation('Proceed?', 'yes') @@ -604,7 +555,6 @@ public function testAcceptingTheRebuildOfferRunsTheBuild() Artisan::shouldReceive('call')->once()->with('build', [], \Mockery::any())->andReturn(0); - // 'y' + enter answers the "Rebuild the site now?" confirm (which defaults to no) with yes. Prompt::fake(['y', Key::ENTER]); $command = $this->app->make(PublishCommand::class); @@ -618,7 +568,6 @@ public function testAcceptingTheRebuildOfferRunsTheBuild() $this->assertStringContainsString('Published [welcome]', $output->fetch()); } - /** Drive the interactive pages picker with faked keystrokes and return the buffered output. */ protected function runPagesPicker(array $keys): BufferedOutput { if (windows_os()) { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index a83edcfff6f..06493f3b096 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -12,11 +12,6 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; -/** - * Covers the PublishCommand spine: the flag surface, all guardrails (§9), and the - * interactive wizard routing (§3). The views and pages handlers are stubs in this step, - * so these tests assert routing and guardrails, not real publishing. - */ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { @@ -26,7 +21,6 @@ class PublishCommandTest extends TestCase protected function tearDown(): void { - // The views-routing tests below publish real files; remove them so the tree stays clean. if (File::isDirectory(Hyde::path('resources/views/vendor'))) { File::deleteDirectory(Hyde::path('resources/views/vendor')); } @@ -42,8 +36,6 @@ protected function tearDown(): void parent::tearDown(); } - // Guardrails: raw tag/provider/config publishing is redirected to vendor:publish (§9). - public function testTagFlagIsRedirectedToVendorPublish() { $this->artisan('publish --tag=foo') @@ -72,8 +64,6 @@ public function testConfigFlagIsRedirectedToVendorPublish() ->assertExitCode(1); } - // Guardrails: the command's own flag combinations (§9). - public function testLayoutsAndComponentsAreMutuallyExclusive() { $this->artisan('publish --layouts --components') @@ -90,8 +80,6 @@ public function testToOptionRequiresThePageFlag() public function testToOptionRequiresANamedPageNotABarePageFlag() { - // --to names one destination, so it is only valid with a single named page (§5.4); a bare --page - // (multi-select) with --to is rejected rather than letting one path stand in for several pages. $this->artisan('publish --page --to=_pages/index.blade.php') ->expectsOutputToContain('--to is only valid when publishing a single page. Use --page=NAME with --to.') ->assertExitCode(1); @@ -107,9 +95,6 @@ public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() ->assertExitCode(1); } - // Flag routing to the views handler. The full views behavior is covered in PublishCommandViewsTest; - // here we assert only that each flag actually reaches the real views publisher (routing coverage). - public function testLayoutsFlagRoutesToViews() { $this->artisan('publish --layouts --no-interaction') @@ -133,7 +118,6 @@ public function testAllFlagRoutesToViews() public function testBarePageFlagRoutesToPages() { - // A bare --page needs the interactive picker; non-interactively it reaches the pages flow and fails there. $this->artisan('publish --page --no-interaction') ->expectsOutputToContain('No page specified for publishing. Provide one, for example --page=welcome.') ->assertExitCode(1); @@ -148,14 +132,11 @@ public function testPageFlagWithEmptyValueFailsBeforeTheWizard() public function testPageFlagWithNameRoutesToPages() { - // An unknown name proves the flag routes into the pages flow and its registry lookup, without writing anything. $this->artisan('publish --page=nonexistent --no-interaction') ->expectsOutputToContain('The page [nonexistent] does not exist.') ->assertExitCode(1); } - // Interactive wizard routing (§3). - public function testWizardRoutesToViews() { $appLayout = (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde').'/framework/resources/views/layouts/app.blade.php'; @@ -169,8 +150,6 @@ public function testWizardRoutesToViews() public function testWizardRoutesToPages() { - // Route through the wizard into the real pages flow. A deliberately modified target makes the overwrite - // guard prove the wizard reached PagesPublisher without depending on the repository fixture contents. $this->modifyDefaultHomePage(); $this->artisan('publish') @@ -200,13 +179,8 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() ->assertExitCode(0); } - // Approach 1 must not swallow genuine mistakes: unknown options and stray arguments - // still hit Symfony's native errors rather than our redirect or a stub handler. - public function testUnknownOptionIsNotSwallowed() { - // A typo for --layouts must surface Symfony's native error, not be eaten by our - // raw-flag interception (which only short-circuits --tag/--provider/--config). $this->expectException(RuntimeException::class); $this->expectExceptionMessage('The "--layout" option does not exist.'); @@ -215,17 +189,12 @@ public function testUnknownOptionIsNotSwallowed() public function testArbitrarySourcePathArgumentIsRejected() { - // The command declares no arguments, so a stray source path is rejected outright - // rather than being interpreted as a publishable target. $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No arguments expected for "publish" command, got "resources/views/foo.blade.php".'); $this->artisan('publish resources/views/foo.blade.php')->run(); } - // The legacy publish commands are removed in v3, not aliased. Invoking one must raise Symfony's - // native command-not-found error, proving the command is gone and that no shim intercepts it. - public function testRemovedLegacyPublishViewsCommandRaisesCommandNotFound() { $this->expectException(CommandNotFoundException::class); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index d5dbf55e640..0ef8df0b276 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -19,18 +19,11 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -/** - * Covers the views publishing flow (§4) and the shared overwrite policy applied to views (§7): - * the grouped multi-select picker, --layouts/--components prefiltering, --all skipping the picker, - * cardinality-aware output, and the missing/identical/modified overwrite behavior with --force. - */ #[CoversClass(PublishCommand::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] class PublishCommandViewsTest extends TestCase { - // Non-interactive scope selection (a scoped group is exactly equivalent to adding --all). - public function testAllPublishesEveryView() { $count = $this->viewCount('layouts') + $this->viewCount('components'); @@ -67,8 +60,6 @@ public function testComponentsPublishesOnlyComponentsNonInteractively() $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); } - // Interactive picker selection (single / many / cross-group), with cardinality-aware output. - public function testPickerCanPublishASingleView() { $this->artisan('publish --layouts') @@ -148,8 +139,6 @@ public function exposeBaseDirectory(array $files): string ])); } - // The picker is prefiltered by the scope flag and uses group-prefixed labels with an "All views" row. - public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() { $output = $this->runViewsPicker(['--layouts' => true], [Key::SPACE, Key::ENTER]); @@ -159,7 +148,6 @@ public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() Prompt::assertOutputContains('layouts/app.blade.php'); Prompt::assertOutputDoesntContain('components/'); - // Checking the "All views" sentinel selects every offered (layouts) view. $this->assertStringContainsString('Published all', $output->fetch()); $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); @@ -184,8 +172,6 @@ public function testAllSentinelPreservesNumericOptionKeys() $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); } - // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. - public function testIdenticalViewsAreSkippedAsAlreadyCurrent() { $this->seedAllViews(); @@ -206,7 +192,6 @@ public function testModifiedViewsCannotBeOverwrittenNonInteractivelyWithoutForce ->expectsOutput('Run again with --force to overwrite.') ->assertExitCode(1); - // Hard stop: the modified file is left untouched and nothing else is written either. $this->assertSame('MODIFIED BY USER', File::get($target)); } @@ -328,12 +313,8 @@ public function testInteractiveConflictPromptCanCancel() $this->assertSame('MODIFIED BY USER', File::get($target)); } - // §4 cardinality-aware output: a mixed run reports what was copied alongside what was already current, - // instead of collapsing to either the "Published all" or the "all up to date" shortcut. - public function testMixedRunReportsPublishedAlongsideAlreadyCurrentViews() { - // Seed only the layouts so they are already current, then publish everything: components copy, layouts skip. $this->artisan('publish --layouts --no-interaction')->assertExitCode(0); $components = $this->viewCount('components'); @@ -356,13 +337,11 @@ protected function source(string $group, string $file): string return (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde')."/framework/resources/views/$group/$file"; } - /** Publish every view so subsequent runs see identical (already current) destinations. */ protected function seedAllViews(): void { $this->artisan('publish --all --no-interaction')->assertExitCode(0); } - /** Modify one already-published view so it is seen as user-modified, and return its target path. */ protected function modifyPublishedView(): string { $target = Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php'); @@ -371,7 +350,6 @@ protected function modifyPublishedView(): string return $target; } - /** Drive the interactive picker with faked keystrokes and return the buffered output. */ protected function runViewsPicker(array $parameters, array $keys): BufferedOutput { if (windows_os()) { From 1134d08cc664446f980598ea9db10d397ea65af1 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 01:20:15 +0200 Subject: [PATCH 37/56] Restore useful code comments --- .../Helpers/InteractiveMultiselect.php | 13 ++++++++++- .../src/Console/Helpers/PagesPublisher.php | 11 ++++++++- .../src/Console/Helpers/ViewsPublisher.php | 12 +++++++++- .../Commands/PublishCommandPagesTest.php | 23 +++++++++++++++++++ .../Feature/Commands/PublishCommandTest.php | 14 +++++++++++ .../Commands/PublishCommandViewsTest.php | 10 ++++++++ 6 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index c4e551f5778..d4b3163e68b 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -11,7 +11,18 @@ use function array_values; use function in_array; -/** @internal This helper is scoped to the publish command flows and should not be used elsewhere. */ +/** + * A small interactive multi-select prompt with an optional "All" sentinel row. + * + * When an $allLabel is given, the list is prepended with a single "select all" option: checking + * that sentinel row means "everything" regardless of the other checkbox state. Callers that do not + * want a bulk affordance (e.g. the pages picker, where "all starter pages at once" is never a sensible + * selection) pass no $allLabel and the row is omitted. The caller supplies an already-labelled + * key => label map (for views these are group-prefixed paths), and gets back the selected option + * keys with the sentinel resolved away. + * + * @internal This helper is scoped to the publish command flows and should not be used elsewhere. + */ class InteractiveMultiselect { /** The sentinel key for the "All" row; option keys are file paths, so this never collides. */ diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index caa2b8e29d2..857cf4a9064 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -25,7 +25,16 @@ use function Laravel\Prompts\select; use function Laravel\Prompts\text; -/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ +/** + * The starter-page publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. + * + * Publishes pages from the {@see PublishablePages} registry into the project's _pages directory. Unlike views, + * a page may have several valid destinations, so the flow is: select the pages, resolve each destination (§5.4: + * --to → non-interactive default → interactive prompt → default), detect any two pages colliding on one target + * (§5.6) before writing, confirm, then apply the shared {@see OverwritePolicy} exactly as the views flow does. + * + * @internal This helper is scoped to the publish command and should not be used elsewhere. + */ class PagesPublisher { /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are _pages/ paths, so it never collides. */ diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 0e6a9b6d1a5..ddd26fc62ad 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -22,7 +22,17 @@ use function sprintf; use function Laravel\Prompts\select; -/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ +/** + * The views publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. + * + * Publishes Hyde's Blade overrides from the two declared groups (layouts, components) into + * resources/views/vendor/hyde/. The flow is: decide every selected file's outcome first (via the shared + * {@see OverwritePolicy}), resolve any modified-file conflicts second (interactive prompt or --force), and + * only then write — so cancelling never leaves a half-published tree. Output is cardinality-aware and + * reports the full breakdown of what was copied, what was already current, and what was left modified. + * + * @internal This helper is scoped to the publish command and should not be used elsewhere. + */ class ViewsPublisher { /** @var array EOL-agnostic destination checksums captured when a file was first blocked. */ diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 7eb18eafd2a..9654735c572 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -22,6 +22,12 @@ use function glob; +/** + * Covers the starter-page publishing flow (§5): named vs. picker selection, the §5.4 destination + * resolution precedence (--to → non-interactive default → interactive prompt → default), --to + * validation, destination-conflict detection (§5.6), the interactive confirm (§5.5), the shared + * overwrite policy (§7) applied to pages, and the interactive-only optional rebuild (§5.7). + */ #[CoversClass(PublishCommand::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase @@ -97,6 +103,8 @@ public function testNumericPageKeyIsResolvedByItsStringKey() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } + // Destination resolution: --to wins over the default (§5.4 step 1). + public function testToOverridesTheDefaultTarget() { $this->artisan('publish --page=posts --to=_pages/index.blade.php --no-interaction') @@ -107,6 +115,8 @@ public function testToOverridesTheDefaultTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); } + // A page with no default target (blank) cannot be resolved non-interactively without --to (§5.4 step 2). + public function testPageWithoutDefaultTargetFailsNonInteractivelyWithoutTo() { $this->artisan('publish --page=blank --no-interaction') @@ -123,6 +133,8 @@ public function testPageWithoutDefaultTargetPublishesWithTo() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } + // --to validation: must live under _pages/ and end in .blade.php (§5.4 step 1, §9). + public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') @@ -268,6 +280,8 @@ public function testCustomPathFromPromptRepromptsUntilValid() Prompt::assertStrippedOutputContains('The path must be within _pages/ and end in .blade.php.'); } + // Interactive picker flow (§5.5): select -> resolve -> confirm. + public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() { $this->artisan('publish --page') @@ -321,6 +335,8 @@ protected function selectPages(): ?array $this->assertFileDoesNotExist(Hyde::path('_pages/404.blade.php')); } + // Destination-conflict detection before any write (§5.6). + public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() { PublishablePages::register(new PublishablePage( @@ -354,6 +370,8 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + // Optional rebuild (§5.7): offered interactively, never non-interactively. + public function testRebuildIsOfferedInteractivelyAfterPublishing() { $this->artisan('publish --page=welcome') @@ -442,6 +460,8 @@ public function testThreePagesResolvingToTheSameTargetReportAllTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + // §7 interactive conflict prompt applied to pages: overwrite / skip / cancel, mirroring the views flow. + public function testInteractiveConflictPromptCanOverwriteAPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); @@ -517,6 +537,9 @@ public function testInteractiveConflictPromptCanCancelForPages() $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } + // §4/§5 cardinality-aware output: a mixed run reports what was published alongside what was already current + // (pluralized), without collapsing to the "all up to date" shortcut. + public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() { $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 06493f3b096..35d1d53da6e 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -12,6 +12,11 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; +/** + * Covers the PublishCommand spine: the flag surface, all guardrails (§9), and the + * interactive wizard routing (§3). The views and pages handlers are stubs in this step, + * so these tests assert routing and guardrails, not real publishing. + */ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { @@ -95,6 +100,9 @@ public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() ->assertExitCode(1); } + // Flag routing to the views handler. The full views behavior is covered in PublishCommandViewsTest; + // here we assert only that each flag actually reaches the real views publisher (routing coverage). + public function testLayoutsFlagRoutesToViews() { $this->artisan('publish --layouts --no-interaction') @@ -179,6 +187,9 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() ->assertExitCode(0); } + // Approach 1 must not swallow genuine mistakes: unknown options and stray arguments + // still hit Symfony's native errors rather than our redirect or a stub handler. + public function testUnknownOptionIsNotSwallowed() { $this->expectException(RuntimeException::class); @@ -195,6 +206,9 @@ public function testArbitrarySourcePathArgumentIsRejected() $this->artisan('publish resources/views/foo.blade.php')->run(); } + // The legacy publish commands are removed in v3, not aliased. Invoking one must raise Symfony's + // native command-not-found error, proving the command is gone and that no shim intercepts it. + public function testRemovedLegacyPublishViewsCommandRaisesCommandNotFound() { $this->expectException(CommandNotFoundException::class); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 0ef8df0b276..e6286a31984 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -19,6 +19,11 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +/** + * Covers the views publishing flow (§4) and the shared overwrite policy applied to views (§7): + * the grouped multi-select picker, --layouts/--components prefiltering, --all skipping the picker, + * cardinality-aware output, and the missing/identical/modified overwrite behavior with --force. + */ #[CoversClass(PublishCommand::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] @@ -172,6 +177,8 @@ public function testAllSentinelPreservesNumericOptionKeys() $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); } + // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. + public function testIdenticalViewsAreSkippedAsAlreadyCurrent() { $this->seedAllViews(); @@ -313,6 +320,9 @@ public function testInteractiveConflictPromptCanCancel() $this->assertSame('MODIFIED BY USER', File::get($target)); } + // §4 cardinality-aware output: a mixed run reports what was copied alongside what was already current, + // instead of collapsing to either the "Published all" or the "all up to date" shortcut. + public function testMixedRunReportsPublishedAlongsideAlreadyCurrentViews() { $this->artisan('publish --layouts --no-interaction')->assertExitCode(0); From 70332f2dce8058a19d02b5c22e13b2cba15dbb69 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 01:21:42 +0200 Subject: [PATCH 38/56] Revert "Restore useful code comments" This reverts commit bad1ba72873c714e2c71a0b5d4cc2c240fdedc42. --- .../Helpers/InteractiveMultiselect.php | 13 +---------- .../src/Console/Helpers/PagesPublisher.php | 11 +-------- .../src/Console/Helpers/ViewsPublisher.php | 12 +--------- .../Commands/PublishCommandPagesTest.php | 23 ------------------- .../Feature/Commands/PublishCommandTest.php | 14 ----------- .../Commands/PublishCommandViewsTest.php | 10 -------- 6 files changed, 3 insertions(+), 80 deletions(-) diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index d4b3163e68b..c4e551f5778 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -11,18 +11,7 @@ use function array_values; use function in_array; -/** - * A small interactive multi-select prompt with an optional "All" sentinel row. - * - * When an $allLabel is given, the list is prepended with a single "select all" option: checking - * that sentinel row means "everything" regardless of the other checkbox state. Callers that do not - * want a bulk affordance (e.g. the pages picker, where "all starter pages at once" is never a sensible - * selection) pass no $allLabel and the row is omitted. The caller supplies an already-labelled - * key => label map (for views these are group-prefixed paths), and gets back the selected option - * keys with the sentinel resolved away. - * - * @internal This helper is scoped to the publish command flows and should not be used elsewhere. - */ +/** @internal This helper is scoped to the publish command flows and should not be used elsewhere. */ class InteractiveMultiselect { /** The sentinel key for the "All" row; option keys are file paths, so this never collides. */ diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 857cf4a9064..caa2b8e29d2 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -25,16 +25,7 @@ use function Laravel\Prompts\select; use function Laravel\Prompts\text; -/** - * The starter-page publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. - * - * Publishes pages from the {@see PublishablePages} registry into the project's _pages directory. Unlike views, - * a page may have several valid destinations, so the flow is: select the pages, resolve each destination (§5.4: - * --to → non-interactive default → interactive prompt → default), detect any two pages colliding on one target - * (§5.6) before writing, confirm, then apply the shared {@see OverwritePolicy} exactly as the views flow does. - * - * @internal This helper is scoped to the publish command and should not be used elsewhere. - */ +/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ class PagesPublisher { /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are _pages/ paths, so it never collides. */ diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index ddd26fc62ad..0e6a9b6d1a5 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -22,17 +22,7 @@ use function sprintf; use function Laravel\Prompts\select; -/** - * The views publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. - * - * Publishes Hyde's Blade overrides from the two declared groups (layouts, components) into - * resources/views/vendor/hyde/. The flow is: decide every selected file's outcome first (via the shared - * {@see OverwritePolicy}), resolve any modified-file conflicts second (interactive prompt or --force), and - * only then write — so cancelling never leaves a half-published tree. Output is cardinality-aware and - * reports the full breakdown of what was copied, what was already current, and what was left modified. - * - * @internal This helper is scoped to the publish command and should not be used elsewhere. - */ +/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ class ViewsPublisher { /** @var array EOL-agnostic destination checksums captured when a file was first blocked. */ diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 9654735c572..7eb18eafd2a 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -22,12 +22,6 @@ use function glob; -/** - * Covers the starter-page publishing flow (§5): named vs. picker selection, the §5.4 destination - * resolution precedence (--to → non-interactive default → interactive prompt → default), --to - * validation, destination-conflict detection (§5.6), the interactive confirm (§5.5), the shared - * overwrite policy (§7) applied to pages, and the interactive-only optional rebuild (§5.7). - */ #[CoversClass(PublishCommand::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase @@ -103,8 +97,6 @@ public function testNumericPageKeyIsResolvedByItsStringKey() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } - // Destination resolution: --to wins over the default (§5.4 step 1). - public function testToOverridesTheDefaultTarget() { $this->artisan('publish --page=posts --to=_pages/index.blade.php --no-interaction') @@ -115,8 +107,6 @@ public function testToOverridesTheDefaultTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); } - // A page with no default target (blank) cannot be resolved non-interactively without --to (§5.4 step 2). - public function testPageWithoutDefaultTargetFailsNonInteractivelyWithoutTo() { $this->artisan('publish --page=blank --no-interaction') @@ -133,8 +123,6 @@ public function testPageWithoutDefaultTargetPublishesWithTo() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } - // --to validation: must live under _pages/ and end in .blade.php (§5.4 step 1, §9). - public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') @@ -280,8 +268,6 @@ public function testCustomPathFromPromptRepromptsUntilValid() Prompt::assertStrippedOutputContains('The path must be within _pages/ and end in .blade.php.'); } - // Interactive picker flow (§5.5): select -> resolve -> confirm. - public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() { $this->artisan('publish --page') @@ -335,8 +321,6 @@ protected function selectPages(): ?array $this->assertFileDoesNotExist(Hyde::path('_pages/404.blade.php')); } - // Destination-conflict detection before any write (§5.6). - public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() { PublishablePages::register(new PublishablePage( @@ -370,8 +354,6 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // Optional rebuild (§5.7): offered interactively, never non-interactively. - public function testRebuildIsOfferedInteractivelyAfterPublishing() { $this->artisan('publish --page=welcome') @@ -460,8 +442,6 @@ public function testThreePagesResolvingToTheSameTargetReportAllTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // §7 interactive conflict prompt applied to pages: overwrite / skip / cancel, mirroring the views flow. - public function testInteractiveConflictPromptCanOverwriteAPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); @@ -537,9 +517,6 @@ public function testInteractiveConflictPromptCanCancelForPages() $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } - // §4/§5 cardinality-aware output: a mixed run reports what was published alongside what was already current - // (pluralized), without collapsing to the "all up to date" shortcut. - public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() { $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 35d1d53da6e..06493f3b096 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -12,11 +12,6 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; -/** - * Covers the PublishCommand spine: the flag surface, all guardrails (§9), and the - * interactive wizard routing (§3). The views and pages handlers are stubs in this step, - * so these tests assert routing and guardrails, not real publishing. - */ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { @@ -100,9 +95,6 @@ public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() ->assertExitCode(1); } - // Flag routing to the views handler. The full views behavior is covered in PublishCommandViewsTest; - // here we assert only that each flag actually reaches the real views publisher (routing coverage). - public function testLayoutsFlagRoutesToViews() { $this->artisan('publish --layouts --no-interaction') @@ -187,9 +179,6 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() ->assertExitCode(0); } - // Approach 1 must not swallow genuine mistakes: unknown options and stray arguments - // still hit Symfony's native errors rather than our redirect or a stub handler. - public function testUnknownOptionIsNotSwallowed() { $this->expectException(RuntimeException::class); @@ -206,9 +195,6 @@ public function testArbitrarySourcePathArgumentIsRejected() $this->artisan('publish resources/views/foo.blade.php')->run(); } - // The legacy publish commands are removed in v3, not aliased. Invoking one must raise Symfony's - // native command-not-found error, proving the command is gone and that no shim intercepts it. - public function testRemovedLegacyPublishViewsCommandRaisesCommandNotFound() { $this->expectException(CommandNotFoundException::class); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index e6286a31984..0ef8df0b276 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -19,11 +19,6 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -/** - * Covers the views publishing flow (§4) and the shared overwrite policy applied to views (§7): - * the grouped multi-select picker, --layouts/--components prefiltering, --all skipping the picker, - * cardinality-aware output, and the missing/identical/modified overwrite behavior with --force. - */ #[CoversClass(PublishCommand::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] @@ -177,8 +172,6 @@ public function testAllSentinelPreservesNumericOptionKeys() $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); } - // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. - public function testIdenticalViewsAreSkippedAsAlreadyCurrent() { $this->seedAllViews(); @@ -320,9 +313,6 @@ public function testInteractiveConflictPromptCanCancel() $this->assertSame('MODIFIED BY USER', File::get($target)); } - // §4 cardinality-aware output: a mixed run reports what was copied alongside what was already current, - // instead of collapsing to either the "Published all" or the "all up to date" shortcut. - public function testMixedRunReportsPublishedAlongsideAlreadyCurrentViews() { $this->artisan('publish --layouts --no-interaction')->assertExitCode(0); From 8a16edefa688ffa0d2c8fa6d43b287c8320182be Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 01:21:44 +0200 Subject: [PATCH 39/56] Revert "Remove unnecessary code comments" This reverts commit 2fb3c30ee18bdecb497d8af8185d8d45b60001ce. --- .../src/Console/Commands/PublishCommand.php | 13 +++++ .../Helpers/InteractiveMultiselect.php | 14 ++++- .../src/Console/Helpers/PagesPublisher.php | 30 +++++++++-- .../src/Console/Helpers/PublishablePage.php | 10 +++- .../src/Console/Helpers/PublishablePages.php | 7 ++- .../src/Console/Helpers/ViewsPublisher.php | 13 ++++- .../framework/src/Enums/OverwriteAction.php | 9 ++++ .../Commands/PublishCommandPagesTest.php | 51 +++++++++++++++++++ .../Feature/Commands/PublishCommandTest.php | 31 +++++++++++ .../Commands/PublishCommandViewsTest.php | 22 ++++++++ 10 files changed, 192 insertions(+), 8 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 98af2dc7895..746f79d31a7 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -14,8 +14,17 @@ use function is_string; use function Laravel\Prompts\select; +/** + * The flag-driven, views-centric publishing command for Hyde Blade customizations, + * with an optional side path for publishing starter pages. + * + * This is the command spine: it owns the full flag surface, all guardrails, and the + * interactive wizard routing. The actual views and pages publishing are delegated to + * handlers that are stubbed out in this step and filled in by later steps. + */ class PublishCommand extends Command { + /** @var string */ protected $signature = 'publish {--layouts : Scope publishing to the Hyde layout views} {--components : Scope publishing to the Hyde component views} @@ -24,6 +33,7 @@ class PublishCommand extends Command {--to= : Destination path for a published page (pages only)} {--force : Overwrite files that you have modified}'; + /** @var string */ protected $description = 'Publish Hyde views and starter pages for customization'; /** @@ -76,6 +86,8 @@ protected function safeHandle(): int return $this->publishViews(); } + // No actionable flags were supplied. We must decide before attempting any prompt: without + // an interactive terminal there is no wizard to run, so we fail with usage guidance instead. if (! $this->input->isInteractive()) { return $this->failWithUsageHint(); } @@ -83,6 +95,7 @@ protected function safeHandle(): int return $this->runWizard(); } + /** Interactive step 1 (§3): route to the views or pages flow, or cancel out. */ protected function runWizard(): int { $choice = select('What do you want to publish?', [ diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index c4e551f5778..186337e3c1c 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -11,7 +11,18 @@ use function array_values; use function in_array; -/** @internal This helper is scoped to the publish command flows and should not be used elsewhere. */ +/** + * A small interactive multi-select prompt with an optional "All" sentinel row. + * + * When an $allLabel is given, the list is prepended with a single "select all" option: checking + * that sentinel row means "everything" regardless of the other checkbox state. Callers that do not + * want a bulk affordance (e.g. the pages picker, where "all starter pages at once" is never a sensible + * selection) pass no $allLabel and the row is omitted. The caller supplies an already-labelled + * key => label map (for views these are group-prefixed paths), and gets back the selected option + * keys with the sentinel resolved away. + * + * @internal This helper is scoped to the publish command flows and should not be used elsewhere. + */ class InteractiveMultiselect { /** The sentinel key for the "All" row; option keys are file paths, so this never collides. */ @@ -30,6 +41,7 @@ public static function select(string $label, array $options, ?string $allLabel = $selected = (array) $prompt->prompt(); + // Selecting the sentinel means "everything", regardless of which other rows were checked. if (in_array(self::ALL, $selected, true)) { return array_keys($options); } diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index caa2b8e29d2..49b3a6bfff3 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -25,7 +25,16 @@ use function Laravel\Prompts\select; use function Laravel\Prompts\text; -/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ +/** + * The starter-page publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. + * + * Publishes pages from the {@see PublishablePages} registry into the project's _pages directory. Unlike views, + * a page may have several valid destinations, so the flow is: select the pages, resolve each destination (§5.4: + * --to → non-interactive default → interactive prompt → default), detect any two pages colliding on one target + * (§5.6) before writing, confirm, then apply the shared {@see OverwritePolicy} exactly as the views flow does. + * + * @internal This helper is scoped to the publish command and should not be used elsewhere. + */ class PagesPublisher { /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are _pages/ paths, so it never collides. */ @@ -57,7 +66,7 @@ public function publish(): int $pages = $this->selectPages(); if ($pages === null) { - return Command::FAILURE; + return Command::FAILURE; // A guidance message was already printed. } if ($pages === []) { @@ -69,6 +78,8 @@ public function publish(): int $resolved = $this->resolveDestinations($pages); if ($resolved === null) { + // A destination could not be resolved (invalid --to, an invalid custom path, or a page with no + // default in non-interactive mode). A guidance message was already printed; this is always a failure. return Command::FAILURE; } @@ -99,7 +110,11 @@ public function publish(): int return Command::SUCCESS; } - /** @return array|null */ + /** + * Determine which pages to publish: a named page directly, or the interactive picker. + * + * @return array|null The selected pages, or null when the run should fail (message printed). + */ protected function selectPages(): ?array { if ($this->hasNamedPage()) { @@ -116,6 +131,7 @@ protected function selectPages(): ?array return [$page]; } + // A bare --page (or the wizard) needs the picker, which requires an interactive terminal. if (! $this->canPrompt()) { $this->command->error('No page specified for publishing. Provide one, for example --page=welcome.'); @@ -172,6 +188,7 @@ protected function resolveDestinations(array $pages): ?array return $resolved; } + /** Resolve one page's destination per the §5.4 precedence. Returns null when it cannot be resolved. */ protected function resolveTarget(PublishablePage $page): ?string { // 1. An explicit --to wins, but only for pages that allow a custom destination (e.g. not 404), and it is @@ -205,6 +222,7 @@ protected function resolveTarget(PublishablePage $page): ?string return $this->promptForTarget($page); } + // 4. Otherwise the default is the only offered destination. return $page->defaultTarget; } @@ -243,6 +261,7 @@ protected function promptForCustomTarget(): ?string return $this->normalizeTargetPath($path); } + /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ protected function validateCustomTarget(string $path): ?string { $normalized = $this->normalizeTargetPath($path); @@ -312,8 +331,10 @@ protected function confirmProceed(array $resolved): bool } /** + * Apply the shared overwrite policy and copy the resolved pages into place. + * * @param array $resolved - * @return array|null + * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). */ protected function write(array $resolved): ?array { @@ -516,6 +537,7 @@ protected function maybeRebuild(): void } } + /** Whether a specific page name was supplied via --page=NAME (as opposed to a bare --page or the wizard). */ protected function hasNamedPage(): bool { $name = $this->command->option('page'); diff --git a/packages/framework/src/Console/Helpers/PublishablePage.php b/packages/framework/src/Console/Helpers/PublishablePage.php index fc718a89eb9..baaaf020985 100644 --- a/packages/framework/src/Console/Helpers/PublishablePage.php +++ b/packages/framework/src/Console/Helpers/PublishablePage.php @@ -14,7 +14,15 @@ */ final class PublishablePage { - /** @param array $alternativeTargets */ + /** + * @param string $key The unique identifier for the page (e.g. 'posts'). + * @param string $label The human-readable name shown in pickers (e.g. 'Posts feed'). + * @param string $description A short help text describing the page. + * @param string $source The framework-relative path to the stub file, resolved via Hyde::vendorPath() when published. + * @param string|null $defaultTarget The default project-relative destination (e.g. '_pages/posts.blade.php'), or null when the page has no default and its destination must be resolved interactively or via --to. + * @param array $alternativeTargets Additional valid destinations, mapping a project-relative path to a human label. + * @param bool $allowCustomTarget Whether the user may publish this page to a custom path. + */ public function __construct( public readonly string $key, public readonly string $label, diff --git a/packages/framework/src/Console/Helpers/PublishablePages.php b/packages/framework/src/Console/Helpers/PublishablePages.php index c3716775567..6b1b31126d8 100644 --- a/packages/framework/src/Console/Helpers/PublishablePages.php +++ b/packages/framework/src/Console/Helpers/PublishablePages.php @@ -28,13 +28,18 @@ public static function get(string $key): ?PublishablePage return static::all()[$key] ?? null; } + /** Register a publishable page, making it available to the publish command. Overrides any page sharing its key. */ public static function register(PublishablePage $page): void { static::$pages = static::all(); static::$pages[$page->key] = $page; } - /** @internal Primarily used to restore state between tests. */ + /** + * Reset the registry back to its default catalog. + * + * @internal Primarily used to restore state between tests. + */ public static function clear(): void { static::$pages = null; diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 0e6a9b6d1a5..3a8ceb8564b 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -22,7 +22,17 @@ use function sprintf; use function Laravel\Prompts\select; -/** @internal This helper is scoped to the publish command and should not be used elsewhere. */ +/** + * The views publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. + * + * Publishes Hyde's Blade overrides from the two declared groups (layouts, components) into + * resources/views/vendor/hyde/. The flow is: decide every selected file's outcome first (via the shared + * {@see OverwritePolicy}), resolve any modified-file conflicts second (interactive prompt or --force), and + * only then write — so cancelling never leaves a half-published tree. Output is cardinality-aware and + * reports the full breakdown of what was copied, what was already current, and what was left modified. + * + * @internal This helper is scoped to the publish command and should not be used elsewhere. + */ class ViewsPublisher { /** @var array EOL-agnostic destination checksums captured when a file was first blocked. */ @@ -309,6 +319,7 @@ protected function viewCount(int $count): string return $count === 1 ? '1 view' : "$count views"; } + /** Find the most specific common parent directory shared by the given files' target paths. */ protected function baseDirectory(array $files): string { $partsMap = collect($files)->map(fn (string $file): array => explode('/', $file)); diff --git a/packages/framework/src/Enums/OverwriteAction.php b/packages/framework/src/Enums/OverwriteAction.php index f638c1acde2..bf3914bb737 100644 --- a/packages/framework/src/Enums/OverwriteAction.php +++ b/packages/framework/src/Enums/OverwriteAction.php @@ -4,11 +4,20 @@ namespace Hyde\Enums; +/** + * The action the {@see \Hyde\Framework\Services\OverwritePolicy} decides to take + * when a source file is about to be published to a destination path. + * + * @see \Hyde\Framework\Services\OverwritePolicy + */ enum OverwriteAction: string { + /** The destination does not exist yet, so the source can be copied freely. */ case Copy = 'copy'; + /** The destination already matches the source, so there is nothing to do. */ case Skip = 'skip'; + /** The destination exists and differs from the source (user-modified), so overwriting is blocked. */ case Blocked = 'blocked'; } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 7eb18eafd2a..5e70c2614a3 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -22,6 +22,12 @@ use function glob; +/** + * Covers the starter-page publishing flow (§5): named vs. picker selection, the §5.4 destination + * resolution precedence (--to → non-interactive default → interactive prompt → default), --to + * validation, destination-conflict detection (§5.6), the interactive confirm (§5.5), the shared + * overwrite policy (§7) applied to pages, and the interactive-only optional rebuild (§5.7). + */ #[CoversClass(PublishCommand::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase @@ -30,6 +36,7 @@ protected function setUp(): void { parent::setUp(); + // Start from a known-empty _pages so each test controls exactly which destinations exist. $this->withoutDefaultPages(); } @@ -41,6 +48,7 @@ protected function tearDown(): void PagesPromptsReset::resetFallbacks(); PublishablePages::clear(); + // Remove anything a test published, then restore the two committed default pages so the tree stays clean. foreach (glob(Hyde::path('_pages/*.blade.php')) as $file) { File::delete($file); } @@ -50,6 +58,8 @@ protected function tearDown(): void parent::tearDown(); } + // Named-page publishing (--page=NAME) with non-interactive destination resolution (§5.4 step 2). + public function testNamedPagePublishesToItsDefaultTargetNonInteractively() { $this->artisan('publish --page=welcome --no-interaction') @@ -97,6 +107,8 @@ public function testNumericPageKeyIsResolvedByItsStringKey() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } + // Destination resolution: --to wins over the default (§5.4 step 1). + public function testToOverridesTheDefaultTarget() { $this->artisan('publish --page=posts --to=_pages/index.blade.php --no-interaction') @@ -107,6 +119,8 @@ public function testToOverridesTheDefaultTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); } + // A page with no default target (blank) cannot be resolved non-interactively without --to (§5.4 step 2). + public function testPageWithoutDefaultTargetFailsNonInteractivelyWithoutTo() { $this->artisan('publish --page=blank --no-interaction') @@ -123,6 +137,8 @@ public function testPageWithoutDefaultTargetPublishesWithTo() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } + // --to validation: must live under _pages/ and end in .blade.php (§5.4 step 1, §9). + public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') @@ -137,6 +153,8 @@ public function testToPathWithWrongExtensionIsRejected() ->assertExitCode(1); } + // A page that disallows custom targets (404) rejects --to and keeps its fixed default. + public function testToIsRejectedForAPageThatDisallowsCustomTargets() { $this->artisan('publish --page=404 --to=_pages/error.blade.php --no-interaction') @@ -159,6 +177,8 @@ public function testBarePageWithToIsRejectedBeforeThePickerAndBeatsThePerPageRea ->assertExitCode(1); } + // Overwrite policy (§7): identical -> skip, modified -> fail without --force, --force overwrites. + public function testIdenticalPageIsSkippedAsAlreadyCurrent() { $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); @@ -210,6 +230,8 @@ public function copy($path, $target): bool $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + // Interactive destination prompt (§5.4 step 3): default / alternative / custom path. + public function testInteractiveResolutionCanChooseAnAlternativeTarget() { $this->artisan('publish --page=posts') @@ -268,8 +290,11 @@ public function testCustomPathFromPromptRepromptsUntilValid() Prompt::assertStrippedOutputContains('The path must be within _pages/ and end in .blade.php.'); } + // Interactive picker flow (§5.5): select -> resolve -> confirm. + public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() { + // Welcome has a single sensible destination, so it is not prompted for; it resolves to its default. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome']) ->expectsOutput('Ready to publish:') @@ -321,8 +346,11 @@ protected function selectPages(): ?array $this->assertFileDoesNotExist(Hyde::path('_pages/404.blade.php')); } + // Destination-conflict detection before any write (§5.6). + public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() { + // Register a second page whose default collides with welcome's default so the picker offers both. PublishablePages::register(new PublishablePage( key: 'clash', label: 'Clashing page', @@ -332,6 +360,8 @@ public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() allowCustomTarget: false, )); + // Neither page is prompted for (welcome and clash each resolve straight to their default), so the + // collision is caught purely from the picker selection, before any destination prompt or write. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', 'clash']) ->expectsOutputToContain('Welcome page and Clashing page both target _pages/index.blade.php.') @@ -354,8 +384,11 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + // Optional rebuild (§5.7): offered interactively, never non-interactively. + public function testRebuildIsOfferedInteractivelyAfterPublishing() { + // Welcome resolves to its default without a destination prompt, so the only interaction is the rebuild offer. $this->artisan('publish --page=welcome') ->expectsOutputToContain('Published [welcome] to [_pages/index.blade.php]') ->expectsConfirmation('Rebuild the site now?', 'no') @@ -384,8 +417,12 @@ public function testPickerCanSelectTheNumericKeyedPage() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } + // Option 2's whole point: the pages picker must NOT offer an "All" row (unlike the views picker). + public function testPickerDoesNotOfferAnAllRow() { + // Space+enter selects the first row (welcome); the next enter accepts "Proceed?" (default yes), the + // last accepts "Rebuild the site now?" (default no) — so the run completes without leftover prompts. $output = $this->runPagesPicker([Key::SPACE, Key::ENTER, Key::ENTER, Key::ENTER]); Prompt::assertOutputContains('Select pages to publish'); @@ -393,9 +430,13 @@ public function testPickerDoesNotOfferAnAllRow() Prompt::assertOutputDoesntContain('All pages'); Prompt::assertOutputDoesntContain('All views'); + // The first offered row is a real page (welcome), not a select-all sentinel, so a single space+enter publishes it. $this->assertStringContainsString('Published [welcome]', $output->fetch()); } + // A bare --page (no name) needs the picker, which needs an interactive terminal, so non-interactively it + // fails in the pages flow (§3/§5). Exercised here through PagesPublisher so the guidance path is covered there. + public function testBarePageWithoutInteractionFailsHelpfully() { $this->artisan('publish --page --no-interaction') @@ -442,6 +483,8 @@ public function testThreePagesResolvingToTheSameTargetReportAllTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + // §7 interactive conflict prompt applied to pages: overwrite / skip / cancel, mirroring the views flow. + public function testInteractiveConflictPromptCanOverwriteAPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); @@ -502,6 +545,7 @@ public function testInteractiveConflictPromptCanSkipAModifiedPage() ->expectsOutputToContain('Run again with --force to overwrite.') ->assertExitCode(0); + // Skipping leaves the file as the user had it, and (nothing was written) never offers a rebuild. $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } @@ -517,8 +561,12 @@ public function testInteractiveConflictPromptCanCancelForPages() $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } + // §4/§5 cardinality-aware output: a mixed run reports what was published alongside what was already current + // (pluralized), without collapsing to the "all up to date" shortcut. + public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() { + // Seed two pages so they are already current, then register a third new page and publish all three. $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); $this->artisan('publish --page=404 --no-interaction')->assertExitCode(0); @@ -530,6 +578,7 @@ public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() defaultTarget: '_pages/about.blade.php', )); + // welcome and 404 are already current; only about is copied — so the run reports both sides. $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', '404', 'about']) ->expectsConfirmation('Proceed?', 'yes') @@ -555,6 +604,7 @@ public function testAcceptingTheRebuildOfferRunsTheBuild() Artisan::shouldReceive('call')->once()->with('build', [], \Mockery::any())->andReturn(0); + // 'y' + enter answers the "Rebuild the site now?" confirm (which defaults to no) with yes. Prompt::fake(['y', Key::ENTER]); $command = $this->app->make(PublishCommand::class); @@ -568,6 +618,7 @@ public function testAcceptingTheRebuildOfferRunsTheBuild() $this->assertStringContainsString('Published [welcome]', $output->fetch()); } + /** Drive the interactive pages picker with faked keystrokes and return the buffered output. */ protected function runPagesPicker(array $keys): BufferedOutput { if (windows_os()) { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index 06493f3b096..a83edcfff6f 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -12,6 +12,11 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; +/** + * Covers the PublishCommand spine: the flag surface, all guardrails (§9), and the + * interactive wizard routing (§3). The views and pages handlers are stubs in this step, + * so these tests assert routing and guardrails, not real publishing. + */ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { @@ -21,6 +26,7 @@ class PublishCommandTest extends TestCase protected function tearDown(): void { + // The views-routing tests below publish real files; remove them so the tree stays clean. if (File::isDirectory(Hyde::path('resources/views/vendor'))) { File::deleteDirectory(Hyde::path('resources/views/vendor')); } @@ -36,6 +42,8 @@ protected function tearDown(): void parent::tearDown(); } + // Guardrails: raw tag/provider/config publishing is redirected to vendor:publish (§9). + public function testTagFlagIsRedirectedToVendorPublish() { $this->artisan('publish --tag=foo') @@ -64,6 +72,8 @@ public function testConfigFlagIsRedirectedToVendorPublish() ->assertExitCode(1); } + // Guardrails: the command's own flag combinations (§9). + public function testLayoutsAndComponentsAreMutuallyExclusive() { $this->artisan('publish --layouts --components') @@ -80,6 +90,8 @@ public function testToOptionRequiresThePageFlag() public function testToOptionRequiresANamedPageNotABarePageFlag() { + // --to names one destination, so it is only valid with a single named page (§5.4); a bare --page + // (multi-select) with --to is rejected rather than letting one path stand in for several pages. $this->artisan('publish --page --to=_pages/index.blade.php') ->expectsOutputToContain('--to is only valid when publishing a single page. Use --page=NAME with --to.') ->assertExitCode(1); @@ -95,6 +107,9 @@ public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() ->assertExitCode(1); } + // Flag routing to the views handler. The full views behavior is covered in PublishCommandViewsTest; + // here we assert only that each flag actually reaches the real views publisher (routing coverage). + public function testLayoutsFlagRoutesToViews() { $this->artisan('publish --layouts --no-interaction') @@ -118,6 +133,7 @@ public function testAllFlagRoutesToViews() public function testBarePageFlagRoutesToPages() { + // A bare --page needs the interactive picker; non-interactively it reaches the pages flow and fails there. $this->artisan('publish --page --no-interaction') ->expectsOutputToContain('No page specified for publishing. Provide one, for example --page=welcome.') ->assertExitCode(1); @@ -132,11 +148,14 @@ public function testPageFlagWithEmptyValueFailsBeforeTheWizard() public function testPageFlagWithNameRoutesToPages() { + // An unknown name proves the flag routes into the pages flow and its registry lookup, without writing anything. $this->artisan('publish --page=nonexistent --no-interaction') ->expectsOutputToContain('The page [nonexistent] does not exist.') ->assertExitCode(1); } + // Interactive wizard routing (§3). + public function testWizardRoutesToViews() { $appLayout = (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde').'/framework/resources/views/layouts/app.blade.php'; @@ -150,6 +169,8 @@ public function testWizardRoutesToViews() public function testWizardRoutesToPages() { + // Route through the wizard into the real pages flow. A deliberately modified target makes the overwrite + // guard prove the wizard reached PagesPublisher without depending on the repository fixture contents. $this->modifyDefaultHomePage(); $this->artisan('publish') @@ -179,8 +200,13 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() ->assertExitCode(0); } + // Approach 1 must not swallow genuine mistakes: unknown options and stray arguments + // still hit Symfony's native errors rather than our redirect or a stub handler. + public function testUnknownOptionIsNotSwallowed() { + // A typo for --layouts must surface Symfony's native error, not be eaten by our + // raw-flag interception (which only short-circuits --tag/--provider/--config). $this->expectException(RuntimeException::class); $this->expectExceptionMessage('The "--layout" option does not exist.'); @@ -189,12 +215,17 @@ public function testUnknownOptionIsNotSwallowed() public function testArbitrarySourcePathArgumentIsRejected() { + // The command declares no arguments, so a stray source path is rejected outright + // rather than being interpreted as a publishable target. $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No arguments expected for "publish" command, got "resources/views/foo.blade.php".'); $this->artisan('publish resources/views/foo.blade.php')->run(); } + // The legacy publish commands are removed in v3, not aliased. Invoking one must raise Symfony's + // native command-not-found error, proving the command is gone and that no shim intercepts it. + public function testRemovedLegacyPublishViewsCommandRaisesCommandNotFound() { $this->expectException(CommandNotFoundException::class); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 0ef8df0b276..d5dbf55e640 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -19,11 +19,18 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; +/** + * Covers the views publishing flow (§4) and the shared overwrite policy applied to views (§7): + * the grouped multi-select picker, --layouts/--components prefiltering, --all skipping the picker, + * cardinality-aware output, and the missing/identical/modified overwrite behavior with --force. + */ #[CoversClass(PublishCommand::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] class PublishCommandViewsTest extends TestCase { + // Non-interactive scope selection (a scoped group is exactly equivalent to adding --all). + public function testAllPublishesEveryView() { $count = $this->viewCount('layouts') + $this->viewCount('components'); @@ -60,6 +67,8 @@ public function testComponentsPublishesOnlyComponentsNonInteractively() $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); } + // Interactive picker selection (single / many / cross-group), with cardinality-aware output. + public function testPickerCanPublishASingleView() { $this->artisan('publish --layouts') @@ -139,6 +148,8 @@ public function exposeBaseDirectory(array $files): string ])); } + // The picker is prefiltered by the scope flag and uses group-prefixed labels with an "All views" row. + public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() { $output = $this->runViewsPicker(['--layouts' => true], [Key::SPACE, Key::ENTER]); @@ -148,6 +159,7 @@ public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() Prompt::assertOutputContains('layouts/app.blade.php'); Prompt::assertOutputDoesntContain('components/'); + // Checking the "All views" sentinel selects every offered (layouts) view. $this->assertStringContainsString('Published all', $output->fetch()); $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/components')); @@ -172,6 +184,8 @@ public function testAllSentinelPreservesNumericOptionKeys() $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); } + // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. + public function testIdenticalViewsAreSkippedAsAlreadyCurrent() { $this->seedAllViews(); @@ -192,6 +206,7 @@ public function testModifiedViewsCannotBeOverwrittenNonInteractivelyWithoutForce ->expectsOutput('Run again with --force to overwrite.') ->assertExitCode(1); + // Hard stop: the modified file is left untouched and nothing else is written either. $this->assertSame('MODIFIED BY USER', File::get($target)); } @@ -313,8 +328,12 @@ public function testInteractiveConflictPromptCanCancel() $this->assertSame('MODIFIED BY USER', File::get($target)); } + // §4 cardinality-aware output: a mixed run reports what was copied alongside what was already current, + // instead of collapsing to either the "Published all" or the "all up to date" shortcut. + public function testMixedRunReportsPublishedAlongsideAlreadyCurrentViews() { + // Seed only the layouts so they are already current, then publish everything: components copy, layouts skip. $this->artisan('publish --layouts --no-interaction')->assertExitCode(0); $components = $this->viewCount('components'); @@ -337,11 +356,13 @@ protected function source(string $group, string $file): string return (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde')."/framework/resources/views/$group/$file"; } + /** Publish every view so subsequent runs see identical (already current) destinations. */ protected function seedAllViews(): void { $this->artisan('publish --all --no-interaction')->assertExitCode(0); } + /** Modify one already-published view so it is seen as user-modified, and return its target path. */ protected function modifyPublishedView(): string { $target = Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php'); @@ -350,6 +371,7 @@ protected function modifyPublishedView(): string return $target; } + /** Drive the interactive picker with faked keystrokes and return the buffered output. */ protected function runViewsPicker(array $parameters, array $keys): BufferedOutput { if (windows_os()) { From 5e97a99f9238c008d46d9d13be605d6d4d6436cc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 01:25:43 +0200 Subject: [PATCH 40/56] Add publish overwrite guard feature tests --- .../Commands/PublishCommandPagesTest.php | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 5e70c2614a3..8a33cc34566 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -50,7 +50,11 @@ protected function tearDown(): void // Remove anything a test published, then restore the two committed default pages so the tree stays clean. foreach (glob(Hyde::path('_pages/*.blade.php')) as $file) { - File::delete($file); + if (File::isDirectory($file)) { + File::deleteDirectory($file); + } else { + File::delete($file); + } } $this->restoreDefaultPages(); @@ -212,6 +216,58 @@ public function testForceOverwritesModifiedPage() $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } + public function testPublishFailsWhenRegisteredPageSourceIsMissing() + { + $missingSource = 'resources/views/homepages/missing-source.blade.php'; + + PublishablePages::register(new PublishablePage( + key: 'missing-source', + label: 'Missing source page', + description: 'A page registered by an extension with a missing source file.', + source: $missingSource, + defaultTarget: '_pages/missing-source.blade.php', + )); + + $this->artisan('publish --page=missing-source --no-interaction') + ->expectsOutputToContain('Error: Cannot publish: source file ['.Hyde::vendorPath($missingSource).'] does not exist.') + ->doesntExpectOutputToContain('Published') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/missing-source.blade.php')); + } + + public function testPublishFailsWhenRegisteredPageSourceIsADirectory() + { + $directorySource = 'resources/views/homepages'; + + PublishablePages::register(new PublishablePage( + key: 'directory-source', + label: 'Directory source page', + description: 'A page registered by an extension with a directory source.', + source: $directorySource, + defaultTarget: '_pages/directory-source.blade.php', + )); + + $this->artisan('publish --page=directory-source --no-interaction') + ->expectsOutputToContain('Error: Cannot publish: source ['.Hyde::vendorPath($directorySource).'] is not a file.') + ->doesntExpectOutputToContain('Published') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/directory-source.blade.php')); + } + + public function testPublishFailsWhenDestinationIsADirectory() + { + File::makeDirectory(Hyde::path('_pages/index.blade.php')); + + $this->artisan('publish --page=welcome --no-interaction') + ->expectsOutputToContain('Error: Cannot publish: destination ['.Hyde::path('_pages/index.blade.php').'] is a directory.') + ->doesntExpectOutputToContain('Published') + ->assertExitCode(1); + + $this->assertDirectoryExists(Hyde::path('_pages/index.blade.php')); + } + public function testCopyFailureFailsWithoutReportingSuccess() { app()->instance(\Illuminate\Filesystem\Filesystem::class, new class extends \Illuminate\Filesystem\Filesystem From d855f7a4605c783a006b9b202c1b2439e8ae828c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 18:49:15 +0200 Subject: [PATCH 41/56] Remove unnecessary comments --- .../src/Console/Commands/PublishCommand.php | 22 ++------ .../Helpers/InteractiveMultiselect.php | 10 +--- .../src/Console/Helpers/PagesPublisher.php | 39 +++----------- .../src/Console/Helpers/PublishablePage.php | 8 --- .../src/Console/Helpers/PublishablePages.php | 12 +---- .../src/Console/Helpers/ViewsPublisher.php | 16 ++---- .../Framework/Services/OverwritePolicy.php | 10 ---- .../Commands/PublishCommandPagesTest.php | 51 ++----------------- .../Feature/Commands/PublishCommandTest.php | 22 -------- .../Commands/PublishCommandViewsTest.php | 16 ------ 10 files changed, 23 insertions(+), 183 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 746f79d31a7..cfad401334b 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -14,14 +14,6 @@ use function is_string; use function Laravel\Prompts\select; -/** - * The flag-driven, views-centric publishing command for Hyde Blade customizations, - * with an optional side path for publishing starter pages. - * - * This is the command spine: it owns the full flag surface, all guardrails, and the - * interactive wizard routing. The actual views and pages publishing are delegated to - * handlers that are stubbed out in this step and filled in by later steps. - */ class PublishCommand extends Command { /** @var string */ @@ -37,13 +29,10 @@ class PublishCommand extends Command protected $description = 'Publish Hyde views and starter pages for customization'; /** - * Intercept the raw input before Symfony's strict option binding so we can redirect the - * curated-out tag/provider/config publishing flags to vendor:publish with a helpful message. - * - * We deliberately do not declare these as options: doing so would advertise them in the - * command's help output, which is exactly the raw-publishing surface this command exists to - * hide. By only short-circuiting these three specific tokens, a genuine typo such as --layout - * still falls through to Symfony's native "unknown option" error. + * --tag/--provider/--config are deliberately not declared as options: doing so would advertise + * them in the command's help output, which is exactly the raw-publishing surface this command + * hides. So they're intercepted here, before Symfony's strict option binding, and redirected to + * vendor:publish. A genuine typo such as --layout still falls through to Symfony's own error. */ public function run(InputInterface $input, OutputInterface $output): int { @@ -86,8 +75,6 @@ protected function safeHandle(): int return $this->publishViews(); } - // No actionable flags were supplied. We must decide before attempting any prompt: without - // an interactive terminal there is no wizard to run, so we fail with usage guidance instead. if (! $this->input->isInteractive()) { return $this->failWithUsageHint(); } @@ -95,7 +82,6 @@ protected function safeHandle(): int return $this->runWizard(); } - /** Interactive step 1 (§3): route to the views or pages flow, or cancel out. */ protected function runWizard(): int { $choice = select('What do you want to publish?', [ diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index 186337e3c1c..6ccf13977a9 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -12,14 +12,8 @@ use function in_array; /** - * A small interactive multi-select prompt with an optional "All" sentinel row. - * - * When an $allLabel is given, the list is prepended with a single "select all" option: checking - * that sentinel row means "everything" regardless of the other checkbox state. Callers that do not - * want a bulk affordance (e.g. the pages picker, where "all starter pages at once" is never a sensible - * selection) pass no $allLabel and the row is omitted. The caller supplies an already-labelled - * key => label map (for views these are group-prefixed paths), and gets back the selected option - * keys with the sentinel resolved away. + * When $allLabel is given, checking that sentinel row means "everything", regardless of which other + * rows are checked; the sentinel is resolved away before the selected keys are returned to the caller. * * @internal This helper is scoped to the publish command flows and should not be used elsewhere. */ diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 49b3a6bfff3..2e79cf28d9d 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -26,13 +26,6 @@ use function Laravel\Prompts\text; /** - * The starter-page publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. - * - * Publishes pages from the {@see PublishablePages} registry into the project's _pages directory. Unlike views, - * a page may have several valid destinations, so the flow is: select the pages, resolve each destination (§5.4: - * --to → non-interactive default → interactive prompt → default), detect any two pages colliding on one target - * (§5.6) before writing, confirm, then apply the shared {@see OverwritePolicy} exactly as the views flow does. - * * @internal This helper is scoped to the publish command and should not be used elsewhere. */ class PagesPublisher @@ -46,7 +39,7 @@ class PagesPublisher /** @var array Modified destinations left unchanged (interactive skip). */ protected array $leftModified = []; - /** Whether the pages were chosen through the interactive picker (which adds the §5.5 confirmation step). */ + /** Whether the pages were chosen through the interactive picker (which adds a confirmation step). */ protected bool $usedPicker = false; public function __construct(protected Command $command, protected InputInterface $input) @@ -55,7 +48,7 @@ public function __construct(protected Command $command, protected InputInterface public function publish(): int { - // §5.4: --to names a single destination, so it is only meaningful for a single named page. A bare --page + // --to names a single destination, so it is only meaningful for a single named page. A bare --page // (multi-select) with --to would have one path stand in for several pages; reject it rather than guess. if ($this->command->option('to') !== null && ! $this->hasNamedPage()) { $this->command->error('--to is only valid when publishing a single page. Use --page=NAME with --to.'); @@ -131,7 +124,6 @@ protected function selectPages(): ?array return [$page]; } - // A bare --page (or the wizard) needs the picker, which requires an interactive terminal. if (! $this->canPrompt()) { $this->command->error('No page specified for publishing. Provide one, for example --page=welcome.'); @@ -188,11 +180,8 @@ protected function resolveDestinations(array $pages): ?array return $resolved; } - /** Resolve one page's destination per the §5.4 precedence. Returns null when it cannot be resolved. */ protected function resolveTarget(PublishablePage $page): ?string { - // 1. An explicit --to wins, but only for pages that allow a custom destination (e.g. not 404), and it is - // validated against _pages/ and the .blade.php extension. if ($this->command->option('to') !== null) { if (! $page->allowCustomTarget) { $this->command->error(sprintf('The [%s] page cannot be published to a custom path; omit --to to use its default (%s).', $page->key, $page->defaultTarget)); @@ -203,7 +192,6 @@ protected function resolveTarget(PublishablePage $page): ?string return $this->validateCustomTarget((string) $this->command->option('to')); } - // 2. Non-interactive falls back to the default; a page without one (e.g. blank) cannot be resolved. if (! $this->canPrompt()) { if ($page->defaultTarget === null) { $this->command->error(sprintf('The [%s] page has no default destination. Provide one with --to.', $page->key)); @@ -214,15 +202,12 @@ protected function resolveTarget(PublishablePage $page): ?string return $page->defaultTarget; } - // 3. Interactively, prompt only when the destination is genuinely ambiguous: the page offers alternative - // targets, or it has no default at all. A page whose default is the one sensible destination (welcome, 404) - // is not prompted for — its custom placement, if allowed, is reached through --to instead. This keeps the - // common "publish the welcome homepage" case a single, frictionless step. + // Only prompt when the destination is genuinely ambiguous: alternative targets exist, or there is no + // default at all. A page with one sensible default (welcome, 404) is never prompted for. if ($page->alternativeTargets !== [] || $page->defaultTarget === null) { return $this->promptForTarget($page); } - // 4. Otherwise the default is the only offered destination. return $page->defaultTarget; } @@ -289,11 +274,7 @@ protected function normalizeTargetPath(string $path): string return (string) preg_replace('#/+#', '/', Str::replace('\\', '/', $path)); } - /** - * Reject the run when two selected pages resolve to the same destination (§5.6), before anything is written. - * - * @param array $resolved - */ + /** @param array $resolved */ protected function assertNoDestinationConflicts(array $resolved): bool { $labelsByTarget = []; @@ -436,8 +417,6 @@ protected function copy(string $source, string $target): void } /** - * Resolve what to do with modified (blocked) destinations, mirroring the views flow (§7). - * * @param array $blocked * @return array|null */ @@ -520,11 +499,9 @@ protected function report(array $written): void } /** - * Offer to rebuild the site after a successful publish (§5.7). - * - * Interactive only, and deliberately defaulting to NO. A single page publish should not auto-rebuild the - * entire site, so the prompt defaults to NO — keep this check here rather than consolidating it into a - * shared rebuild helper, which would tempt a yes-default back in. + * Interactive only, and deliberately defaulting to NO: a single page publish should not auto-rebuild + * the entire site. Keep this check here rather than moving it into a shared rebuild helper, which + * would tempt a yes-default back in. */ protected function maybeRebuild(): void { diff --git a/packages/framework/src/Console/Helpers/PublishablePage.php b/packages/framework/src/Console/Helpers/PublishablePage.php index baaaf020985..3b5e1bb44b8 100644 --- a/packages/framework/src/Console/Helpers/PublishablePage.php +++ b/packages/framework/src/Console/Helpers/PublishablePage.php @@ -4,14 +4,6 @@ namespace Hyde\Console\Helpers; -/** - * An immutable value object describing a starter/default page that can be published into the project's _pages directory. - * - * Unlike view groups, a page may have multiple valid destinations and carries display metadata, so each publishable - * page is modelled explicitly and registered in the {@see PublishablePages} registry rather than as a fixed file map. - * - * @see \Hyde\Console\Helpers\PublishablePages - */ final class PublishablePage { /** diff --git a/packages/framework/src/Console/Helpers/PublishablePages.php b/packages/framework/src/Console/Helpers/PublishablePages.php index 6b1b31126d8..4b5dbcd86cc 100644 --- a/packages/framework/src/Console/Helpers/PublishablePages.php +++ b/packages/framework/src/Console/Helpers/PublishablePages.php @@ -4,14 +4,6 @@ namespace Hyde\Console\Helpers; -/** - * The registry of publishable starter pages. - * - * Ships with Hyde's default catalog (welcome, posts, blank, 404) and serves as an extension point so that - * Hyde Cloud and plugins can register their own publishable pages via {@see PublishablePages::register()}. - * - * @see \Hyde\Console\Helpers\PublishablePage - */ final class PublishablePages { /** @var array|null */ @@ -28,7 +20,7 @@ public static function get(string $key): ?PublishablePage return static::all()[$key] ?? null; } - /** Register a publishable page, making it available to the publish command. Overrides any page sharing its key. */ + /** Overrides any existing page sharing its key. */ public static function register(PublishablePage $page): void { static::$pages = static::all(); @@ -69,7 +61,7 @@ protected static function getDefaultPages(): array label: 'Blank page', description: 'A blank Blade template with just the base layout.', source: 'resources/views/homepages/blank.blade.php', - defaultTarget: null, // An empty starter you drop anywhere: no default, so its destination is always prompted for (or set via --to). + defaultTarget: null, ), new PublishablePage( key: '404', diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 3a8ceb8564b..7cde32d2bbf 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -23,14 +23,6 @@ use function Laravel\Prompts\select; /** - * The views publishing flow for the {@see \Hyde\Console\Commands\PublishCommand}. - * - * Publishes Hyde's Blade overrides from the two declared groups (layouts, components) into - * resources/views/vendor/hyde/. The flow is: decide every selected file's outcome first (via the shared - * {@see OverwritePolicy}), resolve any modified-file conflicts second (interactive prompt or --force), and - * only then write — so cancelling never leaves a half-published tree. Output is cardinality-aware and - * reports the full breakdown of what was copied, what was already current, and what was left modified. - * * @internal This helper is scoped to the publish command and should not be used elsewhere. */ class ViewsPublisher @@ -42,6 +34,10 @@ public function __construct(protected Command $command, protected InputInterface { } + /** + * Every file's outcome is decided, and conflicts resolved, before anything is written — so cancelling + * never leaves a half-published tree. + */ public function publish(): int { [$offered, $labels] = $this->collectOfferedFiles(); @@ -180,8 +176,6 @@ protected function selectFiles(array $offered, array $labels): array } /** - * Decide every selected file's outcome up front, before anything is written. - * * @param array $selected * @return array{0: array, 1: array, 2: array} A tuple of [copy, already-current, blocked] maps, each source => target. */ @@ -213,8 +207,6 @@ protected function destinationChecksum(string $target): string } /** - * Resolve what to do with modified (blocked) files, after the full outcome is known but before any write. - * * @param array $blocked * @return array|null The blocked files to overwrite, or null when the run should stop (cancelled interactively, or blocked non-interactively without --force). */ diff --git a/packages/framework/src/Framework/Services/OverwritePolicy.php b/packages/framework/src/Framework/Services/OverwritePolicy.php index 630caee4d44..d70450a6e91 100644 --- a/packages/framework/src/Framework/Services/OverwritePolicy.php +++ b/packages/framework/src/Framework/Services/OverwritePolicy.php @@ -11,18 +11,8 @@ use function Hyde\unixsum_file; /** - * The shared, pure decision logic for whether a source file may be published to a destination path. - * - * Given a source and a destination, it decides between three outcomes without performing any I/O - * beyond reading the two files, and without any knowledge of what is being published (views, pages, etc.): - * - * - {@see OverwriteAction::Copy} The destination is missing. - * - {@see OverwriteAction::Skip} The destination is unchanged from the source. - * - {@see OverwriteAction::Blocked} The destination exists and was modified by the user. - * * The comparison is EOL-agnostic (via {@see \Hyde\unixsum_file()}) so that files differing only by * their line endings (for example after a CRLF checkout) are treated as unchanged rather than modified. - * There is no historical-checksum manifest; the destination is only ever compared to the current source. */ class OverwritePolicy { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 8a33cc34566..d0533f8031c 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -22,12 +22,6 @@ use function glob; -/** - * Covers the starter-page publishing flow (§5): named vs. picker selection, the §5.4 destination - * resolution precedence (--to → non-interactive default → interactive prompt → default), --to - * validation, destination-conflict detection (§5.6), the interactive confirm (§5.5), the shared - * overwrite policy (§7) applied to pages, and the interactive-only optional rebuild (§5.7). - */ #[CoversClass(PublishCommand::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase @@ -36,7 +30,6 @@ protected function setUp(): void { parent::setUp(); - // Start from a known-empty _pages so each test controls exactly which destinations exist. $this->withoutDefaultPages(); } @@ -48,7 +41,6 @@ protected function tearDown(): void PagesPromptsReset::resetFallbacks(); PublishablePages::clear(); - // Remove anything a test published, then restore the two committed default pages so the tree stays clean. foreach (glob(Hyde::path('_pages/*.blade.php')) as $file) { if (File::isDirectory($file)) { File::deleteDirectory($file); @@ -62,8 +54,6 @@ protected function tearDown(): void parent::tearDown(); } - // Named-page publishing (--page=NAME) with non-interactive destination resolution (§5.4 step 2). - public function testNamedPagePublishesToItsDefaultTargetNonInteractively() { $this->artisan('publish --page=welcome --no-interaction') @@ -77,7 +67,7 @@ public function testNamedPagePublishesToItsDefaultTargetNonInteractively() ); } - // §2/§11: --all means "all views" and does not apply to pages. Combined with --page, the page + // --all means "all views" and does not apply to pages. Combined with --page, the page // flow wins (page-intent is resolved before view-intent) and --all is inert — it must not divert // into the views "Published all" path. PagesPublisher never reads the --all option. public function testAllFlagDoesNotApplyToPagesWhenCombinedWithPage() @@ -100,8 +90,7 @@ public function testUnknownPageNameFailsHelpfully() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // The '404' key is a string on the value object but coerces to an int array key: lookup must compare ->key (§5.1). - + // The '404' key is a string on the value object but coerces to an int array key: lookup must compare ->key. public function testNumericPageKeyIsResolvedByItsStringKey() { $this->artisan('publish --page=404 --no-interaction') @@ -111,8 +100,6 @@ public function testNumericPageKeyIsResolvedByItsStringKey() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } - // Destination resolution: --to wins over the default (§5.4 step 1). - public function testToOverridesTheDefaultTarget() { $this->artisan('publish --page=posts --to=_pages/index.blade.php --no-interaction') @@ -123,8 +110,6 @@ public function testToOverridesTheDefaultTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/posts.blade.php')); } - // A page with no default target (blank) cannot be resolved non-interactively without --to (§5.4 step 2). - public function testPageWithoutDefaultTargetFailsNonInteractivelyWithoutTo() { $this->artisan('publish --page=blank --no-interaction') @@ -141,8 +126,6 @@ public function testPageWithoutDefaultTargetPublishesWithTo() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } - // --to validation: must live under _pages/ and end in .blade.php (§5.4 step 1, §9). - public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') @@ -157,8 +140,6 @@ public function testToPathWithWrongExtensionIsRejected() ->assertExitCode(1); } - // A page that disallows custom targets (404) rejects --to and keeps its fixed default. - public function testToIsRejectedForAPageThatDisallowsCustomTargets() { $this->artisan('publish --page=404 --to=_pages/error.blade.php --no-interaction') @@ -172,7 +153,6 @@ public function testToIsRejectedForAPageThatDisallowsCustomTargets() // destination: the "one path can't serve several pages" guard (message A) must win over any per-page reason // (message B, e.g. 404's custom-path rejection) AND fire before the picker — so this interactive run, which // would hang on an unanswered picker prompt if the picker were reached, asks nothing and exits on message A. - public function testBarePageWithToIsRejectedBeforeThePickerAndBeatsThePerPageReason() { $this->artisan('publish --page --to=_pages/error.blade.php') @@ -181,8 +161,6 @@ public function testBarePageWithToIsRejectedBeforeThePickerAndBeatsThePerPageRea ->assertExitCode(1); } - // Overwrite policy (§7): identical -> skip, modified -> fail without --force, --force overwrites. - public function testIdenticalPageIsSkippedAsAlreadyCurrent() { $this->artisan('publish --page=welcome --no-interaction')->assertExitCode(0); @@ -286,8 +264,6 @@ public function copy($path, $target): bool $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // Interactive destination prompt (§5.4 step 3): default / alternative / custom path. - public function testInteractiveResolutionCanChooseAnAlternativeTarget() { $this->artisan('publish --page=posts') @@ -346,8 +322,6 @@ public function testCustomPathFromPromptRepromptsUntilValid() Prompt::assertStrippedOutputContains('The path must be within _pages/ and end in .blade.php.'); } - // Interactive picker flow (§5.5): select -> resolve -> confirm. - public function testInteractivePickerPublishesSelectedPagesAfterConfirmation() { // Welcome has a single sensible destination, so it is not prompted for; it resolves to its default. @@ -402,11 +376,8 @@ protected function selectPages(): ?array $this->assertFileDoesNotExist(Hyde::path('_pages/404.blade.php')); } - // Destination-conflict detection before any write (§5.6). - public function testTwoPagesResolvingToTheSameTargetAreRejectedBeforeWriting() { - // Register a second page whose default collides with welcome's default so the picker offers both. PublishablePages::register(new PublishablePage( key: 'clash', label: 'Clashing page', @@ -440,8 +411,6 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // Optional rebuild (§5.7): offered interactively, never non-interactively. - public function testRebuildIsOfferedInteractivelyAfterPublishing() { // Welcome resolves to its default without a destination prompt, so the only interaction is the rebuild offer. @@ -473,8 +442,6 @@ public function testPickerCanSelectTheNumericKeyedPage() $this->assertFileExists(Hyde::path('_pages/404.blade.php')); } - // Option 2's whole point: the pages picker must NOT offer an "All" row (unlike the views picker). - public function testPickerDoesNotOfferAnAllRow() { // Space+enter selects the first row (welcome); the next enter accepts "Proceed?" (default yes), the @@ -490,9 +457,7 @@ public function testPickerDoesNotOfferAnAllRow() $this->assertStringContainsString('Published [welcome]', $output->fetch()); } - // A bare --page (no name) needs the picker, which needs an interactive terminal, so non-interactively it - // fails in the pages flow (§3/§5). Exercised here through PagesPublisher so the guidance path is covered there. - + // A bare --page (no name) needs the picker, which needs an interactive terminal, so non-interactively it fails. public function testBarePageWithoutInteractionFailsHelpfully() { $this->artisan('publish --page --no-interaction') @@ -502,8 +467,6 @@ public function testBarePageWithoutInteractionFailsHelpfully() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // §9: a --to path may not escape _pages/ via traversal, even though it starts with _pages/ and ends in .blade.php. - public function testToPathWithParentTraversalIsRejected() { $this->artisan('publish --page=welcome --to=_pages/../secret.blade.php --no-interaction') @@ -513,8 +476,6 @@ public function testToPathWithParentTraversalIsRejected() $this->assertFileDoesNotExist(Hyde::path('secret.blade.php')); } - // §5.6: three or more pages colliding on one target switch the message from "both target" to "all target". - public function testThreePagesResolvingToTheSameTargetReportAllTarget() { foreach (['clash-one' => 'Clash One', 'clash-two' => 'Clash Two'] as $key => $label) { @@ -539,8 +500,6 @@ public function testThreePagesResolvingToTheSameTargetReportAllTarget() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - // §7 interactive conflict prompt applied to pages: overwrite / skip / cancel, mirroring the views flow. - public function testInteractiveConflictPromptCanOverwriteAPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); @@ -617,9 +576,6 @@ public function testInteractiveConflictPromptCanCancelForPages() $this->assertSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } - // §4/§5 cardinality-aware output: a mixed run reports what was published alongside what was already current - // (pluralized), without collapsing to the "all up to date" shortcut. - public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() { // Seed two pages so they are already current, then register a third new page and publish all three. @@ -646,7 +602,6 @@ public function testMixedRunReportsPublishedAlongsideAlreadyCurrentPages() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } - // §5.7: accepting the interactive rebuild offer runs the build command; declining is covered elsewhere. // The command is driven directly (not through the console kernel) so that mocking the Artisan facade // intercepts only maybeRebuild's own build call, rather than the runner's call that dispatches the command. diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index a83edcfff6f..b3d5f96a151 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -12,11 +12,6 @@ use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\RuntimeException; -/** - * Covers the PublishCommand spine: the flag surface, all guardrails (§9), and the - * interactive wizard routing (§3). The views and pages handlers are stubs in this step, - * so these tests assert routing and guardrails, not real publishing. - */ #[CoversClass(PublishCommand::class)] class PublishCommandTest extends TestCase { @@ -26,7 +21,6 @@ class PublishCommandTest extends TestCase protected function tearDown(): void { - // The views-routing tests below publish real files; remove them so the tree stays clean. if (File::isDirectory(Hyde::path('resources/views/vendor'))) { File::deleteDirectory(Hyde::path('resources/views/vendor')); } @@ -42,8 +36,6 @@ protected function tearDown(): void parent::tearDown(); } - // Guardrails: raw tag/provider/config publishing is redirected to vendor:publish (§9). - public function testTagFlagIsRedirectedToVendorPublish() { $this->artisan('publish --tag=foo') @@ -72,8 +64,6 @@ public function testConfigFlagIsRedirectedToVendorPublish() ->assertExitCode(1); } - // Guardrails: the command's own flag combinations (§9). - public function testLayoutsAndComponentsAreMutuallyExclusive() { $this->artisan('publish --layouts --components') @@ -90,8 +80,6 @@ public function testToOptionRequiresThePageFlag() public function testToOptionRequiresANamedPageNotABarePageFlag() { - // --to names one destination, so it is only valid with a single named page (§5.4); a bare --page - // (multi-select) with --to is rejected rather than letting one path stand in for several pages. $this->artisan('publish --page --to=_pages/index.blade.php') ->expectsOutputToContain('--to is only valid when publishing a single page. Use --page=NAME with --to.') ->assertExitCode(1); @@ -107,9 +95,6 @@ public function testNonInteractiveWithNoActionableFlagsFailsWithUsageHint() ->assertExitCode(1); } - // Flag routing to the views handler. The full views behavior is covered in PublishCommandViewsTest; - // here we assert only that each flag actually reaches the real views publisher (routing coverage). - public function testLayoutsFlagRoutesToViews() { $this->artisan('publish --layouts --no-interaction') @@ -133,7 +118,6 @@ public function testAllFlagRoutesToViews() public function testBarePageFlagRoutesToPages() { - // A bare --page needs the interactive picker; non-interactively it reaches the pages flow and fails there. $this->artisan('publish --page --no-interaction') ->expectsOutputToContain('No page specified for publishing. Provide one, for example --page=welcome.') ->assertExitCode(1); @@ -148,14 +132,11 @@ public function testPageFlagWithEmptyValueFailsBeforeTheWizard() public function testPageFlagWithNameRoutesToPages() { - // An unknown name proves the flag routes into the pages flow and its registry lookup, without writing anything. $this->artisan('publish --page=nonexistent --no-interaction') ->expectsOutputToContain('The page [nonexistent] does not exist.') ->assertExitCode(1); } - // Interactive wizard routing (§3). - public function testWizardRoutesToViews() { $appLayout = (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde').'/framework/resources/views/layouts/app.blade.php'; @@ -200,9 +181,6 @@ public function testWizardCancelExitsCleanlyWithoutPublishing() ->assertExitCode(0); } - // Approach 1 must not swallow genuine mistakes: unknown options and stray arguments - // still hit Symfony's native errors rather than our redirect or a stub handler. - public function testUnknownOptionIsNotSwallowed() { // A typo for --layouts must surface Symfony's native error, not be eaten by our diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index d5dbf55e640..529b64ed197 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -19,18 +19,11 @@ use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -/** - * Covers the views publishing flow (§4) and the shared overwrite policy applied to views (§7): - * the grouped multi-select picker, --layouts/--components prefiltering, --all skipping the picker, - * cardinality-aware output, and the missing/identical/modified overwrite behavior with --force. - */ #[CoversClass(PublishCommand::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] class PublishCommandViewsTest extends TestCase { - // Non-interactive scope selection (a scoped group is exactly equivalent to adding --all). - public function testAllPublishesEveryView() { $count = $this->viewCount('layouts') + $this->viewCount('components'); @@ -67,8 +60,6 @@ public function testComponentsPublishesOnlyComponentsNonInteractively() $this->assertDirectoryDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts')); } - // Interactive picker selection (single / many / cross-group), with cardinality-aware output. - public function testPickerCanPublishASingleView() { $this->artisan('publish --layouts') @@ -148,8 +139,6 @@ public function exposeBaseDirectory(array $files): string ])); } - // The picker is prefiltered by the scope flag and uses group-prefixed labels with an "All views" row. - public function testLayoutsPickerIsPrefilteredWithGroupPrefixedLabels() { $output = $this->runViewsPicker(['--layouts' => true], [Key::SPACE, Key::ENTER]); @@ -184,8 +173,6 @@ public function testAllSentinelPreservesNumericOptionKeys() $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); } - // Overwrite policy (§7): missing -> copy, identical -> skip, modified -> confirm or --force. - public function testIdenticalViewsAreSkippedAsAlreadyCurrent() { $this->seedAllViews(); @@ -328,9 +315,6 @@ public function testInteractiveConflictPromptCanCancel() $this->assertSame('MODIFIED BY USER', File::get($target)); } - // §4 cardinality-aware output: a mixed run reports what was copied alongside what was already current, - // instead of collapsing to either the "Published all" or the "all up to date" shortcut. - public function testMixedRunReportsPublishedAlongsideAlreadyCurrentViews() { // Seed only the layouts so they are already current, then publish everything: components copy, layouts skip. From 4108bae5fa9858dcbbb2a305f0c421b89ccc2459 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 19:26:23 +0200 Subject: [PATCH 42/56] Refactor publish conflict resolution --- .../src/Console/Commands/PublishCommand.php | 3 +- .../src/Console/Helpers/BasePublisher.php | 188 ++++++++++++++++++ .../src/Console/Helpers/PagesPublisher.php | 130 ++---------- .../src/Console/Helpers/ViewsPublisher.php | 145 +++----------- .../Commands/PublishCommandPagesTest.php | 8 +- .../Commands/PublishCommandViewsTest.php | 8 +- 6 files changed, 239 insertions(+), 243 deletions(-) create mode 100644 packages/framework/src/Console/Helpers/BasePublisher.php diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index cfad401334b..8918663f692 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -123,8 +123,7 @@ protected function wantsToPublishPage(): bool protected function hasEmptyPageOption(): bool { - return $this->input->hasParameterOption('--page=') - || ($this->input->hasParameterOption('--page') && $this->input->getParameterOption('--page', false) === ''); + return $this->option('page') === ''; } protected function failWithUsageHint(): int diff --git a/packages/framework/src/Console/Helpers/BasePublisher.php b/packages/framework/src/Console/Helpers/BasePublisher.php new file mode 100644 index 00000000000..1a6be21edab --- /dev/null +++ b/packages/framework/src/Console/Helpers/BasePublisher.php @@ -0,0 +1,188 @@ + $copy + * @param array $overwrite + * @return array|null + */ + protected function refreshApprovedWrites(array $copy, array $overwrite): ?array + { + $this->writeRefreshFailed = false; + $written = []; + + foreach ($copy as $record) { + $record = $this->refreshApprovedWrite($record, false); + + if ($record === null) { + return null; + } + + if ($record !== []) { + $written[] = $record; + } + } + + foreach ($overwrite as $record) { + $record = $this->refreshApprovedWrite($record, true); + + if ($record === null) { + return null; + } + + if ($record !== []) { + $written[] = $record; + } + } + + return $written; + } + + /** + * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record + * @return array{source: string, target: string, absolute: string, destinationChecksum?: string}|array{}|null + */ + protected function refreshApprovedWrite(array $record, bool $approvedOverwrite): ?array + { + return match (OverwritePolicy::decide($record['source'], $record['absolute'])) { + OverwriteAction::Copy => $record, + OverwriteAction::Skip => $this->skipCurrentWrite($record), + OverwriteAction::Blocked => $this->handleStillBlockedWrite($record, $approvedOverwrite), + }; + } + + /** + * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record + * @return array{} + */ + protected function skipCurrentWrite(array $record): array + { + $this->noteCurrent($record); + + return []; + } + + /** + * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record + * @return array{source: string, target: string, absolute: string, destinationChecksum?: string}|null + */ + protected function handleStillBlockedWrite(array $record, bool $approvedOverwrite): ?array + { + if (! $approvedOverwrite || ($record['destinationChecksum'] ?? null) !== $this->destinationChecksum($record['absolute'])) { + $this->writeRefreshFailed = true; + $this->command->error(sprintf('Cannot publish: destination [%s] changed after overwrite checks. Run the command again.', $record['target'])); + + return null; + } + + return $record; + } + + /** + * @param array $blocked + * @return array|null + */ + protected function resolveBlocked(array $blocked): ?array + { + if ($blocked === []) { + return []; + } + + if ($this->command->option('force')) { + return $blocked; + } + + if (! $this->canPrompt()) { + $this->command->error('Cannot overwrite modified files without --force:'); + + foreach ($blocked as $record) { + $this->command->line(' '.$record['target']); + } + + $this->command->newLine(); + $this->command->line('Run again with --force to overwrite.'); + + return null; + } + + $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ + 'skip' => 'Skip modified files', + 'overwrite' => 'Overwrite modified files', + 'cancel' => 'Cancel', + ], 'skip'); + + return match ($choice) { + 'overwrite' => $blocked, + 'skip' => [], + default => $this->cancel(), + }; + } + + protected function cancel(): ?array + { + $this->command->infoComment($this->cancelledMessage()); + + return null; + } + + protected function copy(string $source, string $target): void + { + Filesystem::ensureParentDirectoryExists($target); + + if (! Filesystem::copy($source, $target)) { + throw new RuntimeException("Failed to copy [$source] to [$target]."); + } + } + + protected function destinationChecksum(string $target): string + { + return unixsum_file($target); + } + + protected function failedWriteRefresh(): bool + { + return $this->writeRefreshFailed; + } + + /** + * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record + */ + protected function noteCurrent(array $record): void + { + } + + protected function canPrompt(): bool + { + return ConsoleHelper::canUseLaravelPrompts($this->input); + } + + abstract protected function cancelledMessage(): string; +} diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 2e79cf28d9d..baa329e1a1c 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -6,18 +6,14 @@ use Hyde\Console\Concerns\Command; use Hyde\Enums\OverwriteAction; -use Hyde\Facades\Filesystem; use Hyde\Framework\Services\OverwritePolicy; use Hyde\Hyde; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use RuntimeException; -use Symfony\Component\Console\Input\InputInterface; use function array_map; use function count; -use function Hyde\unixsum_file; use function implode; use function preg_replace; use function sprintf; @@ -28,7 +24,7 @@ /** * @internal This helper is scoped to the publish command and should not be used elsewhere. */ -class PagesPublisher +class PagesPublisher extends BasePublisher { /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are _pages/ paths, so it never collides. */ protected const CUSTOM = '__hyde_custom_target__'; @@ -42,10 +38,6 @@ class PagesPublisher /** Whether the pages were chosen through the interactive picker (which adds a confirmation step). */ protected bool $usedPicker = false; - public function __construct(protected Command $command, protected InputInterface $input) - { - } - public function publish(): int { // --to names a single destination, so it is only meaningful for a single named page. A bare --page @@ -91,6 +83,10 @@ public function publish(): int // A null result means the run stopped after the decision but before any write: a non-interactive blocked // run without --force is a hard failure, while an interactive Cancel is a clean exit. if ($written === null) { + if ($this->failedWriteRefresh()) { + return Command::FAILURE; + } + return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; } @@ -335,7 +331,7 @@ protected function write(array $resolved): ?array if ($action === OverwriteAction::Copy) { $copy[] = $record; } elseif ($action === OverwriteAction::Skip) { - $this->current[] = $entry; + $this->noteCurrent($record); } else { $record['destinationChecksum'] = $this->destinationChecksum($record['absolute']); $blocked[] = $record; @@ -352,121 +348,28 @@ protected function write(array $resolved): ?array $written = $this->refreshApprovedWrites($copy, $overwrite); - foreach ($written as $record) { - $this->copy($record['source'], $record['absolute']); - } - - return $written; - } - - /** - * Re-check destinations immediately before copying so a file changed during an interactive prompt is not lost. - * - * @param array $copy - * @param array $overwrite - * @return array - */ - protected function refreshApprovedWrites(array $copy, array $overwrite): array - { - $written = []; - - foreach ($copy as $record) { - $this->refreshApprovedWrite($written, $record, false); + if ($written === null) { + return null; } - foreach ($overwrite as $record) { - $this->refreshApprovedWrite($written, $record, true); + foreach ($written as $record) { + $this->copy($record['source'], $record['absolute']); } return $written; } /** - * @param array $written - * @param array{page: PublishablePage, target: string, source: string, absolute: string, destinationChecksum?: string} $record - */ - protected function refreshApprovedWrite(array &$written, array $record, bool $approvedOverwrite): void - { - match (OverwritePolicy::decide($record['source'], $record['absolute'])) { - OverwriteAction::Copy => $written[] = $record, - OverwriteAction::Skip => $this->current[] = ['page' => $record['page'], 'target' => $record['target']], - OverwriteAction::Blocked => $this->handleStillBlockedWrite($written, $record, $approvedOverwrite), - }; - } - - /** - * @param array $written * @param array{page: PublishablePage, target: string, source: string, absolute: string, destinationChecksum?: string} $record */ - protected function handleStillBlockedWrite(array &$written, array $record, bool $approvedOverwrite): void + protected function noteCurrent(array $record): void { - if (! $approvedOverwrite || ($record['destinationChecksum'] ?? null) !== $this->destinationChecksum($record['absolute'])) { - throw new RuntimeException("Cannot publish: destination [{$record['target']}] changed after overwrite checks. Run the command again."); - } - - $written[] = $record; + $this->current[] = ['page' => $record['page'], 'target' => $record['target']]; } - protected function copy(string $source, string $target): void + protected function cancelledMessage(): string { - Filesystem::ensureParentDirectoryExists($target); - - if (! Filesystem::copy($source, $target)) { - throw new RuntimeException("Failed to copy [$source] to [$target]."); - } - } - - /** - * @param array $blocked - * @return array|null - */ - protected function resolveBlocked(array $blocked): ?array - { - if ($blocked === []) { - return []; - } - - if ($this->command->option('force')) { - return $blocked; - } - - if (! $this->canPrompt()) { - $this->command->error('Cannot overwrite modified files without --force:'); - - foreach ($blocked as $record) { - $this->command->line(' '.$record['target']); - } - - $this->command->newLine(); - $this->command->line('Run again with --force to overwrite.'); - - return null; - } - - $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ - 'skip' => 'Skip modified files', - 'overwrite' => 'Overwrite modified files', - 'cancel' => 'Cancel', - ], 'skip'); - - if ($choice === 'overwrite') { - return $blocked; - } - - if ($choice === 'skip') { - return []; - } - - // Cancelling the overwrite prompt aborts the whole run; announce it as the views flow and the - // "Proceed? no" path both do, so the exit is never silent. This branch is only reached interactively. - $this->command->infoComment('Cancelled. No pages were published.'); - - return null; - } - - protected function destinationChecksum(string $target): string - { - return unixsum_file($target); + return 'Cancelled. No pages were published.'; } /** @param array $written */ @@ -544,9 +447,4 @@ protected function pageCount(int $count): string { return $count === 1 ? '1 page' : "$count pages"; } - - protected function canPrompt(): bool - { - return ConsoleHelper::canUseLaravelPrompts($this->input); - } } diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 7cde32d2bbf..38a39c334ab 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -6,33 +6,24 @@ use Hyde\Console\Concerns\Command; use Hyde\Enums\OverwriteAction; -use Hyde\Facades\Filesystem; use Hyde\Framework\Services\OverwritePolicy; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use RuntimeException; -use Symfony\Component\Console\Input\InputInterface; use function array_keys; use function count; use function explode; -use function Hyde\unixsum_file; use function implode; use function reset; use function sprintf; -use function Laravel\Prompts\select; /** * @internal This helper is scoped to the publish command and should not be used elsewhere. */ -class ViewsPublisher +class ViewsPublisher extends BasePublisher { - /** @var array EOL-agnostic destination checksums captured when a file was first blocked. */ - protected array $blockedChecksums = []; - - public function __construct(protected Command $command, protected InputInterface $input) - { - } + /** @var array Files skipped because already up to date. */ + protected array $current = []; /** * Every file's outcome is decided, and conflicts resolved, before anything is written — so cancelling @@ -51,6 +42,7 @@ public function publish(): int } [$copy, $current, $blocked] = $this->decide($selected); + $this->current = $current; $overwrite = $this->resolveBlocked($blocked); @@ -60,65 +52,19 @@ public function publish(): int return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; } - [$published, $current] = $this->refreshApprovedWrites($copy, $current, $overwrite); + $written = $this->refreshApprovedWrites($copy, $overwrite); - foreach ($published as $source => $target) { - $this->copy($source, $target); + if ($written === null) { + return Command::FAILURE; } - return $this->report($published, $current, $overwrite === [] ? $blocked : [], count($offered)); - } - - /** - * Re-check destinations immediately before copying so a file changed during an interactive prompt is not lost. - * - * @param array $copy - * @param array $current - * @param array $overwrite - * @return array{0: array, 1: array} - */ - protected function refreshApprovedWrites(array $copy, array $current, array $overwrite): array - { - $published = []; - - foreach ($copy as $source => $target) { - $this->refreshApprovedWrite($published, $current, $source, $target, false); - } + $published = $this->recordsToMap($written); - foreach ($overwrite as $source => $target) { - $this->refreshApprovedWrite($published, $current, $source, $target, true); - } - - return [$published, $current]; - } - - /** @param array $published @param array $current */ - protected function refreshApprovedWrite(array &$published, array &$current, string $source, string $target, bool $approvedOverwrite): void - { - match (OverwritePolicy::decide($source, $target)) { - OverwriteAction::Copy => $published[$source] = $target, - OverwriteAction::Skip => $current[$source] = $target, - OverwriteAction::Blocked => $this->handleStillBlockedWrite($published, $source, $target, $approvedOverwrite), - }; - } - - /** @param array $published */ - protected function handleStillBlockedWrite(array &$published, string $source, string $target, bool $approvedOverwrite): void - { - if (! $approvedOverwrite || ($this->blockedChecksums[$source] ?? null) !== $this->destinationChecksum($target)) { - throw new RuntimeException("Cannot publish: destination [$target] changed after overwrite checks. Run the command again."); + foreach ($published as $source => $target) { + $this->copy($source, $target); } - $published[$source] = $target; - } - - protected function copy(string $source, string $target): void - { - Filesystem::ensureParentDirectoryExists($target); - - if (! Filesystem::copy($source, $target)) { - throw new RuntimeException("Failed to copy [$source] to [$target]."); - } + return $this->report($published, $this->current, $overwrite === [] ? $this->recordsToMap($blocked) : [], count($offered)); } /** @@ -177,7 +123,7 @@ protected function selectFiles(array $offered, array $labels): array /** * @param array $selected - * @return array{0: array, 1: array, 2: array} A tuple of [copy, already-current, blocked] maps, each source => target. + * @return array{0: array, 1: array, 2: array} A tuple of [copy records, already-current map, blocked records]. */ protected function decide(array $selected): array { @@ -187,70 +133,48 @@ protected function decide(array $selected): array foreach ($selected as $source => $target) { $action = OverwritePolicy::decide($source, $target); + $record = $this->record($source, $target); if ($action === OverwriteAction::Copy) { - $copy[$source] = $target; + $copy[] = $record; } elseif ($action === OverwriteAction::Skip) { $current[$source] = $target; } else { - $this->blockedChecksums[$source] = $this->destinationChecksum($target); - $blocked[$source] = $target; + $record['destinationChecksum'] = $this->destinationChecksum($target); + $blocked[] = $record; } } return [$copy, $current, $blocked]; } - protected function destinationChecksum(string $target): string + /** @return array{source: string, target: string, absolute: string} */ + protected function record(string $source, string $target): array { - return unixsum_file($target); + return ['source' => $source, 'target' => $target, 'absolute' => $target]; } - /** - * @param array $blocked - * @return array|null The blocked files to overwrite, or null when the run should stop (cancelled interactively, or blocked non-interactively without --force). - */ - protected function resolveBlocked(array $blocked): ?array + /** @param array $records */ + protected function recordsToMap(array $records): array { - if ($blocked === []) { - return []; - } + $map = []; - if ($this->command->option('force')) { - return $blocked; + foreach ($records as $record) { + $map[$record['source']] = $record['target']; } - if (! $this->canPrompt()) { - $this->command->error('Cannot overwrite modified files without --force:'); - - foreach ($blocked as $target) { - $this->command->line(' '.$target); - } - - $this->command->newLine(); - $this->command->line('Run again with --force to overwrite.'); - - return null; - } - - $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ - 'skip' => 'Skip modified files', - 'overwrite' => 'Overwrite modified files', - 'cancel' => 'Cancel', - ], 'skip'); - - return match ($choice) { - 'overwrite' => $blocked, - 'skip' => [], - default => $this->cancel(), - }; + return $map; } - protected function cancel(): ?array + /** @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record */ + protected function noteCurrent(array $record): void { - $this->command->infoComment('Cancelled. No views were published.'); + $this->current[$record['source']] = $record['target']; + } - return null; + protected function cancelledMessage(): string + { + return 'Cancelled. No views were published.'; } /** @@ -329,9 +253,4 @@ protected function baseDirectory(array $files): string return implode('/', $commonParts); } - - protected function canPrompt(): bool - { - return ConsoleHelper::canUseLaravelPrompts($this->input); - } } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index d0533f8031c..9f979773a3e 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -539,12 +539,8 @@ protected function resolveBlocked(array $blocked): ?array } }; - try { - $publisher->publish(); - $this->fail('The publisher should abort when an approved destination changes before copy.'); - } catch (\RuntimeException $exception) { - $this->assertSame('Cannot publish: destination [_pages/index.blade.php] changed after overwrite checks. Run the command again.', $exception->getMessage()); - } + $this->assertSame(1, $publisher->publish()); + $this->assertStringContainsString('Cannot publish: destination [_pages/index.blade.php] changed after overwrite checks. Run the command again.', $output->fetch()); $this->assertSame('MODIFIED AFTER PROMPT', File::get(Hyde::path('_pages/index.blade.php'))); } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 529b64ed197..9487e71ee79 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -279,12 +279,8 @@ protected function resolveBlocked(array $blocked): ?array } }; - try { - $publisher->publish(); - $this->fail('The publisher should abort when an approved destination changes before copy.'); - } catch (\RuntimeException $exception) { - $this->assertSame("Cannot publish: destination [$target] changed after overwrite checks. Run the command again.", $exception->getMessage()); - } + $this->assertSame(1, $publisher->publish()); + $this->assertStringContainsString("Cannot publish: destination [$target] changed after overwrite checks. Run the command again.", $output->fetch()); $this->assertSame('MODIFIED AFTER PROMPT', File::get(Hyde::path($target))); } From 12b883a96937ac6231041dbcd9f2ebf43aa49f13 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 20:35:19 +0200 Subject: [PATCH 43/56] Remove publish checksum revalidation --- .../src/Console/Helpers/BasePublisher.php | 105 +----------------- .../src/Console/Helpers/PagesPublisher.php | 18 +-- .../src/Console/Helpers/ViewsPublisher.php | 18 +-- .../Commands/PublishCommandPagesTest.php | 32 ------ .../Commands/PublishCommandViewsTest.php | 45 -------- 5 files changed, 11 insertions(+), 207 deletions(-) diff --git a/packages/framework/src/Console/Helpers/BasePublisher.php b/packages/framework/src/Console/Helpers/BasePublisher.php index 1a6be21edab..7622c3d7c00 100644 --- a/packages/framework/src/Console/Helpers/BasePublisher.php +++ b/packages/framework/src/Console/Helpers/BasePublisher.php @@ -5,14 +5,11 @@ namespace Hyde\Console\Helpers; use Hyde\Console\Concerns\Command; -use Hyde\Enums\OverwriteAction; use Hyde\Facades\Filesystem; -use Hyde\Framework\Services\OverwritePolicy; use RuntimeException; use Symfony\Component\Console\Input\InputInterface; use function count; -use function Hyde\unixsum_file; use function sprintf; use function Laravel\Prompts\select; @@ -21,94 +18,13 @@ */ abstract class BasePublisher { - protected bool $writeRefreshFailed = false; - public function __construct(protected Command $command, protected InputInterface $input) { } /** - * Re-check destinations immediately before copying so a file changed during an interactive prompt is not lost. - * - * @param array $copy - * @param array $overwrite - * @return array|null - */ - protected function refreshApprovedWrites(array $copy, array $overwrite): ?array - { - $this->writeRefreshFailed = false; - $written = []; - - foreach ($copy as $record) { - $record = $this->refreshApprovedWrite($record, false); - - if ($record === null) { - return null; - } - - if ($record !== []) { - $written[] = $record; - } - } - - foreach ($overwrite as $record) { - $record = $this->refreshApprovedWrite($record, true); - - if ($record === null) { - return null; - } - - if ($record !== []) { - $written[] = $record; - } - } - - return $written; - } - - /** - * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record - * @return array{source: string, target: string, absolute: string, destinationChecksum?: string}|array{}|null - */ - protected function refreshApprovedWrite(array $record, bool $approvedOverwrite): ?array - { - return match (OverwritePolicy::decide($record['source'], $record['absolute'])) { - OverwriteAction::Copy => $record, - OverwriteAction::Skip => $this->skipCurrentWrite($record), - OverwriteAction::Blocked => $this->handleStillBlockedWrite($record, $approvedOverwrite), - }; - } - - /** - * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record - * @return array{} - */ - protected function skipCurrentWrite(array $record): array - { - $this->noteCurrent($record); - - return []; - } - - /** - * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record - * @return array{source: string, target: string, absolute: string, destinationChecksum?: string}|null - */ - protected function handleStillBlockedWrite(array $record, bool $approvedOverwrite): ?array - { - if (! $approvedOverwrite || ($record['destinationChecksum'] ?? null) !== $this->destinationChecksum($record['absolute'])) { - $this->writeRefreshFailed = true; - $this->command->error(sprintf('Cannot publish: destination [%s] changed after overwrite checks. Run the command again.', $record['target'])); - - return null; - } - - return $record; - } - - /** - * @param array $blocked - * @return array|null + * @param array $blocked + * @return array|null */ protected function resolveBlocked(array $blocked): ?array { @@ -162,23 +78,6 @@ protected function copy(string $source, string $target): void } } - protected function destinationChecksum(string $target): string - { - return unixsum_file($target); - } - - protected function failedWriteRefresh(): bool - { - return $this->writeRefreshFailed; - } - - /** - * @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record - */ - protected function noteCurrent(array $record): void - { - } - protected function canPrompt(): bool { return ConsoleHelper::canUseLaravelPrompts($this->input); diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index baa329e1a1c..bf6fd6b5eda 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -12,6 +12,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; +use function array_merge; use function array_map; use function count; use function implode; @@ -83,10 +84,6 @@ public function publish(): int // A null result means the run stopped after the decision but before any write: a non-interactive blocked // run without --force is a hard failure, while an interactive Cancel is a clean exit. if ($written === null) { - if ($this->failedWriteRefresh()) { - return Command::FAILURE; - } - return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; } @@ -311,7 +308,7 @@ protected function confirmProceed(array $resolved): bool * Apply the shared overwrite policy and copy the resolved pages into place. * * @param array $resolved - * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). + * @return array|null The pages actually written, or null when the run should stop (cancelled, or blocked without --force). */ protected function write(array $resolved): ?array { @@ -333,7 +330,6 @@ protected function write(array $resolved): ?array } elseif ($action === OverwriteAction::Skip) { $this->noteCurrent($record); } else { - $record['destinationChecksum'] = $this->destinationChecksum($record['absolute']); $blocked[] = $record; } } @@ -346,11 +342,7 @@ protected function write(array $resolved): ?array $this->leftModified = $overwrite === [] ? array_map(fn (array $record): array => ['page' => $record['page'], 'target' => $record['target']], $blocked) : []; - $written = $this->refreshApprovedWrites($copy, $overwrite); - - if ($written === null) { - return null; - } + $written = array_merge($copy, $overwrite); foreach ($written as $record) { $this->copy($record['source'], $record['absolute']); @@ -360,7 +352,7 @@ protected function write(array $resolved): ?array } /** - * @param array{page: PublishablePage, target: string, source: string, absolute: string, destinationChecksum?: string} $record + * @param array{page: PublishablePage, target: string, source: string, absolute: string} $record */ protected function noteCurrent(array $record): void { @@ -372,7 +364,7 @@ protected function cancelledMessage(): string return 'Cancelled. No pages were published.'; } - /** @param array $written */ + /** @param array $written */ protected function report(array $written): void { if ($written === [] && $this->leftModified === [] && $this->current !== []) { diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 38a39c334ab..65f0315d246 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -11,6 +11,7 @@ use Illuminate\Support\Str; use function array_keys; +use function array_merge; use function count; use function explode; use function implode; @@ -52,11 +53,7 @@ public function publish(): int return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; } - $written = $this->refreshApprovedWrites($copy, $overwrite); - - if ($written === null) { - return Command::FAILURE; - } + $written = array_merge($copy, $overwrite); $published = $this->recordsToMap($written); @@ -123,7 +120,7 @@ protected function selectFiles(array $offered, array $labels): array /** * @param array $selected - * @return array{0: array, 1: array, 2: array} A tuple of [copy records, already-current map, blocked records]. + * @return array{0: array, 1: array, 2: array} A tuple of [copy records, already-current map, blocked records]. */ protected function decide(array $selected): array { @@ -140,7 +137,6 @@ protected function decide(array $selected): array } elseif ($action === OverwriteAction::Skip) { $current[$source] = $target; } else { - $record['destinationChecksum'] = $this->destinationChecksum($target); $blocked[] = $record; } } @@ -154,7 +150,7 @@ protected function record(string $source, string $target): array return ['source' => $source, 'target' => $target, 'absolute' => $target]; } - /** @param array $records */ + /** @param array $records */ protected function recordsToMap(array $records): array { $map = []; @@ -166,12 +162,6 @@ protected function recordsToMap(array $records): array return $map; } - /** @param array{source: string, target: string, absolute: string, destinationChecksum?: string} $record */ - protected function noteCurrent(array $record): void - { - $this->current[$record['source']] = $record['target']; - } - protected function cancelledMessage(): string { return 'Cancelled. No views were published.'; diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 9f979773a3e..34acb75d4cd 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -513,38 +513,6 @@ public function testInteractiveConflictPromptCanOverwriteAPage() $this->assertNotSame('MODIFIED BY USER', File::get(Hyde::path('_pages/index.blade.php'))); } - public function testApprovedOverwriteAbortsWhenDestinationChangesAfterPrompt() - { - File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BEFORE PROMPT'); - - $command = $this->app->make(PublishCommand::class); - $input = new ArrayInput([], $command->getDefinition()); - $output = new BufferedOutput(); - $command->setLaravel($this->app); - $command->setInput($input); - $command->setOutput(new OutputStyle($input, $output)); - - $publisher = new class($command, $input) extends PagesPublisher - { - protected function selectPages(): ?array - { - return [PublishablePages::get('welcome')]; - } - - protected function resolveBlocked(array $blocked): ?array - { - File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED AFTER PROMPT'); - - return $blocked; - } - }; - - $this->assertSame(1, $publisher->publish()); - $this->assertStringContainsString('Cannot publish: destination [_pages/index.blade.php] changed after overwrite checks. Run the command again.', $output->fetch()); - - $this->assertSame('MODIFIED AFTER PROMPT', File::get(Hyde::path('_pages/index.blade.php'))); - } - public function testInteractiveConflictPromptCanSkipAModifiedPage() { File::put(Hyde::path('_pages/index.blade.php'), 'MODIFIED BY USER'); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 9487e71ee79..a4d8c6c8a09 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -240,51 +240,6 @@ public function testInteractiveConflictPromptCanOverwrite() $this->assertNotSame('MODIFIED BY USER', File::get($target)); } - public function testApprovedOverwriteAbortsWhenDestinationChangesAfterPrompt() - { - $source = $this->source('layouts', 'app.blade.php'); - $target = 'resources/views/vendor/hyde/layouts/app.blade.php'; - File::ensureDirectoryExists(Hyde::path('resources/views/vendor/hyde/layouts')); - File::put(Hyde::path($target), 'MODIFIED BEFORE PROMPT'); - - $command = $this->app->make(PublishCommand::class); - $input = new ArrayInput([], $command->getDefinition()); - $output = new BufferedOutput(); - $command->setLaravel($this->app); - $command->setInput($input); - $command->setOutput(new OutputStyle($input, $output)); - - $publisher = new class($command, $input, $source, $target) extends ViewsPublisher - { - public function __construct($command, $input, protected string $source, protected string $target) - { - parent::__construct($command, $input); - } - - protected function collectOfferedFiles(): array - { - return [[$this->source => $this->target], [$this->source => 'layouts/app.blade.php']]; - } - - protected function selectFiles(array $offered, array $labels): array - { - return [$this->source]; - } - - protected function resolveBlocked(array $blocked): ?array - { - File::put(Hyde::path($this->target), 'MODIFIED AFTER PROMPT'); - - return $blocked; - } - }; - - $this->assertSame(1, $publisher->publish()); - $this->assertStringContainsString("Cannot publish: destination [$target] changed after overwrite checks. Run the command again.", $output->fetch()); - - $this->assertSame('MODIFIED AFTER PROMPT', File::get(Hyde::path($target))); - } - public function testInteractiveConflictPromptCanSkip() { $this->seedAllViews(); From 83cddf323de50deac982a66c66bea983d2802966 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 22:57:20 +0200 Subject: [PATCH 44/56] Update documented example --- _pages/index.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_pages/index.blade.php b/_pages/index.blade.php index ed3a8c6175a..dfbdf321fad 100644 --- a/_pages/index.blade.php +++ b/_pages/index.blade.php @@ -52,7 +52,7 @@ class="relative mt-2 text-transparent bg-clip-text bg-linear-to-br logo-gradient This is the default homepage stored as index.blade.php, however you can publish any of the built-in views using the following command: -
php hyde publish:homepage
+
php hyde publish --page

From ee36811ae765c22ac2288ede9e95237df1d0007c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 22:57:14 +0200 Subject: [PATCH 45/56] Fix fragile command parsing --- .../src/Console/Commands/PublishCommand.php | 12 +++++- .../src/Console/Helpers/PagesPublisher.php | 3 +- .../Feature/Commands/PublishCommandTest.php | 37 +++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 8918663f692..5c5040aabfe 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -11,6 +11,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use function array_key_exists; use function is_string; use function Laravel\Prompts\select; @@ -114,11 +115,11 @@ protected function wantsToPublishViews(): bool /** * The --page flag is value-optional, so a bare --page and an absent --page both read as null - * via option(). We check the raw input for its presence to tell the two apart. + * via option(). We check Symfony's parsed options for its presence to tell the two apart. */ protected function wantsToPublishPage(): bool { - return $this->input->hasParameterOption(['--page', '--page=']) || $this->option('page') !== null; + return $this->option('page') !== null || $this->optionWasProvided('page'); } protected function hasEmptyPageOption(): bool @@ -126,6 +127,13 @@ protected function hasEmptyPageOption(): bool return $this->option('page') === ''; } + protected function optionWasProvided(string $name): bool + { + return (function (string $name): bool { + return array_key_exists($name, $this->options); + })->call($this->input, $name); + } + protected function failWithUsageHint(): int { $this->error('Nothing to publish. Try:'); diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index bf6fd6b5eda..4f5a609a3e1 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -16,6 +16,7 @@ use function array_map; use function count; use function implode; +use function is_string; use function preg_replace; use function sprintf; use function Laravel\Prompts\confirm; @@ -414,7 +415,7 @@ protected function hasNamedPage(): bool { $name = $this->command->option('page'); - return $name !== null && $name !== ''; + return is_string($name) && $name !== ''; } /** Find a page by key, comparing the ->key property so a numeric key such as '404' is never lost to array coercion. */ diff --git a/packages/framework/tests/Feature/Commands/PublishCommandTest.php b/packages/framework/tests/Feature/Commands/PublishCommandTest.php index b3d5f96a151..c6c3978353b 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandTest.php @@ -7,6 +7,7 @@ use Hyde\Console\Commands\PublishCommand; use Hyde\Hyde; use Hyde\Testing\TestCase; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\File; use PHPUnit\Framework\Attributes\CoversClass; use Symfony\Component\Console\Exception\CommandNotFoundException; @@ -137,6 +138,42 @@ public function testPageFlagWithNameRoutesToPages() ->assertExitCode(1); } + public function testProgrammaticBarePageFlagRoutesToPages() + { + $this->assertSame(1, Artisan::call('publish', [ + '--page' => null, + '--no-interaction' => true, + ])); + + $this->assertStringContainsString( + 'No page specified for publishing. Provide one, for example --page=welcome.', + Artisan::output() + ); + } + + public function testProgrammaticEmptyPageFlagFailsBeforeTheWizard() + { + $this->assertSame(1, Artisan::call('publish', [ + '--page' => '', + '--no-interaction' => true, + ])); + + $this->assertStringContainsString( + 'The --page option cannot be empty. Use --page for the picker or --page=welcome.', + Artisan::output() + ); + } + + public function testProgrammaticPageNameRoutesToPages() + { + $this->assertSame(1, Artisan::call('publish', [ + '--page' => 'nonexistent', + '--no-interaction' => true, + ])); + + $this->assertStringContainsString('The page [nonexistent] does not exist.', Artisan::output()); + } + public function testWizardRoutesToViews() { $appLayout = (is_dir(Hyde::path('packages')) ? 'packages' : 'vendor/hyde').'/framework/resources/views/layouts/app.blade.php'; From bea60fb53ddbcd4564c4dc420fde211658423c9c Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 22:59:40 +0200 Subject: [PATCH 46/56] Fix page publish target normalization --- .../src/Console/Helpers/PagesPublisher.php | 18 ++++++++------- .../Commands/PublishCommandPagesTest.php | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 4f5a609a3e1..df7e2948edd 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -11,13 +11,14 @@ use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use League\Flysystem\FilesystemException; +use League\Flysystem\WhitespacePathNormalizer; use function array_merge; use function array_map; use function count; use function implode; use function is_string; -use function preg_replace; use function sprintf; use function Laravel\Prompts\confirm; use function Laravel\Prompts\select; @@ -243,29 +244,30 @@ protected function promptForCustomTarget(): ?string /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ protected function validateCustomTarget(string $path): ?string { - $normalized = $this->normalizeTargetPath($path); - - if (! $this->isValidCustomTarget($normalized)) { + if (! $this->isValidCustomTarget($path)) { $this->command->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); return null; } - return $normalized; + return $this->normalizeTargetPath($path); } protected function isValidCustomTarget(string $path): bool { - $normalized = $this->normalizeTargetPath($path); + try { + $normalized = $this->normalizeTargetPath($path); + } catch (FilesystemException) { + return false; + } return Str::startsWith($normalized, '_pages/') - && ! Str::contains($normalized, '..') && Str::endsWith($normalized, '.blade.php'); } protected function normalizeTargetPath(string $path): string { - return (string) preg_replace('#/+#', '/', Str::replace('\\', '/', $path)); + return (new WhitespacePathNormalizer())->normalizePath($path); } /** @param array $resolved */ diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 34acb75d4cd..949b1fd33d0 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -411,6 +411,19 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } + public function testCustomTargetWithSingleDotSegmentConflictsWithNormalizedDefaultTarget() + { + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['welcome', 'blank']) + ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') + ->expectsQuestion('Enter a path within _pages/', '_pages/./index.blade.php') + ->expectsOutputToContain('Welcome page and Blank page both target _pages/index.blade.php.') + ->expectsOutputToContain('Pick one, or set --to for each.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); + } + public function testRebuildIsOfferedInteractivelyAfterPublishing() { // Welcome resolves to its default without a destination prompt, so the only interaction is the rebuild offer. @@ -476,6 +489,15 @@ public function testToPathWithParentTraversalIsRejected() $this->assertFileDoesNotExist(Hyde::path('secret.blade.php')); } + public function testToPathWithTraversalBeforePagesDirectoryIsRejected() + { + $this->artisan('publish --page=welcome --to=../_pages/index.blade.php --no-interaction') + ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('../_pages/index.blade.php')); + } + public function testThreePagesResolvingToTheSameTargetReportAllTarget() { foreach (['clash-one' => 'Clash One', 'clash-two' => 'Clash Two'] as $key => $label) { From 4ecc67eaf0d8c2f7eb5fa8a57e1bd497efbffb58 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 23:04:26 +0200 Subject: [PATCH 47/56] Decouple publisher console interactions --- .../src/Console/Commands/PublishCommand.php | 5 +- .../src/Console/Helpers/BasePublisher.php | 66 +---------- .../src/Console/Helpers/PagesPublisher.php | 73 ++++++------ .../src/Console/Helpers/PublisherConsole.php | 110 ++++++++++++++++++ .../src/Console/Helpers/ViewsPublisher.php | 35 +++--- .../Commands/PublishCommandPagesTest.php | 3 +- .../Commands/PublishCommandViewsTest.php | 5 +- 7 files changed, 172 insertions(+), 125 deletions(-) create mode 100644 packages/framework/src/Console/Helpers/PublisherConsole.php diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 5c5040aabfe..71042ea06cf 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -6,6 +6,7 @@ use Hyde\Console\Concerns\Command; use Hyde\Console\Helpers\PagesPublisher; +use Hyde\Console\Helpers\PublisherConsole; use Hyde\Console\Helpers\ViewsPublisher; use Illuminate\Console\OutputStyle; use Symfony\Component\Console\Input\InputInterface; @@ -100,12 +101,12 @@ protected function runWizard(): int protected function publishViews(): int { - return (new ViewsPublisher($this, $this->input))->publish(); + return (new ViewsPublisher(new PublisherConsole($this, $this->input)))->publish(); } protected function publishPage(): int { - return (new PagesPublisher($this, $this->input))->publish(); + return (new PagesPublisher(new PublisherConsole($this, $this->input)))->publish(); } protected function wantsToPublishViews(): bool diff --git a/packages/framework/src/Console/Helpers/BasePublisher.php b/packages/framework/src/Console/Helpers/BasePublisher.php index 7622c3d7c00..c6f95c78163 100644 --- a/packages/framework/src/Console/Helpers/BasePublisher.php +++ b/packages/framework/src/Console/Helpers/BasePublisher.php @@ -4,71 +4,14 @@ namespace Hyde\Console\Helpers; -use Hyde\Console\Concerns\Command; use Hyde\Facades\Filesystem; use RuntimeException; -use Symfony\Component\Console\Input\InputInterface; - -use function count; -use function sprintf; -use function Laravel\Prompts\select; /** - * @internal Shared conflict resolution for publish command helpers. + * @internal Shared filesystem operations for publish command helpers. */ abstract class BasePublisher { - public function __construct(protected Command $command, protected InputInterface $input) - { - } - - /** - * @param array $blocked - * @return array|null - */ - protected function resolveBlocked(array $blocked): ?array - { - if ($blocked === []) { - return []; - } - - if ($this->command->option('force')) { - return $blocked; - } - - if (! $this->canPrompt()) { - $this->command->error('Cannot overwrite modified files without --force:'); - - foreach ($blocked as $record) { - $this->command->line(' '.$record['target']); - } - - $this->command->newLine(); - $this->command->line('Run again with --force to overwrite.'); - - return null; - } - - $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ - 'skip' => 'Skip modified files', - 'overwrite' => 'Overwrite modified files', - 'cancel' => 'Cancel', - ], 'skip'); - - return match ($choice) { - 'overwrite' => $blocked, - 'skip' => [], - default => $this->cancel(), - }; - } - - protected function cancel(): ?array - { - $this->command->infoComment($this->cancelledMessage()); - - return null; - } - protected function copy(string $source, string $target): void { Filesystem::ensureParentDirectoryExists($target); @@ -77,11 +20,4 @@ protected function copy(string $source, string $target): void throw new RuntimeException("Failed to copy [$source] to [$target]."); } } - - protected function canPrompt(): bool - { - return ConsoleHelper::canUseLaravelPrompts($this->input); - } - - abstract protected function cancelledMessage(): string; } diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index df7e2948edd..7bd52e064a9 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -41,12 +41,16 @@ class PagesPublisher extends BasePublisher /** Whether the pages were chosen through the interactive picker (which adds a confirmation step). */ protected bool $usedPicker = false; + public function __construct(protected PublisherConsole $console) + { + } + public function publish(): int { // --to names a single destination, so it is only meaningful for a single named page. A bare --page // (multi-select) with --to would have one path stand in for several pages; reject it rather than guess. - if ($this->command->option('to') !== null && ! $this->hasNamedPage()) { - $this->command->error('--to is only valid when publishing a single page. Use --page=NAME with --to.'); + if ($this->console->option('to') !== null && ! $this->hasNamedPage()) { + $this->console->error('--to is only valid when publishing a single page. Use --page=NAME with --to.'); return Command::FAILURE; } @@ -58,7 +62,7 @@ public function publish(): int } if ($pages === []) { - $this->command->infoComment('No pages selected; nothing to publish.'); + $this->console->infoComment('No pages selected; nothing to publish.'); return Command::SUCCESS; } @@ -76,7 +80,7 @@ public function publish(): int } if ($this->usedPicker && ! $this->confirmProceed($resolved)) { - $this->command->infoComment('Cancelled. No pages were published.'); + $this->console->infoComment('Cancelled. No pages were published.'); return Command::SUCCESS; } @@ -86,7 +90,7 @@ public function publish(): int // A null result means the run stopped after the decision but before any write: a non-interactive blocked // run without --force is a hard failure, while an interactive Cancel is a clean exit. if ($written === null) { - return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; + return $this->console->canPrompt() ? Command::SUCCESS : Command::FAILURE; } $this->report($written); @@ -106,12 +110,12 @@ public function publish(): int protected function selectPages(): ?array { if ($this->hasNamedPage()) { - $name = (string) $this->command->option('page'); + $name = (string) $this->console->option('page'); $page = $this->findPage($name); if ($page === null) { - $this->command->error("The page [$name] does not exist."); - $this->command->line('Available pages: '.implode(', ', array_map(fn (PublishablePage $page): string => $page->key, PublishablePages::all()))); + $this->console->error("The page [$name] does not exist."); + $this->console->line('Available pages: '.implode(', ', array_map(fn (PublishablePage $page): string => $page->key, PublishablePages::all()))); return null; } @@ -119,8 +123,8 @@ protected function selectPages(): ?array return [$page]; } - if (! $this->canPrompt()) { - $this->command->error('No page specified for publishing. Provide one, for example --page=welcome.'); + if (! $this->console->canPrompt()) { + $this->console->error('No page specified for publishing. Provide one, for example --page=welcome.'); return null; } @@ -177,19 +181,19 @@ protected function resolveDestinations(array $pages): ?array protected function resolveTarget(PublishablePage $page): ?string { - if ($this->command->option('to') !== null) { + if ($this->console->option('to') !== null) { if (! $page->allowCustomTarget) { - $this->command->error(sprintf('The [%s] page cannot be published to a custom path; omit --to to use its default (%s).', $page->key, $page->defaultTarget)); + $this->console->error(sprintf('The [%s] page cannot be published to a custom path; omit --to to use its default (%s).', $page->key, $page->defaultTarget)); return null; } - return $this->validateCustomTarget((string) $this->command->option('to')); + return $this->validateCustomTarget((string) $this->console->option('to')); } - if (! $this->canPrompt()) { + if (! $this->console->canPrompt()) { if ($page->defaultTarget === null) { - $this->command->error(sprintf('The [%s] page has no default destination. Provide one with --to.', $page->key)); + $this->console->error(sprintf('The [%s] page has no default destination. Provide one with --to.', $page->key)); return null; } @@ -245,7 +249,7 @@ protected function promptForCustomTarget(): ?string protected function validateCustomTarget(string $path): ?string { if (! $this->isValidCustomTarget($path)) { - $this->command->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); + $this->console->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); return null; } @@ -283,8 +287,8 @@ protected function assertNoDestinationConflicts(array $resolved): bool if (count($labels) > 1) { // "both" reads correctly for a pair; three or more colliding pages need "all". $verb = count($labels) === 2 ? 'both target' : 'all target'; - $this->command->error(sprintf('%s %s %s.', $this->joinLabels($labels), $verb, $target)); - $this->command->line('Pick one, or set --to for each.'); + $this->console->error(sprintf('%s %s %s.', $this->joinLabels($labels), $verb, $target)); + $this->console->line('Pick one, or set --to for each.'); return false; } @@ -296,13 +300,13 @@ protected function assertNoDestinationConflicts(array $resolved): bool /** @param array $resolved */ protected function confirmProceed(array $resolved): bool { - $this->command->line('Ready to publish:'); + $this->console->line('Ready to publish:'); foreach ($resolved as $entry) { - $this->command->line(sprintf(' %s → %s', $entry['page']->label, $entry['target'])); + $this->console->line(sprintf(' %s → %s', $entry['page']->label, $entry['target'])); } - $this->command->newLine(); + $this->console->newLine(); return confirm('Proceed?', true); } @@ -337,7 +341,7 @@ protected function write(array $resolved): ?array } } - $overwrite = $this->resolveBlocked($blocked); + $overwrite = $this->console->resolveBlocked($blocked, 'Cancelled. No pages were published.'); if ($overwrite === null) { return null; @@ -362,37 +366,32 @@ protected function noteCurrent(array $record): void $this->current[] = ['page' => $record['page'], 'target' => $record['target']]; } - protected function cancelledMessage(): string - { - return 'Cancelled. No pages were published.'; - } - /** @param array $written */ protected function report(array $written): void { if ($written === [] && $this->leftModified === [] && $this->current !== []) { - $this->command->infoComment('All selected pages are already up to date.'); + $this->console->infoComment('All selected pages are already up to date.'); return; } foreach ($written as $record) { - $this->command->infoComment(sprintf('Published [%s] to [%s]', $record['page']->key, $record['target'])); + $this->console->infoComment(sprintf('Published [%s] to [%s]', $record['page']->key, $record['target'])); } if ($this->current !== []) { - $this->command->infoComment(sprintf('%s already up to date and skipped.', $this->pageCount(count($this->current)))); + $this->console->infoComment(sprintf('%s already up to date and skipped.', $this->pageCount(count($this->current)))); } if ($this->leftModified !== []) { - $this->command->newLine(); - $this->command->warn(sprintf('%s left unchanged because they were modified:', $this->pageCount(count($this->leftModified)))); + $this->console->newLine(); + $this->console->warn(sprintf('%s left unchanged because they were modified:', $this->pageCount(count($this->leftModified)))); foreach ($this->leftModified as $entry) { - $this->command->line(' '.$entry['target']); + $this->console->line(' '.$entry['target']); } - $this->command->line('Run again with --force to overwrite.'); + $this->console->line('Run again with --force to overwrite.'); } } @@ -403,19 +402,19 @@ protected function report(array $written): void */ protected function maybeRebuild(): void { - if (! $this->canPrompt()) { + if (! $this->console->canPrompt()) { return; } if (confirm('Rebuild the site now?', false)) { - Artisan::call('build', [], $this->command->getOutput()); + Artisan::call('build', [], $this->console->getOutput()); } } /** Whether a specific page name was supplied via --page=NAME (as opposed to a bare --page or the wizard). */ protected function hasNamedPage(): bool { - $name = $this->command->option('page'); + $name = $this->console->option('page'); return is_string($name) && $name !== ''; } diff --git a/packages/framework/src/Console/Helpers/PublisherConsole.php b/packages/framework/src/Console/Helpers/PublisherConsole.php new file mode 100644 index 00000000000..a01d94fc4a6 --- /dev/null +++ b/packages/framework/src/Console/Helpers/PublisherConsole.php @@ -0,0 +1,110 @@ +command->option($key); + } + + public function canPrompt(): bool + { + return ConsoleHelper::canUseLaravelPrompts($this->input); + } + + public function error(string $message): void + { + $this->command->error($message); + } + + public function warn(string $message): void + { + $this->command->warn($message); + } + + public function line(string $message): void + { + $this->command->line($message); + } + + public function newLine(): void + { + $this->command->newLine(); + } + + public function infoComment(string $message): void + { + $this->command->infoComment($message); + } + + public function getOutput(): OutputStyle + { + return $this->command->getOutput(); + } + + /** + * @param array $blocked + * @return array|null + */ + public function resolveBlocked(array $blocked, string $cancelledMessage): ?array + { + if ($blocked === []) { + return []; + } + + if ($this->option('force')) { + return $blocked; + } + + if (! $this->canPrompt()) { + $this->error('Cannot overwrite modified files without --force:'); + + foreach ($blocked as $record) { + $this->line(' '.$record['target']); + } + + $this->newLine(); + $this->line('Run again with --force to overwrite.'); + + return null; + } + + $choice = select(sprintf('%d selected files already exist and appear modified.', count($blocked)), [ + 'skip' => 'Skip modified files', + 'overwrite' => 'Overwrite modified files', + 'cancel' => 'Cancel', + ], 'skip'); + + return match ($choice) { + 'overwrite' => $blocked, + 'skip' => [], + default => $this->cancel($cancelledMessage), + }; + } + + protected function cancel(string $message): ?array + { + $this->infoComment($message); + + return null; + } +} diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 65f0315d246..3500368c976 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -26,6 +26,10 @@ class ViewsPublisher extends BasePublisher /** @var array Files skipped because already up to date. */ protected array $current = []; + public function __construct(protected PublisherConsole $console) + { + } + /** * Every file's outcome is decided, and conflicts resolved, before anything is written — so cancelling * never leaves a half-published tree. @@ -37,7 +41,7 @@ public function publish(): int $selected = Arr::only($offered, $this->selectFiles($offered, $labels)); if ($selected === []) { - $this->command->infoComment('No views selected; nothing to publish.'); + $this->console->infoComment('No views selected; nothing to publish.'); return Command::SUCCESS; } @@ -45,12 +49,12 @@ public function publish(): int [$copy, $current, $blocked] = $this->decide($selected); $this->current = $current; - $overwrite = $this->resolveBlocked($blocked); + $overwrite = $this->console->resolveBlocked($blocked, 'Cancelled. No views were published.'); // A null resolution means the run stopped after the decision but before any write: a non-interactive // blocked run without --force is a hard failure, while an interactive Cancel is a clean exit. if ($overwrite === null) { - return $this->canPrompt() ? Command::SUCCESS : Command::FAILURE; + return $this->console->canPrompt() ? Command::SUCCESS : Command::FAILURE; } $written = array_merge($copy, $overwrite); @@ -90,11 +94,11 @@ protected function groups(): array 'components' => ViewPublishGroup::fromGroup('hyde-components'), ]; - if ($this->command->option('layouts')) { + if ($this->console->option('layouts')) { return Arr::only($groups, ['layouts']); } - if ($this->command->option('components')) { + if ($this->console->option('components')) { return Arr::only($groups, ['components']); } @@ -111,7 +115,7 @@ protected function selectFiles(array $offered, array $labels): array // --all skips the picker; so does a non-interactive run, where publishing a scoped group is // exactly equivalent to adding --all: one predictable rule, with OverwritePolicy still protecting // any modified files. - if ($this->command->option('all') || ! $this->canPrompt()) { + if ($this->console->option('all') || ! $this->console->canPrompt()) { return array_keys($offered); } @@ -162,11 +166,6 @@ protected function recordsToMap(array $records): array return $map; } - protected function cancelledMessage(): string - { - return 'Cancelled. No views were published.'; - } - /** * @param array $published The files actually written (source => target). * @param array $current The files skipped because already up to date. @@ -175,28 +174,28 @@ protected function cancelledMessage(): string protected function report(array $published, array $current, array $blocked, int $offeredTotal): int { if ($published === [] && $blocked === [] && $current !== []) { - $this->command->infoComment('All selected views are already up to date.'); + $this->console->infoComment('All selected views are already up to date.'); return Command::SUCCESS; } if ($published !== []) { - $this->command->infoComment($this->publishedLine($published, $offeredTotal)); + $this->console->infoComment($this->publishedLine($published, $offeredTotal)); } if ($current !== []) { - $this->command->infoComment(sprintf('%s already up to date and skipped.', $this->viewCount(count($current)))); + $this->console->infoComment(sprintf('%s already up to date and skipped.', $this->viewCount(count($current)))); } if ($blocked !== []) { - $this->command->newLine(); - $this->command->warn(sprintf('%s left unchanged because they were modified:', $this->viewCount(count($blocked)))); + $this->console->newLine(); + $this->console->warn(sprintf('%s left unchanged because they were modified:', $this->viewCount(count($blocked)))); foreach ($blocked as $target) { - $this->command->line(' '.$target); + $this->console->line(' '.$target); } - $this->command->line('Run again with --force to overwrite.'); + $this->console->line('Run again with --force to overwrite.'); } return Command::SUCCESS; diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index 949b1fd33d0..e9bd7ef8dce 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -7,6 +7,7 @@ use Hyde\Console\Commands\PublishCommand; use Hyde\Console\Helpers\ConsoleHelper; use Hyde\Console\Helpers\PagesPublisher; +use Hyde\Console\Helpers\PublisherConsole; use Hyde\Console\Helpers\PublishablePage; use Hyde\Console\Helpers\PublishablePages; use Hyde\Hyde; @@ -357,7 +358,7 @@ public function testEmptyPageSelectionExitsWithoutPublishing() $command->setInput($input); $command->setOutput(new OutputStyle($input, $output)); - $publisher = new class($command, $input) extends PagesPublisher + $publisher = new class(new PublisherConsole($command, $input)) extends PagesPublisher { protected function selectPages(): ?array { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index a4d8c6c8a09..6ead45f7316 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -7,6 +7,7 @@ use Hyde\Console\Commands\PublishCommand; use Hyde\Console\Helpers\ConsoleHelper; use Hyde\Console\Helpers\InteractiveMultiselect; +use Hyde\Console\Helpers\PublisherConsole; use Hyde\Console\Helpers\ViewsPublisher; use Hyde\Facades\Filesystem; use Hyde\Hyde; @@ -80,7 +81,7 @@ public function testEmptyViewSelectionExitsWithoutPublishing() $command->setInput($input); $command->setOutput(new OutputStyle($input, $output)); - $publisher = new class($command, $input) extends ViewsPublisher + $publisher = new class(new PublisherConsole($command, $input)) extends ViewsPublisher { protected function selectFiles(array $offered, array $labels): array { @@ -125,7 +126,7 @@ public function testBaseDirectoryIgnoresSharedSegmentsAfterPathsDiverge() { $command = $this->app->make(PublishCommand::class); $input = new ArrayInput([], $command->getDefinition()); - $publisher = new class($command, $input) extends ViewsPublisher + $publisher = new class(new PublisherConsole($command, $input)) extends ViewsPublisher { public function exposeBaseDirectory(array $files): string { From 0a3d248ad7c02db2af4df633f9e54e6e7a34ffb1 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 23:34:42 +0200 Subject: [PATCH 48/56] Handle publish overwrite policy errors gracefully --- .../src/Console/Helpers/BasePublisher.php | 32 +++++++++++++ .../src/Console/Helpers/PagesPublisher.php | 4 +- .../src/Console/Helpers/ViewsPublisher.php | 6 ++- .../framework/src/Enums/OverwriteAction.php | 3 ++ .../Framework/Services/OverwritePolicy.php | 11 ++--- .../Commands/PublishCommandPagesTest.php | 43 +++++++++++++++-- .../Commands/PublishCommandViewsTest.php | 48 +++++++++++++++++++ .../tests/Unit/OverwritePolicyTest.php | 20 ++++---- 8 files changed, 142 insertions(+), 25 deletions(-) diff --git a/packages/framework/src/Console/Helpers/BasePublisher.php b/packages/framework/src/Console/Helpers/BasePublisher.php index c6f95c78163..1479654f4da 100644 --- a/packages/framework/src/Console/Helpers/BasePublisher.php +++ b/packages/framework/src/Console/Helpers/BasePublisher.php @@ -12,6 +12,38 @@ */ abstract class BasePublisher { + protected int $policyErrors = 0; + + protected function reportPolicyError(PublisherConsole $console, string $source, string $target): void + { + $this->policyErrors++; + + if (! Filesystem::exists($source)) { + $console->error("Skipped [$target]: source file [$source] does not exist."); + + return; + } + + if (! Filesystem::isFile($source)) { + $console->error("Skipped [$target]: source [$source] is not a file."); + + return; + } + + if (Filesystem::isDirectory($target)) { + $console->error("Skipped [$target]: destination is a directory."); + + return; + } + + $console->error("Skipped [$target]: source or destination is invalid."); + } + + protected function hasPolicyErrors(): bool + { + return $this->policyErrors > 0; + } + protected function copy(string $source, string $target): void { Filesystem::ensureParentDirectoryExists($target); diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 7bd52e064a9..407baf77b1d 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -99,7 +99,7 @@ public function publish(): int $this->maybeRebuild(); } - return Command::SUCCESS; + return $this->hasPolicyErrors() ? Command::FAILURE : Command::SUCCESS; } /** @@ -336,6 +336,8 @@ protected function write(array $resolved): ?array $copy[] = $record; } elseif ($action === OverwriteAction::Skip) { $this->noteCurrent($record); + } elseif ($action === OverwriteAction::Error) { + $this->reportPolicyError($this->console, $record['source'], $record['absolute']); } else { $blocked[] = $record; } diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 3500368c976..6d7fcf3b273 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -65,7 +65,9 @@ public function publish(): int $this->copy($source, $target); } - return $this->report($published, $this->current, $overwrite === [] ? $this->recordsToMap($blocked) : [], count($offered)); + $status = $this->report($published, $this->current, $overwrite === [] ? $this->recordsToMap($blocked) : [], count($offered)); + + return $this->hasPolicyErrors() ? Command::FAILURE : $status; } /** @@ -140,6 +142,8 @@ protected function decide(array $selected): array $copy[] = $record; } elseif ($action === OverwriteAction::Skip) { $current[$source] = $target; + } elseif ($action === OverwriteAction::Error) { + $this->reportPolicyError($this->console, $source, $target); } else { $blocked[] = $record; } diff --git a/packages/framework/src/Enums/OverwriteAction.php b/packages/framework/src/Enums/OverwriteAction.php index bf3914bb737..364c1fb058f 100644 --- a/packages/framework/src/Enums/OverwriteAction.php +++ b/packages/framework/src/Enums/OverwriteAction.php @@ -20,4 +20,7 @@ enum OverwriteAction: string /** The destination exists and differs from the source (user-modified), so overwriting is blocked. */ case Blocked = 'blocked'; + + /** The source or destination state is invalid, so this item should be skipped with an error. */ + case Error = 'error'; } diff --git a/packages/framework/src/Framework/Services/OverwritePolicy.php b/packages/framework/src/Framework/Services/OverwritePolicy.php index d70450a6e91..185acf1b918 100644 --- a/packages/framework/src/Framework/Services/OverwritePolicy.php +++ b/packages/framework/src/Framework/Services/OverwritePolicy.php @@ -6,7 +6,6 @@ use Hyde\Enums\OverwriteAction; use Hyde\Facades\Filesystem; -use RuntimeException; use function Hyde\unixsum_file; @@ -18,12 +17,8 @@ class OverwritePolicy { public static function decide(string $source, string $destination): OverwriteAction { - if (! Filesystem::exists($source)) { - throw new RuntimeException("Cannot publish: source file [$source] does not exist."); - } - - if (! Filesystem::isFile($source)) { - throw new RuntimeException("Cannot publish: source [$source] is not a file."); + if (! Filesystem::exists($source) || ! Filesystem::isFile($source)) { + return OverwriteAction::Error; } if (! Filesystem::exists($destination)) { @@ -31,7 +26,7 @@ public static function decide(string $source, string $destination): OverwriteAct } if (Filesystem::isDirectory($destination)) { - throw new RuntimeException("Cannot publish: destination [$destination] is a directory."); + return OverwriteAction::Error; } if (static::filesMatch($source, $destination)) { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index e9bd7ef8dce..c58c78bccdf 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -198,21 +198,22 @@ public function testForceOverwritesModifiedPage() public function testPublishFailsWhenRegisteredPageSourceIsMissing() { $missingSource = 'resources/views/homepages/missing-source.blade.php'; + $target = '_pages/missing-source.blade.php'; PublishablePages::register(new PublishablePage( key: 'missing-source', label: 'Missing source page', description: 'A page registered by an extension with a missing source file.', source: $missingSource, - defaultTarget: '_pages/missing-source.blade.php', + defaultTarget: $target, )); $this->artisan('publish --page=missing-source --no-interaction') - ->expectsOutputToContain('Error: Cannot publish: source file ['.Hyde::vendorPath($missingSource).'] does not exist.') + ->expectsOutputToContain('Skipped ['.Hyde::path($target).']: source file ['.Hyde::vendorPath($missingSource).'] does not exist.') ->doesntExpectOutputToContain('Published') ->assertExitCode(1); - $this->assertFileDoesNotExist(Hyde::path('_pages/missing-source.blade.php')); + $this->assertFileDoesNotExist(Hyde::path($target)); } public function testPublishFailsWhenRegisteredPageSourceIsADirectory() @@ -228,7 +229,7 @@ public function testPublishFailsWhenRegisteredPageSourceIsADirectory() )); $this->artisan('publish --page=directory-source --no-interaction') - ->expectsOutputToContain('Error: Cannot publish: source ['.Hyde::vendorPath($directorySource).'] is not a file.') + ->expectsOutputToContain('Skipped ['.Hyde::path('_pages/directory-source.blade.php').']: source ['.Hyde::vendorPath($directorySource).'] is not a file.') ->doesntExpectOutputToContain('Published') ->assertExitCode(1); @@ -240,13 +241,45 @@ public function testPublishFailsWhenDestinationIsADirectory() File::makeDirectory(Hyde::path('_pages/index.blade.php')); $this->artisan('publish --page=welcome --no-interaction') - ->expectsOutputToContain('Error: Cannot publish: destination ['.Hyde::path('_pages/index.blade.php').'] is a directory.') + ->expectsOutputToContain('Skipped ['.Hyde::path('_pages/index.blade.php').']: destination is a directory.') ->doesntExpectOutputToContain('Published') ->assertExitCode(1); $this->assertDirectoryExists(Hyde::path('_pages/index.blade.php')); } + public function testInvalidRegisteredPageDoesNotStopValidPagesFromPublishing() + { + $missingSource = 'resources/views/homepages/missing-source.blade.php'; + + PublishablePages::register(new PublishablePage( + key: 'missing-source', + label: 'Missing source page', + description: 'A page registered by an extension with a missing source file.', + source: $missingSource, + defaultTarget: '_pages/missing-source.blade.php', + )); + + PublishablePages::register(new PublishablePage( + key: 'about', + label: 'About page', + description: 'A valid page in the same batch.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: '_pages/about.blade.php', + )); + + $this->artisan('publish --page') + ->expectsQuestion('Select pages to publish', ['missing-source', 'about']) + ->expectsConfirmation('Proceed?', 'yes') + ->expectsOutputToContain('Skipped ['.Hyde::path('_pages/missing-source.blade.php').']: source file ['.Hyde::vendorPath($missingSource).'] does not exist.') + ->expectsOutputToContain('Published [about] to [_pages/about.blade.php]') + ->expectsConfirmation('Rebuild the site now?', 'no') + ->assertExitCode(1); + + $this->assertFileDoesNotExist(Hyde::path('_pages/missing-source.blade.php')); + $this->assertFileExists(Hyde::path('_pages/about.blade.php')); + } + public function testCopyFailureFailsWithoutReportingSuccess() { app()->instance(\Illuminate\Filesystem\Filesystem::class, new class extends \Illuminate\Filesystem\Filesystem diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 6ead45f7316..f372e1d0fed 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -228,6 +228,54 @@ public function copy($path, $target): bool $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); } + public function testInvalidViewDoesNotStopValidViewsFromPublishing() + { + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $validSource = $this->source('layouts', 'app.blade.php'); + $missingSource = $this->source('layouts', 'missing-view.blade.php'); + + $publisher = new class(new PublisherConsole($command, $input), $validSource, $missingSource) extends ViewsPublisher + { + public function __construct(PublisherConsole $console, protected string $validSource, protected string $missingSource) + { + parent::__construct($console); + } + + protected function collectOfferedFiles(): array + { + return [ + [ + $this->missingSource => 'resources/views/vendor/hyde/layouts/missing-view.blade.php', + $this->validSource => 'resources/views/vendor/hyde/layouts/app.blade.php', + ], + [ + $this->missingSource => 'layouts/missing-view.blade.php', + $this->validSource => 'layouts/app.blade.php', + ], + ]; + } + + protected function selectFiles(array $offered, array $labels): array + { + return array_keys($offered); + } + }; + + $this->assertSame(1, $publisher->publish()); + + $contents = $output->fetch(); + $this->assertStringContainsString('Skipped [resources/views/vendor/hyde/layouts/missing-view.blade.php]: source file ['.$missingSource.'] does not exist.', $contents); + $this->assertStringContainsString('Published 1 view to [resources/views/vendor/hyde/layouts/app.blade.php]', $contents); + $this->assertFileDoesNotExist(Hyde::path('resources/views/vendor/hyde/layouts/missing-view.blade.php')); + $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); + } + public function testInteractiveConflictPromptCanOverwrite() { $this->seedAllViews(); diff --git a/packages/framework/tests/Unit/OverwritePolicyTest.php b/packages/framework/tests/Unit/OverwritePolicyTest.php index 4bd4a376b6a..4bd0ae43d12 100644 --- a/packages/framework/tests/Unit/OverwritePolicyTest.php +++ b/packages/framework/tests/Unit/OverwritePolicyTest.php @@ -8,7 +8,6 @@ use Hyde\Testing\UnitTestCase; use Hyde\Enums\OverwriteAction; use Hyde\Framework\Services\OverwritePolicy; -use RuntimeException; use function file_put_contents; use function is_dir; @@ -78,25 +77,26 @@ public function testDecidesToSkipWhenFilesDifferOnlyByLineEndings() $this->assertSame(OverwriteAction::Skip, OverwritePolicy::decide($this->source, $this->destination)); } - public function testThrowsCleanExceptionWhenSourceFileIsMissing() + public function testReturnsErrorWhenSourceFileIsMissing() { $this->putDestination('Existing destination'); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Cannot publish: source file [$this->source] does not exist."); + $this->assertSame(OverwriteAction::Error, OverwritePolicy::decide($this->source, $this->destination)); + } + + public function testReturnsErrorWhenSourceIsADirectory() + { + mkdir(Hyde::path($this->source)); - OverwritePolicy::decide($this->source, $this->destination); + $this->assertSame(OverwriteAction::Error, OverwritePolicy::decide($this->source, $this->destination)); } - public function testThrowsCleanExceptionWhenDestinationIsADirectory() + public function testReturnsErrorWhenDestinationIsADirectory() { $this->putSource('Hello world'); mkdir(Hyde::path($this->destination)); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage("Cannot publish: destination [$this->destination] is a directory."); - - OverwritePolicy::decide($this->source, $this->destination); + $this->assertSame(OverwriteAction::Error, OverwritePolicy::decide($this->source, $this->destination)); } protected function putSource(string $contents): void From fd2011b86ca64d24ea1a81297005a2a29549a02a Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 23:48:12 +0200 Subject: [PATCH 49/56] Fix publish option presence check --- packages/framework/src/Console/Commands/PublishCommand.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/framework/src/Console/Commands/PublishCommand.php b/packages/framework/src/Console/Commands/PublishCommand.php index 71042ea06cf..10f17200d4f 100644 --- a/packages/framework/src/Console/Commands/PublishCommand.php +++ b/packages/framework/src/Console/Commands/PublishCommand.php @@ -12,7 +12,6 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function array_key_exists; use function is_string; use function Laravel\Prompts\select; @@ -130,9 +129,7 @@ protected function hasEmptyPageOption(): bool protected function optionWasProvided(string $name): bool { - return (function (string $name): bool { - return array_key_exists($name, $this->options); - })->call($this->input, $name); + return $this->input->hasParameterOption("--$name"); } protected function failWithUsageHint(): int From 694ffbee29585f923d2946c869c16025d4237923 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Sun, 5 Jul 2026 23:50:46 +0200 Subject: [PATCH 50/56] Remove legacy publish alias plan --- new-publish-command-implementation-plan.md | 27 +++++----------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/new-publish-command-implementation-plan.md b/new-publish-command-implementation-plan.md index dc442c1d3ff..31e61d4ea7f 100644 --- a/new-publish-command-implementation-plan.md +++ b/new-publish-command-implementation-plan.md @@ -134,37 +134,22 @@ Hand the agent a single step + the spec — never the whole plan. six files. Suite green. - **Spec ref:** §6. -## Step 7 — Deprecated aliases - -- **Goal:** keep the old commands working as thin, notice-printing delegators. -- **In scope:** convert `publish:views`, `publish:configs`, `publish:homepage` into - aliases that print a one-line deprecation notice and delegate per §8 - (`publish:views [group]` → `publish --layouts`/`--components`; `publish:configs` → - `vendor:publish --tag=hyde-config`; `publish:homepage [template]` → `publish --page=[template]`). -- **Out of scope:** deleting the old commands (they stay through v3); changing new - command behavior; docs. -- **Files:** the three old command classes (now delegating) + alias tests. -- **Depends on:** Steps 4, 5, 6. -- **Done when:** tests assert each alias prints its notice and routes to the new path. - Suite green. -- **Spec ref:** §8. - -## Step 8 — Docs cleanup +## Step 7 — Docs cleanup - **Goal:** align the docs with the new command surface. - **In scope:** fix the nonexistent `php hyde publish:components` reference in `docs/digging-deeper/advanced-markdown.md` (→ `php hyde publish --components`); rewrite the publishing docs around `php hyde publish` (views + `--page`) and `php hyde vendor:publish --tag=hyde-config`; add a short migration note listing the - deprecated aliases. + removed legacy commands and their replacements. - **Out of scope:** any code changes. Docs only. - **Files:** the affected docs pages only. -- **Depends on:** Steps 4–7. -- **Done when:** no doc references a nonexistent command; the deprecated aliases appear - only in the migration note, not the primary flow. +- **Depends on:** Steps 4–6. +- **Done when:** no doc references a nonexistent command; the removed legacy commands + appear only in the migration note, not the primary flow. - **Spec ref:** §12, §8. -## Step 9 (optional) — Acceptance sweep +## Step 8 (optional) — Acceptance sweep - **Goal:** verify nothing drifted across steps. - **In scope:** walk §11 criteria 1–16 one by one; for any not already covered by an From 5de5e76b102fdd6a1f8f9cfd28daba81a7d1b88f Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 6 Jul 2026 00:15:43 +0200 Subject: [PATCH 51/56] Cleanup the code --- .../framework/src/Console/Helpers/InteractiveMultiselect.php | 2 +- packages/framework/src/Console/Helpers/PagesPublisher.php | 2 +- packages/framework/src/Console/Helpers/ViewsPublisher.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index 6ccf13977a9..f70bfa47705 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -33,7 +33,7 @@ public static function select(string $label, array $options, ?string $allLabel = $prompt = new MultiSelectPrompt($label, $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.'); - $selected = (array) $prompt->prompt(); + $selected = $prompt->prompt(); // Selecting the sentinel means "everything", regardless of which other rows were checked. if (in_array(self::ALL, $selected, true)) { diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index 407baf77b1d..c288428cbcf 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -441,6 +441,6 @@ protected function joinLabels(array $labels): string protected function pageCount(int $count): string { - return $count === 1 ? '1 page' : "$count pages"; + return "$count ".Str::plural('page', $count); } } diff --git a/packages/framework/src/Console/Helpers/ViewsPublisher.php b/packages/framework/src/Console/Helpers/ViewsPublisher.php index 6d7fcf3b273..22790a262da 100644 --- a/packages/framework/src/Console/Helpers/ViewsPublisher.php +++ b/packages/framework/src/Console/Helpers/ViewsPublisher.php @@ -225,7 +225,7 @@ protected function publishedLine(array $published, int $offeredTotal): string protected function viewCount(int $count): string { - return $count === 1 ? '1 view' : "$count views"; + return "$count ".Str::plural('view', $count); } /** Find the most specific common parent directory shared by the given files' target paths. */ From 44617191719fd50b29300bf19597dcb79b5935c9 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Mon, 6 Jul 2026 05:23:21 +0200 Subject: [PATCH 52/56] Add missing test --- .../Commands/PublishCommandPagesTest.php | 1 + .../Commands/PublishCommandViewsTest.php | 1 + .../Feature/Commands/PublisherConsoleTest.php | 141 ++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 packages/framework/tests/Feature/Commands/PublisherConsoleTest.php diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index c58c78bccdf..f9688c18167 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -24,6 +24,7 @@ use function glob; #[CoversClass(PublishCommand::class)] +#[CoversClass(PublisherConsole::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase { diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index f372e1d0fed..55abd2764f9 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -21,6 +21,7 @@ use Symfony\Component\Console\Output\BufferedOutput; #[CoversClass(PublishCommand::class)] +#[CoversClass(PublisherConsole::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] class PublishCommandViewsTest extends TestCase diff --git a/packages/framework/tests/Feature/Commands/PublisherConsoleTest.php b/packages/framework/tests/Feature/Commands/PublisherConsoleTest.php new file mode 100644 index 00000000000..fc5ea83f758 --- /dev/null +++ b/packages/framework/tests/Feature/Commands/PublisherConsoleTest.php @@ -0,0 +1,141 @@ +makeConsole(); + + $this->assertSame([], $console->resolveBlocked([], 'Cancelled. No files were published.')); + $this->assertSame('', $output->fetch()); + } + + public function testForceAllowsModifiedFilesToBeOverwrittenWithoutPrompting(): void + { + $blocked = [$this->blockedFile('resources/views/vendor/hyde/layouts/app.blade.php')]; + [$console, $output] = $this->makeConsole(['--force' => true]); + + $this->assertSame($blocked, $console->resolveBlocked($blocked, 'Cancelled. No files were published.')); + $this->assertSame('', $output->fetch()); + } + + public function testNonInteractiveRunRefusesToOverwriteModifiedFilesWithoutForce(): void + { + $blocked = [ + $this->blockedFile('resources/views/vendor/hyde/layouts/app.blade.php'), + $this->blockedFile('_pages/index.blade.php'), + ]; + [$console, $output] = $this->makeConsole(); + + $this->assertNull($console->resolveBlocked($blocked, 'Cancelled. No files were published.')); + + $contents = $output->fetch(); + $this->assertStringContainsString('Cannot overwrite modified files without --force:', $contents); + $this->assertStringContainsString('resources/views/vendor/hyde/layouts/app.blade.php', $contents); + $this->assertStringContainsString('_pages/index.blade.php', $contents); + $this->assertStringContainsString('Run again with --force to overwrite.', $contents); + } + + public function testInteractiveRunCanSkipModifiedFiles(): void + { + $blocked = [$this->blockedFile('_pages/index.blade.php')]; + [$console, $output] = $this->makeInteractiveConsole([Key::ENTER]); + + $this->assertSame([], $console->resolveBlocked($blocked, 'Cancelled. No pages were published.')); + $this->assertSame('', $output->fetch()); + + Prompt::assertOutputContains('1 selected files already exist and appear modified.'); + Prompt::assertOutputContains('Skip modified files'); + } + + public function testInteractiveRunCanOverwriteModifiedFiles(): void + { + $blocked = [$this->blockedFile('resources/views/vendor/hyde/layouts/app.blade.php')]; + [$console, $output] = $this->makeInteractiveConsole([Key::DOWN, Key::ENTER]); + + $this->assertSame($blocked, $console->resolveBlocked($blocked, 'Cancelled. No views were published.')); + $this->assertSame('', $output->fetch()); + } + + public function testInteractiveRunCanCancelBeforeOverwritingModifiedFiles(): void + { + $blocked = [$this->blockedFile('_pages/index.blade.php')]; + [$console, $output] = $this->makeInteractiveConsole([Key::DOWN, Key::DOWN, Key::ENTER]); + + $this->assertNull($console->resolveBlocked($blocked, 'Cancelled. No pages were published.')); + $this->assertStringContainsString('Cancelled. No pages were published.', $output->fetch()); + } + + /** + * @param array $options + * @return array{PublisherConsole, BufferedOutput} + */ + protected function makeConsole(array $options = [], bool $interactive = false): array + { + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput($options, $command->getDefinition()); + $input->setInteractive($interactive); + $output = new BufferedOutput(); + + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + return [new PublisherConsole($command, $input), $output]; + } + + /** + * @param array $keys + * @return array{PublisherConsole, BufferedOutput} + */ + protected function makeInteractiveConsole(array $keys): array + { + ConsoleHelper::mockWindowsOs(false); + PublisherConsolePromptReset::resetFallbacks(); + Prompt::fake($keys); + + return $this->makeConsole(interactive: true); + } + + /** @return array{source: string, target: string, absolute: string} */ + protected function blockedFile(string $target): array + { + return [ + 'source' => 'vendor/hyde/framework/resources/views/source.blade.php', + 'target' => $target, + 'absolute' => base_path($target), + ]; + } +} + +abstract class PublisherConsolePromptReset extends Prompt +{ + public static function resetFallbacks(): void + { + static::$shouldFallback = false; + } +} From 7fdf2c0c6c6110b574570a6c845ff38594954592 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 7 Jul 2026 01:18:37 +0200 Subject: [PATCH 53/56] Test nested page publishing --- .../Commands/PublishCommandPagesTest.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index f9688c18167..d5cc64f5073 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -5,6 +5,7 @@ namespace Hyde\Framework\Testing\Feature\Commands; use Hyde\Console\Commands\PublishCommand; +use Hyde\Console\Helpers\BasePublisher; use Hyde\Console\Helpers\ConsoleHelper; use Hyde\Console\Helpers\PagesPublisher; use Hyde\Console\Helpers\PublisherConsole; @@ -24,6 +25,7 @@ use function glob; #[CoversClass(PublishCommand::class)] +#[CoversClass(BasePublisher::class)] #[CoversClass(PublisherConsole::class)] #[CoversClass(PagesPublisher::class)] class PublishCommandPagesTest extends TestCase @@ -51,6 +53,10 @@ protected function tearDown(): void } } + if (File::isDirectory(Hyde::path('_pages/company'))) { + File::deleteDirectory(Hyde::path('_pages/company')); + } + $this->restoreDefaultPages(); parent::tearDown(); @@ -128,6 +134,21 @@ public function testPageWithoutDefaultTargetPublishesWithTo() $this->assertFileExists(Hyde::path('_pages/about.blade.php')); } + public function testPageCanBePublishedToNestedCustomTarget() + { + $target = '_pages/company/about.blade.php'; + + $this->artisan("publish --page=blank --to=$target --no-interaction") + ->expectsOutputToContain("Published [blank] to [$target]") + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path($target)); + $this->assertSame( + File::get(Hyde::vendorPath('resources/views/homepages/blank.blade.php')), + File::get(Hyde::path($target)) + ); + } + public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') From 8c4f65e52ec0390e8bb3a0e52e11453958cdb2c0 Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 7 Jul 2026 01:38:09 +0200 Subject: [PATCH 54/56] Test generic publish policy error --- .../Commands/PublishCommandViewsTest.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 55abd2764f9..4991b8e5c79 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -5,6 +5,7 @@ namespace Hyde\Framework\Testing\Feature\Commands; use Hyde\Console\Commands\PublishCommand; +use Hyde\Console\Helpers\BasePublisher; use Hyde\Console\Helpers\ConsoleHelper; use Hyde\Console\Helpers\InteractiveMultiselect; use Hyde\Console\Helpers\PublisherConsole; @@ -21,6 +22,7 @@ use Symfony\Component\Console\Output\BufferedOutput; #[CoversClass(PublishCommand::class)] +#[CoversClass(BasePublisher::class)] #[CoversClass(PublisherConsole::class)] #[CoversClass(ViewsPublisher::class)] #[CoversClass(\Hyde\Console\Helpers\InteractiveMultiselect::class)] @@ -277,6 +279,51 @@ protected function selectFiles(array $offered, array $labels): array $this->assertFileExists(Hyde::path('resources/views/vendor/hyde/layouts/app.blade.php')); } + public function testUnclassifiedInvalidViewStateIsReported() + { + $command = $this->app->make(PublishCommand::class); + $input = new ArrayInput([], $command->getDefinition()); + $output = new BufferedOutput(); + $command->setLaravel($this->app); + $command->setInput($input); + $command->setOutput(new OutputStyle($input, $output)); + + $source = $this->source('layouts', 'app.blade.php'); + $target = 'resources/views/vendor/hyde/layouts/app.blade.php'; + + $publisher = new class(new PublisherConsole($command, $input), $source, $target) extends ViewsPublisher + { + public function __construct(PublisherConsole $console, protected string $source, protected string $target) + { + parent::__construct($console); + } + + protected function collectOfferedFiles(): array + { + return [[$this->source => $this->target], [$this->source => 'layouts/app.blade.php']]; + } + + protected function selectFiles(array $offered, array $labels): array + { + return array_keys($offered); + } + + protected function decide(array $selected): array + { + $this->reportPolicyError($this->console, $this->source, $this->target); + + return [[], [], []]; + } + }; + + $this->assertSame(1, $publisher->publish()); + + $this->assertStringContainsString( + 'Skipped [resources/views/vendor/hyde/layouts/app.blade.php]: source or destination is invalid.', + $output->fetch() + ); + } + public function testInteractiveConflictPromptCanOverwrite() { $this->seedAllViews(); From 1f8fcd335d508b88bff7c1c984063d3791e2160e Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 7 Jul 2026 15:12:57 +0200 Subject: [PATCH 55/56] Improve publishable paths handling --- new-publish-command-spec.md | 10 ++- .../src/Console/Helpers/PagesPublisher.php | 87 +++++++++++++++---- .../src/Console/Helpers/PublishablePage.php | 5 ++ .../src/Console/Helpers/PublishablePages.php | 10 ++- .../Commands/PublishCommandPagesTest.php | 83 +++++++++++++----- .../tests/Unit/PublishablePageTest.php | 5 +- .../tests/Unit/PublishablePagesTest.php | 16 ++++ 7 files changed, 172 insertions(+), 44 deletions(-) diff --git a/new-publish-command-spec.md b/new-publish-command-spec.md index af6c428e0b0..07224b0d93d 100644 --- a/new-publish-command-spec.md +++ b/new-publish-command-spec.md @@ -161,6 +161,8 @@ final class PublishablePage /** @var array path => human label */ public array $alternativeTargets = [], public bool $allowCustomTarget = true, + /** @var class-string */ + public string $pageClass = BladePage::class, // controls custom target validation ) {} } @@ -209,8 +211,10 @@ php hyde publish --page=welcome --force one path cannot stand in as the destination for several pages. `--to` is also rejected for a page that declares `allowCustomTarget = false` (e.g. `404`), which has one fixed destination. -1. `--to=PATH` → use it. Rejected if the page disallows custom targets; otherwise must resolve - under `_pages/` and end in `.blade.php`, else fail. +1. `--to=PATH` → use it. Rejected if the page disallows custom targets; otherwise it must resolve + to a source file for the publishable page's configured `pageClass`. For the default catalog this + means a `BladePage` source path such as `_pages/index.blade.php`, but custom `source_root`, + `source_directories`, file extensions, and extended page classes are respected. 2. Non-interactive, no `--to` → use `defaultTarget`. If `defaultTarget` is null (e.g. `blank`), there is nothing to fall back to → fail helpfully, pointing to `--to`. 3. Interactive AND (`alternativeTargets` non-empty OR `defaultTarget` is null) → prompt. `allowCustomTarget` @@ -353,7 +357,7 @@ migration reference. - `publish --config` → redirect to `vendor:publish --tag=hyde-config`. - `--layouts` + `--components` together → mutually exclusive error. - `--to` without `--page` → `--to is only valid when publishing a page.` -- `--to` path outside `_pages/` or wrong extension → fail with a valid example. +- `--to` path outside the selected publishable page class's source directory or with the wrong extension → fail with a valid example. - Non-interactive with no actionable flags → fail with usage examples. --- diff --git a/packages/framework/src/Console/Helpers/PagesPublisher.php b/packages/framework/src/Console/Helpers/PagesPublisher.php index c288428cbcf..de187682380 100644 --- a/packages/framework/src/Console/Helpers/PagesPublisher.php +++ b/packages/framework/src/Console/Helpers/PagesPublisher.php @@ -8,6 +8,7 @@ use Hyde\Enums\OverwriteAction; use Hyde\Framework\Services\OverwritePolicy; use Hyde\Hyde; +use Hyde\Pages\Concerns\HydePage; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -29,7 +30,7 @@ */ class PagesPublisher extends BasePublisher { - /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are _pages/ paths, so it never collides. */ + /** Sentinel key for the "Custom path…" row in the destination prompt; real targets are source paths, so it never collides. */ protected const CUSTOM = '__hyde_custom_target__'; /** @var array Pages skipped because the destination is already up to date. */ @@ -188,7 +189,7 @@ protected function resolveTarget(PublishablePage $page): ?string return null; } - return $this->validateCustomTarget((string) $this->console->option('to')); + return $this->validateCustomTarget((string) $this->console->option('to'), $page); } if (! $this->console->canPrompt()) { @@ -228,28 +229,28 @@ protected function promptForTarget(PublishablePage $page): ?string $choice = (string) select(sprintf('Where should "%s" be published?', $page->label), $options); - return $choice === self::CUSTOM ? $this->promptForCustomTarget() : $choice; + return $choice === self::CUSTOM ? $this->promptForCustomTarget($page) : $choice; } - protected function promptForCustomTarget(): ?string + protected function promptForCustomTarget(PublishablePage $page): ?string { $path = text( - label: 'Enter a path within _pages/', - placeholder: '_pages/example.blade.php', + label: sprintf('Enter a %s source path', class_basename($page->pageClass)), + placeholder: $this->exampleTargetPath($page), required: true, - validate: fn (string $value): ?string => $this->isValidCustomTarget($value) + validate: fn (string $value): ?string => $this->isValidCustomTarget($value, $page) ? null - : 'The path must be within _pages/ and end in .blade.php.' + : $this->customTargetPromptError($page) ); return $this->normalizeTargetPath($path); } - /** Validate a user-supplied destination: it must live under _pages/ and be a Blade page. Returns null on failure. */ - protected function validateCustomTarget(string $path): ?string + /** Validate a user-supplied destination against the publishable page's configured Hyde page model. */ + protected function validateCustomTarget(string $path, PublishablePage $page): ?string { - if (! $this->isValidCustomTarget($path)) { - $this->console->error('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.'); + if (! $this->isValidCustomTarget($path, $page)) { + $this->console->error($this->customTargetOptionError($page)); return null; } @@ -257,7 +258,7 @@ protected function validateCustomTarget(string $path): ?string return $this->normalizeTargetPath($path); } - protected function isValidCustomTarget(string $path): bool + protected function isValidCustomTarget(string $path, PublishablePage $page): bool { try { $normalized = $this->normalizeTargetPath($path); @@ -265,8 +266,10 @@ protected function isValidCustomTarget(string $path): bool return false; } - return Str::startsWith($normalized, '_pages/') - && Str::endsWith($normalized, '.blade.php'); + $pageClass = $page->pageClass; + + return $this->targetIsWithinSourceDirectory($normalized, $page->pageClass) + && Str::endsWith($normalized, $pageClass::fileExtension()); } protected function normalizeTargetPath(string $path): string @@ -274,6 +277,60 @@ protected function normalizeTargetPath(string $path): string return (new WhitespacePathNormalizer())->normalizePath($path); } + /** @param class-string $pageClass */ + protected function targetIsWithinSourceDirectory(string $target, string $pageClass): bool + { + $sourceDirectory = $this->normalizeTargetPath($pageClass::sourceDirectory()); + + return $sourceDirectory === '' + || Str::startsWith($target, "$sourceDirectory/"); + } + + protected function customTargetOptionError(PublishablePage $page): string + { + $pageClass = $page->pageClass; + + return sprintf( + 'The --to path must be within %s and end in %s, for example %s.', + $this->sourceDirectoryLabel($page), + $pageClass::fileExtension(), + $this->customTargetExamplePath($page) + ); + } + + protected function customTargetPromptError(PublishablePage $page): string + { + $pageClass = $page->pageClass; + + return sprintf( + 'The path must be within %s and end in %s.', + $this->sourceDirectoryLabel($page), + $pageClass::fileExtension() + ); + } + + protected function sourceDirectoryLabel(PublishablePage $page): string + { + $pageClass = $page->pageClass; + $sourceDirectory = $this->normalizeTargetPath($pageClass::sourceDirectory()); + + return $sourceDirectory === '' ? 'the project root' : "$sourceDirectory/"; + } + + protected function exampleTargetPath(PublishablePage $page): string + { + $pageClass = $page->pageClass; + + return $this->normalizeTargetPath($pageClass::sourcePath('example')); + } + + protected function customTargetExamplePath(PublishablePage $page): string + { + return $page->defaultTarget !== null + ? $this->normalizeTargetPath($page->defaultTarget) + : $this->exampleTargetPath($page); + } + /** @param array $resolved */ protected function assertNoDestinationConflicts(array $resolved): bool { diff --git a/packages/framework/src/Console/Helpers/PublishablePage.php b/packages/framework/src/Console/Helpers/PublishablePage.php index 3b5e1bb44b8..5341a8bee97 100644 --- a/packages/framework/src/Console/Helpers/PublishablePage.php +++ b/packages/framework/src/Console/Helpers/PublishablePage.php @@ -4,6 +4,9 @@ namespace Hyde\Console\Helpers; +use Hyde\Pages\BladePage; +use Hyde\Pages\Concerns\HydePage; + final class PublishablePage { /** @@ -14,6 +17,7 @@ final class PublishablePage * @param string|null $defaultTarget The default project-relative destination (e.g. '_pages/posts.blade.php'), or null when the page has no default and its destination must be resolved interactively or via --to. * @param array $alternativeTargets Additional valid destinations, mapping a project-relative path to a human label. * @param bool $allowCustomTarget Whether the user may publish this page to a custom path. + * @param class-string $pageClass The Hyde page model this page is published as; controls custom target validation. */ public function __construct( public readonly string $key, @@ -23,6 +27,7 @@ public function __construct( public readonly ?string $defaultTarget, public readonly array $alternativeTargets = [], public readonly bool $allowCustomTarget = true, + public readonly string $pageClass = BladePage::class, ) { } } diff --git a/packages/framework/src/Console/Helpers/PublishablePages.php b/packages/framework/src/Console/Helpers/PublishablePages.php index 4b5dbcd86cc..79555fefdb8 100644 --- a/packages/framework/src/Console/Helpers/PublishablePages.php +++ b/packages/framework/src/Console/Helpers/PublishablePages.php @@ -4,6 +4,8 @@ namespace Hyde\Console\Helpers; +use Hyde\Pages\BladePage; + final class PublishablePages { /** @var array|null */ @@ -46,15 +48,15 @@ protected static function getDefaultPages(): array label: 'Welcome page', description: 'The default Hyde welcome page.', source: 'resources/views/homepages/welcome.blade.php', - defaultTarget: '_pages/index.blade.php', + defaultTarget: BladePage::sourcePath('index'), ), new PublishablePage( key: 'posts', label: 'Posts feed', description: 'A feed of your latest posts. Perfect for a blog site!', source: 'resources/views/homepages/post-feed.blade.php', - defaultTarget: '_pages/posts.blade.php', - alternativeTargets: ['_pages/index.blade.php' => 'Use as your site homepage'], + defaultTarget: BladePage::sourcePath('posts'), + alternativeTargets: [BladePage::sourcePath('index') => 'Use as your site homepage'], ), new PublishablePage( key: 'blank', @@ -68,7 +70,7 @@ protected static function getDefaultPages(): array label: '404 page', description: 'A custom 404 error page.', source: 'resources/views/pages/404.blade.php', - defaultTarget: '_pages/404.blade.php', + defaultTarget: BladePage::sourcePath('404'), allowCustomTarget: false, ), ]); diff --git a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php index d5cc64f5073..8863485f642 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandPagesTest.php @@ -12,6 +12,7 @@ use Hyde\Console\Helpers\PublishablePage; use Hyde\Console\Helpers\PublishablePages; use Hyde\Hyde; +use Hyde\Pages\BladePage; use Hyde\Testing\TestCase; use Illuminate\Console\OutputStyle; use Illuminate\Support\Facades\Artisan; @@ -34,6 +35,7 @@ protected function setUp(): void { parent::setUp(); + BladePage::setSourceDirectory('_pages'); $this->withoutDefaultPages(); } @@ -44,6 +46,7 @@ protected function tearDown(): void ConsoleHelper::clearMocks(); PagesPromptsReset::resetFallbacks(); PublishablePages::clear(); + BladePage::setSourceDirectory('_pages'); foreach (glob(Hyde::path('_pages/*.blade.php')) as $file) { if (File::isDirectory($file)) { @@ -57,6 +60,10 @@ protected function tearDown(): void File::deleteDirectory(Hyde::path('_pages/company')); } + if (File::isDirectory(Hyde::path('content'))) { + File::deleteDirectory(Hyde::path('content')); + } + $this->restoreDefaultPages(); parent::tearDown(); @@ -149,6 +156,18 @@ public function testPageCanBePublishedToNestedCustomTarget() ); } + public function testDefaultTargetUsesConfiguredBladePageSourceDirectory() + { + BladePage::setSourceDirectory('content/pages'); + PublishablePages::clear(); + + $this->artisan('publish --page=welcome --no-interaction') + ->expectsOutputToContain('Published [welcome] to [content/pages/index.blade.php]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('content/pages/index.blade.php')); + } + public function testToPathOutsidePagesDirectoryIsRejected() { $this->artisan('publish --page=welcome --to=resources/views/foo.blade.php --no-interaction') @@ -163,6 +182,40 @@ public function testToPathWithWrongExtensionIsRejected() ->assertExitCode(1); } + public function testToPathIsValidatedAgainstThePublishablePageClass() + { + PublishablePages::register(new PublishablePage( + key: 'markdown', + label: 'Markdown page', + description: 'A Markdown starter page.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: null, + pageClass: PublishCommandPagesCustomPage::class, + )); + + $this->artisan('publish --page=markdown --to=content/custom/about.mdx --no-interaction') + ->expectsOutputToContain('Published [markdown] to [content/custom/about.mdx]') + ->assertExitCode(0); + + $this->assertFileExists(Hyde::path('content/custom/about.mdx')); + } + + public function testToPathWrongForThePublishablePageClassIsRejected() + { + PublishablePages::register(new PublishablePage( + key: 'markdown', + label: 'Markdown page', + description: 'A Markdown starter page.', + source: 'resources/views/homepages/blank.blade.php', + defaultTarget: null, + pageClass: PublishCommandPagesCustomPage::class, + )); + + $this->artisan('publish --page=markdown --to=_pages/about.blade.php --no-interaction') + ->expectsOutputToContain('The --to path must be within content/custom/ and end in .mdx, for example content/custom/example.mdx.') + ->assertExitCode(1); + } + public function testToIsRejectedForAPageThatDisallowsCustomTargets() { $this->artisan('publish --page=404 --to=_pages/error.blade.php --no-interaction') @@ -336,7 +389,7 @@ public function testInteractiveResolutionCanChooseACustomPath() { $this->artisan('publish --page=blank') ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') - ->expectsQuestion('Enter a path within _pages/', '_pages/custom.blade.php') + ->expectsQuestion('Enter a BladePage source path', '_pages/custom.blade.php') ->expectsOutputToContain('Published [blank] to [_pages/custom.blade.php]') ->expectsConfirmation('Rebuild the site now?', 'no') ->assertExitCode(0); @@ -459,7 +512,7 @@ public function testCustomTargetWithDuplicateSlashesConflictsWithNormalizedDefau $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', 'blank']) ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') - ->expectsQuestion('Enter a path within _pages/', '_pages//index.blade.php') + ->expectsQuestion('Enter a BladePage source path', '_pages//index.blade.php') ->expectsOutputToContain('Welcome page and Blank page both target _pages/index.blade.php.') ->expectsOutputToContain('Pick one, or set --to for each.') ->assertExitCode(1); @@ -472,7 +525,7 @@ public function testCustomTargetWithSingleDotSegmentConflictsWithNormalizedDefau $this->artisan('publish --page') ->expectsQuestion('Select pages to publish', ['welcome', 'blank']) ->expectsQuestion('Where should "Blank page" be published?', '__hyde_custom_target__') - ->expectsQuestion('Enter a path within _pages/', '_pages/./index.blade.php') + ->expectsQuestion('Enter a BladePage source path', '_pages/./index.blade.php') ->expectsOutputToContain('Welcome page and Blank page both target _pages/index.blade.php.') ->expectsOutputToContain('Pick one, or set --to for each.') ->assertExitCode(1); @@ -536,24 +589,6 @@ public function testBarePageWithoutInteractionFailsHelpfully() $this->assertFileDoesNotExist(Hyde::path('_pages/index.blade.php')); } - public function testToPathWithParentTraversalIsRejected() - { - $this->artisan('publish --page=welcome --to=_pages/../secret.blade.php --no-interaction') - ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') - ->assertExitCode(1); - - $this->assertFileDoesNotExist(Hyde::path('secret.blade.php')); - } - - public function testToPathWithTraversalBeforePagesDirectoryIsRejected() - { - $this->artisan('publish --page=welcome --to=../_pages/index.blade.php --no-interaction') - ->expectsOutputToContain('The --to path must be within _pages/ and end in .blade.php, for example _pages/index.blade.php.') - ->assertExitCode(1); - - $this->assertFileDoesNotExist(Hyde::path('../_pages/index.blade.php')); - } - public function testThreePagesResolvingToTheSameTargetReportAllTarget() { foreach (['clash-one' => 'Clash One', 'clash-two' => 'Clash Two'] as $key => $label) { @@ -704,3 +739,9 @@ public static function resetFallbacks(): void static::$shouldFallback = false; } } + +class PublishCommandPagesCustomPage extends \Hyde\Pages\MarkdownPage +{ + public static string $sourceDirectory = 'content/custom'; + public static string $fileExtension = '.mdx'; +} diff --git a/packages/framework/tests/Unit/PublishablePageTest.php b/packages/framework/tests/Unit/PublishablePageTest.php index 31b4a743d5c..1b707eb912b 100644 --- a/packages/framework/tests/Unit/PublishablePageTest.php +++ b/packages/framework/tests/Unit/PublishablePageTest.php @@ -5,6 +5,7 @@ namespace Hyde\Framework\Testing\Unit; use ReflectionClass; +use Hyde\Pages\BladePage; use Hyde\Testing\UnitTestCase; use Hyde\Console\Helpers\PublishablePage; @@ -30,9 +31,10 @@ public function testConstructsWithAllProperties() $this->assertSame('_pages/posts.blade.php', $page->defaultTarget); $this->assertSame(['_pages/index.blade.php' => 'Use as your site homepage'], $page->alternativeTargets); $this->assertFalse($page->allowCustomTarget); + $this->assertSame(BladePage::class, $page->pageClass); } - public function testOptionalPropertiesDefaultToNoAlternativesAndCustomTargetAllowed() + public function testOptionalPropertiesDefaultToNoAlternativesCustomTargetAllowedAndBladePageClass() { $page = new PublishablePage( key: 'welcome', @@ -44,6 +46,7 @@ public function testOptionalPropertiesDefaultToNoAlternativesAndCustomTargetAllo $this->assertSame([], $page->alternativeTargets); $this->assertTrue($page->allowCustomTarget); + $this->assertSame(BladePage::class, $page->pageClass); } public function testDefaultTargetMayBeNullForPagesWithoutADefaultDestination() diff --git a/packages/framework/tests/Unit/PublishablePagesTest.php b/packages/framework/tests/Unit/PublishablePagesTest.php index 9d52572ddef..98125e1d916 100644 --- a/packages/framework/tests/Unit/PublishablePagesTest.php +++ b/packages/framework/tests/Unit/PublishablePagesTest.php @@ -4,6 +4,7 @@ namespace Hyde\Framework\Testing\Unit; +use Hyde\Pages\BladePage; use Hyde\Testing\UnitTestCase; use Hyde\Console\Helpers\PublishablePage; use Hyde\Console\Helpers\PublishablePages; @@ -16,11 +17,13 @@ class PublishablePagesTest extends UnitTestCase { protected function setUp(): void { + BladePage::setSourceDirectory('_pages'); PublishablePages::clear(); } protected function tearDown(): void { + BladePage::setSourceDirectory('_pages'); PublishablePages::clear(); } @@ -48,6 +51,19 @@ public function testDefaultCatalogTargets() $this->assertSame('_pages/404.blade.php', $pages['404']->defaultTarget); } + public function testDefaultCatalogTargetsFollowTheConfiguredBladePageSourceDirectory() + { + BladePage::setSourceDirectory('content/pages'); + PublishablePages::clear(); + + $pages = PublishablePages::all(); + + $this->assertSame('content/pages/index.blade.php', $pages['welcome']->defaultTarget); + $this->assertSame('content/pages/posts.blade.php', $pages['posts']->defaultTarget); + $this->assertSame('content/pages/404.blade.php', $pages['404']->defaultTarget); + $this->assertSame(['content/pages/index.blade.php' => 'Use as your site homepage'], $pages['posts']->alternativeTargets); + } + public function testDefaultCatalogSources() { $pages = PublishablePages::all(); From ccbdbce20e2e52b320882b4965413443f051a1bc Mon Sep 17 00:00:00 2001 From: Emma De Silva Date: Tue, 7 Jul 2026 15:41:06 +0200 Subject: [PATCH 56/56] Fix interactive multiselect edge cases --- .../Helpers/InteractiveMultiselect.php | 6 ++--- .../Commands/PublishCommandViewsTest.php | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php index f70bfa47705..831c45031c8 100644 --- a/packages/framework/src/Console/Helpers/InteractiveMultiselect.php +++ b/packages/framework/src/Console/Helpers/InteractiveMultiselect.php @@ -25,13 +25,13 @@ class InteractiveMultiselect /** * @param array $options Map of option key => display label. * @param string|null $allLabel Label for the "select all" row, or null to omit it entirely. - * @return array The selected option keys (never includes the sentinel). + * @return array The selected option keys (never includes the sentinel). */ public static function select(string $label, array $options, ?string $allLabel = null): array { $choices = $allLabel !== null ? [self::ALL => $allLabel] + $options : $options; - $prompt = new MultiSelectPrompt($label, $choices, [], 10, 'required', hint: 'Navigate with arrow keys, space to select, enter to confirm.'); + $prompt = new MultiSelectPrompt($label, $choices, [], 10, false, hint: 'Navigate with arrow keys, space to select, enter to confirm.'); $selected = $prompt->prompt(); @@ -40,6 +40,6 @@ public static function select(string $label, array $options, ?string $allLabel = return array_keys($options); } - return array_values(array_filter($selected, fn (string $key): bool => $key !== self::ALL)); + return array_values(array_filter($selected, fn ($key): bool => (string) $key !== self::ALL)); } } diff --git a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php index 4991b8e5c79..0efd83035d3 100644 --- a/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php +++ b/packages/framework/tests/Feature/Commands/PublishCommandViewsTest.php @@ -177,6 +177,28 @@ public function testAllSentinelPreservesNumericOptionKeys() $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'], 'All options')); } + public function testPickerCanSelectNumericOptionKeyDirectly() + { + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + Prompt::fake([Key::SPACE, Key::ENTER]); + + $this->assertSame([404], InteractiveMultiselect::select('Select test option', [404 => 'Not found'])); + } + + public function testPickerCanSubmitAnEmptySelection() + { + if (windows_os()) { + $this->markTestSkipped('Interactive prompts are not applicable on Windows systems.'); + } + + Prompt::fake([Key::ENTER]); + + $this->assertSame([], InteractiveMultiselect::select('Select test option', ['one' => 'One'])); + } + public function testIdenticalViewsAreSkippedAsAlreadyCurrent() { $this->seedAllViews();