From 28deee994223152e31eed4c3e2ff026e8c758e78 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 03:35:24 +0200
Subject: [PATCH 01/40] 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 f62a7b52dd901e85719ab860a6fd1e989f600eee Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 03:35:26 +0200
Subject: [PATCH 02/40] 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 81de9b58e28b4f62d5d2f063be6257252977a50e Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 03:47:45 +0200
Subject: [PATCH 03/40] 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 9cfc01ab4d9072c259467413685befbe2143f25d Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 03:51:51 +0200
Subject: [PATCH 04/40] 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 9c878086310a81d792e5611cd2f23f5a69d85c6f Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 04:36:31 +0200
Subject: [PATCH 05/40] 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 8b549c9d4a550f5dceb3804d981b68dacda41a64 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 04:50:35 +0200
Subject: [PATCH 06/40] 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 e84e4d0d991ad3aa10a58abde2f9cc144fd854f4 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 16:19:27 +0200
Subject: [PATCH 07/40] 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 fdba13bb5c46513b90fe63562c73db5b6d1a1971 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 16:48:25 +0200
Subject: [PATCH 08/40] 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 1318406d86cdd3a0954ad3e0465e12020e0faf14 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 17:33:46 +0200
Subject: [PATCH 09/40] 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 bad493a8366acce055f857fd6c85b0b78db34d06 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 17:49:41 +0200
Subject: [PATCH 10/40] 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 76a64e821ff895c0fe7d6a4218a567d6793be8db Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 17:59:18 +0200
Subject: [PATCH 11/40] 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 ddcda1c36bba62c4751a4ad8b1070b93d8f71ec3 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 18:23:54 +0200
Subject: [PATCH 12/40] 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 b4829a69894df0a41a2e3fde72e60cabe735ac6d Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 18:46:59 +0200
Subject: [PATCH 13/40] 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 655d6225ac392b9b5c268000dccfa560ae8c66e3 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 19:08:58 +0200
Subject: [PATCH 14/40] 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 53d257cff328de5ff83f6cc7672411871ccf69c0 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 19:31:32 +0200
Subject: [PATCH 15/40] =?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 f545e7978a12127f6bc1a6249827904d37351e86 Mon Sep 17 00:00:00 2001
From: StyleCI Bot
Date: Sat, 4 Jul 2026 17:32:32 +0000
Subject: [PATCH 16/40] 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 58785a5089b047ec50931b69ad9ec54e69bf0eae Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 19:42:42 +0200
Subject: [PATCH 17/40] 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 c4c8a66896918d9205d277de1da248f2228ca94a Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 19:57:34 +0200
Subject: [PATCH 18/40] 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 0b4746945115f63449652198d52525a6ed4d452c Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:15:44 +0200
Subject: [PATCH 19/40] 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 207d568da8ce8a146bfc90d257a4f36a6d743b1c Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:17:56 +0200
Subject: [PATCH 20/40] 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 296922d7bb6e1539d2c4e13770fd620dd767de6d Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:18:55 +0200
Subject: [PATCH 21/40] =?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 e2649bd9c2bb105c2774fcd2801ff2d4491d0d29 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:22:02 +0200
Subject: [PATCH 22/40] =?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 366da1118b0b1c698d406ac293f9d094a0716ec4 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:24:26 +0200
Subject: [PATCH 23/40] 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 8d378d52e3f1cb0bc1959aec015ac679d7c5e66d Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:27:06 +0200
Subject: [PATCH 24/40] =?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 394a4aa2c257f19476f039a2718869d1763fb61f Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:41:39 +0200
Subject: [PATCH 25/40] 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 4368116cb054d71f88fbaa2753df7089a742601f Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:45:40 +0200
Subject: [PATCH 26/40] 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 78b3515b27a3b8e39a57ecfea9440a3afb2283af Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sat, 4 Jul 2026 23:56:13 +0200
Subject: [PATCH 27/40] 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 afc2d252c33061b60f46677dc27151cc7384aa10 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:00:14 +0200
Subject: [PATCH 28/40] 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 556acf85e5957448b948a26f320154ecb0fc7cd3 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:01:06 +0200
Subject: [PATCH 29/40] 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 088401c2a8591f19f9b715272786f723d01231ec Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:03:12 +0200
Subject: [PATCH 30/40] 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 495311cb2122ad0d7a44e57ad26ce7caaa1bcf74 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:17:18 +0200
Subject: [PATCH 31/40] 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 1ba05fb93df63cc8e97b98535b135b5d89f08551 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:20:22 +0200
Subject: [PATCH 32/40] 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 1e546a9a966d3c856d9ed3f730f39049518767d5 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:25:41 +0200
Subject: [PATCH 33/40] 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 8d6afd489e7d94dfe07e2c6331c405f90aedc3b4 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 00:33:10 +0200
Subject: [PATCH 34/40] 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 62d568b0089d415ca5729e6a67696b3d178f63e3 Mon Sep 17 00:00:00 2001
From: StyleCI Bot
Date: Sat, 4 Jul 2026 22:26:29 +0000
Subject: [PATCH 35/40] 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 2fb3c30ee18bdecb497d8af8185d8d45b60001ce Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 01:07:09 +0200
Subject: [PATCH 36/40] 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 bad1ba72873c714e2c71a0b5d4cc2c240fdedc42 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 01:20:15 +0200
Subject: [PATCH 37/40] 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 b42523520450a92e9b0e7a36702052ed2bda0f11 Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 01:21:42 +0200
Subject: [PATCH 38/40] 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 cb96fc553b559198fe5c287c12405c81bfbe4c0f Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 01:21:44 +0200
Subject: [PATCH 39/40] 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 4675cd65aeaacad7e34f39e17ef95adbc9e73bec Mon Sep 17 00:00:00 2001
From: Emma De Silva
Date: Sun, 5 Jul 2026 01:25:43 +0200
Subject: [PATCH 40/40] 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