diff --git a/.gitea/workflows/cms-content-validation.yml b/.gitea/workflows/cms-content-validation.yml new file mode 100644 index 0000000..0c785f3 --- /dev/null +++ b/.gitea/workflows/cms-content-validation.yml @@ -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 diff --git a/.gitea/workflows/deploy-blog.yml b/.gitea/workflows/deploy-blog.yml index 8cfb55f..4cd75a0 100644 --- a/.gitea/workflows/deploy-blog.yml +++ b/.gitea/workflows/deploy-blog.yml @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c5d57ae..cbbaa0b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/.gitea/workflows/production-blog.yml b/.gitea/workflows/production-blog.yml index 97df055..20ff24e 100644 --- a/.gitea/workflows/production-blog.yml +++ b/.gitea/workflows/production-blog.yml @@ -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 diff --git a/.gitea/workflows/production-candidate.yml b/.gitea/workflows/production-candidate.yml index 80db176..7778ee9 100644 --- a/.gitea/workflows/production-candidate.yml +++ b/.gitea/workflows/production-candidate.yml @@ -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 diff --git a/README.md b/README.md index 2e68f6c..850383b 100644 --- a/README.md +++ b/README.md @@ -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//`. 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 diff --git a/docs/decap-cms-gitea-setup.md b/docs/decap-cms-gitea-setup.md new file mode 100644 index 0000000..1726bf2 --- /dev/null +++ b/docs/decap-cms-gitea-setup.md @@ -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/*.` 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. diff --git a/docs/decap-cms-pilot.md b/docs/decap-cms-pilot.md new file mode 100644 index 0000000..d726b9f --- /dev/null +++ b/docs/decap-cms-pilot.md @@ -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: + +- +- +- +- +- +- + +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. diff --git a/package-lock.json b/package-lock.json index d56d58d..02131bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 51b7cb4..62aed0e 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/_headers b/public/_headers index 5bf6b40..0bc0977 100644 --- a/public/_headers +++ b/public/_headers @@ -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 diff --git a/public/admin/config.yml b/public/admin/config.yml new file mode 100644 index 0000000..e0c4af4 --- /dev/null +++ b/public/admin/config.yml @@ -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: [] diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..d6d48fd --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,20 @@ + + + + + + + + + Happy Tails preview content manager + + + + + + + diff --git a/public/images/uploads/david-okonkwo.jpg b/public/images/uploads/david-okonkwo.jpg new file mode 100644 index 0000000..e297f88 Binary files /dev/null and b/public/images/uploads/david-okonkwo.jpg differ diff --git a/public/images/uploads/dog-grooming.jpg b/public/images/uploads/dog-grooming.jpg new file mode 100644 index 0000000..6563795 Binary files /dev/null and b/public/images/uploads/dog-grooming.jpg differ diff --git a/public/images/uploads/dog-walking.jpg b/public/images/uploads/dog-walking.jpg new file mode 100644 index 0000000..2758e8e Binary files /dev/null and b/public/images/uploads/dog-walking.jpg differ diff --git a/public/images/uploads/emily-chen.jpg b/public/images/uploads/emily-chen.jpg new file mode 100644 index 0000000..9f68d8b Binary files /dev/null and b/public/images/uploads/emily-chen.jpg differ diff --git a/public/images/uploads/happy-tails-team.jpg b/public/images/uploads/happy-tails-team.jpg new file mode 100644 index 0000000..ed27456 Binary files /dev/null and b/public/images/uploads/happy-tails-team.jpg differ diff --git a/public/images/uploads/hero-dogs.jpg b/public/images/uploads/hero-dogs.jpg new file mode 100644 index 0000000..45ee1ab Binary files /dev/null and b/public/images/uploads/hero-dogs.jpg differ diff --git a/public/images/uploads/marcus-rivera.jpg b/public/images/uploads/marcus-rivera.jpg new file mode 100644 index 0000000..51a0d05 Binary files /dev/null and b/public/images/uploads/marcus-rivera.jpg differ diff --git a/public/images/uploads/sarah-mitchell.jpg b/public/images/uploads/sarah-mitchell.jpg new file mode 100644 index 0000000..ed22d81 Binary files /dev/null and b/public/images/uploads/sarah-mitchell.jpg differ diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 038aad6..8bcfe53 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -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.'); diff --git a/scripts/verify-build.mjs b/scripts/verify-build.mjs index ce881b9..9cef6ae 100644 --- a/scripts/verify-build.mjs +++ b/scripts/verify-build.mjs @@ -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('')) 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']; diff --git a/scripts/verify-decap.mjs b/scripts/verify-decap.mjs new file mode 100644 index 0000000..b7681b2 --- /dev/null +++ b/scripts/verify-decap.mjs @@ -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.'); diff --git a/sites/www/content/page-images/primary.md b/sites/www/content/page-images/primary.md new file mode 100644 index 0000000..82c7b8a --- /dev/null +++ b/sites/www/content/page-images/primary.md @@ -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 +--- diff --git a/sites/www/content/team/david-okonkwo.md b/sites/www/content/team/david-okonkwo.md index b79bb0f..80d2888 100644 --- a/sites/www/content/team/david-okonkwo.md +++ b/sites/www/content/team/david-okonkwo.md @@ -1,7 +1,7 @@ --- name: David Okonkwo role: Overnight Care -image: /images/david-okonkwo.jpg +image: /images/uploads/david-okonkwo.jpg order: 40 --- diff --git a/sites/www/content/team/emily-chen.md b/sites/www/content/team/emily-chen.md index 4aa9b76..956570a 100644 --- a/sites/www/content/team/emily-chen.md +++ b/sites/www/content/team/emily-chen.md @@ -1,7 +1,7 @@ --- name: Emily Chen role: Pack Walker -image: /images/emily-chen.jpg +image: /images/uploads/emily-chen.jpg order: 30 --- diff --git a/sites/www/content/team/marcus-rivera.md b/sites/www/content/team/marcus-rivera.md index 94ad4b6..f649864 100644 --- a/sites/www/content/team/marcus-rivera.md +++ b/sites/www/content/team/marcus-rivera.md @@ -1,7 +1,7 @@ --- name: Marcus Rivera role: Lead Trainer -image: /images/marcus-rivera.jpg +image: /images/uploads/marcus-rivera.jpg order: 20 --- diff --git a/sites/www/content/team/sarah-mitchell.md b/sites/www/content/team/sarah-mitchell.md index e418090..2642c65 100644 --- a/sites/www/content/team/sarah-mitchell.md +++ b/sites/www/content/team/sarah-mitchell.md @@ -1,7 +1,7 @@ --- name: Sarah Mitchell role: Founder & Head Groomer -image: /images/sarah-mitchell.jpg +image: /images/uploads/sarah-mitchell.jpg order: 10 --- diff --git a/src/components/Hero.astro b/src/components/Hero.astro index 1b23b63..12f1ae8 100644 --- a/src/components/Hero.astro +++ b/src/components/Hero.astro @@ -1,9 +1,10 @@ --- import { site } from '../lib/site-data.js'; +const { image = site.hero.image, imageAlt = site.hero.imageAlt } = Astro.props; ---
-
+

