diff --git a/CHANGELOG.md b/CHANGELOG.md index f9748e6..4021b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,15 @@ Notable user-visible changes to Food Help are recorded here. Community publicati ### Added +- A separate Food Help project homepage build, with an authoritative available-community list, prominent Kingston link, public project/contact context, root sitemap and robots files, and a real 404 page. +- Independent `project:check`, `project:build`, `project:preview` and `project:release` workflows for the root without advancing community releases. - Top-level Emergency food and Affordable food browsing, with Emergency as the default and a stable `/affordable-food/` route. - Location-specific resource cards that can share one provider identity without merging venue schedules. - Isolated local review builds for visibly labelled draft records. ### Changed +- Configured community directories can link discreetly back to the Food Help project without changing their resource URLs or provider data. - Category controls now show only primary types represented in the active browsing view, and omit “All types” when only one type is available. - Published resources require reviewed, evidence-backed cost information. Free services appear in Emergency food, low-cost and subsidized services appear in Affordable food, and mixed-cost services appear in both without duplicate records. - Static, printable, downloaded, offline and public-data surfaces continue to preserve the complete published directory. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1bdd2f..580c48f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Read `AGENTS.md` and the selected `deployments//AGENTS.md`. Changes to platform behaviour belong in generic source; community facts and choices belong in that deployment folder. Never solve a community requirement with a locality branch in application code. -Use the supported Node version, `npm ci`, and install Playwright browsers. Run `npm run check -- --site deployments/` for a local change or `npm run check -- --all` for shared software before requesting review. Explain the problem, resulting behaviour, verification and any effects on public data, privacy, caches or deployment. Keep dependencies minimal; development-only tools still need a reason and a lockfile change. Validation does not deploy; publishing selects one community and an exact source revision. +Use the supported Node version, `npm ci`, and install Playwright browsers. Run `npm run project:check` for a root project-site change, `npm run check -- --site deployments/` for a local directory change, or `npm run check -- --all` for shared software before requesting review. Explain the problem, resulting behaviour, verification and any effects on public data, privacy, caches or deployment. Keep dependencies minimal; development-only tools still need a reason and a lockfile change. Validation does not deploy; publishing selects the project site or one community and an exact source revision. Provider suggestions are review leads. Supply source links, what was checked and when, uncertainties and the proposed fact change. Do not submit private correspondence, personal contact details or credentials. A maintainer/operator must approve facts before publication. Automated checks never make that editorial decision. diff --git a/README.md b/README.md index e9e6b45..0a83254 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,21 @@ Food Help builds an accessible, installable, offline-capable food-resource directory from reviewed local data. It is a static Vite + TypeScript application with no runtime package dependencies, accounts, database or server application. +The project homepage at [food-help.ca](https://food-help.ca) is a separate static target and community hub. Its public copy lives in `project-site/site.json`; `project-site/communities.json` is the single authoritative list of available directories. The root links people to local directories and does not duplicate their food-provider records. + **A new community requires no changes to generic application source.** Shared software lives on `main`; each community supplies a folder containing `site.json`, `resources.json`, optional `assets/` and optional sanitized `usage.json`. Building and releasing one community does not update another. The real Kingston configuration and eight reviewed listings are in `deployments/kingston/`, for [kingston.food-help.ca](https://kingston.food-help.ca). The neutral starter in `examples/exampleville/` is fictional and excluded from indexing. It must not be used to find real services. +## Work on the project homepage + +```sh +npm run project:check +npm run project:dev +``` + +Open `http://127.0.0.1:4175`. The normal local build is a noindex preview at `dist/project/`; build it without starting a server with `npm run project:build`, or serve an existing build with `npm run project:preview`. A production build and `release/project` pointer are explicitly separate from every community build and release. See [deployment](docs/deployment.md#food-help-project-site) before publication. + ## Start independently Download, clone, fork or use this repository as a template. No TGC account, hosting purchase or analytics service is required. Use Node **24.12 or later in the Node 24 line** (see `.node-version`). @@ -59,13 +70,14 @@ Published schedules are never a live availability feed. Location is optional and | Location | Role | | --- | --- | | `deployments//site.json`, `resources.json` | Community configuration and reviewed fact authority | +| `project-site/site.json`, `communities.json`, `styles.css` | Root project-site copy, available-community authority and presentation | | Selected folder's `assets/`, optional `usage.json`, `AGENTS.md` | Branding, reviewed aggregates and local rules | | `examples/exampleville/` | Neutral fictional starter and test fixture | | `src/`, `schemas/`, `scripts/` | Shared application, data contracts and tooling | | `src/copy/en.ts` | Application copy and documented additive language path | | `tests/` | Unit, contract, browser, offline and synthetic-community checks | | `.github/workflows/` | Repeatable validation, separate from explicit publication | -| `.generated/`, `artifacts/`, `dist//` | Disposable generated output; never hand-maintained authority | +| `.generated/`, `artifacts/`, `dist/project/`, `dist//` | Disposable generated output; never hand-maintained authority | Read [maintenance](docs/maintenance.md) for adding listings, communities and shared upgrades; [architecture](docs/architecture.md) for generation/offline behaviour; [interoperability](docs/interoperability.md) for the preserved v1/HSDS mapping; [verification](docs/verification.md) for automated versus physical-device evidence; and the [changelog](CHANGELOG.md) for unreleased user-visible changes. diff --git a/deployments/kingston/site.json b/deployments/kingston/site.json index fef4154..2865cc9 100644 --- a/deployments/kingston/site.json +++ b/deployments/kingston/site.json @@ -62,5 +62,6 @@ "data_rights": { "statement": "Resource facts remain subject to their original sources and provider rights. No separate open-data licence is asserted for this deployment." }, - "software_source_url": "https://github.com/True-Good-Craft/Food-Help" + "software_source_url": "https://github.com/True-Good-Craft/Food-Help", + "project_home_url": "https://food-help.ca" } diff --git a/docs/analytics.md b/docs/analytics.md index 0c42646..ca9a095 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -2,6 +2,10 @@ Food Help and the fictional starter work with `"analytics": { "enabled": false }`. Disabled deployments contain no collector endpoint or collector CSP permission. Public aggregate files are independent of browser collection and never enable it. +The root project site also explicitly keeps analytics disabled. It has no client JavaScript, collector endpoint, cookie, identifier or consent storage. Its contact action is a direct link to the existing public True Good Craft email address; it does not embed the True Good Craft inquiry form or connect to its private form storage. + +Project-site interest must not be counted as Kingston directory activity. The existing Food Help/Lighthouse adapter is deployment-scoped, and no root-site collector contract or origin registration is configured here. Any later root measurement would require a separately reviewed property name, allowed `https://food-help.ca` origin, bounded event contract, public wording, retention/reporting treatment and coordinated Lighthouse configuration outside this repository. Until those approvals and compatibility checks exist, keep `project-site/site.json` set to disabled and do not reuse Kingston constants or reporting. + Collection requires an explicitly reviewed deployment configuration, a production build and the exact configured HTTPS canonical origin. Preview URLs and preview builds suppress it. The emitter runs only on the interactive home directory: resource pages, the simple directory, downloads and other informational pages do not produce page-history events. ## Fixed events and wire formats diff --git a/docs/architecture.md b/docs/architecture.md index cd48548..c146dee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,8 +2,12 @@ Food Help produces one static community artifact per selected configuration. There is no tenant router, server API, database, account system or admin portal. `deployments//` contains that community's authority; `schemas/` and shared semantic validation define the contract. `src/`, `schemas/` and `scripts/` are shared on `main`. Generated files are never authoring authority. +The root project site is a smaller, separate static artifact. `project-site/site.json` owns its public copy and integration choices, while `project-site/communities.json` is the only list used to generate community cards, crawlable links, JSON discovery and machine-readable project guidance. Its output is `dist/project/`; it contains no provider records, client JavaScript, service worker, browser storage or analytics. `scripts/build-project.ts` cannot select or write a community output, and community builds do not read the project-site community list. + Setup requires an explicit `--site` folder. Development, build and preview select the same folder, with `examples/exampleville/` as the neutral default when omitted. `check --all` may discover community folders for validation; publication never discovers targets implicitly. Each community release chooses an exact shared-source revision and can retain its prior artifact while another advances. +The project site similarly advances only through `release/project`. It is not a community release pointer and must be connected to a distinct root hosting project. Building, checking, merging or releasing the root does not advance `release/`; adding a community directory does not automatically publish the root list. + ## Build order `scripts/build.ts` owns the pipeline; `scripts/lib/web.ts` renders the related web/discovery surfaces together. diff --git a/docs/configuration.md b/docs/configuration.md index b247af3..faf8337 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,6 +28,7 @@ Use `--site deployments/kingston` for Kingston or `--site deployments//` folder. Building is local and does not create a hosting project, push a repository, change DNS or contact Cloudflare. Deploy to a dedicated root HTTPS origin. No Cloudflare runtime service is required by the directory application. +## Food Help project site + +The root `https://food-help.ca/` project site is an independent target, not a community directory. Its source is `project-site/`, its output is `dist/project/`, and its release pointer is `release/project`. Build and check it locally with: + +```sh +npm run project:check +npm run project:build +npm run project:preview +``` + +The normal build is a noindex review artifact. Production requires corresponding source for the exact revision: + +```sh +npm run project:build -- --production --source-revision +npm run project:release -- --ref +``` + +After review and separate publication authorization, adding `--publish` to `project:release` advances only `release/project` using the same lease-protected release-plan policy as community releases. A dedicated root hosting project should watch only that branch, run `npm run project:build -- --production`, publish `dist/project`, and provide `CF_PAGES_COMMIT_SHA`. It must not watch `release/kingston`, build a `deployments/` folder, or combine root and community outputs. Retain the previous complete root artifact, source revision and hosting release identifier for rollback. + +Before the first release, an authorized operator must create or select that distinct static hosting project, attach only the approved `food-help.ca` hostname, disable host-injected analytics/scripts/HTML rewriting, and verify the generated headers, canonical, 404 and sitemap on the actual origin. Preview and provider-generated aliases must remain noindex. The generated project artifact has no client JavaScript, service worker or analytics integration. + +The intended redirect posture is one hop with path and query preserved: + +- `http://food-help.ca/*` → `https://food-help.ca/*`; +- `http://www.food-help.ca/*` and `https://www.food-help.ca/*` → `https://food-help.ca/*`. + +These redirects require the relevant DNS, certificate and hosting configuration and are not made by the repository build. Inspect current zone/project state before applying them; do not change mail or unrelated TGC records. + +### Search Console and sitemap submission + +Use one Google Search Console **Domain property** for `food-help.ca` so the root and current/future subdomains are covered for ownership. An authorized account owner must add the property and publish Google's supplied DNS TXT verification record. That external verification is distinct from having valid metadata in the build. + +After the root and Kingston production origins are live and verified, submit both sitemaps separately within that property: + +- `https://food-help.ca/sitemap.xml` +- `https://kingston.food-help.ca/sitemap.xml` + +The root sitemap intentionally contains only root-site pages. Each community retains its own canonical URLs, robots policy and sitemap; do not canonicalize or copy subdomain pages into the root sitemap. Search Console verification and sitemap acceptance still do not guarantee crawling or indexing. Record actual submission responses, inspect representative canonical/indexing reports after discovery, and keep previews, fictional examples and provider aliases noindex. + Before production, review public facts, data rights, contacts, operator statement, optional analytics policy, source availability and canonical origin. Set `example_content: false` only after replacing the fictional dataset. Enable indexing explicitly if appropriate. For Kingston: ```sh diff --git a/docs/maintenance.md b/docs/maintenance.md index 3d8669e..bb7339d 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -2,6 +2,12 @@ Configuration and facts belong in the folder selected by `--site`. For Kingston this is `deployments/kingston/`; independent operators can use their own folder. Generic source must not acquire community-specific branches or hardcoded locality values. +## Maintain the project homepage and community list + +Root-site copy and integration choices live in `project-site/site.json`. Available community names, coverage and canonical directory URLs live only in `project-site/communities.json`; do not duplicate provider records or add fictional/coming-soon communities. Keep an ordinary HTTPS link from the root to each local directory, and set that directory's optional `project_home_url` when it should link back. + +Adding a community requires two separately reviewed publication decisions: first publish and verify the community directory at its canonical origin, then add its record to `project-site/communities.json` and release the root site. Confirm the community's own sitemap and indexing configuration independently; the root sitemap does not include subdomain pages. Run `npm run project:check` for root-only work and `npm run check -- --all` when shared community rendering or contracts also change. + ## Add or correct a food programme 1. Read the selected folder's `AGENTS.md` and review ownership instructions. Check the provider's current official sources and any supporting evidence. Treat submissions and automated findings as leads requiring human review. diff --git a/docs/verification.md b/docs/verification.md index f06da05..0344c0c 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -10,6 +10,8 @@ Coverage includes schema strictness, evidence/ID relationships, dates, schedules Browser checks cover no-JavaScript access, small screens, keyboard entry, automated WCAG A/AA checks, local search/category controls, absence of default telemetry/cookies/identifiers, optional aggregate/analytics separation, and the actual operator deployment with outbound test traffic blocked. Chromium, Firefox and WebKit exercise normal and offline navigation and corrupt-data fallback. Chromium additionally closes/reopens a persistent browser process offline and exercises failed release installation, user-approved activation in two tabs, cache cleanup and rollback. These lifecycle tests are intentionally listed once rather than repeated as empty claims for every engine. +The separate root project-site check builds both noindex preview and indexable production fixtures. It verifies the authoritative community projection, ordinary links, canonical/social metadata, sitemap and robots output, provider-alias noindex headers, absence of client analytics/service-worker code, real 404 responses, 320px layout, keyboard skip navigation and no-JavaScript use in Chromium, Firefox and WebKit. These checks establish implementation readiness only; they do not verify DNS, Search Console, hosting redirects or actual indexing. + Inspect generated screenshots in `artifacts/screenshots/`, Playwright's HTML report, retained failure traces and the exact report path printed by the selected build under `artifacts/reports/{deployment_id}/`. Browser profiles, reports and all generated output are private disposable artifacts and excluded from source control. A passing fixture is not evidence that the selected real deployment passed; retain the actual target and source/artifact identity with results. Automated accessibility checks cannot establish complete accessibility. Before a real production proof, manually review keyboard/focus order, screen-reader announcements, zoom, print and supported phone installation. Verify Android and iOS standalone launch, first-visit offline readiness, reconnection and browser eviction behaviour on actual devices. Desktop WebKit is not proof of every iOS installed-app condition. diff --git a/package.json b/package.json index b637d9f..3fe5962 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,11 @@ "dev": "node scripts/build.ts --dev", "build": "node scripts/build.ts", "preview": "node scripts/serve.ts", + "project:dev": "node scripts/build-project.ts --dev", + "project:build": "node scripts/build-project.ts", + "project:preview": "node scripts/serve-project.ts", + "project:check": "node scripts/check-project.ts", + "project:release": "node scripts/release-project.ts", "contracts": "node scripts/lib/contracts.ts", "typecheck": "npm run contracts && tsc --noEmit", "test": "npm run contracts && node --test tests/unit/*.test.ts tests/contracts/*.test.ts", diff --git a/playwright.project.config.ts b/playwright.project.config.ts new file mode 100644 index 0000000..604053a --- /dev/null +++ b/playwright.project.config.ts @@ -0,0 +1,16 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: 'tests/e2e', + testMatch: 'project-site.spec.ts', + timeout: 45_000, + expect: { timeout: 12_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list'], ['html', { open: 'never' }]], + use: { trace: 'retain-on-failure', screenshot: 'only-on-failure' }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } } + ] +}); diff --git a/project-site/communities.json b/project-site/communities.json new file mode 100644 index 0000000..ec1b96a --- /dev/null +++ b/project-site/communities.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "communities": [ + { + "id": "kingston", + "name": "Kingston", + "coverage": "Kingston, Ontario", + "canonical_url": "https://kingston.food-help.ca", + "description": "Emergency and affordable food resources for Kingston." + } + ] +} diff --git a/project-site/site.json b/project-site/site.json new file mode 100644 index 0000000..08892e5 --- /dev/null +++ b/project-site/site.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "site_name": "Food Help", + "short_name": "Food Help", + "canonical_origin": "https://food-help.ca", + "language": "en", + "locale": "en-CA", + "text_direction": "ltr", + "description": "Find trusted local directories for emergency and affordable food support, starting with Kingston, Ontario.", + "headline": "Find food help in your community.", + "introduction": "Food Help makes emergency and affordable food resources easier to find. It started in Kingston and is built so other communities can use it too.", + "scope": "Each local directory brings practical details, schedules and contact routes together without replacing the organizations that provide food support.", + "maintenance": "Listings are maintained from public sources, provider input and corrections. Local review owners keep qualifications and uncertainty visible rather than guessing when details are unclear.", + "operator": { + "name": "True Good Craft", + "url": "https://truegoodcraft.ca", + "lead": "Jamie Whelan", + "statement": "Jamie Whelan and True Good Craft built Food Help and maintain the shared project. Each community directory remains a separately reviewed local publication." + }, + "contact": { + "email": "jamie@truegoodcraft.ca", + "label": "Email Jamie", + "description": "Suggest a food program, ask to list a community directory, or discuss starting one." + }, + "repository_url": "https://github.com/True-Good-Craft/Food-Help", + "setup_url": "https://github.com/True-Good-Craft/Food-Help#start-independently", + "contribution_url": "https://github.com/True-Good-Craft/Food-Help/blob/main/CONTRIBUTING.md", + "community_guide_url": "https://github.com/True-Good-Craft/Food-Help/blob/main/docs/maintenance.md#prepare-a-new-community", + "analytics": { + "enabled": false + }, + "indexing": { + "enabled": true + } +} diff --git a/project-site/styles.css b/project-site/styles.css new file mode 100644 index 0000000..98dd16f --- /dev/null +++ b/project-site/styles.css @@ -0,0 +1,98 @@ +:root { + font-family:"Atkinson Hyperlegible Next",system-ui,-apple-system,"Segoe UI",sans-serif; + color:#253c43; + background:#f6f3eb; + font-synthesis:none; + text-rendering:optimizeLegibility; + --paper:#f6f3eb; + --white:#fffefa; + --navy:#173f4c; + --teal:#285b5b; + --brick:#985032; + --muted:#51676b; + --line:#cdcfc5; + --pale:#edf0e8; +} +*{box-sizing:border-box} +html{scroll-behavior:smooth} +body{margin:0;min-width:280px;background:var(--paper);font-size:1rem;line-height:1.6} +a{color:var(--teal);text-underline-offset:.2em} +h1,h2,h3,p,li,a{overflow-wrap:anywhere} +h1,h2,h3{color:var(--navy);line-height:1.15} +:focus-visible{outline:3px solid #aa4c1e;outline-offset:4px} +.skip-link{position:fixed;left:.75rem;top:.75rem;z-index:10;padding:.75rem 1rem;background:var(--navy);color:white;transform:translateY(-180%)} +.skip-link:focus{transform:none} +.site-header{border-top:5px solid var(--navy);border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--paper) 96%,white)} +.header-inner,.page-width{width:min(1120px,calc(100% - 3rem));margin-inline:auto} +.header-inner{min-height:82px;display:flex;align-items:center;justify-content:space-between;gap:2rem} +.brand{display:inline-flex;align-items:center;gap:.7rem;color:var(--navy);font-size:1.2rem;font-weight:780;letter-spacing:-.025em;text-decoration:none} +.brand img{width:40px;height:40px;border-radius:8px} +.site-nav{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.25rem 1.25rem} +.site-nav a{display:inline-flex;align-items:center;min-height:44px;color:var(--navy);font-weight:680;text-decoration:none} +.site-nav a:hover{text-decoration:underline} +.hero{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(300px,.8fr);gap:clamp(2rem,6vw,5.5rem);align-items:end;padding:clamp(3rem,8vw,6.5rem) 0 2.5rem} +.eyebrow{margin:0 0 .8rem;color:var(--brick);font-size:.875rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase} +h1{max-width:14ch;margin:0;font-size:clamp(2.7rem,6.3vw,5.6rem);font-weight:650;letter-spacing:-.04em} +.hero-lead{max-width:40rem;margin:1.4rem 0 0;color:var(--muted);font-size:clamp(1.08rem,1.8vw,1.3rem);line-height:1.55} +.hero-note{border-left:4px solid var(--brick);padding:.25rem 0 .25rem 1.25rem;color:var(--muted)} +.hero-note strong{display:block;color:var(--navy);font-size:1.15rem} +.hero-note p{margin:.35rem 0 0} +.section{padding:clamp(3rem,7vw,5.5rem) 0;border-top:1px solid var(--line)} +.section-heading{display:grid;grid-template-columns:minmax(0,.72fr) minmax(300px,1fr);gap:2rem 5rem;align-items:start;margin-bottom:2rem} +.section-heading h2{margin:0;font-size:clamp(2rem,4vw,3.2rem);font-weight:650;letter-spacing:-.03em} +.section-heading p{max-width:45rem;margin:0;color:var(--muted);font-size:1.08rem} +.community-card{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:1.5rem 3rem;align-items:center;padding:clamp(1.5rem,4vw,2.75rem);background:var(--navy);color:var(--white);border-radius:6px;box-shadow:0 18px 46px #173f4c18} +.community-card::before{content:"";position:absolute;inset:0 auto 0 0;width:7px;background:#c36a42;border-radius:6px 0 0 6px} +.community-card h3{margin:.25rem 0;color:var(--white);font-size:clamp(1.8rem,3.2vw,2.65rem);font-weight:650;letter-spacing:-.025em} +.community-card p{max-width:46rem;margin:.45rem 0;color:#e4ece8} +.community-meta{display:block;color:#bdd0ca;font-size:.9rem;font-weight:700;letter-spacing:.04em} +.button{display:inline-flex;min-height:48px;align-items:center;justify-content:center;padding:.7rem 1.05rem;border:1px solid currentColor;border-radius:4px;font-weight:750;text-align:center;text-decoration:none} +.button-light{background:var(--white);color:var(--navy)} +.button-light:hover{background:var(--pale)} +.button-primary{background:var(--navy);color:var(--white)} +.button-primary:hover{background:#0b2e39} +.text-link{display:inline-flex;align-items:center;min-height:44px;font-weight:700} +.principles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem;list-style:none;margin:2rem 0 0;padding:0;counter-reset:principle} +.principles li{counter-increment:principle;padding:1.5rem;background:var(--white);border:1px solid var(--line);border-radius:4px} +.principles li::before{content:"0" counter(principle);display:block;margin-bottom:1rem;color:var(--brick);font-size:.85rem;font-weight:800;letter-spacing:.12em} +.principles strong{display:block;margin-bottom:.35rem;color:var(--navy);font-size:1.15rem} +.principles p{margin:0;color:var(--muted)} +.split{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem} +.info-card{padding:clamp(1.5rem,4vw,2.5rem);background:var(--white);border:1px solid var(--line);border-radius:4px} +.info-card h2{margin:0 0 1rem;font-size:clamp(1.65rem,3vw,2.25rem);font-weight:650;letter-spacing:-.025em} +.info-card p{color:var(--muted)} +.link-list{display:flex;flex-wrap:wrap;gap:.4rem 1.25rem;margin-top:1.25rem} +.contact-panel{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2rem;align-items:center;padding:clamp(1.75rem,5vw,3.5rem);background:var(--pale);border:1px solid #a6b5aa;border-radius:4px} +.contact-panel h2{margin:0;font-size:clamp(1.8rem,3.5vw,2.75rem);font-weight:650;letter-spacing:-.03em} +.contact-panel p{max-width:44rem;margin:.7rem 0 0;color:var(--muted)} +.site-footer{padding:2.5rem 0;background:var(--navy);color:var(--white)} +.footer-inner{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2rem;align-items:start} +.site-footer strong{font-size:1.05rem} +.site-footer p{max-width:48rem;margin:.45rem 0;color:#dce7e3} +.footer-links{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.25rem 1.2rem} +.footer-links a{display:inline-flex;align-items:center;min-height:44px;color:var(--white)} +.not-found{min-height:60vh;padding:clamp(4rem,12vw,8rem) 0} +.not-found h1{font-size:clamp(2.5rem,7vw,5rem)} +.not-found p{max-width:38rem;color:var(--muted);font-size:1.1rem} +@media(max-width:760px){ + .header-inner{align-items:flex-start;flex-direction:column;gap:.25rem;padding-block:.75rem} + .site-nav{justify-content:flex-start;gap:.1rem 1rem} + .hero,.section-heading,.split,.contact-panel,.footer-inner{grid-template-columns:1fr} + .hero{gap:2rem;padding-top:3.5rem} + .community-card{grid-template-columns:1fr} + .principles{grid-template-columns:1fr} + .footer-links{justify-content:flex-start} +} +@media(max-width:420px){ + .header-inner,.page-width{width:calc(100% - 2rem)} + .brand{font-size:1.05rem} + .brand img{width:36px;height:36px} + .site-nav{width:100%;justify-content:space-between;gap:0 .6rem} + .site-nav a{font-size:.92rem} + h1{font-size:2.55rem} + .community-card,.info-card{padding:1.35rem} + .button{width:100%} +} +@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}} +@media(forced-colors:active){.community-card,.info-card,.contact-panel,.button{border:1px solid CanvasText}.community-card::before{display:none}} +@media print{.site-nav,.contact-panel,.site-footer{display:none}.section{padding:2rem 0}.community-card{background:white;color:black;border:1px solid black;box-shadow:none}.community-card h3,.community-card p,.community-meta{color:black}} diff --git a/schemas/site.schema.json b/schemas/site.schema.json index 883cb9f..53f3894 100644 --- a/schemas/site.schema.json +++ b/schemas/site.schema.json @@ -399,6 +399,9 @@ }, "software_source_url": { "$ref": "#/$defs/https" + }, + "project_home_url": { + "$ref": "#/$defs/https" } }, "$defs": { diff --git a/scripts/build-project.ts b/scripts/build-project.ts new file mode 100644 index 0000000..465554a --- /dev/null +++ b/scripts/build-project.ts @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MPL-2.0 +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { watch } from 'node:fs'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { files } from './build.ts'; +import { renderHeaders } from './lib/headers.ts'; +import { loadProjectSite, type ProjectSite, type CommunityList } from './lib/project-site.ts'; +import { readText } from './lib/text.ts'; +import { escape as e } from '../src/presentation.ts'; + +export type ProjectBuildOptions = { sourceDir?: string; outDir?: string; production?: boolean; sourceRevision?: string; sourceUrl?: string }; +const digest = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex'); +const argument = (name: string) => { const index = process.argv.indexOf(name); if (index < 0) return undefined; const next = process.argv[index + 1]; if (!next || next.startsWith('--')) throw new Error(`${name} requires a value`); return next; }; + +function sourceLink(site: ProjectSite, revision?: string, explicit?: string): string { + if (explicit) { const url = new URL(explicit); if (url.protocol !== 'https:') throw new Error('Covered source URL must use HTTPS'); return explicit; } + if (!revision) return site.repository_url; + const repository = new URL(site.repository_url); + if (repository.hostname !== 'github.com' || !/^\/[^/]+\/[^/]+\/?$/.test(repository.pathname)) throw new Error('Production source revision requires a GitHub repository URL or explicit --source-url'); + return `${repository.origin}${repository.pathname.replace(/\/$/, '')}/tree/${revision}`; +} + +function projectHeaders(html: string, production: boolean): Record { + const scripts = [...html.matchAll(/]*)?>([\s\S]*?)<\/script>/g)].map(match => match[1]).filter(Boolean); + const hashes = scripts.map(value => `'sha256-${createHash('sha256').update(value!).digest('base64')}'`).join(' '); + return { + 'Content-Security-Policy': `default-src 'none'; script-src ${hashes || "'none'"}; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'none'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'`, + 'X-Content-Type-Options': 'nosniff', + 'Referrer-Policy': 'no-referrer', + 'X-Frame-Options': 'DENY', + 'Permissions-Policy': 'geolocation=(), camera=(), microphone=(), payment=()', + ...(production ? { 'Strict-Transport-Security': 'max-age=31536000' } : {}), + 'Cache-Control': 'no-cache' + }; +} + +function head(site: ProjectSite, pathName: string, title: string, description: string, index: boolean, css: string, logo: string, jsonld?: unknown): string { + const canonical = pathName === '/' ? `` : ''; + const json = jsonld ? `` : ''; + return `${e(title)}${canonical}${json}`; +} + +function header(site: ProjectSite, logo: string): string { + return ``; +} + +function footer(site: ProjectSite): string { + return ``; +} + +function home(site: ProjectSite, communities: CommunityList, production: boolean, css: string, logo: string, coveredSource: string): string { + const index = production && site.indexing.enabled; + const communityCards = communities.communities.map((community, index) => ``).join(''); + const mail = `mailto:${e(site.contact.email)}?subject=${encodeURIComponent('Food Help community inquiry')}`; + const jsonld = { '@context': 'https://schema.org', '@graph': [ + { '@type': 'WebSite', name: site.site_name, url: `${site.canonical_origin}/`, description: site.description, inLanguage: site.language, publisher: { '@id': `${site.canonical_origin}/#operator` } }, + { '@type': 'Organization', '@id': `${site.canonical_origin}/#operator`, name: site.operator.name, url: site.operator.url }, + { '@type': 'ItemList', name: 'Food Help community directories', itemListElement: communities.communities.map((community, index) => ({ '@type': 'ListItem', position: index + 1, name: `${community.name} Food Help`, url: `${community.canonical_url}/` })) } + ] }; + return `${head(site, '/', site.site_name, site.description, index, css, logo, jsonld)}${header(site, logo)}
+

