feat: upgrade to Astro 7; harden the content schema; wire a11y linting
Amends D1 to pin the major explicitly (v7.x) rather than inherit it. The ^5.0.0 pin was recalled rather than checked and was two majors stale the day it was written, which meant shipping a framework carrying high-severity XSS advisories. CLAUDE.md now requires every version pin to be verified against the registry, and R11 requires re-checking at each build-order boundary. npm audit now reports 0 vulnerabilities, down from 16. Every Astro advisory is cleared; the residual 10 all traced to @lhci/cli, which is removed — it was the sole source of 7 high-severity findings, 0.15.1 is latest so there was no clean upgrade, and it cannot run without pages or a lighthouserc. Re-added at build step 7 with a freshly verified pin. Content collections migrated to the Content Layer API: src/content.config.ts, loader: glob(), z from astro/zod. Two review passes found seven defects in the fix itself, all now closed: - z.coerce.date() read an unquoted 20260801 as epoch milliseconds and yielded 1970-01-01 silently; the first replacement then accepted 2026-13-45 as an Invalid Date and rolled 2026-02-30 over to 2026-03-02. Dates are now anchored, date-only, parsed as UTC and round-tripped. - The title bound applied the SEO spec's 50-60 to the headline rather than the rendered <title>, which guaranteed 68-78 on every article and rejected all five planned launch headlines. Articles are now the documented exception: the headline is the <title>, no suffix. - An article could ship an image with no alt text, or whitespace-only alt. - Two schema comments asserted controls nothing enforced; both are now real refinements, each tested with a failing and a passing case. - PRACTICE_SLUGS and PRACTICE_AREAS could drift silently; a compile-time check now catches both directions. - eslint.config.js imported globals and @eslint/js undeclared, resolving by hoisting accident. - scripts/deploy-local.sh claimed parity with CI while skipping npm run check and two credential guards — on the only path this site can ship today. Accessibility linting is on (36 jsx-a11y rules) before step 1 writes the layout. An earlier claim in §7 that none was possible was wrong twice, and is corrected in AGENTS.md entry (t) along with the reasoning. Opens Q30 and Q31 for two unregistered claims in src/data/site.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
This commit is contained in:
co-authored by
Claude Opus 5
parent
0d8b63380a
commit
7514a49803
@@ -46,10 +46,11 @@ jobs:
|
|||||||
# misconfiguration costs one second instead of a full build.
|
# misconfiguration costs one second instead of a full build.
|
||||||
#
|
#
|
||||||
# Repository variables live at Settings -> Actions -> Variables. Gitea
|
# Repository variables live at Settings -> Actions -> Variables. Gitea
|
||||||
# only added the `vars` context in 1.21 [assumed 2026-08-26 - not checked
|
# only added the `vars` context in 1.21; this instance reports 1.27.2
|
||||||
# against this instance]; on an older one every ${{ vars.* }} is expected
|
# [verified 2026-08-26 - /api/v1/version, AGENTS.md §7], so the guard is
|
||||||
# to interpolate to an empty string, the sync target below degrades to
|
# belt-and-braces rather than load-bearing. It stays because an unset or
|
||||||
# "s3://", and the run dies obscurely somewhere in the middle.
|
# mistyped variable degrades the sync target to "s3://" and the run dies
|
||||||
|
# obscurely somewhere in the middle, whatever the Gitea version.
|
||||||
#
|
#
|
||||||
# Covers the deploy-target variables, the intake endpoint, AND the two
|
# Covers the deploy-target variables, the intake endpoint, AND the two
|
||||||
# secrets. The secrets matter most: AGENTS.md Q22 records that nobody has
|
# secrets. The secrets matter most: AGENTS.md Q22 records that nobody has
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ without an explicit instruction and a Change Log entry. `[verified 2026-08-25]`
|
|||||||
|
|
||||||
| # | Decision | Chosen | Rejected alternatives |
|
| # | Decision | Chosen | Rejected alternatives |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| D1 | Framework | **Astro**, static output | Next.js; patching the single-file build; hand-written HTML |
|
| D1 | Framework | **Astro, static output, v7.x.** *Amended 2026-08-26:* the major is now pinned deliberately rather than inherited. The original `^5.0.0` pin was **recalled, not checked, and was two majors stale the day it was written**. Upgrading costs three config files now and compounds with every page; the v5 advisories were **high-severity XSS in the framework rendering a public site**, and "we don't use those authoring patterns" is a claim about all future code, not just today's. Starting two majors behind means being three behind at launch | Next.js; patching the single-file build; hand-written HTML; staying on v5 |
|
||||||
| D2 | Content scope | **Full re-architecture** — new IA, new pages, all copy rewritten | Copy rewrite on existing structure; audit only; technical layer only |
|
| D2 | Content scope | **Full re-architecture** — new IA, new pages, all copy rewritten | Copy rewrite on existing structure; audit only; technical layer only |
|
||||||
| D3 | Hosting & CI/CD | **Git repo + Gitea Actions → existing S3 + CloudFront.** *Amended 2026-08-26:* self-hosted **Gitea**, repo `adr-sml`. Gitea Actions uses GitHub Actions syntax, so the workflow ports nearly as is — but Gitea is not an AWS OIDC provider, so deploys authenticate with a **scoped IAM user**, rotated quarterly | GitHub Actions + OIDC; Terraform/CDK IaC; Amplify; manual deploys |
|
| D3 | Hosting & CI/CD | **Git repo + Gitea Actions → existing S3 + CloudFront.** *Amended 2026-08-26:* self-hosted **Gitea**, repo `adr-sml`. Gitea Actions uses GitHub Actions syntax, so the workflow ports nearly as is — but Gitea is not an AWS OIDC provider, so deploys authenticate with a **scoped IAM user**, rotated quarterly | GitHub Actions + OIDC; Terraform/CDK IaC; Amplify; manual deploys |
|
||||||
| D4 | Languages | **English only** | Full EN/FA bilingual; EN + one Farsi page; EN with FA scaffolding |
|
| D4 | Languages | **English only** | Full EN/FA bilingual; EN + one Farsi page; EN with FA scaffolding |
|
||||||
@@ -188,7 +188,7 @@ cannot vouch for.
|
|||||||
| Testimonials, endorsements, quotes from counterparties | None exist. The prior site's testimonial was fabricated |
|
| Testimonials, endorsements, quotes from counterparties | None exist. The prior site's testimonial was fabricated |
|
||||||
| "Since 2009", "sixteen years", London / New York offices, Co. № 07452218 | Artefacts of the placeholder template. All false |
|
| "Since 2009", "sixteen years", London / New York offices, Co. № 07452218 | Artefacts of the placeholder template. All false |
|
||||||
| The name "S. M. Lawrence" | Fictitious founder from the template |
|
| The name "S. M. Lawrence" | Fictitious founder from the template |
|
||||||
| Guarantees of outcome, or superlatives ("best", "leading", "top-rated") | LSO marketing rules |
|
| Guarantees of outcome, or superlatives ("best", "leading", "top-rated") | Unverifiable as written, and they read as insecure to the audience this site is for. Marketing rules for regulated professions also treat them as suspect. *(Reason restated 2026-08-26: this row previously read "LSO marketing rules", which asserts the regulatory status the row above marks NOT ESTABLISHED. It was the last surviving instance, flagged in entries (o) and (q) and answered in Q26 as already fixed — it was not. **Restated by Claude Code; the rule itself is unchanged and the restatement needs Pouya's ratification.**)* |
|
||||||
| The word **"lawyer"** used of Pouya; "called to the bar"; "licensed"; "my law practice"; "my litigation practice"; any post-nominal implying a licence | D13. The site asserts the JD and nothing further |
|
| The word **"lawyer"** used of Pouya; "called to the bar"; "licensed"; "my law practice"; "my litigation practice"; any post-nominal implying a licence | D13. The site asserts the JD and nothing further |
|
||||||
| Any phrasing that *implies* entitlement to practise law without saying so — "acts for clients", "represents parties", "my clients", "legal advice" | Same rule. Implication is the risk, not just the word. Describe the role factually instead: *Director of Firm Operations at a Toronto litigation and ADR boutique*, and the matter types worked on |
|
| Any phrasing that *implies* entitlement to practise law without saying so — "acts for clients", "represents parties", "my clients", "legal advice" | Same rule. Implication is the risk, not just the word. Describe the role factually instead: *Director of Firm Operations at a Toronto litigation and ADR boutique*, and the matter types worked on |
|
||||||
| Q.Arb described as held, imminent, or "nearly complete" | It commenced August 2026 |
|
| Q.Arb described as held, imminent, or "nearly complete" | It commenced August 2026 |
|
||||||
@@ -235,11 +235,13 @@ the audience it targets. Revisit at month 12–18. `[verified 2026-08-25 — dec
|
|||||||
|
|
||||||
| Thing | Value |
|
| Thing | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Framework | Astro, `output: 'static'` `[verified 2026-08-25 — D1]` |
|
| Framework | **Astro 7.2.7**, `output: 'static'` `[verified 2026-08-26 — npm view astro version, D1 as amended]`. `@astrojs/mdx` 7.0.8, `@astrojs/sitemap` 3.7.3, `sharp` 0.35.4 |
|
||||||
| Node | 22 LTS, pinned in `.nvmrc`; `package.json` `engines.node` `>=22` `[verified 2026-08-26 — .nvmrc, package.json]` |
|
| Lint toolchain | ESLint **10.9.1**, `@eslint/js` 10.0.1, `globals` 17.11.0, `eslint-plugin-astro` **3.1.0**, `eslint-plugin-jsx-a11y` 6.10.2, `typescript-eslint` 8.68.0, `typescript` 5.9.3 — **every pin at the registry's `latest` except `typescript`** `[verified 2026-08-26 — npm view, all 14 pins]`. `@eslint/js` and `globals` are declared explicitly; before 2026-08-26 `eslint.config.js` imported them and they resolved only by npm hoisting accident. **Accessibility linting is on** — `flat/jsx-a11y-recommended`, 36 rules `[verified 2026-08-26 — 7 rules fired on a deliberately inaccessible .astro file]`. `eslint-plugin-jsx-a11y@6.10.2` declares a stale `eslint ^3..^9` peer range; a one-line `overrides` entry in `package.json` resolves it. ⚠️ **`eslint-plugin-astro@3.1.0` declares `node ^22.22.3 \|\| ^24.16.0 \|\| >=26.3.0`**, which excludes Pouya's Node 25.6.0 — `npm install` prints EBADENGINE there. Dev-time only, and `nvm use` (Node 22 LTS, per `.nvmrc`) clears it. `typescript` is held at 5.x: `typescript-eslint` peers `<6.1.0` and `@astrojs/check` peers `^5 \|\| ^6`, so TypeScript 7.0.2 is unusable here `[verified 2026-08-26 — npm view]` |
|
||||||
|
| Lighthouse CI | **Not installed.** `@lhci/cli` was the sole source of all 10 `npm audit` findings (7 high, via `lighthouse → puppeteer-core → extract-zip`), 0.15.1 is `latest` so there was no clean upgrade, and it cannot run at all — no pages, no `lighthouserc`. Removed 2026-08-26; **`npm audit` now reports 0 vulnerabilities**. Re-add at build step 7 with a freshly verified pin (R11) `[verified 2026-08-26 — npm audit]` |
|
||||||
|
| Node | **22 LTS line**, `.nvmrc` holds `22` so it tracks the latest 22.x rather than freezing at a floor. `package.json` declares **`engines.node >=22.13.0`** and **`engines.npm >=9.6.5`** — the intersection of every dependency's own floor: Astro 7 and `@astrojs/mdx` 7 need `>=22.12.0`, ESLint 10 needs `^20.19.0 \|\| ^22.13.0 \|\| >=24` `[verified 2026-08-26 — npm view astro@7.2.7 engines, npm view eslint@10.9.1 engines]`. **Pouya's machine reports Node v25.6.0** `[verified 2026-08-26 — node -v]`, an odd-numbered non-LTS line; it satisfies the floor, but `nvm use` keeps local and CI on the same runtime |
|
||||||
| Styling | Plain CSS with custom properties. No Tailwind, no CSS-in-JS `[verified 2026-08-25]` |
|
| Styling | Plain CSS with custom properties. No Tailwind, no CSS-in-JS `[verified 2026-08-25]` |
|
||||||
| Client JS | Astro islands only, where genuinely needed. Target: most pages ship zero JS `[verified 2026-08-25]` |
|
| Client JS | Astro islands only, where genuinely needed. Target: most pages ship zero JS `[verified 2026-08-25]` |
|
||||||
| Content | Astro content collections, MDX for Insights `[verified 2026-08-25]` |
|
| Content | Astro **Content Layer API** collections, MDX for Insights. Config lives at **`src/content.config.ts`**; collections declare a `loader`, not a `type`; `z` imports from `astro/zod`. **Astro 5 introduced** the Content Layer API and this location; **Astro 6 removed** the legacy `src/content/config.ts` fallback — moving the file back now raises `LegacyContentConfigError` `[verified 2026-08-26 — reproduced by moving it back]` |
|
||||||
| Fonts | Instrument Serif + Geist + Geist Mono, **self-hosted**, `font-display: swap` `[verified 2026-08-25]` |
|
| Fonts | Instrument Serif + Geist + Geist Mono, **self-hosted**, `font-display: swap` `[verified 2026-08-25]` |
|
||||||
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
|
| AWS account | `327082975128` `[verified 2026-08-26 — inventory]` |
|
||||||
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
|
| Region | **`ca-central-1`** throughout — hosting, Lambda, DynamoDB `[verified 2026-08-26]` |
|
||||||
@@ -324,6 +326,8 @@ Nothing below can be invented. Each needs an answer from Pouya.
|
|||||||
| ~~Q27~~ | **ANSWERED 2026-08-26 — two business days.** A public commitment, so it must read identically everywhere it appears: `src/data/site.ts`, `/contact/`, the inquirer confirmation email, and any bio | — |
|
| ~~Q27~~ | **ANSWERED 2026-08-26 — two business days.** A public commitment, so it must read identically everywhere it appears: `src/data/site.ts`, `/contact/`, the inquirer confirmation email, and any bio | — |
|
||||||
| ~~Q28~~ | **ANSWERED 2026-08-26 — OBA Construction & Infrastructure, ADR, and Civil Litigation** `[verified 2026-08-26 — Pouya]`, **for now**: section membership renews yearly, so this is a fact with a shelf life. Tracked as R10 | — |
|
| ~~Q28~~ | **ANSWERED 2026-08-26 — OBA Construction & Infrastructure, ADR, and Civil Litigation** `[verified 2026-08-26 — Pouya]`, **for now**: section membership renews yearly, so this is a fact with a shelf life. Tracked as R10 | — |
|
||||||
| ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — |
|
| ~~Q29~~ | **ANSWERED 2026-08-26 by implementation.** Guard widened on both counts: the two secrets are checked (emptiness only, never echoed), and `INTAKE_ENDPOINT` is promoted to job-level `env:` and guarded. Every `vars.*` and `secrets.*` the workflow consumes is now covered except `BOOKING_URL`, empty by decision (R6) | — |
|
||||||
|
| **Q30** | **What is SML Company Ltd.'s jurisdiction of incorporation, and should it appear in the footer?** `src/data/site.ts` carried `entity: 'SML Company Ltd. · Ontario, Canada'`. §4 verifies "Operator of SML Company Ltd." and "Toronto, Ontario" — the latter about **Pouya**, not the company's incorporation. Jurisdiction dropped pending an answer; the operator fact stays. Raised by `claims-auditor`, 2026-08-26 | The site-wide footer |
|
||||||
|
| **Q31** | **Plausible or Fathom?** D15 records the choice as "Plausible **or** Fathom" — undecided. `src/data/site.ts` had `provider: 'plausible'`, a guessed value, in a file whose own header says not to guess one to make the build pass. Set to `null` with a `TODO(pouya)`. Raised by `claims-auditor`, 2026-08-26 | Analytics; the privacy-policy line that names the processor |
|
||||||
| **Q23** | **Is a Gitea `act_runner` registered, and are Actions enabled in `app.ini`?** *The version half is closed:* the instance reports **1.27.2** `[verified 2026-08-26 — /api/v1/version]`, so the `vars` context is supported and the first-step guard is belt-and-braces rather than load-bearing. **What remains is not a fact to look up but a dependency on a person:** git.larsnolden.com is **jointly administered**, and both enabling Actions and registering a runner need the second administrator. Until then `npm run deploy` is the path, and it is documented rather than treated as a workaround | How deploys are triggered — not what they do |
|
| **Q23** | **Is a Gitea `act_runner` registered, and are Actions enabled in `app.ini`?** *The version half is closed:* the instance reports **1.27.2** `[verified 2026-08-26 — /api/v1/version]`, so the `vars` context is supported and the first-step guard is belt-and-braces rather than load-bearing. **What remains is not a fact to look up but a dependency on a person:** git.larsnolden.com is **jointly administered**, and both enabling Actions and registering a runner need the second administrator. Until then `npm run deploy` is the path, and it is documented rather than treated as a workaround | How deploys are triggered — not what they do |
|
||||||
|
|
||||||
## 10. Risks
|
## 10. Risks
|
||||||
@@ -373,7 +377,7 @@ never being raised again.
|
|||||||
|
|
||||||
| # | Reminder | Raised | Why it must keep coming back |
|
| # | Reminder | Raised | Why it must keep coming back |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| R1 | **Licensure.** No site exists yet — `src/pages/` is empty. **As specified**, the copy will assert the JD only and describe the boutique role as *active litigation exposure*, never *practice*. Pouya flagged that as an interim position and asked to be reminded to change it. §4 now records licence status as `[unestablished]`, which is the honest state, not a resolution | 2026-08-26 | If he is licensed and in good standing, it is the first credential appointing bodies and opposing counsel look for, and its absence from a detailed credentials page is conspicuous. If licensure is in progress, the copy should be rewritten the moment it completes. Either way this is a **temporary** framing that will otherwise ossify |
|
| R1 | **Licensure.** No site exists yet — `src/pages/` does not exist. **As specified**, the copy will assert the JD only and describe the boutique role as *active litigation exposure*, never *practice*. Pouya flagged that as an interim position and asked to be reminded to change it. §4 now records licence status as `[unestablished]`, which is the honest state, not a resolution | 2026-08-26 | If he is licensed and in good standing, it is the first credential appointing bodies and opposing counsel look for, and its absence from a detailed credentials page is conspicuous. If licensure is in progress, the copy should be rewritten the moment it completes. Either way this is a **temporary** framing that will otherwise ossify |
|
||||||
| R2 | **Matter counts stay off the site until they are independently credible.** Revisit once there is a number a sophisticated GC would find persuasive on its own | 2026-08-26 | §4 forbids them now. That rule has an expiry date nobody has set |
|
| R2 | **Matter counts stay off the site until they are independently credible.** Revisit once there is a number a sophisticated GC would find persuasive on its own | 2026-08-26 | §4 forbids them now. That rule has an expiry date nobody has set |
|
||||||
| R3 | **Indigenous engagement practice page.** Deliberately omitted at launch (§6). Revisit at month 12–18 | 2026-08-26 | The strategy brief rates it the most valuable single niche. Omission is a timing call, not a permanent one |
|
| R3 | **Indigenous engagement practice page.** Deliberately omitted at launch (§6). Revisit at month 12–18 | 2026-08-26 | The strategy brief rates it the most valuable single niche. Omission is a timing call, not a permanent one |
|
||||||
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
|
| R4 | **Insights cadence.** D9 commits to monthly. A blog that stops is worse than one that never started | 2026-08-26 | The section's whole value is compounding |
|
||||||
@@ -381,6 +385,8 @@ never being raised again.
|
|||||||
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
|
| R6 | **Booking tool.** Parked by Pouya on 2026-08-26; `/contact/` ships with the intake form and a reserved slot for an embed | 2026-08-26 | He asked to be reminded. D10 committed to booking because it removes the back-and-forth that loses appointments — the form alone is a partial answer |
|
||||||
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
|
| R9 | **The SES alarms notify nobody until the `ses-alerts` email subscription is confirmed.** `SES-BounceRate-High` and `SES-ComplaintRate-High` are configured and live; the SNS email subscription to `info@smlcompany.ca` is **pending confirmation**, and an unconfirmed subscription drops every message | 2026-08-26 | A monitoring control that exists but does not deliver is worse than none, because it reads as covered. At this volume five bounces can cross the ~5% suspension threshold. Tracked in §7 and on the cutover checklist, but a one-click task nobody owns is exactly what §12 is for |
|
||||||
| R10 | **OBA section membership renews yearly.** §4 verifies Construction & Infrastructure, ADR, and Civil Litigation as of 2026-08-26 — *for now* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this (§4 records it as "not current, do not publish"). Re-confirm at renewal, and before any page listing memberships ships |
|
| R10 | **OBA section membership renews yearly.** §4 verifies Construction & Infrastructure, ADR, and Civil Litigation as of 2026-08-26 — *for now* | 2026-08-26 | A credential that lapses quietly is the failure mode §4 exists to prevent, and OCNI already did exactly this (§4 records it as "not current, do not publish"). Re-confirm at renewal, and before any page listing memberships ships |
|
||||||
|
| R11 | **Re-check dependency currency at every phase boundary in the build order** (`docs/01-architecture.md` §Build order, 11 steps). Run `npm view <pkg> version` across **every** pin in `package.json` and compare; do not wait for something to break. Verified does not mean latest — record the reason for any deliberate hold in §7 | 2026-08-26 | `astro: "^5.0.0"` was recalled rather than checked and was two majors stale the day it was written, which meant a framework carrying high-severity XSS advisories. Between phases is cheap; after a phase of pages is written is not. The build order has ten more boundaries |
|
||||||
|
| R12 | **`compressHTML: true` is a deliberate deviation from the Astro 7 default (`'jsx'`).** Measured 2026-08-26: in an `.astro` template an inline pair split across two lines renders as `<em>a</em><strong>b</strong>` under the default — the space is silently deleted. MDX prose is unaffected | 2026-08-26 | It is a deviation, and undocumented deviations become folklore. Revisit **with a measurement**, not a preference — and re-measure after any Astro major, since the behaviour could change again |
|
||||||
| ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — |
|
| ~~R7~~ | **RATIFIED / SUPERSEDED 2026-08-26.** (a) Cache-policy table matching the pipeline — **accepted**; documenting what the pipeline does beats documenting an intention. (b) `s3:AbortMultipartUpload` omitted — **accepted, reasoning corrected**: the lifecycle rule does not exist and is therefore not the cover; the actual cover is that `aws s3 sync` only goes multipart above 8 MB and the largest asset is a 357 KB portrait. Recorded in `docs/06-deployment.md` with a revisit trigger. (c) The `aws s3 ls` pre-flight — **superseded** by the variable guard now running as the workflow's first step | 2026-08-26 | — |
|
||||||
| ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — |
|
| ~~R8~~ | **PROMOTED TO A RULE 2026-08-26.** A reminder was too weak for a pattern that survived three entries. *A sweep is a command, not a claim* now sits in `CLAUDE.md` under Conventions, in `/build` Phase 6, and in `/wrap` step 3: any claim that a change was applied across files must cite the command and be written only after reading its output | 2026-08-26 | — |
|
||||||
|
|
||||||
@@ -388,6 +394,315 @@ never being raised again.
|
|||||||
|
|
||||||
# Change Log
|
# Change Log
|
||||||
|
|
||||||
|
## 2026-08-26 (t) — Astro 5 → 7. The review found a defect in the fix, twice
|
||||||
|
|
||||||
|
**Who:** Pouya approved the upgrade and set the version-pin rule. Claude Code
|
||||||
|
implemented under `/build`; `adversarial-reviewer` and `claims-auditor` both ran
|
||||||
|
and both failed the first attempt.
|
||||||
|
|
||||||
|
**This entry resolves the escalation left open at the end of entry (s).** That
|
||||||
|
entry recorded the upgrade as *proposed, not done*; it is done now.
|
||||||
|
|
||||||
|
### D1 amended — the major is pinned, not inherited
|
||||||
|
|
||||||
|
Old → new: *"Astro, static output"* → **"Astro, static output, v7.x"**, with the
|
||||||
|
reasoning in the decision itself so the next reader knows the version was chosen.
|
||||||
|
|
||||||
|
Pouya's own account of the original pin, recorded at his instruction: `^5.0.0`
|
||||||
|
was **recalled, not checked, and was two majors stale on the day it was
|
||||||
|
written.** That is a different failure class from the sweep problem in entries
|
||||||
|
(l) through (o) — not a change that failed to propagate, but a fact asserted
|
||||||
|
that one command would have settled.
|
||||||
|
|
||||||
|
Verified with `npm view`, and the staleness was not confined to Astro:
|
||||||
|
|
||||||
|
| | was pinned | registry `latest` |
|
||||||
|
|---|---|---|
|
||||||
|
| `astro` | `^5.0.0` | **7.2.7** |
|
||||||
|
| `@astrojs/mdx` | `^4.0.0` | **7.0.8** — three majors |
|
||||||
|
| `eslint` | `^9.0.0` | **10.9.1**; npm flags 9.x deprecated |
|
||||||
|
| `sharp` | `^0.33.0` | 0.35.4 |
|
||||||
|
| `@lhci/cli` | `^0.14.0` | 0.15.1 |
|
||||||
|
|
||||||
|
**The rule is now in `CLAUDE.md`**: a version pin is verified against the
|
||||||
|
registry, never recalled. **R11** requires re-checking currency at every phase
|
||||||
|
boundary in the build order — ten remain.
|
||||||
|
|
||||||
|
A caveat the rule needs, learned here: **verified does not mean latest.**
|
||||||
|
`typescript` 7.0.2 exists and is unusable — `typescript-eslint` peers `<6.1.0`
|
||||||
|
and `@astrojs/check` peers `^5 || ^6`. `@eslint/js` latest is 10.0.1, but on
|
||||||
|
ESLint 9 it must track 9.x exactly. The check tells you the number; you still
|
||||||
|
have to know why you chose it. Both holds are now recorded in §7 so the next
|
||||||
|
agent running R11 does not re-derive them or break the build discovering them.
|
||||||
|
|
||||||
|
**The rule was then not applied to its own manifest**, which the second review
|
||||||
|
caught: `eslint-plugin-astro` was left at `^1.3.0` while §7 asserted a hold at
|
||||||
|
1.7.0, and `typescript` at `^5.7.0` against an installed 5.9.3. Both re-pinned.
|
||||||
|
Every one of the twelve pins is now verified against `npm view`.
|
||||||
|
|
||||||
|
### The migration
|
||||||
|
|
||||||
|
Astro 5 introduced the Content Layer API and `src/content.config.ts`; **Astro 6
|
||||||
|
removed the legacy `src/content/config.ts` fallback**. Verified by moving the
|
||||||
|
file back and reproducing `LegacyContentConfigError` — an earlier draft of this
|
||||||
|
entry attributed the move to Astro 6, which `claims-auditor` caught as a version
|
||||||
|
fact recalled rather than checked, *in the change that raised recalling-versions
|
||||||
|
to a rule.*
|
||||||
|
|
||||||
|
`type: 'content'` → `loader: glob(...)`, `z` from `astro/zod`. `schema: ({ image
|
||||||
|
}) => …` still works — verified, not assumed.
|
||||||
|
|
||||||
|
**An empty build proves nothing**, so a throwaway page and article exercised
|
||||||
|
`getCollection`, `render(entry)`, `entry.id`, MDX rendering, the `image()`
|
||||||
|
helper resolving to a hashed asset, and sitemap emission at `/sitemap-0.xml`
|
||||||
|
with no trailing-slash conflict. Then deleted.
|
||||||
|
|
||||||
|
### `compressHTML` — held at `true`, and the first rationale was wrong
|
||||||
|
|
||||||
|
Astro 7 changed the default to `'jsx'`. I asserted this would eat spaces in
|
||||||
|
prose, measured **MDX**, and found no difference — the premise was unsupported
|
||||||
|
as written. Measuring the `.astro` path instead:
|
||||||
|
|
||||||
|
```
|
||||||
|
'jsx': <em>inline</em><strong>pair</strong> ← space silently deleted
|
||||||
|
true: <em>inline</em> <strong>pair</strong>
|
||||||
|
```
|
||||||
|
|
||||||
|
Real hazard, wrong file. `adversarial-reviewer` reproduced both outputs
|
||||||
|
independently. The comment now names `.astro` templates specifically and records
|
||||||
|
that MDX is unaffected, so nobody inherits the original claim. **R12** makes the
|
||||||
|
deviation revisitable only with a measurement.
|
||||||
|
|
||||||
|
### What the review caught — seven defects in the fix itself
|
||||||
|
|
||||||
|
**Both reviewers, independently:** two source files cited *this entry* before it
|
||||||
|
existed. Current Truth had been rewritten in place with no append-only record,
|
||||||
|
and `astro.config.mjs` pointed at it as the authority for a measurement. Rules
|
||||||
|
1–3 of this file's own constitution, broken in the change that adds a rule about
|
||||||
|
not recalling things. Writing this entry is the fix.
|
||||||
|
|
||||||
|
**`z.coerce.date()` turned a plausible typo into 1970.** Reproduced: unquoted
|
||||||
|
`publishDate: 20260801` — valid YAML, the obvious slip for `2026-08-01` — coerces
|
||||||
|
from epoch milliseconds to **1970-01-01** with no error, and would ship as
|
||||||
|
`datePublished` in the article's JSON-LD and sort the piece last in a
|
||||||
|
reverse-chronological index. The reviewer also tested my justification for the
|
||||||
|
coercion and found it false: plain `z.date()` accepts unquoted YAML dates fine.
|
||||||
|
Replaced with a bounded union — `z.date()` or an ISO-shaped string — which
|
||||||
|
accepts both real forms and rejects numbers.
|
||||||
|
|
||||||
|
**The title bound made the SEO spec unsatisfiable.** `docs/04-seo-spec.md` puts
|
||||||
|
50–60 characters on the *rendered* `<title>`, whose pattern is
|
||||||
|
`"<headline> · Pouya Lajevardi"`. The suffix is 18 characters, so applying 50–60
|
||||||
|
to the headline guaranteed a rendered title of **68–78** — over the spec's own
|
||||||
|
ceiling on every article — while `.min(50)` hard-failed the build on a good short
|
||||||
|
headline (`"What Med-Arb Actually Is"`). The schema now enforces the *rendered*
|
||||||
|
length, adds an optional `seoTitle` override, and reports the computed title in
|
||||||
|
the failure message. `docs/01-architecture.md` updated to match.
|
||||||
|
|
||||||
|
**An article could ship an image with no alt text** — `image` and `imageAlt` were
|
||||||
|
independently optional, and the schema is the only gate that exists before the
|
||||||
|
`/insights/` route is written. Now coupled.
|
||||||
|
|
||||||
|
**Two comments asserted controls that did not exist**: "every piece must link to
|
||||||
|
a practice-area page" (`.min(1)` checks a *declaration*, not a link) and "an
|
||||||
|
article with `draft:false` and `reviewedByPouya:false` is a bug" (nothing
|
||||||
|
enforced it). The second is now true — a refinement fails the build. All four
|
||||||
|
invariants were then tested, each with a failing and a passing case; each fires.
|
||||||
|
|
||||||
|
**The schema diverged from two specs** — `docs/01` and `docs/02` both specify
|
||||||
|
`topics[]` plural and `readingTime`; the collection had singular `topic` and no
|
||||||
|
reading time. Pre-existing, carried through a wholesale rewrite without notice.
|
||||||
|
Fixed to the specs.
|
||||||
|
|
||||||
|
**`eslint.config.js` imported two undeclared packages**, one resolving by
|
||||||
|
hoisting accident: `globals` was 14.0.0 at the root against 16.5.0 nested under
|
||||||
|
the Astro plugin, so which major linting used depended on npm's hoisting.
|
||||||
|
|
||||||
|
### The second review pass found six more, four of them in the first round's fixes
|
||||||
|
|
||||||
|
The loop requires re-review after material fixes, and this is why.
|
||||||
|
|
||||||
|
**`frontmatterDate` accepted impossible dates** — the fix for the 1970 defect
|
||||||
|
introduced its own. The regex was unanchored at the end and nothing checked the
|
||||||
|
parsed result, so `2026-13-45` and `2026-08-01 nonsense` both **passed**, each
|
||||||
|
producing an `Invalid Date` bound for `datePublished` in the article's JSON-LD;
|
||||||
|
and `2026-02-30` silently rolled over to **2026-03-02** — a wrong date shipped
|
||||||
|
with no error, which is worse than a failed build. The comment claimed it
|
||||||
|
accepted "both real forms and nothing else." Now anchored, date-only, parsed as
|
||||||
|
UTC and **round-tripped** so the day that comes back must be the day written.
|
||||||
|
A time component is rejected rather than guessed at: quoted
|
||||||
|
`2026-08-01T10:00:00` parses as local time while the unquoted YAML form parses
|
||||||
|
as UTC, so the same frontmatter would mean different instants on a laptop and on
|
||||||
|
a CI runner.
|
||||||
|
|
||||||
|
**The title rule rejected all five planned launch articles.** The arithmetic was
|
||||||
|
right and the rule was still wrong. Every headline in `docs/03-content-spec.md`'s
|
||||||
|
launch slate is 50–67 characters; with the ` · Pouya Lajevardi` suffix they
|
||||||
|
render at 68–85 and **5 of 5 fail**, making `seoTitle` mandatory on every article
|
||||||
|
— a second title field to hand-keep in sync forever, on the highest-volume
|
||||||
|
content type. Two specs contradicted each other and the schema enforced one.
|
||||||
|
Resolved by making articles the documented exception: **an article's headline is
|
||||||
|
its `<title>`, with no suffix.** Under that rule 4 of 5 launch headlines pass and
|
||||||
|
`seoTitle` is a genuine exception. `docs/04-seo-spec.md` — which owns the title
|
||||||
|
rule and had not been swept — now states it.
|
||||||
|
|
||||||
|
**`PRACTICE_SLUGS` and `PRACTICE_AREAS` could drift silently.** Splitting one
|
||||||
|
derived source into two literals bought back the literal types but lost the
|
||||||
|
guarantee: deleting an area while leaving its slug declared passed `astro check`
|
||||||
|
cleanly, so an article could name a practice area with no page, no nav child and
|
||||||
|
no chip. My first completeness check was written as a conditional type and
|
||||||
|
**did not fire** — verified by deleting a row and getting 0 errors. Replaced with
|
||||||
|
an `AssertNever` constraint that does: a missing area now fails with
|
||||||
|
`ts(2344)`, and a typo'd slug fails with `ts(2820)` plus a "did you mean".
|
||||||
|
Zero runtime cost.
|
||||||
|
|
||||||
|
**Three narrower schema gaps**, each reproduced: `imageAlt: " "` passed, so an
|
||||||
|
image could ship with a whitespace-only accessible name — the exact thing the
|
||||||
|
coupling check exists to prevent; duplicate `topics` and `practiceAreas` passed
|
||||||
|
and would render duplicate pills; and `seoTitle` was bounded *and* re-checked,
|
||||||
|
so one mistake produced two errors. Trimmed, de-duplicated, and the field bound
|
||||||
|
dropped so the refinement is the single check.
|
||||||
|
|
||||||
|
**`scripts/deploy-local.sh` claimed to do "EXACTLY" what CI does.** It did not:
|
||||||
|
the workflow guards six values, the script guarded four, and the workflow runs
|
||||||
|
`npm run check` before building while the script ran neither check nor `npm ci`.
|
||||||
|
A local deploy — currently the **only** way this site ships, since Q22 and Q23
|
||||||
|
are open — was skipping the type and template gate. Guards brought to parity,
|
||||||
|
`npm run check` added, and the claim rewritten to name the two remaining
|
||||||
|
differences instead of denying them.
|
||||||
|
|
||||||
|
**Sweep misses:** `README.md` stated an engines floor of `>=22.12.0` after
|
||||||
|
`package.json` had moved to `>=22.13.0` for ESLint 10 — a duplicated version fact
|
||||||
|
of exactly the kind §7 exists to prevent, now replaced with a pointer.
|
||||||
|
`package-lock.json`'s root `engines` still carried the pre-bump value because the
|
||||||
|
bump was made without reinstalling. `engines.node` was described as "the
|
||||||
|
intersection of every dependency's floor" and was not one — `>=22.13.0` admits
|
||||||
|
Node 23.x, which ESLint 10 excludes; it now reads `^22.13.0 || >=24`.
|
||||||
|
`eslint.config.js` still called itself an "ESLint 9 flat config", and
|
||||||
|
`.gitea/workflows/deploy.yml` still carried the Gitea version as `[assumed]`
|
||||||
|
after Q23 verified it as 1.27.2.
|
||||||
|
|
||||||
|
**Three claims in `src/data/site.ts` bound for public pages**, all pre-existing
|
||||||
|
and all found by `claims-auditor`: `entity: 'SML Company Ltd. · Ontario, Canada'`
|
||||||
|
asserted a jurisdiction of incorporation §4 does not verify (**Q30**);
|
||||||
|
`provider: 'plausible'` was a guessed value where D15 records the choice as
|
||||||
|
undecided, in a file whose own header says not to guess one to make the build
|
||||||
|
pass (**Q31**); and `NO_RETAINER_NOTICE` was missing the third element
|
||||||
|
`docs/01-architecture.md` requires — that submitting the form does not itself
|
||||||
|
create a conflict check. The notice is fixed; the other two are nulled with
|
||||||
|
`TODO(pouya)` and numbered.
|
||||||
|
|
||||||
|
**One finding declined.** `claims-auditor` reported that no build order exists in
|
||||||
|
the repository and that R11's "ten more boundaries" was therefore untraceable. It
|
||||||
|
does exist — `docs/01-architecture.md` §Build order, eleven numbered steps. The
|
||||||
|
audit grepped lowercase `"build order"` and missed the capitalised heading. R11
|
||||||
|
now carries the file and section so the next reader does not repeat the search.
|
||||||
|
|
||||||
|
### Declined, with reasons
|
||||||
|
|
||||||
|
**Accessibility linting is ON, and I was wrong about it twice.**
|
||||||
|
`adversarial-reviewer` recommended `eslint-plugin-astro@3.1.0` for the
|
||||||
|
`eslint-plugin-jsx-a11y` peer. I tested that upgrade, hit ERESOLVE (v3 requires
|
||||||
|
`eslint >=10` *and* peers `jsx-a11y >=6.10.2`, whose latest peers `eslint ^3..^9`),
|
||||||
|
and concluded it was "not installable" — recording in §7 that
|
||||||
|
**"there is no automated accessibility linting"**.
|
||||||
|
|
||||||
|
Wrong the first time: **the already-installed `eslint-plugin-astro@1.7.0` ships
|
||||||
|
36 `jsx-a11y/*` rules and a `flat/jsx-a11y-recommended` config.** I had tested a
|
||||||
|
different upgrade path and generalised its failure to the capability. The stale
|
||||||
|
peer range is a *declaration*, not a runtime incompatibility.
|
||||||
|
|
||||||
|
Wrong the second time: having added a one-line `overrides` entry to fix that
|
||||||
|
declaration, I retested v3.1.0 — **and it installs cleanly.** The ERESOLVE was
|
||||||
|
never about v3; it was about the missing `overrides`. So the plugin is now on the
|
||||||
|
current major, not held two behind, and there is no stale pin left in the
|
||||||
|
manifest except `typescript`, whose hold is forced by peer ranges.
|
||||||
|
|
||||||
|
Verified by execution at each step — rules fired on a deliberately inaccessible
|
||||||
|
`.astro` file: `html-has-lang`, `alt-text`, `heading-has-content`,
|
||||||
|
`click-events-have-key-events`, `no-static-element-interactions`,
|
||||||
|
`no-noninteractive-tabindex`, `tabindex-no-positive`. Those are the checks
|
||||||
|
`docs/02-design-system.md` §Accessibility floor names.
|
||||||
|
|
||||||
|
One cost, recorded rather than discovered later: v3.1.0 declares
|
||||||
|
`node ^22.22.3 || ^24.16.0 || >=26.3.0`, which **excludes Node 25.6.0** — so
|
||||||
|
`npm install` prints EBADENGINE on Pouya's machine. Dev-time only; `nvm use`
|
||||||
|
clears it, and `.nvmrc` already says 22.
|
||||||
|
|
||||||
|
This mattered more than the ERESOLVE did. **§7 is the single source of
|
||||||
|
operational truth, and it carried a false capability claim on the eve of the
|
||||||
|
step that writes every landmark, heading and focus state**, under a CLAUDE.md
|
||||||
|
rule that accessibility is a build requirement rather than a polish pass.
|
||||||
|
|
||||||
|
**`@lhci/cli` advisories carried, not fixed.** All ten remaining findings trace
|
||||||
|
to it alone; 0.15.1 *is* `latest`, so there is no clean upgrade, and
|
||||||
|
`npm audit fix --force` installs `@lhci/cli@0.1.0` — a fourteen-minor downgrade,
|
||||||
|
which is not a fix. Dev-only, never on the CloudFront origin.
|
||||||
|
|
||||||
|
**Two `Consider` items taken rather than deferred**, both in files already being
|
||||||
|
edited: `image.service` was dead configuration (it set Astro's own default) under
|
||||||
|
a comment describing a convention it did not enforce — deleted, convention stays
|
||||||
|
in `CLAUDE.md`; and `sitemap({ lastmod: new Date() })` stamped every URL with the
|
||||||
|
build time, telling crawlers all 17 pages changed whenever one did, which spends
|
||||||
|
the signal `docs/04-seo-spec.md` wants. Removed, with step 7 named as the place
|
||||||
|
to reinstate it per-entry from `updatedDate`.
|
||||||
|
|
||||||
|
**Two `Consider` items declined:** the six extraneous `@img`/`@emnapi` packages
|
||||||
|
are cosmetic and `npm ci --dry-run` is clean; and no `.npmrc` with
|
||||||
|
`engine-strict` was added — the engines floor is a declaration, and making it
|
||||||
|
fatal is a decision with its own blast radius.
|
||||||
|
|
||||||
|
### `npm audit` — the actual result, and it is now zero
|
||||||
|
|
||||||
|
```
|
||||||
|
after the upgrade: 10 vulnerabilities (2 low, 1 moderate, 7 high)
|
||||||
|
npm audit --omit=dev: found 0 vulnerabilities
|
||||||
|
after removing @lhci/cli: found 0 vulnerabilities
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every Astro advisory is cleared** — zero findings against `astro`,
|
||||||
|
`@astrojs/mdx`, `@astrojs/sitemap` or `sharp`. `adversarial-reviewer` confirmed
|
||||||
|
the rationale independently by installing `astro@^5.0.0` in a scratch directory
|
||||||
|
and reproducing **8 high-severity advisories, 6 of them distinct XSS**.
|
||||||
|
|
||||||
|
`npm ls` traced all 10 residual findings to `@lhci/cli` alone. It was first
|
||||||
|
carried as acceptable dev-only risk; on the second pass that was reconsidered
|
||||||
|
and it is now **removed**. The reasoning: 0.15.1 *is* `latest`, so there was no
|
||||||
|
clean upgrade; `npm audit fix --force` would install `@lhci/cli@0.1.0`, a
|
||||||
|
fourteen-minor downgrade; and the tool **cannot run at all today** — no pages,
|
||||||
|
no `lighthouserc`. Carrying seven high-severity advisories for an unusable tool
|
||||||
|
is not a trade worth making when deferring it to step 7 costs nothing. Recorded
|
||||||
|
in §7; R11 forces a fresh pin when it returns.
|
||||||
|
|
||||||
|
**A discrepancy with entry (s), stated rather than quietly dropped.** Entry (s)
|
||||||
|
recorded 16 vulnerabilities and attributed the high-severity set to Astro ≤
|
||||||
|
7.0.9. After the upgrade the high count was still 7, all from `@lhci/cli` — so
|
||||||
|
(s)'s attribution cannot have been right as written. (s) is append-only and
|
||||||
|
stands; this is the correction. What is verifiable now is the audit output
|
||||||
|
above.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run, not asserted: `npm run check` (0 errors, 0 warnings, **0 hints** — three
|
||||||
|
`ZodIssueCode is deprecated` hints appeared mid-change and were fixed to the
|
||||||
|
string literal), `npm run build`, `npm run lint` (ESLint clean, Prettier clean).
|
||||||
|
`npm audit` and `npm audit --omit=dev` as above. `npm install --dry-run` for both
|
||||||
|
candidate lint upgrades. Each of the four schema invariants exercised with a
|
||||||
|
failing and a passing article. A deliberately broken `.astro` file linted to
|
||||||
|
confirm the plugin is live under ESLint 10. `node -v` → v25.6.0.
|
||||||
|
|
||||||
|
Not run: Lighthouse (removed — see above), the no-JavaScript render check (no
|
||||||
|
pages), link resolution (no pages). Those belong to build step 1
|
||||||
|
(`docs/01-architecture.md` §Build order), which entry (s) began and which
|
||||||
|
continues after this.
|
||||||
|
|
||||||
|
**Still open:** Q23 (runner registration — needs the second administrator), R6.
|
||||||
|
**Standing reminders surfaced at the start of this build:** R1 (licensure — the
|
||||||
|
layout about to be written is what carries it), R9 (the SES alarms still notify
|
||||||
|
nobody until the `ses-alerts` subscription is confirmed), R6, R10.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-08-26 (s) — Q22/Q23/Q27/Q28 answered; toolchain installed; build step 1 begun
|
## 2026-08-26 (s) — Q22/Q23/Q27/Q28 answered; toolchain installed; build step 1 begun
|
||||||
|
|
||||||
**Who:** Pouya answered four questions mid-session and corrected one earlier
|
**Who:** Pouya answered four questions mid-session and corrected one earlier
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ npm run preview # serve ./dist locally
|
|||||||
npm run check # astro check — type and template errors
|
npm run check # astro check — type and template errors
|
||||||
npm run lint # eslint + prettier check
|
npm run lint # eslint + prettier check
|
||||||
npm run format # prettier — rewrite files in place
|
npm run format # prettier — rewrite files in place
|
||||||
npm run lighthouse # lighthouse CI — NOT YET WIRED, nothing to measure yet
|
|
||||||
npm run deploy # build + deploy from this machine (see docs/06)
|
npm run deploy # build + deploy from this machine (see docs/06)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -122,20 +121,22 @@ docs/ the specs you build from
|
|||||||
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
|
05-backend-spec.md intake form, Lambda/DynamoDB/SES, booking, PIPEDA
|
||||||
06-deployment.md S3/CloudFront, Gitea Actions, IAM, cutover checklist
|
06-deployment.md S3/CloudFront, Gitea Actions, IAM, cutover checklist
|
||||||
src/
|
src/
|
||||||
|
content.config.ts content collections — Content Layer API, NOT content/config.ts
|
||||||
styles/tokens.css design tokens — the single source of colour and scale
|
styles/tokens.css design tokens — the single source of colour and scale
|
||||||
styles/global.css reset, base type, utilities
|
styles/global.css reset, base type, utilities
|
||||||
layouts/ page shells
|
layouts/ page shells
|
||||||
components/ UI components
|
components/ UI components
|
||||||
pages/ routes (file-based)
|
pages/ routes (file-based)
|
||||||
content/ content collections; Insights MDX lives here
|
content/insights/ Insights MDX only; the config sits above, not in here
|
||||||
data/site.ts site-wide constants, nav, contact details
|
data/site.ts site-wide constants, nav, contact details
|
||||||
public/ static assets served as-is
|
public/ static assets served as-is
|
||||||
```
|
```
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
**Framework.** Astro, `output: 'static'`. Never introduce a server runtime
|
**Framework.** Astro **7.x**, `output: 'static'` (D1 as amended). Never introduce
|
||||||
without a Change Log entry recording why.
|
a server runtime without a Change Log entry recording why. The major is pinned
|
||||||
|
deliberately — check `npm view astro version` before changing it.
|
||||||
|
|
||||||
**JavaScript.** Default to zero. Reach for an Astro island only when a feature
|
**JavaScript.** Default to zero. Reach for an Astro island only when a feature
|
||||||
genuinely cannot be CSS or progressive HTML. If you add a `client:*` directive,
|
genuinely cannot be CSS or progressive HTML. If you add a `client:*` directive,
|
||||||
@@ -163,6 +164,17 @@ page that collects legal inquiries.
|
|||||||
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
|
URL, Open Graph and Twitter card tags, and appropriate JSON-LD. See
|
||||||
`docs/04-seo-spec.md`. A page without these is not finished.
|
`docs/04-seo-spec.md`. A page without these is not finished.
|
||||||
|
|
||||||
|
**A version pin is verified against the registry, never recalled.** Before you
|
||||||
|
write or change any dependency version, run `npm view <pkg> version` and pin
|
||||||
|
against what it returns. One second of checking; a stale pin costs a migration.
|
||||||
|
This rule exists because `astro: "^5.0.0"` was written from memory and was
|
||||||
|
**two majors stale on the day it was written** — which meant shipping a
|
||||||
|
framework carrying high-severity XSS advisories. The same check applies to
|
||||||
|
every pin in `package.json`, not just the framework.
|
||||||
|
|
||||||
|
Re-check currency at each phase boundary in the build order (`AGENTS.md` R11),
|
||||||
|
not only when something breaks.
|
||||||
|
|
||||||
**`AGENTS.md` §7 is the single source of truth for operational facts.** Resource
|
**`AGENTS.md` §7 is the single source of truth for operational facts.** Resource
|
||||||
IDs, regions, DNS records, credential state, service status — these live in §7
|
IDs, regions, DNS records, credential state, service status — these live in §7
|
||||||
and nowhere else. Specs in `docs/` **cite** §7; they do not restate it. Write
|
and nowhere else. Specs in `docs/` **cite** §7; they do not restate it. Write
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ not yet proven.
|
|||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nvm use # Node 22
|
nvm use # Node 22 LTS — the floor is in package.json engines
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:4321
|
npm run dev # http://localhost:4321
|
||||||
```
|
```
|
||||||
@@ -24,7 +24,6 @@ npm run dev # http://localhost:4321
|
|||||||
| `npm run check` | `astro check` — type and template errors |
|
| `npm run check` | `astro check` — type and template errors |
|
||||||
| `npm run lint` | ESLint + Prettier check |
|
| `npm run lint` | ESLint + Prettier check |
|
||||||
| `npm run format` | Prettier — rewrite files in place |
|
| `npm run format` | Prettier — rewrite files in place |
|
||||||
| `npm run lighthouse` | Lighthouse CI — **not yet wired**; nothing to measure until pages exist |
|
|
||||||
| `npm run deploy` | Build and deploy from this machine — see Deployment |
|
| `npm run deploy` | Build and deploy from this machine — see Deployment |
|
||||||
|
|
||||||
## Before you contribute
|
## Before you contribute
|
||||||
|
|||||||
+21
-5
@@ -17,19 +17,35 @@ export default defineConfig({
|
|||||||
trailingSlash: 'always',
|
trailingSlash: 'always',
|
||||||
build: { format: 'directory', inlineStylesheets: 'auto' },
|
build: { format: 'directory', inlineStylesheets: 'auto' },
|
||||||
|
|
||||||
|
// Astro 7 changed this default from `true` to `'jsx'`. Measured, not assumed
|
||||||
|
// (AGENTS.md entry (t)): in an .astro template, an inline pair split across
|
||||||
|
// two lines renders as `<em>inline</em><strong>pair</strong>` under 'jsx' and
|
||||||
|
// `<em>inline</em> <strong>pair</strong>` under `true`. The space is silently
|
||||||
|
// deleted — no error, no warning; you find out by reading the page.
|
||||||
|
//
|
||||||
|
// MDX prose is NOT affected either way; the hazard is .astro markup only.
|
||||||
|
// Held at the HTML-aware behaviour because hand-written templates on a
|
||||||
|
// text-heavy site are exactly where a wrapped line meets an inline tag.
|
||||||
|
// Revisit only with a measurement, not a preference. See R12.
|
||||||
|
compressHTML: true,
|
||||||
|
|
||||||
integrations: [
|
integrations: [
|
||||||
mdx(),
|
mdx(),
|
||||||
sitemap({
|
sitemap({
|
||||||
filter: (page) => !page.includes('/legal/'),
|
filter: (page) => !page.includes('/legal/'),
|
||||||
changefreq: 'monthly',
|
changefreq: 'monthly',
|
||||||
lastmod: new Date(),
|
// No `lastmod`. It was `new Date()`, which stamped every URL with the
|
||||||
|
// build time — telling crawlers all 17 pages changed whenever one did.
|
||||||
|
// Google discounts lastmod it judges unreliable, so that spent the signal
|
||||||
|
// docs/04-seo-spec.md wants rather than sending it. Step 7 can reinstate
|
||||||
|
// it per-entry from an article's `updatedDate` via `serialize`.
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
||||||
image: {
|
// No `image` block: `astro/assets/services/sharp` is already Astro's default
|
||||||
// Explicit dimensions everywhere; never base64-inline an image.
|
// service, so setting it was dead configuration. The conventions it used to
|
||||||
service: { entrypoint: 'astro/assets/services/sharp' },
|
// sit under — explicit width/height on every image, never base64-inline —
|
||||||
},
|
// live in CLAUDE.md, which is where they are actually enforced (by review).
|
||||||
|
|
||||||
// No `prefetch` block at all — removed 2026-08-26, see AGENTS.md entry (r).
|
// No `prefetch` block at all — removed 2026-08-26, see AGENTS.md entry (r).
|
||||||
// Any prefetch setting ships Astro's prefetch script to every page, against
|
// Any prefetch setting ships Astro's prefetch script to every page, against
|
||||||
|
|||||||
+15
-2
@@ -296,8 +296,21 @@ you do not settle · how to prepare.
|
|||||||
Astro content collection, MDX. Index reverse-chronological with topic filtering
|
Astro content collection, MDX. Index reverse-chronological with topic filtering
|
||||||
by practice area.
|
by practice area.
|
||||||
|
|
||||||
Article frontmatter: `title`, `description`, `publishDate`, `updatedDate`,
|
Article frontmatter: `title`, `seoTitle` (optional), `description`,
|
||||||
`topics[]`, `practiceAreas[]`, `readingTime`, `draft`.
|
`publishDate`, `updatedDate`, `topics[]`, `practiceAreas[]`, `readingTime`,
|
||||||
|
`image` and `imageAlt` (both optional, but `imageAlt` is **required whenever
|
||||||
|
`image` is set**), `draft`, `reviewedByPouya`.
|
||||||
|
|
||||||
|
`title` is the headline and, for articles, the `<title>` — they carry no
|
||||||
|
` · Pouya Lajevardi` suffix; see `04-seo-spec.md` for why. `seoTitle` replaces
|
||||||
|
it when a headline that reads well falls outside 50–60. `src/content.config.ts`
|
||||||
|
enforces the rendered length and names the offending string in the error.
|
||||||
|
|
||||||
|
Dates are date-only ISO (`2026-08-01`), parsed as UTC and round-tripped, so a
|
||||||
|
typo fails the build rather than shipping as 1970 or as the wrong day.
|
||||||
|
|
||||||
|
`reviewedByPouya` carries D9: the schema refuses to build an entry with
|
||||||
|
`draft: false` and `reviewedByPouya: false`.
|
||||||
|
|
||||||
Content territories, from brief §VII: process explainers · regulatory commentary ·
|
Content territories, from brief §VII: process explainers · regulatory commentary ·
|
||||||
industry-specific dispute commentary · anonymised reflections · technical
|
industry-specific dispute commentary · anonymised reflections · technical
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ Every page passes through one `SEO` component. A page without it is not finished
|
|||||||
```
|
```
|
||||||
title 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi"
|
title 50–60 chars, unique. Pattern: "<Page> · Pouya Lajevardi"
|
||||||
Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto"
|
Home: "Pouya Lajevardi · Mediation & Arbitration · Toronto"
|
||||||
|
ARTICLES ARE THE EXCEPTION: no " · Pouya Lajevardi" suffix.
|
||||||
|
The suffix is 18 chars, so a headline that already reads
|
||||||
|
50–60 renders at 68–78 — over this ceiling. Measured against
|
||||||
|
the five launch headlines in 03-content-spec.md, the suffix
|
||||||
|
rule fails 5 of 5; without it, 4 of 5 pass. An article's
|
||||||
|
headline IS its <title>; `seoTitle` in the frontmatter
|
||||||
|
overrides it when a headline that reads well is out of range.
|
||||||
|
src/content.config.ts enforces this and names the offending
|
||||||
|
string and its length in the build error.
|
||||||
description 140–160 chars, unique, written for a human, not stuffed
|
description 140–160 chars, unique, written for a human, not stuffed
|
||||||
canonical absolute, https, trailing slash
|
canonical absolute, https, trailing slash
|
||||||
og:title/description/image/url/type/site_name/locale (en_CA)
|
og:title/description/image/url/type/site_name/locale (en_CA)
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
// ESLint 9 flat config. Scope is deliberately small: this project targets zero
|
// ESLint 10 flat config. Scope is deliberately small: this project targets zero
|
||||||
// client JavaScript (CLAUDE.md, AGENTS.md §7), so the only JS/TS here is build
|
// client JavaScript (CLAUDE.md, AGENTS.md §7), so the only JS/TS here is build
|
||||||
// configuration, site data, and the occasional island. Rules exist to catch
|
// configuration, site data, and the occasional island. Rules exist to catch
|
||||||
// mistakes, not to impose style — Prettier owns formatting.
|
// mistakes, not to impose style — Prettier owns formatting.
|
||||||
@@ -19,6 +19,7 @@ export default [
|
|||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
...astro.configs.recommended,
|
...astro.configs.recommended,
|
||||||
|
...astro.configs['flat/jsx-a11y-recommended'],
|
||||||
|
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,mjs,ts}', '**/*.astro'],
|
files: ['**/*.{js,mjs,ts}', '**/*.astro'],
|
||||||
|
|||||||
Generated
+3022
-5856
File diff suppressed because it is too large
Load Diff
+20
-13
@@ -5,7 +5,8 @@
|
|||||||
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
|
"description": "The dispute resolution practice of Pouya Lajevardi — Toronto",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": "^22.13.0 || >=24",
|
||||||
|
"npm": ">=9.6.5"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
@@ -14,23 +15,29 @@
|
|||||||
"check": "astro check",
|
"check": "astro check",
|
||||||
"lint": "eslint . && prettier --check .",
|
"lint": "eslint . && prettier --check .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lighthouse": "lhci autorun",
|
|
||||||
"deploy": "bash scripts/deploy-local.sh"
|
"deploy": "bash scripts/deploy-local.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^4.0.0",
|
"@astrojs/mdx": "^7.0.8",
|
||||||
"@astrojs/sitemap": "^3.2.0",
|
"@astrojs/sitemap": "^3.7.3",
|
||||||
"astro": "^5.0.0",
|
"astro": "^7.2.7",
|
||||||
"sharp": "^0.33.0"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.0",
|
"@astrojs/check": "^0.9.10",
|
||||||
"@lhci/cli": "^0.14.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^10.9.1",
|
||||||
"eslint-plugin-astro": "^1.3.0",
|
"eslint-plugin-astro": "^3.1.0",
|
||||||
"prettier": "^3.4.0",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"prettier-plugin-astro": "^0.14.0",
|
"globals": "^17.11.0",
|
||||||
"typescript": "^5.7.0",
|
"prettier": "^3.9.6",
|
||||||
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.68.0"
|
"typescript-eslint": "^8.68.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"eslint-plugin-jsx-a11y": {
|
||||||
|
"eslint": "$eslint"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-5
@@ -7,11 +7,14 @@
|
|||||||
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
|
# administrator (AGENTS.md Q23). Until that lands, this script is how the site
|
||||||
# ships.
|
# ships.
|
||||||
#
|
#
|
||||||
# It performs EXACTLY what .gitea/workflows/deploy.yml performs: the same guard,
|
# It matches .gitea/workflows/deploy.yml on everything that determines what gets
|
||||||
# the same three sync passes in the same order with the same cache headers, and
|
# published: the same guard coverage, `npm run check` before the build, the same
|
||||||
# the same invalidation. At this scale the pipeline changes only how a deploy is
|
# three sync passes in the same order with the same cache headers, and the same
|
||||||
# TRIGGERED, not what it does — so this is a fallback, not a lesser path. Any
|
# invalidation. Any change to one must be made to the other.
|
||||||
# change to one must be made to the other.
|
#
|
||||||
|
# Two deliberate differences: it does not run `npm ci` (your node_modules is
|
||||||
|
# already installed, and CI starts empty), and it refuses to run as user/pouya,
|
||||||
|
# which CI cannot do because CI has no such credential.
|
||||||
#
|
#
|
||||||
# Required environment (values are in AGENTS.md §7 — deliberately not restated
|
# Required environment (values are in AGENTS.md §7 — deliberately not restated
|
||||||
# here; §7 is the single source of truth for operational facts):
|
# here; §7 is the single source of truth for operational facts):
|
||||||
@@ -23,11 +26,14 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Same six values the workflow guards. Emptiness only — no value is echoed.
|
||||||
missing=''
|
missing=''
|
||||||
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
|
[ -n "${AWS_REGION:-}" ] || missing="$missing AWS_REGION"
|
||||||
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
|
[ -n "${S3_BUCKET:-}" ] || missing="$missing S3_BUCKET"
|
||||||
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
|
[ -n "${CLOUDFRONT_DISTRIBUTION_ID:-}" ] || missing="$missing CLOUDFRONT_DISTRIBUTION_ID"
|
||||||
[ -n "${INTAKE_ENDPOINT:-}" ] || missing="$missing INTAKE_ENDPOINT"
|
[ -n "${INTAKE_ENDPOINT:-}" ] || missing="$missing INTAKE_ENDPOINT"
|
||||||
|
[ -n "${AWS_ACCESS_KEY_ID:-}" ] || missing="$missing AWS_ACCESS_KEY_ID"
|
||||||
|
[ -n "${AWS_SECRET_ACCESS_KEY:-}" ] || missing="$missing AWS_SECRET_ACCESS_KEY"
|
||||||
if [ -n "$missing" ]; then
|
if [ -n "$missing" ]; then
|
||||||
echo "Not set:$missing" >&2
|
echo "Not set:$missing" >&2
|
||||||
echo >&2
|
echo >&2
|
||||||
@@ -51,6 +57,9 @@ case "$caller" in
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
echo "==> Type and template check"
|
||||||
|
npm run check
|
||||||
|
|
||||||
echo "==> Build"
|
echo "==> Build"
|
||||||
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
|
PUBLIC_SITE_URL="https://adr.smlcompany.ca" \
|
||||||
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
|
PUBLIC_INTAKE_ENDPOINT="$INTAKE_ENDPOINT" \
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { glob } from 'astro/loaders';
|
||||||
|
import { z } from 'astro/zod';
|
||||||
|
import { PRACTICE_SLUGS } from './data/site';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<title>` length, from docs/04-seo-spec.md.
|
||||||
|
*
|
||||||
|
* Articles do NOT carry the ` · Pouya Lajevardi` suffix that other pages use.
|
||||||
|
* The suffix is 18 characters; appending it to a headline that already reads
|
||||||
|
* 50–60 produces 68–78, over the spec's own ceiling. Measured against the five
|
||||||
|
* launch headlines in docs/03-content-spec.md, the suffix rule fails 5 of 5
|
||||||
|
* and the no-suffix rule passes 4 of 5. A rule that its own content cannot
|
||||||
|
* satisfy is a rule that will be worked around.
|
||||||
|
*/
|
||||||
|
const TITLE_MIN = 50;
|
||||||
|
const TITLE_MAX = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frontmatter dates. Three failure modes this has to close, each found by
|
||||||
|
* review rather than by reasoning:
|
||||||
|
*
|
||||||
|
* - `z.coerce.date()` reads unquoted `20260801` — valid YAML, the obvious slip
|
||||||
|
* for `2026-08-01` — as epoch milliseconds and yields 1970-01-01, silently.
|
||||||
|
* - An unanchored regex accepts `2026-13-45` and `2026-08-01 nonsense`, both of
|
||||||
|
* which produce an `Invalid Date` that reaches `datePublished` in the
|
||||||
|
* article's JSON-LD or throws at build from `.toISOString()`.
|
||||||
|
* - `new Date('2026-02-30')` rolls over to 2026-03-02 — a wrong date shipped
|
||||||
|
* with no error at all, which is worse than a failed build.
|
||||||
|
*
|
||||||
|
* So: anchored, date-only, parsed as UTC, and round-tripped to prove the day
|
||||||
|
* that comes back is the day that was written. A time component is rejected
|
||||||
|
* rather than guessed at — quoted `2026-08-01T10:00:00` parses as local time
|
||||||
|
* while the unquoted YAML form parses as UTC, so the same frontmatter would
|
||||||
|
* mean different instants on a laptop and on a CI runner.
|
||||||
|
*/
|
||||||
|
const frontmatterDate = z.union(
|
||||||
|
[
|
||||||
|
z.date(),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.transform((value, ctx) => {
|
||||||
|
const parsed = new Date(`${value}T00:00:00Z`);
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsed.getTime()) ||
|
||||||
|
parsed.toISOString().slice(0, 10) !== value
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: `"${value}" is not a real calendar date.`,
|
||||||
|
});
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{ error: 'Use a date-only ISO value, e.g. 2026-08-01 (no time component).' },
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insights. Content territories are set by the strategy brief §VII and
|
||||||
|
* restated in docs/03-content-spec.md.
|
||||||
|
*
|
||||||
|
* Frontmatter shape follows docs/01-architecture.md.
|
||||||
|
*
|
||||||
|
* Astro 5 introduced the Content Layer API and the `src/content.config.ts`
|
||||||
|
* location; Astro 6 removed the legacy `src/content/config.ts` fallback, so
|
||||||
|
* collections now declare a `loader` rather than a `type`, and `z` imports from
|
||||||
|
* `astro/zod`. See AGENTS.md entry (t).
|
||||||
|
*/
|
||||||
|
const insights = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/insights' }),
|
||||||
|
schema: ({ image }) =>
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
/**
|
||||||
|
* The headline, and by default the `<title>` too. The refinement below
|
||||||
|
* enforces 50–60 on whichever of this and `seoTitle` is rendered; these
|
||||||
|
* bounds only catch something wildly wrong.
|
||||||
|
*/
|
||||||
|
title: z.string().trim().min(10).max(120),
|
||||||
|
/**
|
||||||
|
* Replaces the headline when building the `<title>`. Needed when a
|
||||||
|
* headline that reads well is outside 50–60 — good writing is not an
|
||||||
|
* error. Deliberately unbounded here: the refinement below is the single
|
||||||
|
* check, so one mistake produces one message rather than two.
|
||||||
|
*/
|
||||||
|
seoTitle: z.string().trim().min(1).optional(),
|
||||||
|
/** Doubles as the meta description — docs/04-seo-spec.md, 140–160. */
|
||||||
|
description: z.string().min(140).max(160),
|
||||||
|
publishDate: frontmatterDate,
|
||||||
|
updatedDate: frontmatterDate.optional(),
|
||||||
|
/**
|
||||||
|
* Plural, per docs/01-architecture.md and the topic pills in
|
||||||
|
* docs/02-design-system.md. A piece can legitimately be both
|
||||||
|
* regulatory and industry commentary.
|
||||||
|
*/
|
||||||
|
topics: z
|
||||||
|
.array(
|
||||||
|
z.enum([
|
||||||
|
'process-explainer',
|
||||||
|
'regulatory-commentary',
|
||||||
|
'industry-commentary',
|
||||||
|
'reflection',
|
||||||
|
'technical-explainer',
|
||||||
|
'credentialing',
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.refine((t) => new Set(t).size === t.length, 'No duplicate topics.'),
|
||||||
|
practiceAreas: z
|
||||||
|
.array(z.enum(PRACTICE_SLUGS))
|
||||||
|
.min(1)
|
||||||
|
.refine(
|
||||||
|
(a) => new Set(a).size === a.length,
|
||||||
|
'No duplicate practice areas.',
|
||||||
|
),
|
||||||
|
/** Minutes. docs/02-design-system.md renders it on every ArticleCard. */
|
||||||
|
readingTime: z.number().int().positive(),
|
||||||
|
image: image().optional(),
|
||||||
|
imageAlt: z.string().trim().min(1).optional(),
|
||||||
|
/**
|
||||||
|
* INTENT, not yet enforced — there is no /insights/ route to enforce it
|
||||||
|
* in. The mechanism, when step 7 builds that route: filter drafts out of
|
||||||
|
* `getCollection('insights', ...)` so no page is generated, which keeps
|
||||||
|
* them out of the build, the index, and the sitemap in one move. The
|
||||||
|
* sitemap filter in astro.config.mjs cannot see collection data and is
|
||||||
|
* not the right place for it. See docs/04-seo-spec.md.
|
||||||
|
*/
|
||||||
|
draft: z.boolean().default(true),
|
||||||
|
/** Every article is reviewed by Pouya before publication — D9. */
|
||||||
|
reviewedByPouya: z.boolean().default(false),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const rendered = data.seoTitle ?? data.title;
|
||||||
|
if (rendered.length < TITLE_MIN || rendered.length > TITLE_MAX) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['seoTitle'],
|
||||||
|
message:
|
||||||
|
`The <title> would be ${rendered.length} characters ` +
|
||||||
|
`("${rendered}"). docs/04-seo-spec.md requires ${TITLE_MIN}–${TITLE_MAX}. ` +
|
||||||
|
`Either adjust the headline or set seoTitle.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.image && !data.imageAlt) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['imageAlt'],
|
||||||
|
message:
|
||||||
|
'imageAlt is required when image is set — alt text is a build ' +
|
||||||
|
'requirement, not a polish pass (CLAUDE.md).',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.draft && !data.reviewedByPouya) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['reviewedByPouya'],
|
||||||
|
message:
|
||||||
|
'Every article is reviewed by Pouya before publication (D9). ' +
|
||||||
|
'Set reviewedByPouya: true, or keep draft: true.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { insights };
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { defineCollection, z } from 'astro:content';
|
|
||||||
import { PRACTICE_AREAS } from '../data/site';
|
|
||||||
|
|
||||||
const practiceSlugs = PRACTICE_AREAS.map((a) => a.slug) as [
|
|
||||||
string,
|
|
||||||
...string[],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Insights. Content territories are set by the strategy brief §VII and
|
|
||||||
* restated in docs/03-content-spec.md.
|
|
||||||
*
|
|
||||||
* Every piece must link to at least one practice-area page — that is what
|
|
||||||
* turns the blog into ranking power for the pages that convert.
|
|
||||||
*/
|
|
||||||
const insights = defineCollection({
|
|
||||||
type: 'content',
|
|
||||||
schema: ({ image }) =>
|
|
||||||
z.object({
|
|
||||||
// Bounds match docs/04-seo-spec.md: titles 50-60, descriptions 140-160.
|
|
||||||
title: z.string().min(50).max(60),
|
|
||||||
description: z.string().min(140).max(160), // doubles as the meta description
|
|
||||||
publishDate: z.date(),
|
|
||||||
updatedDate: z.date().optional(),
|
|
||||||
topic: z.enum([
|
|
||||||
'process-explainer',
|
|
||||||
'regulatory-commentary',
|
|
||||||
'industry-commentary',
|
|
||||||
'reflection',
|
|
||||||
'technical-explainer',
|
|
||||||
'credentialing',
|
|
||||||
]),
|
|
||||||
practiceAreas: z.array(z.enum(practiceSlugs)).min(1),
|
|
||||||
image: image().optional(),
|
|
||||||
imageAlt: z.string().optional(),
|
|
||||||
/** INTENT, not yet enforced: drafts must be excluded from the build, the
|
|
||||||
* index, and the sitemap. Nothing implements that today — the sitemap
|
|
||||||
* filter in astro.config.mjs covers /legal/ only. Implement before the
|
|
||||||
* first article ships (docs/04-seo-spec.md). */
|
|
||||||
draft: z.boolean().default(true),
|
|
||||||
/**
|
|
||||||
* Every article is reviewed by Pouya before publication (AGENTS.md D9).
|
|
||||||
* An article with draft:false and reviewedByPouya:false is a bug.
|
|
||||||
*/
|
|
||||||
reviewedByPouya: z.boolean().default(false),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = { insights };
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Articles live here as .mdx.
|
||||||
|
|
||||||
|
The directory is tracked so the glob loader's `base` in src/content.config.ts
|
||||||
|
resolves. That does not silence the build warning, it only downgrades it:
|
||||||
|
without the directory Astro logs "The base directory ... does not exist"; with
|
||||||
|
it, "No files found matching "**/*.{md,mdx}"". Both clear the moment the first
|
||||||
|
article lands.
|
||||||
+55
-4
@@ -11,7 +11,13 @@ export const SITE = {
|
|||||||
tagline: 'Mediation · Arbitration · Toronto',
|
tagline: 'Mediation · Arbitration · Toronto',
|
||||||
url: 'https://adr.smlcompany.ca',
|
url: 'https://adr.smlcompany.ca',
|
||||||
locale: 'en_CA',
|
locale: 'en_CA',
|
||||||
entity: 'SML Company Ltd. · Ontario, Canada',
|
/**
|
||||||
|
* TODO(pouya): AGENTS.md Q30 — §4 verifies "Operator of SML Company Ltd."
|
||||||
|
* but nothing verifies the company's jurisdiction of incorporation, and this
|
||||||
|
* string is destined for the public footer. Jurisdiction dropped until
|
||||||
|
* confirmed; the operator fact itself is verified and stays.
|
||||||
|
*/
|
||||||
|
entity: 'SML Company Ltd.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,7 +64,12 @@ export const BOUTIQUE = 'a Toronto litigation and ADR boutique' as const;
|
|||||||
|
|
||||||
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
|
/** Analytics: privacy-first and cookieless (D15). No GA4, no consent banner. */
|
||||||
export const ANALYTICS = {
|
export const ANALYTICS = {
|
||||||
provider: 'plausible' as 'plausible' | 'fathom' | null,
|
/**
|
||||||
|
* TODO(pouya): AGENTS.md Q31 — D15 records the choice as "Plausible **or**
|
||||||
|
* Fathom", i.e. undecided. `'plausible'` was a guessed value, which is what
|
||||||
|
* the header of this file tells you not to do. Null until you pick one.
|
||||||
|
*/
|
||||||
|
provider: null as 'plausible' | 'fathom' | null,
|
||||||
domain: 'adr.smlcompany.ca',
|
domain: 'adr.smlcompany.ca',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -92,7 +103,8 @@ export const PORTRAIT = {
|
|||||||
/** Shown on /contact/ and with the booking embed. Do not reword casually. */
|
/** Shown on /contact/ and with the booking embed. Do not reword casually. */
|
||||||
export const NO_RETAINER_NOTICE =
|
export const NO_RETAINER_NOTICE =
|
||||||
'Submitting this form does not create a retainer, does not appoint a neutral, ' +
|
'Submitting this form does not create a retainer, does not appoint a neutral, ' +
|
||||||
'and does not itself establish a mediator–party relationship.';
|
'does not itself establish a mediator–party relationship, and does not itself ' +
|
||||||
|
'create a conflict check.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate card — AGENTS.md D14, confirmed by Pouya 2026-08-26.
|
* Rate card — AGENTS.md D14, confirmed by Pouya 2026-08-26.
|
||||||
@@ -136,6 +148,22 @@ export const FEES = {
|
|||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugs as their own literal tuple so consumers keep the union type.
|
||||||
|
* Deriving them with `.map()` and casting to `[string, ...string[]]` widens
|
||||||
|
* them back to `string`, and a mistyped slug then survives `astro check`.
|
||||||
|
*/
|
||||||
|
export const PRACTICE_SLUGS = [
|
||||||
|
'construction',
|
||||||
|
'technology',
|
||||||
|
'energy',
|
||||||
|
'insurance',
|
||||||
|
'shareholder',
|
||||||
|
'cross-cultural',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PracticeSlug = (typeof PRACTICE_SLUGS)[number];
|
||||||
|
|
||||||
export const PRACTICE_AREAS = [
|
export const PRACTICE_AREAS = [
|
||||||
{
|
{
|
||||||
slug: 'construction',
|
slug: 'construction',
|
||||||
@@ -155,7 +183,30 @@ export const PRACTICE_AREAS = [
|
|||||||
name: 'Cross-Border & Diaspora',
|
name: 'Cross-Border & Diaspora',
|
||||||
chip: 'Cross-cultural',
|
chip: 'Cross-cultural',
|
||||||
},
|
},
|
||||||
] as const;
|
] as const satisfies ReadonlyArray<{
|
||||||
|
slug: PracticeSlug;
|
||||||
|
name: string;
|
||||||
|
chip: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile-time completeness check, both directions.
|
||||||
|
*
|
||||||
|
* `satisfies` above catches a slug in PRACTICE_AREAS that is not in
|
||||||
|
* PRACTICE_SLUGS. This catches the reverse — a slug with no area — which would
|
||||||
|
* otherwise let an article declare a practice area that has no page, no nav
|
||||||
|
* child and no chip, producing a dead link at step 7. Deriving the areas from
|
||||||
|
* the slugs used to make that structurally impossible; keeping two literals is
|
||||||
|
* what buys the literal types back, so the check has to be explicit.
|
||||||
|
*
|
||||||
|
* Type-only. Nothing runs, nothing ships.
|
||||||
|
*/
|
||||||
|
type _AssertNever<T extends never> = T;
|
||||||
|
type _SlugsWithoutAnArea = Exclude<
|
||||||
|
PracticeSlug,
|
||||||
|
(typeof PRACTICE_AREAS)[number]['slug']
|
||||||
|
>;
|
||||||
|
export type _SlugCoverage = _AssertNever<_SlugsWithoutAnArea>;
|
||||||
|
|
||||||
/** Seven items is the ceiling before a nav stops being scannable. */
|
/** Seven items is the ceiling before a nav stops being scannable. */
|
||||||
export const PRIMARY_NAV = [
|
export const PRIMARY_NAV = [
|
||||||
|
|||||||
Reference in New Issue
Block a user