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'
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Use Node 22
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
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
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
- name: Validate and build selected site
|
||||
run: npm run build:site
|
||||
- name: Verify selected output
|
||||
|
||||
@@ -28,13 +28,15 @@ jobs:
|
||||
ASTRO_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Use Node 22
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
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
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
- name: Validate and build selected site
|
||||
run: npm run build:site
|
||||
- name: Verify selected output
|
||||
|
||||
@@ -29,13 +29,15 @@ jobs:
|
||||
ASTRO_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Use Node 22
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
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
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
- name: Validate and build production blog
|
||||
run: npm run build:site
|
||||
- name: Verify production blog output
|
||||
|
||||
@@ -29,13 +29,15 @@ jobs:
|
||||
ASTRO_TELEMETRY_DISABLED: '1'
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
uses: https://github.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Use Node 22
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
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
|
||||
run: npm ci --prefer-offline --no-audit --no-fund
|
||||
- name: Validate and build production site
|
||||
run: npm run build:site
|
||||
- name: Verify production output
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
This repository is a static Astro pilot for `3031023.xyz`. The existing
|
||||
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
|
||||
|
||||
- `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.
|
||||
|
||||
Blog posts and primary-site FAQs are now static Markdown collections with
|
||||
validated frontmatter and draft filtering. CMS workflows, public sign-up
|
||||
changes, n8n changes, and onboarding-worker changes remain out of scope.
|
||||
validated frontmatter and draft filtering. Production CMS publishing, public
|
||||
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
|
||||
|
||||
@@ -21,6 +26,7 @@ Use Node 22.12 or newer within the Node 22 release line.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
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
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
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-formats": "^3.0.1",
|
||||
"astro": "~7.2.0",
|
||||
"js-yaml": "4.3.2",
|
||||
"wrangler": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"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:site": "node scripts/build-site.mjs",
|
||||
"verify:build": "node scripts/verify-build.mjs",
|
||||
@@ -17,6 +18,7 @@
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"astro": "~7.2.0",
|
||||
"js-yaml": "4.3.2",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
X-Frame-Options: SAMEORIGIN
|
||||
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 addFormats from 'ajv-formats';
|
||||
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 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)}`);
|
||||
}
|
||||
|
||||
for (const file of await readdir('public/images')) {
|
||||
const info = await stat(join('public/images', file));
|
||||
if (info.size > 500 * 1024) throw new Error(`Image exceeds the 500 KiB pilot limit: ${file}`);
|
||||
async function filesUnder(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
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]}`));
|
||||
}
|
||||
|
||||
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');
|
||||
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.`);
|
||||
@@ -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}.`);
|
||||
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 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') {
|
||||
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
|
||||
role: Overnight Care
|
||||
image: /images/david-okonkwo.jpg
|
||||
image: /images/uploads/david-okonkwo.jpg
|
||||
order: 40
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Emily Chen
|
||||
role: Pack Walker
|
||||
image: /images/emily-chen.jpg
|
||||
image: /images/uploads/emily-chen.jpg
|
||||
order: 30
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Marcus Rivera
|
||||
role: Lead Trainer
|
||||
image: /images/marcus-rivera.jpg
|
||||
image: /images/uploads/marcus-rivera.jpg
|
||||
order: 20
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Sarah Mitchell
|
||||
role: Founder & Head Groomer
|
||||
image: /images/sarah-mitchell.jpg
|
||||
image: /images/uploads/sarah-mitchell.jpg
|
||||
order: 10
|
||||
---
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
import { site } from '../lib/site-data.js';
|
||||
const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props;
|
||||
---
|
||||
<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-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>
|
||||
</section>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const { eyebrow, title, lede, image, imageAlt } = Astro.props;
|
||||
---
|
||||
<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>
|
||||
{image && <img src={image} alt={imageAlt} loading="eager" />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
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({
|
||||
loader: glob({ base: './sites/blog/content/posts', pattern: '**/*.md' }),
|
||||
schema: z.object({
|
||||
@@ -40,10 +45,28 @@ const team = defineCollection({
|
||||
schema: z.object({
|
||||
name: 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),
|
||||
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('/');
|
||||
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 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 PageIntro from '../components/PageIntro.astro';
|
||||
import LocationMap from '../components/LocationMap.astro';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { isBlog, site } from '../lib/site-data.js';
|
||||
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';
|
||||
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 pageImages = !isBlog ? (await getCollection('pageImages'))[0]?.data : undefined;
|
||||
const heroImage = isBlog ? undefined : (pageImages?.homeHeroImage ?? null);
|
||||
const heroImageAlt = isBlog ? undefined : pageImages?.homeHeroImageAlt;
|
||||
---
|
||||
<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> : <>
|
||||
<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} />
|
||||
</>}
|
||||
</BaseLayout>
|
||||
|
||||
@@ -7,5 +7,6 @@ import { isBlog, site } from '../lib/site-data.js';
|
||||
if (isBlog) return Astro.redirect('/');
|
||||
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 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; }
|
||||
.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--text-only,.story-grid--text-only { grid-template-columns: minmax(0, 720px); }
|
||||
.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; }
|
||||
.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-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-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-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; }
|
||||
.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; }
|
||||
|
||||