{site.hero.eyebrow}

{site.hero.headline}

{site.hero.copy}

{site.hero.cta}
-
{site.hero.imageAlt}
Kind care. Every day.
+ {image &&
{imageAlt}
Kind care. Every day.
}
diff --git a/src/components/PageIntro.astro b/src/components/PageIntro.astro index 050f2d1..93bc328 100644 --- a/src/components/PageIntro.astro +++ b/src/components/PageIntro.astro @@ -2,7 +2,7 @@ const { eyebrow, title, lede, image, imageAlt } = Astro.props; ---
-
+

{eyebrow}

{title}

{lede}

{image && {imageAlt}}
diff --git a/src/content.config.ts b/src/content.config.ts index aae28d5..c11e262 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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 }; diff --git a/src/pages/about.astro b/src/pages/about.astro index c9dd5a5..b68bfee 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -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; --- -

How we work

{site.story.heading}

{site.story.copy}

Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.

{site.story.imageAlt}

Meet the team

Good people for good dogs.

{teamCards.map(({ member, Content }) =>

{member.data.role}

{member.data.name}

)}
+

How we work

{site.story.heading}

{site.story.copy}

Our team communicates clearly, keeps routines consistent, and makes safety a habit—not an afterthought.

{pageImages?.aboutStoryImage && {pageImages.aboutStoryImageAlt}}

Meet the team

Good people for good dogs.

{teamCards.map(({ member, Content }) =>
{member.data.image ? {`${member.data.name}, : }

{member.data.role}

{member.data.name}

)}
diff --git a/src/pages/contact.astro b/src/pages/contact.astro index cc93da4..db3b128 100644 --- a/src/pages/contact.astro +++ b/src/pages/contact.astro @@ -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; --- -

Happy Tails Pet Care

We would love to hear from you.

{site.contact.address}

{site.contact.phone}

{site.contact.email}

{site.contact.hours}

This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.

{site.contact.showMap &&
}
+

Happy Tails Pet Care

We would love to hear from you.

{site.contact.address}

{site.contact.phone}

{site.contact.email}

{site.contact.hours}

This form is intentionally disabled until the approved server-mapped n8n contact route is connected. It does not claim to send messages.

{site.contact.showMap &&
}
diff --git a/src/pages/index.astro b/src/pages/index.astro index fde9143..fde60a4 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -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; --- - + {isBlog ?

The journal

Small notes for happier dog days.

{posts.map((post) =>

{post.data.tags.join(' · ')}

{post.data.title}

{post.data.description}

{post.data.pubDate.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' })}
)}

This static sample now uses Markdown with validated frontmatter. Draft entries stay out of the journal and sitemap.

: <>

The Happy Tails difference

A familiar face, a thoughtful routine, a very happy dog.

We keep our pack intentionally small, so every dog gets the calm attention and consistent care they deserve.

{site.features.map((feature) =>
{feature.icon}

{feature.title}

{feature.description}

)}
-
{site.story.imageAlt}

Our story

{site.story.heading}

{site.story.copy}

Meet the team →
+
{pageImages?.homeStoryImage && {pageImages.homeStoryImageAlt}}

Our story

{site.story.heading}

{site.story.copy}

Meet the team →

Loved by local families

Five-star care, shared in their own words.

{site.reviews.map((review) =>
★★★★★

“{review.quote}”

{review.author} Google review
)}

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.

}
diff --git a/src/pages/services.astro b/src/pages/services.astro index ecccb7f..b4bdfb9 100644 --- a/src/pages/services.astro +++ b/src/pages/services.astro @@ -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; --- -
{serviceCards.map(({ service, Content }) => )}
+
{serviceCards.map(({ service, Content }) => )}
diff --git a/src/styles/global.css b/src/styles/global.css index 4cec8d4..73d9b3b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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; }