Emergency and affordable food directories

${e(site.headline)}

${e(site.introduction)}

+

Choose a community

Community directories are the place to find food programs. This project homepage does not duplicate their listings.

${communityCards}
+

Information people can check

${e(site.maintenance)}

  1. Public sources

    Published program pages and trusted public directories provide an evidence trail.

  2. Provider input

    Provider details can clarify what is offered and what people should know before travelling.

  3. Corrections

    Community feedback is reviewed before it changes a public listing.

+
+

Bring a program or community forward

${e(site.contact.description)} A short note is enough; do not include private client or service-user information.

${e(site.contact.label)}
+

Covered source for this build: Food Help repository.

+
${footer(site)}`; +} + +function notFound(site: ProjectSite, css: string, logo: string): string { + return `${head(site, '/404.html', `Page not found | ${site.site_name}`, 'That Food Help page could not be found.', false, css, logo)}${header(site, logo)}

404 · Page not found

That page isn’t here.

Return to the Food Help project homepage or open an available community directory.

Food Help homepage

${footer(site)}`; +} + +export async function buildProject(options: ProjectBuildOptions = {}) { + const root = process.cwd(), sourceDir = path.resolve(options.sourceDir ?? 'project-site'), outDir = path.resolve(options.outDir ?? 'dist/project'), production = options.production ?? false; + if (!['dist', 'artifacts', '.generated'].some(directory => outDir.startsWith(path.join(root, directory) + path.sep)) || outDir === sourceDir || sourceDir.startsWith(outDir + path.sep)) throw new Error('Project output must be a child of dist/, artifacts/ or .generated/ and must not contain source'); + const revision = options.sourceRevision ?? process.env.FOOD_HELP_SOURCE_REVISION ?? process.env.CF_PAGES_COMMIT_SHA; + if (revision && !/^[0-9a-f]{40}$/i.test(revision)) throw new Error('Source revision must be an exact 40-character Git commit SHA'); + const { site, communities } = await loadProjectSite(sourceDir); + if (new URL(site.canonical_origin).hostname.endsWith('.invalid')) throw new Error('Project canonical origin cannot use .invalid'); + const coveredSource = sourceLink(site, revision, options.sourceUrl); + if (production && !revision && !options.sourceUrl) throw new Error('Production requires --source-revision or --source-url for the corresponding covered source'); + await rm(outDir, { recursive: true, force: true }); await mkdir(path.join(outDir, 'assets'), { recursive: true }); + const write = async (name: string, value: string | Uint8Array) => { const target = path.join(outDir, name); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, value); }; + const font = await readFile('src/assets/fonts/atkinson-hyperlegible-next.woff2'), fontPath = `/assets/atkinson-${digest(font).slice(0, 16)}.woff2`; await write(fontPath.slice(1), font); + const logo = Buffer.from(await readText('src/assets/brand/logo.svg')), logoPath = `/assets/logo-${digest(logo).slice(0, 16)}.svg`; await write(logoPath.slice(1), logo); + const cssText = `@font-face{font-family:"Atkinson Hyperlegible Next";src:url("${fontPath}") format("woff2");font-style:normal;font-weight:200 800;font-display:swap}\n${await readText(path.join(sourceDir, 'styles.css'))}`; + const cssPath = `/assets/project-${digest(cssText).slice(0, 16)}.css`; await write(cssPath.slice(1), cssText); + const html = home(site, communities, production, cssPath, logoPath, coveredSource), missing = notFound(site, cssPath, logoPath); + await write('index.html', html); await write('404.html', missing); + await write('communities.json', JSON.stringify(communities, null, 2) + '\n'); + await write('sitemap.xml', `${production && site.indexing.enabled ? `${site.canonical_origin}/` : ''}`); + await write('robots.txt', `User-agent: *\nAllow: /\n# Preview builds and hosting aliases also send noindex headers.\nSitemap: ${site.canonical_origin}/sitemap.xml\n`); + await write('llms.txt', `# ${site.site_name}\n\n${site.description}\n\nCommunity directories:\n${communities.communities.map(community => `- ${community.name} (${community.coverage}): ${community.canonical_url}/`).join('\n')}\n\nProject source and setup: ${site.repository_url}\n`); + const common = projectHeaders(html, production); if (!(production && site.indexing.enabled)) common['X-Robots-Tag'] = 'noindex, follow'; + const headers = renderHeaders(common, { + '/404.html': { 'X-Robots-Tag': 'noindex, follow' }, + '/communities.json': { 'Content-Type': 'application/json; charset=utf-8', 'X-Robots-Tag': 'noindex' }, + '/assets/*': { 'Cache-Control': 'public, max-age=31536000, immutable' } + }); + await write('_headers', headers); await write('_redirects', '/index.html / 301\n'); + const fileHashes = Object.fromEntries(await Promise.all((await files(outDir)).map(async file => [file, digest(await readFile(path.join(outDir, file)))]))); + const release = digest(JSON.stringify(fileHashes)); + const reportDir = path.resolve('artifacts/reports/project'); await mkdir(reportDir, { recursive: true }); + const reportPath = path.join(reportDir, `${digest(outDir).slice(0, 16)}.json`); + await writeFile(reportPath, JSON.stringify({ target: 'project', source_directory: sourceDir, canonical_origin: site.canonical_origin, source_revision: revision ?? null, source_url: coveredSource, production, indexing: production && site.indexing.enabled, analytics: false, communities: communities.communities.map(community => community.id), output: outDir, release, file_hashes: fileHashes }, null, 2) + '\n'); + console.log(`Built Food Help project site: ${communities.communities.length} ${communities.communities.length === 1 ? 'community' : 'communities'}, release ${release.slice(0, 16)} → ${outDir}`); + console.log(`Build report: ${reportPath}`); + return { site, communities, outDir, release, reportPath }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const options = { sourceDir: argument('--source') ?? 'project-site', outDir: argument('--out'), production: process.argv.includes('--production'), sourceRevision: argument('--source-revision'), sourceUrl: argument('--source-url') }; + const result = await buildProject(options); + if (process.argv.includes('--dev')) { + if (options.production) throw new Error('Development serves a noindex preview; do not combine --dev and --production'); + const { serve } = await import('./serve.ts'); await serve({ root: result.outDir, port: Number(argument('--port') ?? 4175) }); + let timer: ReturnType, rebuilding = false; + const rebuild = () => { clearTimeout(timer); timer = setTimeout(async () => { if (rebuilding) return; rebuilding = true; try { await buildProject(options); } catch (error) { console.error(error); } finally { rebuilding = false; } }, 250); }; + for (const directory of [options.sourceDir, 'src/assets/brand', 'src/assets/fonts']) watch(directory, { recursive: true }, rebuild); + } +} diff --git a/scripts/check-project.ts b/scripts/check-project.ts new file mode 100644 index 0000000..1b3efce --- /dev/null +++ b/scripts/check-project.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +import { spawn } from 'node:child_process'; +import { buildProject } from './build-project.ts'; +import { generateContracts } from './lib/contracts.ts'; + +const run = (script: string, args: string[] = []) => new Promise((resolve, reject) => { + const child = spawn(process.execPath, [script, ...args], { stdio: 'inherit', env: process.env }); + child.on('error', reject); child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${script} failed (${code})`))); +}); + +await generateContracts(); +const preview = await buildProject({ outDir: 'artifacts/project-check/preview' }); +const production = await buildProject({ outDir: 'artifacts/project-check/production', production: true, sourceRevision: 'a'.repeat(40) }); +process.env.FOOD_HELP_TEST_PROJECT = JSON.stringify({ preview: preview.outDir, production: production.outDir }); +await run('node_modules/typescript/bin/tsc', ['--noEmit']); +await run('--test', ['tests/unit/project-site.test.ts', 'tests/contracts/project-site.test.ts']); +await run('node_modules/@playwright/test/cli.js', ['test', '--config', 'playwright.project.config.ts']); +console.log('PASS: Food Help project site is ready for local publication review. External hosting, redirects, Search Console and analytics remain separate approvals.'); diff --git a/scripts/check.ts b/scripts/check.ts index 312bf65..6412786 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { readFile, writeFile, mkdir, copyFile, readdir, rm } from 'node:fs/promises'; import { build } from './build.ts'; +import { buildProject } from './build-project.ts'; import { digest } from './lib/web.ts'; import { argumentsFor, starterSite } from './lib/selection.ts'; const options = argumentsFor(); @@ -20,6 +21,9 @@ await mkdir('.generated', { recursive: true }); try { await mkdir('.generated/check.lock'); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Another Food Help check is active. Checks sharing test fixtures are explicitly serialized. If a process crashed, confirm it has stopped before removing .generated/check.lock.'); throw error; } try { const independent = await build({ siteDir: 'examples/exampleville', outDir: 'artifacts/exampleville' }); +const projectPreview = await buildProject({ outDir: 'artifacts/project-check/preview' }); +const projectProduction = await buildProject({ outDir: 'artifacts/project-check/production', production: true, sourceRevision: 'a'.repeat(40) }); +process.env.FOOD_HELP_TEST_PROJECT = JSON.stringify({ preview: projectPreview.outDir, production: projectProduction.outDir }); const requestedReviewSource = options.siteDir ?? starterSite; const requestedReviewData = JSON.parse(await readFile(`${requestedReviewSource}/resources.json`, 'utf8')) as { resources?: Array<{ publication_status?: string }> }; const reviewSource = requestedReviewData.resources?.some(resource => resource.publication_status === 'draft') ? requestedReviewSource : starterSite; diff --git a/scripts/lib/project-site.ts b/scripts/lib/project-site.ts new file mode 100644 index 0000000..59332b2 --- /dev/null +++ b/scripts/lib/project-site.ts @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: MPL-2.0 +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export type ProjectSite = { + schema_version: 1; + site_name: string; + short_name: string; + canonical_origin: string; + language: string; + locale: string; + text_direction: 'ltr' | 'rtl'; + description: string; + headline: string; + introduction: string; + scope: string; + maintenance: string; + operator: { name: string; url: string; lead: string; statement: string }; + contact: { email: string; label: string; description: string }; + repository_url: string; + setup_url: string; + contribution_url: string; + community_guide_url: string; + analytics: { enabled: false }; + indexing: { enabled: boolean }; +}; + +export type Community = { id: string; name: string; coverage: string; canonical_url: string; description: string }; +export type CommunityList = { schema_version: 1; communities: Community[] }; + +const object = (value: unknown, label: string): Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +}; +const exactKeys = (value: Record, keys: string[], label: string) => { + const extras = Object.keys(value).filter(key => !keys.includes(key)); + if (extras.length) throw new Error(`${label} has unknown fields: ${extras.join(', ')}`); +}; +const text = (value: unknown, label: string): string => { + if (typeof value !== 'string' || !value.trim() || value.length > 1_000) throw new Error(`${label} must be non-empty text`); + return value; +}; +const https = (value: unknown, label: string, originOnly = false): string => { + const input = text(value, label); const url = new URL(input); + if (url.protocol !== 'https:' || url.username || url.password || url.search || (originOnly && (url.hash || url.pathname !== '/' || input.endsWith('/')))) throw new Error(`${label} must be a clean HTTPS ${originOnly ? 'origin' : 'URL'}`); + return input; +}; + +export function assertProjectSite(value: unknown): asserts value is ProjectSite { + const site = object(value, 'project-site/site.json'); + exactKeys(site, ['schema_version', 'site_name', 'short_name', 'canonical_origin', 'language', 'locale', 'text_direction', 'description', 'headline', 'introduction', 'scope', 'maintenance', 'operator', 'contact', 'repository_url', 'setup_url', 'contribution_url', 'community_guide_url', 'analytics', 'indexing'], 'project-site/site.json'); + if (site.schema_version !== 1) throw new Error('Unsupported project site schema version'); + for (const field of ['site_name', 'short_name', 'language', 'locale', 'description', 'headline', 'introduction', 'scope', 'maintenance'] as const) text(site[field], field); + if (!['ltr', 'rtl'].includes(String(site.text_direction))) throw new Error('text_direction must be ltr or rtl'); + https(site.canonical_origin, 'canonical_origin', true); + for (const field of ['repository_url', 'setup_url', 'contribution_url', 'community_guide_url'] as const) https(site[field], field); + const operator = object(site.operator, 'operator'); exactKeys(operator, ['name', 'url', 'lead', 'statement'], 'operator'); + for (const field of ['name', 'lead', 'statement'] as const) text(operator[field], `operator.${field}`); https(operator.url, 'operator.url'); + const contact = object(site.contact, 'contact'); exactKeys(contact, ['email', 'label', 'description'], 'contact'); + for (const field of ['email', 'label', 'description'] as const) text(contact[field], `contact.${field}`); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(contact.email))) throw new Error('contact.email must be a public email address'); + const analytics = object(site.analytics, 'analytics'); exactKeys(analytics, ['enabled'], 'analytics'); + if (analytics.enabled !== false) throw new Error('The project site has no approved analytics integration; enabled must remain false'); + const indexing = object(site.indexing, 'indexing'); exactKeys(indexing, ['enabled'], 'indexing'); + if (typeof indexing.enabled !== 'boolean') throw new Error('indexing.enabled must be boolean'); +} + +export function assertCommunityList(value: unknown): asserts value is CommunityList { + const list = object(value, 'project-site/communities.json'); exactKeys(list, ['schema_version', 'communities'], 'project-site/communities.json'); + if (list.schema_version !== 1 || !Array.isArray(list.communities) || list.communities.length === 0) throw new Error('Community list must contain at least one available community'); + const ids = new Set(), origins = new Set(); + for (const [index, entry] of list.communities.entries()) { + const community = object(entry, `communities[${index}]`); exactKeys(community, ['id', 'name', 'coverage', 'canonical_url', 'description'], `communities[${index}]`); + const id = text(community.id, `communities[${index}].id`), origin = https(community.canonical_url, `communities[${index}].canonical_url`, true); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`Invalid community id: ${id}`); + if (ids.has(id) || origins.has(origin)) throw new Error(`Duplicate community id or canonical URL: ${id}`); + ids.add(id); origins.add(origin); + for (const field of ['name', 'coverage', 'description'] as const) text(community[field], `communities[${index}].${field}`); + } +} + +export async function loadProjectSite(sourceDir = 'project-site'): Promise<{ site: ProjectSite; communities: CommunityList }> { + const site: unknown = JSON.parse(await readFile(path.join(sourceDir, 'site.json'), 'utf8')); + const communities: unknown = JSON.parse(await readFile(path.join(sourceDir, 'communities.json'), 'utf8')); + assertProjectSite(site); assertCommunityList(communities); + return { site, communities }; +} diff --git a/scripts/lib/web.ts b/scripts/lib/web.ts index aea0948..0c4912c 100644 --- a/scripts/lib/web.ts +++ b/scripts/lib/web.ts @@ -80,7 +80,7 @@ export function document(page: Page, site: Site, assets: Assets, production: boo
${page.review ? `

${c.draftPreview}

` : ''}${!home ? `` : ''}${site.example_content ? `

${c.example}

` : ''}${page.body}${standalone ? `

${c.licenses}

${e(site.data_rights.statement)}

${c.softwareLicense} MPL-2.0 · ${c.attribution}

${sourceLink(site)}
` : ''} ${standalone ? '' : `${!home ? `

` : ''}
`}
-
${!home ? `` : ''}
+ ${standalone ? '' : ``}`; } export function securityHeaders(html: string, site: Site, production: boolean): Record { diff --git a/scripts/release-project.ts b/scripts/release-project.ts new file mode 100644 index 0000000..ae07f36 --- /dev/null +++ b/scripts/release-project.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MPL-2.0 +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { buildProject } from './build-project.ts'; +import { releasePlan } from './lib/release-plan.ts'; + +const value = (name: string) => { const index = process.argv.indexOf(name); if (index < 0) return undefined; const next = process.argv[index + 1]; if (!next || next.startsWith('--')) throw new Error(`${name} needs a value`); return next; }; +const ref = value('--ref'), remote = value('--remote') ?? 'origin'; +if (!ref || ref.startsWith('-')) throw new Error('Explicit revision required: npm run project:release -- --ref [--publish]'); +const git = (...args: string[]) => execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +const revision = git('rev-parse', '--verify', `${ref}^{commit}`), currentRevision = git('rev-parse', 'HEAD'); +if (git('status', '--porcelain')) throw new Error('Commit or preserve working-tree changes before releasing an exact source revision'); +releasePlan({ deployment: 'project', revision, currentRevision, remote }); +const verifiedHead = () => { + const head = git('rev-parse', 'HEAD'); + if (head !== revision || git('status', '--porcelain')) throw new Error('Source revision or working tree changed during verification; nothing was published'); + return head; +}; +const run = (script: string, args: string[]) => { const result = spawnSync(process.execPath, [script, ...args], { stdio: 'inherit' }); if (result.error) throw result.error; if (result.status !== 0) throw new Error(`${script} failed; nothing was published`); }; +run('scripts/check-project.ts', []); verifiedHead(); +const output = await buildProject({ production: true, sourceRevision: revision, sourceUrl: value('--source-url') }); verifiedHead(); +const previousRevision = git('ls-remote', remote, 'refs/heads/release/project').split(/\s+/)[0] || undefined; +const plan = releasePlan({ deployment: 'project', revision, currentRevision: verifiedHead(), previousRevision, remote }); +const receipt = { ...plan, artifact: output.outDir, report: output.reportPath, release: output.release, canonical_origin: output.site.canonical_origin, publication_requested: process.argv.includes('--publish') }; +await mkdir('artifacts/releases', { recursive: true }); await writeFile(`artifacts/releases/project-${revision}.json`, JSON.stringify(receipt, null, 2) + '\n'); +console.log(JSON.stringify(receipt, null, 2)); +if (!process.argv.includes('--publish')) console.log('Validated local project-site release plan only. Add --publish to advance release/project and trigger only its configured host.'); +else if (!plan.changed) console.log('The project site already points to this revision; no push or deployment triggered.'); +else { verifiedHead(); execFileSync('git', plan.pushArguments, { stdio: 'inherit' }); console.log(`Published only ${plan.branch}. Verify the root host reports ${revision} before declaring the project site live.`); } diff --git a/scripts/serve-project.ts b/scripts/serve-project.ts new file mode 100644 index 0000000..aa78cd6 --- /dev/null +++ b/scripts/serve-project.ts @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MPL-2.0 +import { serve } from './serve.ts'; +const index = process.argv.indexOf('--port'); +const port = index < 0 ? 4175 : Number(process.argv[index + 1]); +if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('--port requires a valid port number'); +await serve({ root: 'dist/project', port }); diff --git a/src/copy/en.ts b/src/copy/en.ts index 2100127..707a875 100644 --- a/src/copy/en.ts +++ b/src/copy/en.ts @@ -44,7 +44,7 @@ export const copy = { analyticsPreference: 'Allow optional aggregate usage events', analyticsPreferenceNote: 'This choice is saved in this browser on this website. Global Privacy Control, Do Not Track and local suppression settings override it. Browsing works the same with collection off.', analyticsEnabled: 'Optional collection is on. You can turn it off here at any time.', analyticsDisabled: 'Optional collection is off.', analyticsSuppressed: { disabled: 'Optional collection is disabled.', preview: 'Optional collection is off on preview addresses.', privacy: 'Optional collection is off because your browser requests privacy protection.', operator: 'Optional collection is off because a local suppression setting is active.', storage: 'Optional collection is off because privacy choices could not be saved or read.' }, - about: 'About this directory', maintained: 'Maintained by', software: 'Built with Food Help', licenses: 'Licences and data rights', softwareLicense: 'Application source: Mozilla Public License 2.0.', fontLicense: 'Atkinson Hyperlegible Next: SIL Open Font License 1.1.', dataLicense: 'Resource data rights', json: 'Public JSON dataset', + about: 'About this directory', projectHome: 'Food Help project', maintained: 'Maintained by', software: 'Built with Food Help', licenses: 'Licences and data rights', softwareLicense: 'Application source: Mozilla Public License 2.0.', fontLicense: 'Atkinson Hyperlegible Next: SIL Open Font License 1.1.', dataLicense: 'Resource data rights', json: 'Public JSON dataset', coveredSource: 'Corresponding source for this release', attribution: 'Attribution and licensing notices', aboutShort: 'Each listing links to its sources. Details can change, and this directory may not include every service.', saveDirectory: 'Save this directory for later', yourPrivacy: 'Your privacy', sourcesData: 'Sources and public data', listingsUpdated: 'Listings updated', savedCopy: 'Saved copy', online: 'Online', saved: 'Validated directory saved for offline use.', cached: 'Showing the last saved validated directory. Check its review dates.', network: 'Showing the latest validated directory.', unavailable: 'A fresh dataset could not be loaded. The published page remains available below.', diff --git a/tests/contracts/output.test.ts b/tests/contracts/output.test.ts index 53eadf3..0acaaf6 100644 --- a/tests/contracts/output.test.ts +++ b/tests/contracts/output.test.ts @@ -32,6 +32,7 @@ for (const { directory, sourceDir, production } of [...selectedBuilds, { directo const canonical = doc.find(n => n.nodeName === 'link' && attr(n, 'rel') === 'canonical')!; const url = attr(canonical, 'href')!; expect(new URL(url).origin).toBe(site.canonical_origin); expect(doc.find(n => attr(n, 'property') === 'og:url')?.attrs).toContainEqual({ name: 'content', value: url }); + if (site.project_home_url) expect(doc.some(node => node.nodeName === 'a' && attr(node, 'href') === site.project_home_url)).toBe(true); expect(doc.filter(n => n.nodeName === 'h1')).toHaveLength(1); let previousLevel = 0; for (const heading of doc.filter(n => /^h[1-6]$/.test(n.nodeName))) { const level = Number(heading.nodeName.slice(1)); expect(level, `${file} skips a heading level`).toBeLessThanOrEqual(previousLevel + 1); previousLevel = level; } diff --git a/tests/contracts/project-site.test.ts b/tests/contracts/project-site.test.ts new file mode 100644 index 0000000..ed2c0dd --- /dev/null +++ b/tests/contracts/project-site.test.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { readFile, access } from 'node:fs/promises'; +import { parse } from 'parse5'; +import { responseHeaders } from '../../scripts/lib/headers.ts'; + +type Node = { nodeName: string; value?: string; attrs?: { name: string; value: string }[]; childNodes?: Node[] }; +const nodes = (node: Node): Node[] => [node, ...(node.childNodes ?? []).flatMap(nodes)]; +const attr = (node: Node, name: string) => node.attrs?.find(attribute => attribute.name === name)?.value; +const targets = process.env.FOOD_HELP_TEST_PROJECT ? JSON.parse(process.env.FOOD_HELP_TEST_PROJECT) as { preview: string; production: string } : { preview: 'dist/project', production: 'dist/project' }; + +test('project output has crawlable community links, useful metadata and no client dependency', async () => { + const source = JSON.parse(await readFile('project-site/communities.json', 'utf8')) as { communities: Array<{ canonical_url: string }> }; + const html = await readFile(path.join(targets.production, 'index.html'), 'utf8'), doc = nodes(parse(html) as unknown as Node); + assert.equal(attr(doc.find(node => node.nodeName === 'link' && attr(node, 'rel') === 'canonical')!, 'href'), 'https://food-help.ca/'); + assert.equal(attr(doc.find(node => node.nodeName === 'meta' && attr(node, 'name') === 'description')!, 'content')?.includes('emergency and affordable food'), true); + assert.equal(attr(doc.find(node => node.nodeName === 'meta' && attr(node, 'property') === 'og:url')!, 'content'), 'https://food-help.ca/'); + assert.equal(attr(doc.find(node => node.nodeName === 'meta' && attr(node, 'name') === 'twitter:card')!, 'content'), 'summary'); + for (const community of source.communities) assert.ok(doc.some(node => node.nodeName === 'a' && attr(node, 'href') === `${community.canonical_url}/`)); + assert.equal(doc.some(node => node.nodeName === 'script' && Boolean(attr(node, 'src'))), false); + assert.deepEqual(JSON.parse(await readFile(path.join(targets.production, 'communities.json'), 'utf8')), JSON.parse(await readFile('project-site/communities.json', 'utf8'))); + await assert.rejects(access(path.join(targets.production, 'service-worker.js'))); +}); + +test('preview and production discovery policies stay distinct and aliases remain noindex', async () => { + const preview = await readFile(path.join(targets.preview, 'index.html'), 'utf8'), production = await readFile(path.join(targets.production, 'index.html'), 'utf8'); + assert.match(preview, /name="robots" content="noindex,follow"/); + assert.match(production, /name="robots" content="index,follow"/); + assert.doesNotMatch(preview, /analytics|metrics\/event|service-worker/i); + assert.equal((await readFile(path.join(targets.preview, 'sitemap.xml'), 'utf8')).includes(''), false); + assert.equal((await readFile(path.join(targets.production, 'sitemap.xml'), 'utf8')).match(//g)?.length, 1); + const headers = await readFile(path.join(targets.production, '_headers'), 'utf8'); + assert.equal(responseHeaders(headers, new URL('https://food-help.ca/'))['X-Robots-Tag'], undefined); + assert.equal(responseHeaders(headers, new URL('https://project.pages.dev/'))['X-Robots-Tag'], 'noindex'); + assert.equal(responseHeaders(headers, new URL('https://review.project.pages.dev/'))['X-Robots-Tag'], 'noindex'); + assert.equal(responseHeaders(headers, new URL('https://food-help.ca/404.html'))['X-Robots-Tag'], 'noindex, follow'); + assert.match(await readFile(path.join(targets.production, 'robots.txt'), 'utf8'), /Sitemap: https:\/\/food-help\.ca\/sitemap\.xml/); +}); diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts index 1b2d077..681a18f 100644 --- a/tests/e2e/offline.spec.ts +++ b/tests/e2e/offline.spec.ts @@ -1,6 +1,7 @@ import { test, expect, chromium, type Page } from '@playwright/test'; import { serve } from '../../scripts/serve.ts'; -import { cp, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import path from 'node:path'; const EMERGENCY_COUNT = 4; const AFFORDABLE_COUNT = 2; const ready = async (page: Page) => { @@ -50,9 +51,11 @@ test('bad response never overwrites last-good data; absent storage leaves static await page.goto(origin); await expect(page.locator('#resource-list article')).toHaveCount(EMERGENCY_COUNT); await expect(page.locator('#filters')).toBeHidden(); } finally { await context.close(); await new Promise(resolve => server.close(() => resolve())); } }); -test('cold restart uses the persisted worker and data with no network', async ({ browserName }, info) => { +test('cold restart uses the persisted worker and data with no network', async ({ browserName }) => { test.skip(browserName !== 'chromium', 'Persistent browser-process restart is exercised in Chromium; all engines exercise offline navigation.'); - const profile = info.outputPath('persistent-profile'); + // Keep Chromium's nested on-disk CacheStorage path below Windows path limits. + await mkdir('artifacts/profiles', { recursive: true }); + const profile = await mkdtemp(path.resolve('artifacts/profiles/cold-exampleville-')); let context = await chromium.launchPersistentContext(profile, { headless: true }); try { const page = await context.newPage(); await page.goto('http://127.0.0.1:4173/affordable-food/'); await ready(page); diff --git a/tests/e2e/project-site.spec.ts b/tests/e2e/project-site.spec.ts new file mode 100644 index 0000000..d2e5e8a --- /dev/null +++ b/tests/e2e/project-site.spec.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +import { test, expect } from '@playwright/test'; +import { serve } from '../../scripts/serve.ts'; + +const targets = process.env.FOOD_HELP_TEST_PROJECT ? JSON.parse(process.env.FOOD_HELP_TEST_PROJECT) as { preview: string; production: string } : { preview: 'dist/project', production: 'dist/project' }; + +test('project homepage works on mobile, by keyboard and without JavaScript', async ({ browser, page }) => { + const server = await serve({ root: targets.preview, port: 0 }); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + await page.setViewportSize({ width: 320, height: 760 }); await page.goto(origin); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('Find food help in your community.'); + await expect(page.getByRole('link', { name: 'Open Kingston directory' })).toHaveAttribute('href', 'https://kingston.food-help.ca/'); + await expect(page.getByRole('link', { name: 'Email Jamie' })).toHaveAttribute('href', /mailto:jamie@truegoodcraft\.ca/); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320); + await page.keyboard.press('Tab'); await expect(page.getByRole('link', { name: 'Skip to main content' })).toBeFocused(); + await page.keyboard.press('Enter'); await expect(page.locator('#main')).toBeFocused(); + const noScriptContext = await browser.newContext({ javaScriptEnabled: false, viewport: { width: 320, height: 760 } }); + const noScriptPage = await noScriptContext.newPage(); await noScriptPage.goto(origin); + await expect(noScriptPage.getByRole('heading', { level: 1 })).toHaveText('Find food help in your community.'); + await expect(noScriptPage.getByRole('link', { name: 'Open Kingston directory' })).toBeVisible(); + const missing = await noScriptPage.goto(`${origin}/not-a-real-page`); expect(missing?.status()).toBe(404); + await expect(noScriptPage.getByRole('heading', { level: 1 })).toHaveText('That page isn’t here.'); + await expect(noScriptPage.getByRole('link', { name: 'Food Help homepage' })).toHaveAttribute('href', '/'); + await noScriptContext.close(); + } finally { await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); } +}); diff --git a/tests/unit/project-site.test.ts b/tests/unit/project-site.test.ts new file mode 100644 index 0000000..53c2ed7 --- /dev/null +++ b/tests/unit/project-site.test.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MPL-2.0 +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { assertCommunityList, assertProjectSite, loadProjectSite } from '../../scripts/lib/project-site.ts'; + +test('the project source has one authoritative available-community list connected to Kingston', async () => { + const { site, communities } = await loadProjectSite(); + const kingston = JSON.parse(await readFile('deployments/kingston/site.json', 'utf8')) as { canonical_origin: string; project_home_url?: string }; + assert.equal(communities.communities[0]?.id, 'kingston'); + assert.equal(communities.communities[0]?.canonical_url, kingston.canonical_origin); + assert.equal(kingston.project_home_url, site.canonical_origin); + assert.equal(site.analytics.enabled, false); +}); + +test('project source validation rejects unsafe integration and ambiguous community records', () => { + const base = { + schema_version: 1, site_name: 'Food Help', short_name: 'Food Help', canonical_origin: 'https://food-help.ca', language: 'en', locale: 'en-CA', text_direction: 'ltr', description: 'Description', headline: 'Headline', introduction: 'Introduction', scope: 'Scope', maintenance: 'Maintenance', + operator: { name: 'Operator', url: 'https://operator.example', lead: 'Owner', statement: 'Statement' }, contact: { email: 'owner@example.com', label: 'Email owner', description: 'Contact description' }, repository_url: 'https://github.com/example/project', setup_url: 'https://github.com/example/project#setup', contribution_url: 'https://github.com/example/project/blob/main/CONTRIBUTING.md', community_guide_url: 'https://github.com/example/project/blob/main/docs/maintenance.md#community', analytics: { enabled: false }, indexing: { enabled: true } + }; + assert.doesNotThrow(() => assertProjectSite(base)); + assert.throws(() => assertProjectSite({ ...base, analytics: { enabled: true } }), /no approved analytics/); + assert.throws(() => assertProjectSite({ ...base, canonical_origin: 'http://food-help.ca' }), /HTTPS origin/); + const community = { id: 'one', name: 'One', coverage: 'One, Ontario', canonical_url: 'https://one.food-help.ca', description: 'Directory one' }; + assert.doesNotThrow(() => assertCommunityList({ schema_version: 1, communities: [community] })); + assert.throws(() => assertCommunityList({ schema_version: 1, communities: [community, community] }), /Duplicate/); +}); diff --git a/tests/unit/release-plan.test.ts b/tests/unit/release-plan.test.ts index aa350b1..5cecd7f 100644 --- a/tests/unit/release-plan.test.ts +++ b/tests/unit/release-plan.test.ts @@ -12,6 +12,9 @@ test('publication selects exactly one independent community pointer and uses a l assert.equal(releasePlan({ deployment: 'one-community', revision, currentRevision: revision, previousRevision: revision }).changed, false); assert.throws(() => releasePlan({ deployment: 'one-community', revision, currentRevision: previousRevision }), /currently checked-out/); assert.throws(() => releasePlan({ deployment: '../other', revision, currentRevision: revision }), /deployment ID/); + const project = releasePlan({ deployment: 'project', revision, currentRevision: revision }); + assert.equal(project.branch, 'release/project'); + assert.ok(!project.pushArguments.join(' ').includes('release/one-community')); }); test('shared changes validate communities; a data change selects only its community', () => { const folders = ['deployments/one-community', 'deployments/other-community'];