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
@@ -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',
|
||||
url: 'https://adr.smlcompany.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;
|
||||
|
||||
/**
|
||||
@@ -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. */
|
||||
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',
|
||||
} as const;
|
||||
|
||||
@@ -92,7 +103,8 @@ export const PORTRAIT = {
|
||||
/** Shown on /contact/ and with the booking embed. Do not reword casually. */
|
||||
export const NO_RETAINER_NOTICE =
|
||||
'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.
|
||||
@@ -136,6 +148,22 @@ export const FEES = {
|
||||
],
|
||||
} 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 = [
|
||||
{
|
||||
slug: 'construction',
|
||||
@@ -155,7 +183,30 @@ export const PRACTICE_AREAS = [
|
||||
name: 'Cross-Border & Diaspora',
|
||||
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. */
|
||||
export const PRIMARY_NAV = [
|
||||
|
||||
Reference in New Issue
Block a user