Merge pull request 'Launch preview-only Decap CMS' (#1) from cms into main

Reviewed-on: SloanOps/3031023-xyz#1
This commit is contained in:
2026-09-04 14:09:26 +00:00
37 changed files with 1326 additions and 33 deletions
@@ -0,0 +1,36 @@
name: CMS content validation
on:
pull_request:
branches: [main]
jobs:
validate:
name: cms-content-validation
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
env:
ASTRO_TELEMETRY_DISABLED: '1'
steps:
- name: Checkout source
uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Use Node 22
uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install locked dependencies
run: npm ci --prefer-offline --no-audit --no-fund
- name: Verify Decap configuration and content boundaries
run: npm run verify:cms
- name: Build primary site
run: SITE_KEY=www npm run build:site
- name: Verify primary site
run: SITE_KEY=www npm run verify:build
- name: Build blog
run: SITE_KEY=blog npm run build:site
- name: Verify blog
run: SITE_KEY=blog npm run verify:build
+5 -3
View File
@@ -28,13 +28,15 @@ jobs:
ASTRO_TELEMETRY_DISABLED: '1' ASTRO_TELEMETRY_DISABLED: '1'
steps: steps:
- name: Checkout source - name: Checkout source
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Use Node 22 - name: Use Node 22
uses: https://github.com/actions/setup-node@v4 uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install locked dependencies - name: Install locked dependencies
run: npm ci run: npm ci --prefer-offline --no-audit --no-fund
- name: Validate and build selected site - name: Validate and build selected site
run: npm run build:site run: npm run build:site
- name: Verify selected output - name: Verify selected output
+5 -3
View File
@@ -28,13 +28,15 @@ jobs:
ASTRO_TELEMETRY_DISABLED: '1' ASTRO_TELEMETRY_DISABLED: '1'
steps: steps:
- name: Checkout source - name: Checkout source
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Use Node 22 - name: Use Node 22
uses: https://github.com/actions/setup-node@v4 uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install locked dependencies - name: Install locked dependencies
run: npm ci run: npm ci --prefer-offline --no-audit --no-fund
- name: Validate and build selected site - name: Validate and build selected site
run: npm run build:site run: npm run build:site
- name: Verify selected output - name: Verify selected output
+5 -3
View File
@@ -29,13 +29,15 @@ jobs:
ASTRO_TELEMETRY_DISABLED: '1' ASTRO_TELEMETRY_DISABLED: '1'
steps: steps:
- name: Checkout source - name: Checkout source
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Use Node 22 - name: Use Node 22
uses: https://github.com/actions/setup-node@v4 uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install locked dependencies - name: Install locked dependencies
run: npm ci run: npm ci --prefer-offline --no-audit --no-fund
- name: Validate and build production blog - name: Validate and build production blog
run: npm run build:site run: npm run build:site
- name: Verify production blog output - name: Verify production blog output
+5 -3
View File
@@ -29,13 +29,15 @@ jobs:
ASTRO_TELEMETRY_DISABLED: '1' ASTRO_TELEMETRY_DISABLED: '1'
steps: steps:
- name: Checkout source - name: Checkout source
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Use Node 22 - name: Use Node 22
uses: https://github.com/actions/setup-node@v4 uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: package-lock.json
- name: Install locked dependencies - name: Install locked dependencies
run: npm ci run: npm ci --prefer-offline --no-audit --no-fund
- name: Validate and build production site - name: Validate and build production site
run: npm run build:site run: npm run build:site
- name: Verify production output - name: Verify production output
+14 -2
View File
@@ -3,6 +3,10 @@
This repository is a static Astro pilot for `3031023.xyz`. The existing This repository is a static Astro pilot for `3031023.xyz`. The existing
Cloudflare Pages site remains the rollback target while this branch is tested. Cloudflare Pages site remains the rollback target while this branch is tested.
The planned preview-only editing experiment is documented in the [Decap CMS
Gitea pilot](docs/decap-cms-pilot.md). It does not authorize CMS activation, a
production merge, or a domain change.
## Repository layout ## Repository layout
- `sites/www/` is the primary-site content location; it serves `3031023.xyz`. - `sites/www/` is the primary-site content location; it serves `3031023.xyz`.
@@ -12,8 +16,9 @@ Cloudflare Pages site remains the rollback target while this branch is tested.
- `schemas/` and `fixtures/` provide the Phase 1 JSON contract proof. - `schemas/` and `fixtures/` provide the Phase 1 JSON contract proof.
Blog posts and primary-site FAQs are now static Markdown collections with Blog posts and primary-site FAQs are now static Markdown collections with
validated frontmatter and draft filtering. CMS workflows, public sign-up validated frontmatter and draft filtering. Production CMS publishing, public
changes, n8n changes, and onboarding-worker changes remain out of scope. sign-up changes, n8n changes, and onboarding-worker changes remain out of
scope; the linked Decap plan is a preview-only editing experiment.
## Local checks ## Local checks
@@ -21,6 +26,7 @@ Use Node 22.12 or newer within the Node 22 release line.
```bash ```bash
npm ci npm ci
npm run verify:cms
SITE_KEY=www npm run build:site SITE_KEY=www npm run build:site
SITE_KEY=www npm run verify:build SITE_KEY=www npm run verify:build
SITE_KEY=blog npm run build:site SITE_KEY=blog npm run build:site
@@ -31,6 +37,12 @@ Each selected site builds to `dist/<site-key>/`. The Worker configurations use
only static assets and a custom 404 page. They contain no custom domains, only static assets and a custom 404 page. They contain no custom domains,
routes, account IDs, or secrets. routes, account IDs, or secrets.
`npm run verify:cms` checks the fixed Gitea branch/backend, the four Markdown
content contracts, the fixed page-image record, the bounded raster-media boundary, the pinned admin
shell, and the stable secret-free pull-request status context. The live steps
remain unapplied; see the [Gitea pilot settings](docs/decap-cms-gitea-setup.md)
for the exact review checklist.
## Preview deployment ## Preview deployment
The Gitea workflows upload scoped non-production preview versions of the The Gitea workflows upload scoped non-production preview versions of the
+213
View File
@@ -0,0 +1,213 @@
# Decap CMS: Gitea settings
Status: the preview-only pilot was accepted for promotion on 2026-09-04. The
repository now targets the permanent `cms` staging branch. Update the exact
CORS origin and PKCE OAuth redirect only after its first preview deployment
confirms the predicted alias. Nothing has been merged to `main`.
The repository implementation targets these fixed public values:
| Setting | Value |
| --- | --- |
| Gitea repository | `SloanOps/3031023-xyz` |
| Decap write branch | `cms` |
| Gitea base URL | `https://git.sloanops.com` |
| Gitea API root | `https://git.sloanops.com/api/v1` |
| Stable CMS origin | `https://cms-3031023-xyz-www.ksloan90.workers.dev` |
| Exact OAuth redirect URI | `https://cms-3031023-xyz-www.ksloan90.workers.dev/admin/` |
| Required status context | `CMS content validation / cms-content-validation (pull_request)` |
The pilot and permanent branch names are intentionally slash-free. Decap
CMS 3.15.1 inserts the configured Gitea branch directly into its tree-API URL;
a slash-named branch such as `cms/pilot` is split as an API path and its 404 is
presented as an empty collection. The permanent `cms` branch is still a
non-production staging branch: changes reach `main` only through the protected
pull-request flow.
The Workers alias was confirmed after the first approved preview deployment.
If Cloudflare later assigns a different stable origin,
update the repository configuration, CORS origin, and OAuth redirect URI
together through review before enabling login.
## Runner cache settings
The Dockerized runner uses a persistent `/data` volume and the same dedicated
Docker network as its job containers. Its non-confidential cache settings are:
```yaml
cache:
enabled: true
dir: /data/cache
host: sloanops-gitea-runner
port: 8088
offline_mode: true
container:
network: sloanops-actions
```
`offline_mode` is safe here because every external `uses:` reference is pinned
to a reviewed full commit SHA. It prevents the runner from refreshing mutable
action branches on every job; npm packages remain keyed separately by
`package-lock.json` through setup-node's cache integration.
## Applied CORS policy
The tracked Gitea service in
`/home/sloan/gitRepos/SloanOps/infrastructure/docker-compose.yml` contains
these entries in the existing `gitea.environment` list and was deployed
through the normal infrastructure change process:
```yaml
- GITEA__cors__ENABLED=true
- GITEA__cors__ALLOW_DOMAIN=https://cms-3031023-xyz-www.ksloan90.workers.dev
- GITEA__cors__METHODS=GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS
- GITEA__cors__MAX_AGE=10m
- GITEA__cors__ALLOW_CREDENTIALS=false
- GITEA__cors__HEADERS=Authorization,Content-Type
```
This is one exact origin, no wildcard, no cookie-bearing cross-origin request,
and only the API methods and non-safelisted request headers used by the Gitea
backend. Do not add the production domains, the journal preview, arbitrary
Workers preview hosts, or localhost. Gitea's same-origin login page continues
to use its own session cookie; Decap API calls use the OAuth bearer token.
After an approved Gitea deployment, verify a preflight without including a
token or cookie:
```bash
curl --request OPTIONS 'https://git.sloanops.com/api/v1/version' \
--header 'Origin: https://cms-3031023-xyz-www.ksloan90.workers.dev' \
--header 'Access-Control-Request-Method: GET' \
--header 'Access-Control-Request-Headers: authorization,content-type'
```
The response must allow only the configured origin, requested method, and
headers. Repeat with an unrelated origin and confirm that Gitea does not grant
it cross-origin access. Also verify normal Git, web UI, API, and Actions
behavior before continuing.
## OAuth application
The Gitea OAuth2 application was created after the stable preview origin and
CORS response were verified:
- Application name: `3031023.xyz Decap CMS`.
- Confidential client: **unchecked**. The browser client must use PKCE.
- Redirect URI: exactly
`https://cms-3031023-xyz-www.ksloan90.workers.dev/admin/`, including the
trailing slash.
- Do not add wildcards, localhost, either production hostname, or the journal
preview as additional redirects.
- Copy only the public client ID into `backend.app_id` in
`public/admin/config.yml` through a reviewed commit.
- Do not store or use a client secret in the repository, Actions, Cloudflare,
or Decap configuration.
The CMS account still needs repository write access; the branch rules below
are the security boundary that reduces that write access to pilot content and
media paths.
## Applied branch protection rules
Create both rules before granting a CMS/client account write access or testing
login. Gitea uses only the first matching rule, so the order is material.
### Priority 1: production `main`
| Gitea control | Required value |
| --- | --- |
| Protected branch pattern | `main` |
| Priority | `1` (first/highest) |
| Push | Disabled for everyone; no push allowlist |
| Force push | Disabled |
| Required approvals | `1` |
| Approval allowlist | Enabled; only the named SloanOps operator user/team |
| Dismiss stale approvals | Enabled |
| Ignore stale approvals | Disabled |
| Block rejected reviews | Enabled |
| Block outstanding official review requests | Enabled |
| Block outdated pull requests | Enabled |
| Status checks | Enabled |
| Status check pattern | `CMS content validation / cms-content-validation (pull_request)` |
| Merge allowlist | Enabled; only the named SloanOps operator user/team |
| Administrators must follow protection | Enabled |
| Protection bypass allowlist | Disabled |
Leave both file-pattern fields empty on this rule. Direct pushes are already
disabled, and executable or configuration changes must remain possible only in
a separate operator-authored, reviewed pull request. The fallback rule prevents
CMS users from placing those files on any source branch.
This rule means every `main` change arrives through a current pull request,
passes the one secret-free job that validates the CMS contract and builds and
verifies both sites, receives a current SloanOps approval, and is merged by a
SloanOps operator. A CMS/client account must not be added to the approval,
merge, administrator, or bypass allowlists.
### Priority 2: all-branch fallback
| Gitea control | Required value |
| --- | --- |
| Protected branch pattern | `*` |
| Priority | `2` (after `main`) |
| Push | Disabled, except the unprotected file patterns below |
| Force push | Disabled |
| Unprotected file patterns | Exact semicolon-separated value below |
| Protected file patterns | Empty; every path omitted from the unprotected list remains protected |
| Protection bypass allowlist | Enabled only for the named SloanOps operator user/team |
| CMS/client account | Write access, but absent from every allowlist |
Enter this exact single value in **Unprotected file patterns**:
```text
sites/www/content/**;sites/blog/content/**;public/images/uploads/*.jpg;public/images/uploads/*.jpeg;public/images/uploads/*.png;public/images/uploads/*.webp;public/images/uploads/*.avif
```
Do not add a nested upload glob. The current Decap configuration writes media
directly into the flat uploads directory, and the local verifier rejects nested
media paths. Gitea file patterns are case-insensitive; CI verifies actual file
signatures and the 500 KiB limit before a preview can upload.
Everything not listed is protected from CMS/client pushes on `cms` and
on every differently named branch, including:
```text
.gitea/**
astro.config.mjs
package.json
package-lock.json
schemas/**
scripts/**
src/**
wrangler/**
sites.json
sites/*/site.json
public/admin/**
public/_headers
public/images/*
```
The final `public/images/*` line describes the effect of omission, not an
additional Gitea field value: existing approved images stay protected while
the explicitly listed `public/images/uploads/*.<raster>` paths remain writable.
Workflows, source, scripts, package files, schemas, manifests, Wrangler
configuration, `site.json` files, and the CMS application therefore cannot be
altered by choosing another branch name.
## Verification before an editor invitation
1. Run `npm ci`, `npm run verify:cms`, and both site build/verifier pairs.
2. Create `cms` from the reviewed baseline only after approval.
3. Apply both protection rules together and confirm `main` is priority 1 and
`*` is priority 2.
4. Use the same non-operator account intended for Decap to prove allowed
Markdown edits succeed and every protected-path/direct-`main` attempt fails.
5. Open, but do not merge, a pull request into `main`. Confirm Gitea reports
the exact required status context above and blocks merge without both its
successful result and a current SloanOps approval.
6. Confirm both branch previews remain no-index and production is unchanged.
Do not treat a Decap UI restriction as authorization. Gitea's first-match
branch rules are the enforcement boundary, and the pull-request build is the
content/media integrity boundary.
+348
View File
@@ -0,0 +1,348 @@
# Decap CMS Gitea pilot and promotion
Status: the preview-only pilot was accepted for promotion to the permanent
`cms` staging branch on 2026-09-04. Repository configuration now targets the
predicted stable `cms` preview origin. CORS and OAuth must be changed together
only after that first preview confirms the alias. Nothing has been merged to
`main`, and production remains unchanged.
Updated: 2026-09-04
## Purpose
Use the `3031023.xyz` demonstration repository to determine whether
Decap CMS can provide a useful editing interface for all sites owned under one
root domain without allowing an editor to publish directly to production or
modify deployment code.
The pilot covers both the primary Happy Tails site and its journal subdomain.
That matches the SloanOps repository boundary: one private repository per root
domain, with independently built sites beneath `sites/`. A future client who
owns the root-domain site also owns the hosted subdomains represented in that
repository.
This plan does not authorize a production merge, domain change, contact-form
activation, repository invitation, or removal of a rollback resource.
## Current evidence
- `3031023.xyz` and `blog.3031023.xyz` build independently from this
repository and deploy through separate Workers Static Assets workflows.
- A push outside `main` already uploads no-index primary and/or blog preview
versions according to the paths changed.
- Blog posts, FAQs, services, and team members are Markdown collections with
Astro/Zod validation and are suitable for a bounded Decap pilot.
- The live Gitea instance reported version `1.27.3` during the Phase 9 review.
Decap's Gitea backend requires Gitea 1.20 or newer.
- Credential-free preflight verification grants the exact stable CMS origin
and does not grant an unrelated origin.
- Decap authenticates to Gitea with a non-confidential PKCE OAuth application.
Its Gitea backend currently has no editorial pull-request workflow, so it
writes directly to the configured branch.
- The Gitea backend does not support Git LFS. Pilot media must remain small,
ordinary repository assets.
References:
- <https://decapcms.org/docs/gitea-backend/>
- <https://decapcms.org/docs/configure-decap-cms/>
- <https://decapcms.org/docs/widgets/image/>
- <https://docs.gitea.com/development/oauth2-provider/>
- <https://docs.gitea.com/usage/access-control/protected-branches/>
- <https://github.com/decaporg/decap-cms/issues/7115>
The exact Gitea CORS, OAuth, and two-rule branch-protection settings are
recorded in [Decap CMS pilot: Gitea settings](decap-cms-gitea-setup.md).
## Repository implementation
The reviewed pilot implementation is the baseline for the permanent `cms`
staging branch:
- `public/admin/` provides a permanently no-index admin shell and one shared
configuration for both site builds.
- The shell pins Decap CMS `3.15.1` by exact URL and verified SHA-384 integrity
value rather than a floating release range.
- The backend is fixed to `SloanOps/3031023-xyz` and `cms` and contains
only the public client ID for the non-confidential Gitea PKCE application.
- All four Markdown content collections and every persisted frontmatter field
are modeled with the current Astro/Zod bounds. Markdown controls remain
text-first; embedded images and general file widgets are absent.
- A fixed image-only Markdown record controls the primary home, About,
Services, and Contact page imagery without exposing either `site.json`.
- Team and page-image fields accept optional bounded uploads. Editors can add,
replace, or remove an image reference while retaining accessible image
descriptions and a layout fallback. URL insertion and SVG paths are
rejected. Builds additionally verify the extension, raster signature, flat
upload path, and 500 KiB limit.
- `.gitea/workflows/cms-content-validation.yml` runs on every pull request into
`main`, uses no secrets, and reports
`CMS content validation / cms-content-validation (pull_request)` after
checking the Decap contract and building and verifying both sites.
- The existing branch preview workflows remain in place. Local output checks
now prove preview robots rules and page metadata stay no-index and that the
admin response has a no-index header rule.
- Validation, preview, and production workflows cache npm's download store by
`package-lock.json` while retaining clean `npm ci` installs with
`--prefer-offline --no-audit --no-fund`. The runner advertises its cache on a
fixed host and port reachable from job containers, avoiding the timeout caused
by its former auto-detected private address. Warm jobs reuse dependency
tarballs without caching `node_modules`.
- All external workflow actions are pinned to reviewed commit SHAs. The runner
may therefore set `cache.offline_mode: true` and reuse its local action
repository cache without trusting a mutable tag or refreshing GitHub on every
job.
The pilot dependencies were verified on `cms-pilot`. The same protection rules
apply to `cms`; its exact-origin CORS and OAuth values are the remaining
operator-controlled promotion step.
## Proposed pilot architecture
```text
Decap at /admin/
|
| Gitea PKCE OAuth and content/media commits only
v
cms branch
|
+--> primary no-index Worker preview
|
+--> blog no-index Worker preview
|
v
manual Gitea pull request to protected main
|
v
required validation + SloanOps approval
|
v
production merge and deploy only with separate approval
```
The shared `public/` directory may serve `/admin/` in both preview builds. That
is acceptable because both sites belong to the same root-domain repository and
the same owner. One Decap configuration exposes clearly labeled Primary Site
and Journal collection groups.
Predicted preview URLs for branch `cms`:
- `https://cms-3031023-xyz-www.ksloan90.workers.dev/admin/`
- `https://cms-3031023-xyz-www.ksloan90.workers.dev/`
- `https://cms-3031023-xyz-blog.ksloan90.workers.dev/`
Confirm the aliases from the successful Gitea Actions run rather than treating
these predicted URLs as authoritative.
## Initial Decap scope
Allow editing and creation only within these collections:
| CMS group | Repository path | Initial capability |
| --- | --- | --- |
| Primary site FAQs | `sites/www/content/faq/**` | Create, edit, draft, reorder, delete after confirmation |
| Primary services | `sites/www/content/services/**` | Create, edit, draft, reorder, feature |
| Primary team | `sites/www/content/team/**` | Create, edit, draft, reorder, add, replace, or remove a portrait |
| Primary page images | `sites/www/content/page-images/primary.md` | Add, replace, or remove home, About, Services, and Contact images and edit their descriptions |
| Journal posts | `sites/blog/content/posts/**` | Create, edit, draft, date, tag, update |
| Shared pilot media | `public/images/uploads/**` | Select or upload bounded raster images |
Do not expose `sites.json`, either `site.json`, schemas, workflows, Wrangler
configuration, package files, source components, scripts, contact routing, or
analytics through the CMS during this pilot.
Configure the default media library with a conservative file-size limit. Do
not accept executables, archives, HTML, scripts, or SVG uploads. Asset rights,
alt text, crop, dimensions, and optimization remain operator review items even
when the CMS accepts an upload.
The media gallery lists only `public/images/uploads`, so the pilot seeds that
directory with the images referenced by CMS-controlled fields. Removing an
image from content clears the field and uses the page's text-only or initials
fallback; it does not delete a potentially shared asset. Delete a gallery asset
only after confirming that no content entry references it.
## Repository protection model
### `main`
- Disable direct pushes for CMS/client users.
- Require a pull request.
- Require at least one SloanOps approval.
- Require the dedicated CMS content validation status.
- Block merging an outdated or rejected pull request.
- Permit merges only by the SloanOps operator allowlist.
- Keep force-push disabled.
### Fallback rule for non-production branches
Create a branch-protection fallback covering every branch, with the specific
`main` rule at higher priority. CMS/client users may push only the following
unprotected patterns:
```text
sites/www/content/**
sites/blog/content/**
public/images/uploads/*.jpg
public/images/uploads/*.jpeg
public/images/uploads/*.png
public/images/uploads/*.webp
public/images/uploads/*.avif
```
Add the equivalent nested-path globs if the CMS is allowed to create media
subdirectories. Listing raster extensions explicitly prevents an editor from
using the media directory as a general unprotected file path.
All other paths remain protected, including:
```text
.gitea/**
astro.config.mjs
package.json
package-lock.json
schemas/**
scripts/**
src/**
wrangler/**
sites.json
sites/*/site.json
public/admin/**
```
SloanOps operator accounts may use the branch-rule bypass allowlist for
reviewed development. Verify the restrictions through the same Gitea account
used by Decap; a UI-only field restriction is not a repository security
boundary.
The protected-path fallback matters even though `main` requires a pull
request. Preview workflows inherit Cloudflare credentials. An editor must not
be able to create another branch, alter a workflow or executable build file,
and run unreviewed code with those credentials.
## Pull-request validation
Add a secret-free validation workflow for pull requests into `main`. It should
always:
1. install locked dependencies;
2. build and verify the primary site;
3. build and verify the blog;
4. reject content that fails the Astro/Zod schema; and
5. report one stable required status context.
Preview deployment remains branch-specific and may use the existing Worker
workflows. Cloudflare credentials stay scoped to the deployment step; content
validation does not need them.
Because Decap writes to a fixed `cms` branch, SloanOps creates the pull
request in Gitea. After an approved merge, synchronize the CMS branch with
`main` before the next editing cycle. Do not automate production merge.
The accepted pilot is promoted by replacing the disposable branch with the
permanent, slash-free `cms` branch. It remains a preview/staging branch and
retains the same pull-request, validation, and approval path into `main`.
## Gitea and Decap setup
1. Add a non-confidential Gitea OAuth application for the exact preview
`/admin/` redirect URI or URIs. Store only the public client ID in Decap.
2. Enable Gitea API CORS for the exact approved preview origin or origins.
Allow only required methods and headers, including `Authorization` and
`Content-Type`; do not use a wildcard origin.
3. Create `cms` from the reviewed repository baseline.
4. Review the pinned Decap admin assets beneath `public/admin/`. Do not replace
the exact version and integrity value with a floating range.
5. Configure the Gitea backend for `SloanOps/3031023-xyz` and branch
`cms`.
6. Model every persisted frontmatter field with limits matching
`src/content.config.ts`. Do not let a CMS save silently remove an existing
field.
7. Apply and verify the `main` and fallback branch protections before inviting
or testing a non-operator editor.
8. Add the secret-free pull-request validation workflow.
9. Push only the review branch and confirm both no-index preview builds before
any CMS login test.
## Test matrix
Record the Gitea execution, commit, and preview identifiers for each test. Do
not record OAuth tokens or Cloudflare credentials.
| ID | Test | Expected result |
| --- | --- | --- |
| DC-01 | Build both sites before CMS changes | Current build and verifier suites pass. |
| DC-02 | Open each preview `/admin/` route | Admin shell loads and remains no-index. |
| DC-03 | Authenticate with the approved operator account | PKCE login succeeds and the configured collections load. |
| DC-04 | Authenticate with an account without repository write access | Repository editing is unavailable. |
| DC-05 | Edit an existing draft journal post | One content-only commit lands on `cms`; the blog preview rebuilds. |
| DC-06 | Create, edit, and delete a disposable journal post | Filename, dates, tags, body, draft state, and cleanup remain schema-valid. |
| DC-07 | Edit a primary FAQ | Only the primary preview rebuilds and displays the change. |
| DC-08 | Edit service ordering and featured state | YAML types remain correct and the primary verifier passes. |
| DC-09 | Add, replace, and remove a team portrait | The selected upload renders in the team card; removal uses the initials fallback. |
| DC-10 | Upload a bounded raster image and select it in the fixed page-image record | File lands only in the approved media path and renders on the relevant primary preview page. |
| DC-11 | Attempt an oversized upload and a disallowed file type | The CMS size limit rejects the oversized file before upload; Gitea's unprotected-extension patterns reject the disallowed path. |
| DC-12 | Attempt to modify `.gitea/workflows/**`, source, package, script, schema, Wrangler, and site-manifest paths with the CMS account | Gitea rejects every push. |
| DC-13 | Attempt to create a differently named branch and change a protected file | The fallback rule rejects the push; branch naming cannot bypass protection. |
| DC-14 | Attempt a direct push to `main` | Gitea rejects it. |
| DC-15 | Open a pull request from `cms` to `main` | Both sites validate; required review and merge controls are present. |
| DC-16 | Attempt to merge without status or approval | Gitea blocks the merge. |
| DC-17 | Inspect both preview responses | Expected routes work; preview metadata/headers prevent indexing; production remains unchanged. |
| DC-18 | Revert disposable CMS changes on `cms` | Preview returns to baseline without changing either public site. |
| DC-19 | Refresh, sign out, reauthenticate, and let a session expire | Session behavior is understandable and no unsaved-content loss is observed. |
| DC-20 | Review commit attribution and audit history | Gitea identifies the editor and the exact content/media changes. |
Do not merge DC-15 during the initial infrastructure and editing test. A later
harmless demo-content merge requires explicit approval and repeats both public
site checks after deployment.
## Acceptance criteria
Accept the pilot only when:
- both site families are understandable in one CMS;
- content edits preserve every modeled field and pass Astro validation;
- previews update reliably without moving production traffic;
- direct `main` pushes and protected-path changes fail for the CMS account;
- PR validation and operator approval are mandatory;
- media limits and repository growth remain acceptable;
- login/session behavior is usable; and
- rollback requires no production-domain or DNS change.
Stop and redesign if the Gitea backend cannot honor the branch/file
protections, OAuth grants materially broader access than the accepted account
role, content serialization loses fields, or previews require granting the CMS
account control over executable deployment files.
## Rollback and cleanup
If the pilot is rejected or paused:
1. Revert or delete disposable changes on `cms`.
2. Delete the two disposable preview aliases/versions if desired; do not delete
production Workers or retained legacy rollback resources.
3. Revoke/delete the Decap OAuth application.
4. Remove the pilot CORS origins and redeploy Gitea after verifying normal Git,
UI, API, and Actions behavior.
5. Remove `/admin/` only through a reviewed commit.
6. Remove or retain branch protections deliberately; never weaken `main` as
cleanup.
## Implementation estimate
Expected hands-on implementation time: **6–9 hours**, normally one focused
working day. Budget up to two elapsed days if OAuth/CORS behavior or Gitea
protection semantics require troubleshooting.
| Work | Estimate |
| --- | ---: |
| Decap admin shell, pinned dependency, and Gitea configuration | 1–1.5 hours |
| Model blog, FAQ, services, and team collections | 1.5–2 hours |
| Add secret-free PR validation and preview links/instructions | 1–1.5 hours |
| Configure CORS, OAuth, CMS branch, and protection rules | 1–1.5 hours |
| Run the authentication, editing, protection, preview, and rollback matrix | 1.5–2.5 hours |
| Record results and resolve small integration defects | 0.5 hour |
This estimate covers the internal demo pilot, not a polished client-editable
service. Client rollout still needs observed usability, account invitation and
offboarding procedures, support scope, media policy, and pricing.
+1
View File
@@ -11,6 +11,7 @@
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"astro": "~7.2.0", "astro": "~7.2.0",
"js-yaml": "4.3.2",
"wrangler": "^4.0.0" "wrangler": "^4.0.0"
}, },
"engines": { "engines": {
+2
View File
@@ -8,6 +8,7 @@
}, },
"scripts": { "scripts": {
"validate": "node scripts/validate.mjs", "validate": "node scripts/validate.mjs",
"verify:cms": "npm run validate && node scripts/verify-decap.mjs",
"build": "npm run validate && ASTRO_TELEMETRY_DISABLED=1 astro build", "build": "npm run validate && ASTRO_TELEMETRY_DISABLED=1 astro build",
"build:site": "node scripts/build-site.mjs", "build:site": "node scripts/build-site.mjs",
"verify:build": "node scripts/verify-build.mjs", "verify:build": "node scripts/verify-build.mjs",
@@ -17,6 +18,7 @@
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"astro": "~7.2.0", "astro": "~7.2.0",
"js-yaml": "4.3.2",
"wrangler": "^4.0.0" "wrangler": "^4.0.0"
} }
} }
+4
View File
@@ -3,3 +3,7 @@
Referrer-Policy: strict-origin-when-cross-origin Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: SAMEORIGIN X-Frame-Options: SAMEORIGIN
Permissions-Policy: geolocation=(), microphone=(), camera=() Permissions-Policy: geolocation=(), microphone=(), camera=()
/admin/*
X-Robots-Tag: noindex, nofollow, noarchive
Cache-Control: no-store
+306
View File
@@ -0,0 +1,306 @@
backend:
name: gitea
repo: SloanOps/3031023-xyz
branch: cms
app_id: c07dba05-eb09-48f0-8da4-6df775a13995
api_root: https://git.sloanops.com/api/v1
base_url: https://git.sloanops.com
auth_endpoint: https://git.sloanops.com/login/oauth/authorize
publish_mode: simple
site_url: https://cms-3031023-xyz-www.ksloan90.workers.dev
display_url: https://cms-3031023-xyz-www.ksloan90.workers.dev
show_preview_links: false
media_folder: public/images/uploads
public_folder: /images/uploads
slug:
encoding: ascii
clean_accents: true
sanitize_replacement: "-"
collections:
- name: primary_faq
label: Primary site — FAQs
label_singular: FAQ
description: Answers shown on the primary Happy Tails site. Draft entries stay out of the generated site.
folder: sites/www/content/faq
create: true
delete: true
extension: md
format: frontmatter
identifier_field: question
slug: "{{slug}}"
summary: "{{question}} ({{category}})"
sortable_fields: [order, question, category, draft]
fields:
- label: Question
name: question
widget: string
pattern: ['^.{12,180}$', 'Use 12–180 characters.']
- label: Category
name: category
widget: string
default: General
pattern: ['^.{2,48}$', 'Use 2–48 characters.']
- label: Display order
name: order
widget: number
value_type: int
min: 0
max: 99
step: 1
default: 0
- label: Draft (excluded from generated site)
name: draft
widget: boolean
default: false
- label: Answer
name: body
widget: markdown
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
editor_components: []
- name: primary_services
label: Primary site — Services
label_singular: Service
description: Service cards shown on the primary Happy Tails site.
folder: sites/www/content/services
create: true
delete: true
extension: md
format: frontmatter
identifier_field: title
slug: "{{slug}}"
summary: "{{title}} — {{price}}"
sortable_fields: [order, title, featured, draft]
fields:
- label: Title
name: title
widget: string
pattern: ['^.{3,80}$', 'Use 3–80 characters.']
- label: Price label
name: price
widget: string
pattern: ['^.{2,48}$', 'Use 2–48 characters.']
- label: Included items
name: items
widget: list
min: 1
max: 8
field:
label: Item
name: item
widget: string
pattern: ['^.{3,80}$', 'Use 3–80 characters.']
- label: Display order
name: order
widget: number
value_type: int
min: 0
max: 99
step: 1
default: 0
- label: Featured
name: featured
widget: boolean
default: false
- label: Draft (excluded from generated site)
name: draft
widget: boolean
default: false
- label: Description
name: body
widget: markdown
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
editor_components: []
- name: primary_team
label: Primary site — Team
label_singular: Team member
description: Team profiles shown on the primary Happy Tails site.
folder: sites/www/content/team
create: true
delete: true
extension: md
format: frontmatter
identifier_field: name
slug: "{{slug}}"
summary: "{{name}} — {{role}}"
sortable_fields: [order, name, role, draft]
fields:
- label: Name
name: name
widget: string
pattern: ['^.{3,80}$', 'Use 3–80 characters.']
- label: Role
name: role
widget: string
pattern: ['^.{3,80}$', 'Use 3–80 characters.']
- label: Approved raster image
name: image
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config:
max_file_size: 512000
multiple: false
- label: Display order
name: order
widget: number
value_type: int
min: 0
max: 99
step: 1
default: 0
- label: Draft (excluded from generated site)
name: draft
widget: boolean
default: false
- label: Biography
name: body
widget: markdown
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
editor_components: []
- name: primary_page_images
label: Primary site — Page images
description: Optional images and required accessible descriptions for the primary site's main pages.
files:
- label: Home, About, Services, and Contact
name: primary
file: sites/www/content/page-images/primary.md
fields:
- label: Home hero image
name: homeHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Home hero image description
name: homeHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: Home story image
name: homeStoryImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Home story image description
name: homeStoryImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: About hero image
name: aboutHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: About hero image description
name: aboutHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: About story image
name: aboutStoryImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: About story image description
name: aboutStoryImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: Services hero image
name: servicesHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Services hero image description
name: servicesHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- label: Contact hero image
name: contactHeroImage
widget: image
required: false
choose_url: false
allow_multiple: false
pattern: ['^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$', 'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.']
media_library:
config: { max_file_size: 512000, multiple: false }
- label: Contact hero image description
name: contactHeroImageAlt
widget: string
pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.']
- name: journal_posts
label: Journal — Posts
label_singular: Journal post
description: Markdown posts published on the Happy Tails Journal preview. Draft entries stay out of the generated site.
folder: sites/blog/content/posts
create: true
delete: true
extension: md
format: frontmatter
identifier_field: title
slug: "{{slug}}"
summary: "{{title}} — {{pubDate}}"
sortable_fields: [pubDate, updatedDate, title, draft]
fields:
- label: Title
name: title
widget: string
pattern: ['^.{12,100}$', 'Use 12–100 characters.']
- label: Description
name: description
widget: text
pattern: ['^[\s\S]{30,180}$', 'Use 30–180 characters.']
- label: Publish date
name: pubDate
widget: date
format: YYYY-MM-DD
- label: Updated date
name: updatedDate
widget: date
format: YYYY-MM-DD
required: false
- label: Tags
name: tags
widget: list
min: 1
max: 4
field:
label: Tag
name: tag
widget: string
pattern: ['^.{2,32}$', 'Use 2–32 characters.']
- label: Draft (excluded from generated site)
name: draft
widget: boolean
default: false
- label: Body
name: body
widget: markdown
buttons: [bold, italic, link, heading-two, heading-three, quote, bulleted-list, numbered-list]
editor_components: []
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex,nofollow,noarchive" />
<meta name="referrer" content="no-referrer" />
<link href="/admin/config.yml" type="text/yaml" rel="cms-config-url" />
<title>Happy Tails preview content manager</title>
</head>
<body>
<noscript>The preview content manager requires JavaScript.</noscript>
<!-- Keep the exact version and integrity hash in sync with scripts/verify-decap.mjs. -->
<script
src="https://unpkg.com/decap-cms@3.15.1/dist/decap-cms.js"
integrity="sha384-in6eHztHveqQ7uMZ1fDaKlDmacQLFuLH2wWrFTiymyuS8zQ5bixwL8U3AeRi8h/L"
crossorigin="anonymous"
></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+44 -5
View File
@@ -1,7 +1,7 @@
import Ajv2020 from 'ajv/dist/2020.js'; import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats'; import addFormats from 'ajv-formats';
import { readdir, readFile, stat } from 'node:fs/promises'; import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path'; import { extname, join, relative, sep } from 'node:path';
const readJson = async (path) => JSON.parse(await readFile(path, 'utf8')); const readJson = async (path) => JSON.parse(await readFile(path, 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: true }); const ajv = new Ajv2020({ allErrors: true, strict: true });
@@ -23,8 +23,47 @@ for (const key of Object.keys(sites.sites)) {
if (!siteCheck(data)) throw new Error(`${key} site data rejected: ${ajv.errorsText(siteCheck.errors)}`); if (!siteCheck(data)) throw new Error(`${key} site data rejected: ${ajv.errorsText(siteCheck.errors)}`);
} }
for (const file of await readdir('public/images')) { async function filesUnder(directory) {
const info = await stat(join('public/images', file)); const entries = await readdir(directory, { withFileTypes: true });
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${file}`); const files = [];
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) files.push(...await filesUnder(path));
else if (entry.isFile()) files.push(path);
else throw new Error(`Unsupported filesystem entry in public images: ${path}`);
}
return files;
} }
console.log('Intake fixtures, deployment manifest, site data, and image limits are valid.');
const uploadRoot = join('public', 'images', 'uploads');
const allowedUploadExtensions = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
const ascii = (value) => Buffer.from(value, 'ascii');
const startsWith = (buffer, signature) => buffer.subarray(0, signature.length).equals(signature);
const isRasterForExtension = (buffer, extension) => {
if (extension === '.jpg' || extension === '.jpeg') return startsWith(buffer, Buffer.from([0xff, 0xd8, 0xff]));
if (extension === '.png') return startsWith(buffer, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
if (extension === '.webp') return buffer.length >= 12 && buffer.subarray(0, 4).equals(ascii('RIFF')) && buffer.subarray(8, 12).equals(ascii('WEBP'));
if (extension === '.avif' && buffer.length >= 16 && buffer.subarray(4, 8).equals(ascii('ftyp'))) {
for (let offset = 8; offset + 4 <= Math.min(buffer.length, 64); offset += 4) {
const brand = buffer.subarray(offset, offset + 4).toString('ascii');
if (brand === 'avif' || brand === 'avis') return true;
}
}
return false;
};
for (const path of await filesUnder('public/images')) {
const imagePath = relative('public/images', path).split(sep).join('/');
const info = await stat(path);
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${imagePath}`);
const uploadPath = relative(uploadRoot, path);
const isUpload = uploadPath !== '..' && !uploadPath.startsWith(`..${sep}`);
if (!isUpload) continue;
if (uploadPath.includes(sep)) throw new Error(`Nested CMS media paths are not allowed: ${imagePath}`);
const extension = extname(uploadPath).toLowerCase();
if (!allowedUploadExtensions.has(extension)) throw new Error(`CMS media is not an allowed raster extension: ${imagePath}`);
const buffer = await readFile(path);
if (!isRasterForExtension(buffer, extension)) throw new Error(`CMS media does not match its raster extension: ${imagePath}`);
}
console.log('Intake fixtures, deployment manifest, site data, and bounded raster images are valid.');
+20
View File
@@ -16,6 +16,21 @@ for (const { file, html } of pages) {
for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`)); for (const match of html.matchAll(/(?:src|href)="(\/images\/[^"?#]+)"/g)) await access(resolve(root, `.${match[1]}`));
} }
const robots = await readFile(resolve(root, 'robots.txt'), 'utf8');
if (process.env.DEPLOY_ENV === 'production') {
if (!robots.includes('Allow: /') || !robots.includes('Sitemap: https://')) throw new Error(`${key} production robots rules are not indexable.`);
} else {
if (robots !== 'User-agent: *\nDisallow: /\n') throw new Error(`${key} preview robots rules must disallow all crawling.`);
for (const { file, html } of pages) if (!html.includes('<meta name="robots" content="noindex,nofollow">')) throw new Error(`${file} is missing preview no-index metadata.`);
}
await Promise.all(['admin/index.html', 'admin/config.yml', '_headers'].map((file) => access(resolve(root, file))));
const admin = await readFile(resolve(root, 'admin/index.html'), 'utf8');
if (!admin.includes('content="noindex,nofollow,noarchive"')) throw new Error('Admin shell is missing no-index metadata.');
if (!admin.includes('decap-cms@3.15.1/dist/decap-cms.js')) throw new Error('Admin shell is missing the pinned Decap CMS application.');
const headers = await readFile(resolve(root, '_headers'), 'utf8');
if (!/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers)) throw new Error('Admin response headers are missing the no-index rule.');
const home = await readFile(resolve(root, 'index.html'), 'utf8'); const home = await readFile(resolve(root, 'index.html'), 'utf8');
if (key === 'www') { if (key === 'www') {
for (const { file, html } of pages) if (!html.includes('href="https://blog.3031023.xyz/"')) throw new Error(`${file} is missing the Happy Tails Journal link.`); for (const { file, html } of pages) if (!html.includes('href="https://blog.3031023.xyz/"')) throw new Error(`${file} is missing the Happy Tails Journal link.`);
@@ -25,6 +40,11 @@ if (key === 'www') {
for (const title of ['Dog walking', 'Grooming', 'Overnight care']) if (!services.includes(title)) throw new Error(`Service Markdown entry is missing: ${title}.`); for (const title of ['Dog walking', 'Grooming', 'Overnight care']) if (!services.includes(title)) throw new Error(`Service Markdown entry is missing: ${title}.`);
const about = await readFile(resolve(root, 'about/index.html'), 'utf8'); const about = await readFile(resolve(root, 'about/index.html'), 'utf8');
for (const name of ['Sarah Mitchell', 'Marcus Rivera', 'Emily Chen', 'David Okonkwo']) if (!about.includes(name)) throw new Error(`Team Markdown entry is missing: ${name}.`); for (const name of ['Sarah Mitchell', 'Marcus Rivera', 'Emily Chen', 'David Okonkwo']) if (!about.includes(name)) throw new Error(`Team Markdown entry is missing: ${name}.`);
for (const image of ['/images/uploads/hero-dogs.jpg', '/images/uploads/happy-tails-team.jpg']) if (!home.includes(image)) throw new Error(`CMS-controlled homepage image is missing: ${image}.`);
for (const image of ['/images/uploads/sarah-mitchell.jpg', '/images/uploads/marcus-rivera.jpg', '/images/uploads/emily-chen.jpg', '/images/uploads/david-okonkwo.jpg']) if (!about.includes(image)) throw new Error(`CMS-controlled team image is missing: ${image}.`);
const contact = await readFile(resolve(root, 'contact/index.html'), 'utf8');
if (!contact.includes('/images/uploads/dog-walking.jpg')) throw new Error('CMS-controlled contact image is missing.');
if (!services.includes('/images/uploads/dog-grooming.jpg')) throw new Error('CMS-controlled services image is missing.');
} }
if (key === 'blog') { if (key === 'blog') {
const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist', 'weekend-care-prep']; const publishedPosts = ['first-walk-after-work', 'gentler-grooming-day', 'overnight-care-checklist', 'weekend-care-prep'];
+236
View File
@@ -0,0 +1,236 @@
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import yaml from 'js-yaml';
const fail = (message) => { throw new Error(message); };
const assert = (condition, message) => { if (!condition) fail(message); };
const same = (actual, expected) => JSON.stringify(actual) === JSON.stringify(expected);
function expectSubset(actual, expected, path) {
assert(actual && typeof actual === 'object', `${path} must be an object.`);
for (const [key, value] of Object.entries(expected)) {
const currentPath = `${path}.${key}`;
if (value && typeof value === 'object' && !Array.isArray(value)) expectSubset(actual[key], value, currentPath);
else assert(same(actual[key], value), `${currentPath} must be ${JSON.stringify(value)}.`);
}
}
const configPath = 'public/admin/config.yml';
const config = yaml.load(await readFile(configPath, 'utf8'));
const expectedOrigin = 'https://cms-3031023-xyz-www.ksloan90.workers.dev';
const expectedBackend = {
name: 'gitea',
repo: 'SloanOps/3031023-xyz',
branch: 'cms',
api_root: 'https://git.sloanops.com/api/v1',
base_url: 'https://git.sloanops.com',
auth_endpoint: 'https://git.sloanops.com/login/oauth/authorize',
};
expectSubset(config.backend, expectedBackend, 'backend');
assert(same(Object.keys(config.backend).sort(), [...Object.keys(expectedBackend), 'app_id'].sort()), 'The backend must not contain secret-bearing or unreviewed options.');
assert(typeof config.backend.app_id === 'string' && /^[A-Za-z0-9._-]+$/.test(config.backend.app_id), 'backend.app_id must be a non-confidential public client ID or the review placeholder.');
assert(config.publish_mode === 'simple', 'The Gitea pilot must use simple mode on its fixed review branch.');
assert(config.site_url === expectedOrigin && config.display_url === expectedOrigin, 'The CMS must use the stable primary preview origin.');
assert(config.local_backend === undefined, 'The pilot configuration must not enable an alternate local backend.');
expectSubset(config, {
media_folder: 'public/images/uploads',
public_folder: '/images/uploads',
}, 'config');
assert(config.media_library === undefined, 'Top-level media_library is reserved for registered external libraries; bound the built-in uploader on the image field.');
const markdownField = {
widget: 'markdown',
buttons: ['bold', 'italic', 'link', 'heading-two', 'heading-three', 'quote', 'bulleted-list', 'numbered-list'],
editor_components: [],
};
const cmsImagePattern = [
'^/images/uploads/[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:jpe?g|png|webp|avif)$',
'Choose an approved JPG, PNG, WebP, or AVIF upload. SVG and general files are not allowed.',
];
const optionalImageField = {
widget: 'image',
required: false,
choose_url: false,
allow_multiple: false,
pattern: cmsImagePattern,
media_library: { config: { max_file_size: 512000, multiple: false } },
};
const imageAltField = { widget: 'string', pattern: ['^.{8,180}$', 'Describe the image in 8–180 characters.'] };
const expectedCollections = {
primary_faq: {
folder: 'sites/www/content/faq',
identifier_field: 'question',
fields: {
question: { widget: 'string', pattern: ['^.{12,180}$', 'Use 12–180 characters.'] },
category: { widget: 'string', default: 'General', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] },
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
primary_services: {
folder: 'sites/www/content/services',
identifier_field: 'title',
fields: {
title: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
price: { widget: 'string', pattern: ['^.{2,48}$', 'Use 2–48 characters.'] },
items: { widget: 'list', min: 1, max: 8, field: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] } },
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
featured: { widget: 'boolean', default: false },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
primary_team: {
folder: 'sites/www/content/team',
identifier_field: 'name',
fields: {
name: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
role: { widget: 'string', pattern: ['^.{3,80}$', 'Use 3–80 characters.'] },
image: optionalImageField,
order: { widget: 'number', value_type: 'int', min: 0, max: 99, step: 1, default: 0 },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
journal_posts: {
folder: 'sites/blog/content/posts',
identifier_field: 'title',
fields: {
title: { widget: 'string', pattern: ['^.{12,100}$', 'Use 12–100 characters.'] },
description: { widget: 'text', pattern: ['^[\\s\\S]{30,180}$', 'Use 30–180 characters.'] },
pubDate: { widget: 'date', format: 'YYYY-MM-DD' },
updatedDate: { widget: 'date', format: 'YYYY-MM-DD', required: false },
tags: { widget: 'list', min: 1, max: 4, field: { widget: 'string', pattern: ['^.{2,32}$', 'Use 2–32 characters.'] } },
draft: { widget: 'boolean', default: false },
body: markdownField,
},
},
};
assert(Array.isArray(config.collections), 'collections must be an array.');
assert(same(config.collections.map(({ name }) => name).sort(), [...Object.keys(expectedCollections), 'primary_page_images'].sort()), 'Decap must expose the four content collections and one fixed page-image collection.');
for (const collection of config.collections.filter(({ name }) => expectedCollections[name])) {
const expected = expectedCollections[collection.name];
expectSubset(collection, {
folder: expected.folder,
identifier_field: expected.identifier_field,
create: true,
delete: true,
extension: 'md',
format: 'frontmatter',
slug: '{{slug}}',
}, `collections.${collection.name}`);
const fields = Object.fromEntries(collection.fields.map((field) => [field.name, field]));
assert(same(Object.keys(fields).sort(), Object.keys(expected.fields).sort()), `${collection.name} must preserve every modeled frontmatter field and body.`);
for (const [fieldName, fieldContract] of Object.entries(expected.fields)) expectSubset(fields[fieldName], fieldContract, `${collection.name}.${fieldName}`);
}
const pageImageCollection = config.collections.find(({ name }) => name === 'primary_page_images');
assert(pageImageCollection.folder === undefined && pageImageCollection.create === undefined && pageImageCollection.delete === undefined, 'Page images must remain a fixed file collection.');
assert(Array.isArray(pageImageCollection.files) && pageImageCollection.files.length === 1, 'Page images must expose exactly one fixed record.');
const pageImageFile = pageImageCollection.files[0];
expectSubset(pageImageFile, { name: 'primary', file: 'sites/www/content/page-images/primary.md' }, 'primary_page_images.primary');
const expectedPageImageFields = {
homeHeroImage: optionalImageField,
homeHeroImageAlt: imageAltField,
homeStoryImage: optionalImageField,
homeStoryImageAlt: imageAltField,
aboutHeroImage: optionalImageField,
aboutHeroImageAlt: imageAltField,
aboutStoryImage: optionalImageField,
aboutStoryImageAlt: imageAltField,
servicesHeroImage: optionalImageField,
servicesHeroImageAlt: imageAltField,
contactHeroImage: optionalImageField,
contactHeroImageAlt: imageAltField,
};
const pageImageFields = Object.fromEntries(pageImageFile.fields.map((field) => [field.name, field]));
assert(same(Object.keys(pageImageFields).sort(), Object.keys(expectedPageImageFields).sort()), 'Page images must expose only the six approved image and description pairs.');
for (const [fieldName, fieldContract] of Object.entries(expectedPageImageFields)) expectSubset(pageImageFields[fieldName], fieldContract, `primary_page_images.${fieldName}`);
function visitField(field) {
assert(field.widget !== 'file', `General file widget is forbidden: ${field.name}.`);
if (field.fields) field.fields.forEach(visitField);
if (field.field) visitField(field.field);
}
const allFields = config.collections.flatMap(({ fields, files }) => fields ?? files.flatMap((file) => file.fields));
allFields.forEach(visitField);
const imageFields = allFields.filter(({ widget }) => widget === 'image');
assert(imageFields.length === 7, 'Only the team image and six fixed page-image fields may expose media selection.');
for (const field of imageFields) expectSubset(field, optionalImageField, `image field ${field.name}`);
const schema = await readFile('src/content.config.ts', 'utf8');
for (const fragment of [
'title: z.string().min(12).max(100)',
'description: z.string().min(30).max(180)',
'updatedDate: z.coerce.date().optional()',
'tags: z.array(z.string().min(2).max(32)).min(1).max(4)',
"question: z.string().min(12).max(180)",
"category: z.string().min(2).max(48).default('General')",
'title: z.string().min(3).max(80)',
'price: z.string().min(2).max(48)',
'items: z.array(z.string().min(3).max(80)).min(1).max(8)',
'name: z.string().min(3).max(80)',
'role: z.string().min(3).max(80)',
'const optionalCmsImage = z.preprocess(',
'image: optionalCmsImage',
'homeHeroImage: optionalCmsImage',
'contactHeroImageAlt: z.string().min(8).max(180)',
'order: z.number().int().min(0).max(99).default(0)',
'draft: z.boolean().default(false)',
'featured: z.boolean().default(false)',
]) assert(schema.includes(fragment), `Astro schema changed; review the Decap contract for: ${fragment}`);
async function verifyContentPaths(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
assert(/^[a-z0-9][a-z0-9-]*$/.test(entry.name), `Content directory is outside the Markdown slug contract: ${path}`);
await verifyContentPaths(path);
} else {
assert(entry.isFile(), `Special content paths are outside the pilot contract: ${path}`);
assert(/^[a-z0-9][a-z0-9-]*\.md$/.test(entry.name), `Content filename is outside the Markdown slug contract: ${path}`);
}
}
}
for (const directory of Object.values(expectedCollections).map(({ folder }) => folder)) await verifyContentPaths(directory);
await verifyContentPaths('sites/www/content/page-images');
const admin = await readFile('public/admin/index.html', 'utf8');
assert(admin.includes('content="noindex,nofollow,noarchive"'), 'The admin shell must always carry no-index metadata.');
assert(admin.includes('href="/admin/config.yml"') && admin.includes('rel="cms-config-url"'), 'The admin shell must load the reviewed configuration.');
assert(admin.includes('https://unpkg.com/decap-cms@3.15.1/dist/decap-cms.js'), 'The admin shell must pin Decap CMS 3.15.1 exactly.');
assert(admin.includes('sha384-in6eHztHveqQ7uMZ1fDaKlDmacQLFuLH2wWrFTiymyuS8zQ5bixwL8U3AeRi8h/L'), 'The Decap bundle must retain its verified SHA-384 integrity value.');
assert(!/decap-cms@(latest|[~^*])/.test(admin), 'Floating Decap CMS versions are forbidden.');
const headers = await readFile('public/_headers', 'utf8');
assert(/\/admin\/\*[\s\S]*X-Robots-Tag: noindex, nofollow, noarchive/.test(headers), 'Admin responses must include an X-Robots-Tag no-index rule.');
const workflow = yaml.load(await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8'));
assert(workflow.name === 'CMS content validation', 'The validation workflow display name is part of the required status context.');
assert(workflow.on?.pull_request && same(workflow.on.pull_request.branches, ['main']), 'The validation workflow must run for every pull request into main.');
assert(workflow.on.pull_request.paths === undefined, 'The required validation workflow must not use path filters.');
assert(workflow.jobs?.validate?.name === 'cms-content-validation', 'The validation job name is part of the required status context.');
const workflowText = await readFile('.gitea/workflows/cms-content-validation.yml', 'utf8');
assert(!workflowText.includes('secrets.'), 'CMS pull-request validation must remain secret-free.');
for (const command of ['npm run verify:cms', 'SITE_KEY=www npm run build:site', 'SITE_KEY=www npm run verify:build', 'SITE_KEY=blog npm run build:site', 'SITE_KEY=blog npm run verify:build']) {
assert(workflowText.includes(command), `Validation workflow is missing: ${command}`);
}
for (const path of [
'.gitea/workflows/cms-content-validation.yml',
'.gitea/workflows/deploy.yml',
'.gitea/workflows/deploy-blog.yml',
'.gitea/workflows/production-candidate.yml',
'.gitea/workflows/production-blog.yml',
]) {
const text = await readFile(path, 'utf8');
assert(text.includes('uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4'), `${path} must pin checkout v4 to its reviewed commit.`);
assert(text.includes('uses: https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4'), `${path} must pin setup-node v4 to its reviewed commit.`);
assert(/cache:\s*npm/.test(text) && /cache-dependency-path:\s*package-lock\.json/.test(text), `${path} must cache npm downloads by package-lock.json.`);
assert(text.includes('npm ci --prefer-offline --no-audit --no-fund'), `${path} must retain a clean lockfile install with quiet, cache-friendly npm options.`);
}
console.log('Verified Decap backend, four content contracts, fixed page images, bounded media, admin pin, and stable PR status context.');
+14
View File
@@ -0,0 +1,14 @@
---
homeHeroImage: /images/uploads/hero-dogs.jpg
homeHeroImageAlt: Two happy dogs playing outdoors
homeStoryImage: /images/uploads/happy-tails-team.jpg
homeStoryImageAlt: Happy Tails team with dogs in the park
aboutHeroImage: /images/uploads/happy-tails-team.jpg
aboutHeroImageAlt: Dogs and the Happy Tails team enjoying the park
aboutStoryImage: /images/uploads/happy-tails-team.jpg
aboutStoryImageAlt: Happy Tails team with dogs in the park
servicesHeroImage: /images/uploads/dog-grooming.jpg
servicesHeroImageAlt: A dog receiving gentle grooming care
contactHeroImage: /images/uploads/dog-walking.jpg
contactHeroImageAlt: A happy dog out for a walk
---
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: David Okonkwo name: David Okonkwo
role: Overnight Care role: Overnight Care
image: /images/david-okonkwo.jpg image: /images/uploads/david-okonkwo.jpg
order: 40 order: 40
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: Emily Chen name: Emily Chen
role: Pack Walker role: Pack Walker
image: /images/emily-chen.jpg image: /images/uploads/emily-chen.jpg
order: 30 order: 30
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: Marcus Rivera name: Marcus Rivera
role: Lead Trainer role: Lead Trainer
image: /images/marcus-rivera.jpg image: /images/uploads/marcus-rivera.jpg
order: 20 order: 20
--- ---
+1 -1
View File
@@ -1,7 +1,7 @@
--- ---
name: Sarah Mitchell name: Sarah Mitchell
role: Founder & Head Groomer role: Founder & Head Groomer
image: /images/sarah-mitchell.jpg image: /images/uploads/sarah-mitchell.jpg
order: 10 order: 10
--- ---
+3 -2
View File
@@ -1,9 +1,10 @@
--- ---
import { site } from '../lib/site-data.js'; import { site } from '../lib/site-data.js';
const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props;
--- ---
<section class="hero"> <section class="hero">
<div class="shell hero-grid"> <div class:list={['shell hero-grid', { 'hero-grid--text-only': !image }]}>
<div class="hero-copy"><p class="eyebrow">{site.hero.eyebrow}</p><h1>{site.hero.headline}</h1><p class="lede">{site.hero.copy}</p><a class="button" href={site.hero.ctaHref}>{site.hero.cta} <span aria-hidden="true">→</span></a></div> <div class="hero-copy"><p class="eyebrow">{site.hero.eyebrow}</p><h1>{site.hero.headline}</h1><p class="lede">{site.hero.copy}</p><a class="button" href={site.hero.ctaHref}>{site.hero.cta} <span aria-hidden="true">→</span></a></div>
<div class="hero-image"><img src={site.hero.image} alt={site.hero.imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div> {image && <div class="hero-image"><img src={image} alt={imageAlt} fetchpriority="high" /><div class="image-note">Kind care. Every day.</div></div>}
</div> </div>
</section> </section>
+1 -1
View File
@@ -2,7 +2,7 @@
const { eyebrow, title, lede, image, imageAlt } = Astro.props; const { eyebrow, title, lede, image, imageAlt } = Astro.props;
--- ---
<section class="page-intro"> <section class="page-intro">
<div class="shell page-intro-grid"> <div class:list={['shell page-intro-grid', { 'page-intro-grid--text-only': !image }]}>
<div><p class="eyebrow">{eyebrow}</p><h1>{title}</h1><p class="lede">{lede}</p></div> <div><p class="eyebrow">{eyebrow}</p><h1>{title}</h1><p class="lede">{lede}</p></div>
{image && <img src={image} alt={imageAlt} loading="eager" />} {image && <img src={image} alt={imageAlt} loading="eager" />}
</div> </div>
+25 -2
View File
@@ -1,6 +1,11 @@
import { defineCollection, z } from 'astro:content'; import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders'; import { glob } from 'astro/loaders';
const optionalCmsImage = z.preprocess(
(value) => value === '' || value === null ? undefined : value,
z.string().regex(/^\/images\/uploads\/[A-Za-z0-9][A-Za-z0-9._-]*\.(?:jpe?g|png|webp|avif)$/).optional(),
);
const blog = defineCollection({ const blog = defineCollection({
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }), loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
schema: z.object({ schema: z.object({
@@ -40,10 +45,28 @@ const team = defineCollection({
schema: z.object({ schema: z.object({
name: z.string().min(3).max(80), name: z.string().min(3).max(80),
role: z.string().min(3).max(80), role: z.string().min(3).max(80),
image: z.string().startsWith('/images/'), image: optionalCmsImage,
order: z.number().int().min(0).max(99).default(0), order: z.number().int().min(0).max(99).default(0),
draft: z.boolean().default(false), draft: z.boolean().default(false),
}), }),
}); });
export const collections = { blog, faq, services, team }; const pageImages = defineCollection({
loader: glob({ base: './sites/www/content/page-images', pattern: '**/*.md' }),
schema: z.object({
homeHeroImage: optionalCmsImage,
homeHeroImageAlt: z.string().min(8).max(180),
homeStoryImage: optionalCmsImage,
homeStoryImageAlt: z.string().min(8).max(180),
aboutHeroImage: optionalCmsImage,
aboutHeroImageAlt: z.string().min(8).max(180),
aboutStoryImage: optionalCmsImage,
aboutStoryImageAlt: z.string().min(8).max(180),
servicesHeroImage: optionalCmsImage,
servicesHeroImageAlt: z.string().min(8).max(180),
contactHeroImage: optionalCmsImage,
contactHeroImageAlt: z.string().min(8).max(180),
}),
});
export const collections = { blog, faq, services, team, pageImages };
+2 -1
View File
@@ -6,5 +6,6 @@ import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/'); if (isBlog) return Astro.redirect('/');
const team = (await getCollection('team', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order); const team = (await getCollection('team', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
const teamCards = await Promise.all(team.map(async (member) => ({ member, Content: (await render(member)).Content }))); const teamCards = await Promise.all(team.map(async (member) => ({ member, Content: (await render(member)).Content })));
const pageImages = (await getCollection('pageImages'))[0]?.data;
--- ---
<BaseLayout title={`About | ${site.name}`}><PageIntro eyebrow="Our pack" title="We know that great care starts with knowing your dog." lede="The Happy Tails team brings patience, playfulness, and real experience to every visit." image="/images/happy-tails-team.jpg" imageAlt="Dogs and the Happy Tails team enjoying the park" /><section class="section"><div class="shell story-grid"><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /></div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{teamCards.map(({ member, Content }) => <article class="team-card"><div class="team-avatar" aria-hidden="true">{member.data.name.split(' ').map((part) => part[0]).join('')}</div><div><p class="eyebrow">{member.data.role}</p><h3>{member.data.name}</h3><Content /></div></article>)}</div></div></section></BaseLayout> <BaseLayout title={`About | ${site.name}`}><PageIntro eyebrow="Our pack" title="We know that great care starts with knowing your dog." lede="The Happy Tails team brings patience, playfulness, and real experience to every visit." image={pageImages?.aboutHeroImage} imageAlt={pageImages?.aboutHeroImageAlt} /><section class="section"><div class:list={['shell story-grid', { 'story-grid--text-only': !pageImages?.aboutStoryImage }]}><div><p class="eyebrow">How we work</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><p>Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.</p></div>{pageImages?.aboutStoryImage && <img src={pageImages.aboutStoryImage} alt={pageImages.aboutStoryImageAlt} loading="lazy" />}</div></section><section class="section surface"><div class="shell"><div class="section-heading"><p class="eyebrow">Meet the team</p><h2>Good people for good dogs.</h2></div><div class="team-grid">{teamCards.map(({ member, Content }) => <article class="team-card">{member.data.image ? <img class="team-photo" src={member.data.image} alt={`${member.data.name}, ${member.data.role}`} loading="lazy" /> : <div class="team-avatar" aria-hidden="true">{member.data.name.split(' ').map((part) => part[0]).join('')}</div>}<div><p class="eyebrow">{member.data.role}</p><h3>{member.data.name}</h3><Content /></div></article>)}</div></div></section></BaseLayout>
+3 -1
View File
@@ -2,7 +2,9 @@
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import PageIntro from '../components/PageIntro.astro'; import PageIntro from '../components/PageIntro.astro';
import LocationMap from '../components/LocationMap.astro'; import LocationMap from '../components/LocationMap.astro';
import { getCollection } from 'astro:content';
import { isBlog, site } from '../lib/site-data.js'; import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/'); if (isBlog) return Astro.redirect('/');
const pageImages = (await getCollection('pageImages'))[0]?.data;
--- ---
<BaseLayout title={`Contact | ${site.name}`}><PageIntro eyebrow="Get in touch" title="Let's make your dog's day." lede="Reach out to ask a question or start planning the right care routine." image="/images/dog-walking.jpg" imageAlt="A happy dog out for a walk" /><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section>{site.contact.showMap && <section class="section surface"><div class="shell"><LocationMap address={site.contact.address} label={site.name} /></div></section>}</BaseLayout> <BaseLayout title={`Contact | ${site.name}`}><PageIntro eyebrow="Get in touch" title="Let's make your dog's day." lede="Reach out to ask a question or start planning the right care routine." image={pageImages?.contactHeroImage} imageAlt={pageImages?.contactHeroImageAlt} /><section class="section"><div class="shell contact-grid"><div><p class="eyebrow">Happy Tails Pet Care</p><h2>We would love to hear from you.</h2><address class="contact-details"><p>{site.contact.address}</p><p><a href={`tel:${site.contact.phoneHref}`}>{site.contact.phone}</a></p><p><a href={`mailto:${site.contact.email}`}>{site.contact.email}</a></p><p>{site.contact.hours}</p></address></div><form class="contact-form"><label>Name<input type="text" autocomplete="name" disabled /></label><label>Email<input type="email" autocomplete="email" disabled /></label><label>How can we help?<textarea rows="5" disabled></textarea></label><button class="button" type="button" disabled>Contact route coming soon</button><p>This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.</p></form></div></section>{site.contact.showMap && <section class="section surface"><div class="shell"><LocationMap address={site.contact.address} label={site.name} /></div></section>}</BaseLayout>
+5 -2
View File
@@ -6,12 +6,15 @@ import Hero from '../components/Hero.astro';
import { isBlog, site } from '../lib/site-data.js'; import { isBlog, site } from '../lib/site-data.js';
const posts = isBlog ? (await getCollection('blog', ({ data }) => !data.draft)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) : []; const posts = isBlog ? (await getCollection('blog', ({ data }) => !data.draft)).sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()) : [];
const faqs = !isBlog ? (await getCollection('faq', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order) : []; const faqs = !isBlog ? (await getCollection('faq', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order) : [];
const pageImages = !isBlog ? (await getCollection('pageImages'))[0]?.data : undefined;
const heroImage = isBlog ? undefined : (pageImages?.homeHeroImage ?? null);
const heroImageAlt = isBlog ? undefined : pageImages?.homeHeroImageAlt;
--- ---
<BaseLayout> <BaseLayout>
<Hero /> <Hero image={heroImage} imageAlt={heroImageAlt} />
{isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{posts.map((post) => <article class="post-card"><p class="tag">{post.data.tags.join(' · ')}</p><h3><a href={`/posts/${post.id}/`}>{post.data.title}</a></h3><p>{post.data.description}</p><footer>{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}</footer></article>)}</div><p class="phase-note">This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.</p></div></section> : <> {isBlog ? <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">The journal</p><h2>Small notes for happier dog days.</h2></div><div class="post-grid">{posts.map((post) => <article class="post-card"><p class="tag">{post.data.tags.join(' · ')}</p><h3><a href={`/posts/${post.id}/`}>{post.data.title}</a></h3><p>{post.data.description}</p><footer>{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}</footer></article>)}</div><p class="phase-note">This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.</p></div></section> : <>
<section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section> <section class="section"><div class="shell"><div class="section-heading split"><div><p class="eyebrow">The Happy Tails difference</p><h2>A familiar face, a thoughtful routine, a very happy dog.</h2></div><p>We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.</p></div><div class="feature-grid">{site.features.map((feature) => <article class="feature"><span>{feature.icon}</span><h3>{feature.title}</h3><p>{feature.description}</p></article>)}</div></div></section>
<section class="section surface"><div class="shell story-grid"><img src={site.story.image} alt={site.story.imageAlt} loading="lazy" /><div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section> <section class="section surface"><div class:list={['shell story-grid', { 'story-grid--text-only': !pageImages?.homeStoryImage }]}>{pageImages?.homeStoryImage && <img src={pageImages.homeStoryImage} alt={pageImages.homeStoryImageAlt} loading="lazy" />}<div><p class="eyebrow">Our story</p><h2>{site.story.heading}</h2><p class="lede">{site.story.copy}</p><a class="text-link" href="/about">Meet the team →</a></div></div></section>
<section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq entries={faqs} /> <section class="section"><div class="shell"><div class="section-heading"><p class="eyebrow">Loved by local families</p><h2>Five-star care, shared in their own words.</h2></div><div class="review-grid">{site.reviews.map((review) => <blockquote class="review"><div aria-label={`${review.rating} out of 5 stars`} class="stars">★★★★★</div><p>“{review.quote}”</p><footer>{review.author} <span>Google review</span></footer></blockquote>)}</div><p class="review-disclosure">Review cards are a presentation pattern. Before launch, display only reviews approved for reuse and preserve their source, date, rating, and attribution under the business’s Google review policy.</p></div></section><Faq entries={faqs} />
</>} </>}
</BaseLayout> </BaseLayout>
+2 -1
View File
@@ -7,5 +7,6 @@ import { isBlog, site } from '../lib/site-data.js';
if (isBlog) return Astro.redirect('/'); if (isBlog) return Astro.redirect('/');
const services = (await getCollection('services', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order); const services = (await getCollection('services', ({ data }) => !data.draft)).sort((a, b) => a.data.order - b.data.order);
const serviceCards = await Promise.all(services.map(async (service) => ({ service, Content: (await render(service)).Content }))); const serviceCards = await Promise.all(services.map(async (service) => ({ service, Content: (await render(service)).Content })));
const pageImages = (await getCollection('pageImages'))[0]?.data;
--- ---
<BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image="/images/dog-grooming.jpg" imageAlt="A dog receiving gentle grooming care" /><section class="section"><div class="shell service-grid">{serviceCards.map(({ service, Content }) => <article class:list={['service-card', { featured: service.data.featured }]}><p class="eyebrow">{service.data.price}</p><h2>{service.data.title}</h2><Content /><ul>{service.data.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.data.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout> <BaseLayout title={`Services | ${site.name}`}><PageIntro eyebrow="Services" title="Everything your dog needs for a brighter day." lede="Flexible, personal care—with no one-size-fits-all routines." image={pageImages?.servicesHeroImage} imageAlt={pageImages?.servicesHeroImageAlt} /><section class="section"><div class="shell service-grid">{serviceCards.map(({ service, Content }) => <article class:list={['service-card', { featured: service.data.featured }]}><p class="eyebrow">{service.data.price}</p><h2>{service.data.title}</h2><Content /><ul>{service.data.items.map((item) => <li>{item}</li>)}</ul><a class="text-link" href="/contact">Ask about {service.data.title.toLowerCase()} →</a></article>)}</div></section><CallToAction eyebrow="A simple start" heading="Let's meet your dog first." copy="Tell us a little about their routine, personality, and what would make their day better." href="/contact" label="Book a meet and greet" /></BaseLayout>
+3
View File
@@ -20,6 +20,7 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.menu-button { display: none; font: inherit; font-weight: 600; color: var(--ink); background: transparent; border: 0; } .menu-button { display: none; font: inherit; font-weight: 600; color: var(--ink); background: transparent; border: 0; }
.hero { padding: clamp(4rem, 9vw, 8rem) 0; overflow: hidden; background: linear-gradient(110deg, var(--surface) 0%, var(--surface) 68%, #fff 68%); } .hero { padding: clamp(4rem, 9vw, 8rem) 0; overflow: hidden; background: linear-gradient(110deg, var(--surface) 0%, var(--surface) 68%, #fff 68%); }
.hero-grid,.story-grid,.contact-grid { display: grid; grid-template-columns: 1.02fr .98fr; align-items: center; gap: clamp(3rem, 7vw, 8rem); } .hero-grid,.story-grid,.contact-grid { display: grid; grid-template-columns: 1.02fr .98fr; align-items: center; gap: clamp(3rem, 7vw, 8rem); }
.hero-grid--text-only,.story-grid--text-only { grid-template-columns: minmax(0, 720px); }
.hero-copy { padding: 1rem 0; } .hero-copy { padding: 1rem 0; }
.eyebrow,.tag { color: color-mix(in srgb, var(--accent), var(--ink) 25%); text-transform: uppercase; letter-spacing: .13em; font-size: .73rem; font-weight: 700; margin-bottom: 1rem; } .eyebrow,.tag { color: color-mix(in srgb, var(--accent), var(--ink) 25%); text-transform: uppercase; letter-spacing: .13em; font-size: .73rem; font-weight: 700; margin-bottom: 1rem; }
.lede { color: #52616b; font-size: clamp(1.1rem, 2vw, 1.32rem); max-width: 48ch; } .lede { color: #52616b; font-size: clamp(1.1rem, 2vw, 1.32rem); max-width: 48ch; }
@@ -45,7 +46,9 @@ nav a[aria-current='page'], nav a:hover { color: var(--ink); }
.review footer { font-weight: 700; }.review footer span { display: block; margin-top: .15rem; color: #75828c; font-size: .82rem; font-weight: 400; } .review footer { font-weight: 700; }.review footer span { display: block; margin-top: .15rem; color: #75828c; font-size: .82rem; font-weight: 400; }
.review-disclosure,.phase-note { margin: 1.8rem auto 0; color: #66747d; font-size: .86rem; max-width: 75ch; } .review-disclosure,.phase-note { margin: 1.8rem auto 0; color: #66747d; font-size: .86rem; max-width: 75ch; }
.page-intro { padding: clamp(3rem, 5vw, 4.75rem) 0; background: var(--surface); overflow: hidden; }.page-intro-grid { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; gap: clamp(2rem, 6vw, 6rem); }.page-intro h1 { max-width: 15ch; font-size: clamp(2.75rem, 5vw, 5rem); }.page-intro .lede { max-width: 55ch; }.page-intro img { width: 100%; aspect-ratio: 1.25 / 1; object-fit: cover; border-radius: 1rem; box-shadow: 1rem 1rem 0 color-mix(in srgb, var(--accent), transparent 80%); } .page-intro { padding: clamp(3rem, 5vw, 4.75rem) 0; background: var(--surface); overflow: hidden; }.page-intro-grid { display: grid; grid-template-columns: 1.2fr .8fr; align-items: center; gap: clamp(2rem, 6vw, 6rem); }.page-intro h1 { max-width: 15ch; font-size: clamp(2.75rem, 5vw, 5rem); }.page-intro .lede { max-width: 55ch; }.page-intro img { width: 100%; aspect-ratio: 1.25 / 1; object-fit: cover; border-radius: 1rem; box-shadow: 1rem 1rem 0 color-mix(in srgb, var(--accent), transparent 80%); }
.page-intro-grid--text-only { grid-template-columns: minmax(0, 820px); }
.team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }.team-card { display: grid; grid-template-columns: 120px 1fr; overflow: hidden; background: #fff; border: 1px solid #e8ecea; border-radius: .85rem; transition: transform .25s ease, box-shadow .25s ease; }.team-card:hover { transform: translateY(-.35rem); box-shadow: 0 1rem 2rem #102a4312; }.team-avatar { display: grid; place-items: center; margin: 1.2rem 0 1.2rem 1.2rem; border-radius: .6rem; color: var(--ink); background: linear-gradient(145deg, var(--surface), color-mix(in srgb, var(--accent), #fff 75%)); font-family: 'DM Serif Display', Georgia, serif; font-size: 2rem; letter-spacing: -.08em; }.team-card > div:last-child { padding: 1.5rem; } .team-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }.team-card { display: grid; grid-template-columns: 120px 1fr; overflow: hidden; background: #fff; border: 1px solid #e8ecea; border-radius: .85rem; transition: transform .25s ease, box-shadow .25s ease; }.team-card:hover { transform: translateY(-.35rem); box-shadow: 0 1rem 2rem #102a4312; }.team-avatar { display: grid; place-items: center; margin: 1.2rem 0 1.2rem 1.2rem; border-radius: .6rem; color: var(--ink); background: linear-gradient(145deg, var(--surface), color-mix(in srgb, var(--accent), #fff 75%)); font-family: 'DM Serif Display', Georgia, serif; font-size: 2rem; letter-spacing: -.08em; }.team-card > div:last-child { padding: 1.5rem; }
.team-photo { width: 120px; height: 100%; min-height: 180px; object-fit: cover; }
.service-card { padding: 2rem; border: 1px solid #dce4e1; border-radius: .85rem; background: #fff; transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease; }.service-card:hover { transform: translateY(-.35rem); border-color: color-mix(in srgb, var(--accent), #fff 65%); box-shadow: 0 1rem 2rem #102a4312; }.service-card.featured { border: 2px solid var(--accent); transform: translateY(-.5rem); }.service-card.featured:hover { transform: translateY(-.7rem); }.service-card h2 { font-size: 2.2rem; }.service-card ul { padding: 0; list-style: none; }.service-card li { padding: .4rem 0; border-top: 1px solid #e8ecea; }.service-card li::before { content: '✓'; color: var(--accent); font-weight: 700; margin-right: .55rem; } .service-card { padding: 2rem; border: 1px solid #dce4e1; border-radius: .85rem; background: #fff; transition: transform .25s ease, box-shadow .25s ease, border-color .25s ease; }.service-card:hover { transform: translateY(-.35rem); border-color: color-mix(in srgb, var(--accent), #fff 65%); box-shadow: 0 1rem 2rem #102a4312; }.service-card.featured { border: 2px solid var(--accent); transform: translateY(-.5rem); }.service-card.featured:hover { transform: translateY(-.7rem); }.service-card h2 { font-size: 2.2rem; }.service-card ul { padding: 0; list-style: none; }.service-card li { padding: .4rem 0; border-top: 1px solid #e8ecea; }.service-card li::before { content: '✓'; color: var(--accent); font-weight: 700; margin-right: .55rem; }
.centered { text-align: center; }.centered .lede { margin-inline: auto; } .centered { text-align: center; }.centered .lede { margin-inline: auto; }
.contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; } .contact-grid { align-items: start; }.contact-details { margin-top: 2rem; font-style: normal; color: #52616b; }.contact-details p { margin-bottom: .8rem; }.contact-details a { color: var(--ink); font-weight: 700; }.contact-form { display: grid; gap: 1rem; padding: 2rem; background: var(--surface); border-radius: .85rem; }.contact-form label { display: grid; gap: .4rem; font-size: .9rem; font-weight: 700; }.contact-form input,.contact-form textarea { width: 100%; padding: .8rem; font: inherit; border: 1px solid #cbd5d1; border-radius: .35rem; background: #fff; }.contact-form button:disabled { cursor: not-allowed; opacity: .6; }.contact-form p { margin-bottom: 0; color: #66747d; font-size: .85rem; }