Merge pull request 'Launch preview-only Decap CMS' (#1) from cms into main
Reviewed-on: SloanOps/3031023-xyz#1
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: []
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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.');
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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.');
|
||||||
@@ -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,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,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,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,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
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||