diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4f7277b..cfce0d0 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,10 +1,33 @@ name: Check Food Help on: workflow_dispatch: + pull_request: + push: + branches: [main] permissions: contents: read jobs: + select: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version-file: .node-version + - id: matrix + env: + FOOD_HELP_CHECK_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + FOOD_HELP_CHECK_HEAD: ${{ github.event.pull_request.head.sha || github.sha }} + run: node scripts/select-checks.ts check: + needs: select + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select.outputs.matrix) }} runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -15,11 +38,13 @@ jobs: cache: npm - run: npm ci - run: npx playwright install --with-deps chromium firefox webkit - - run: npm run check + - run: npm run check -- --site "$FOOD_HELP_CHECK_SITE" + env: + FOOD_HELP_CHECK_SITE: ${{ matrix.site }} - uses: actions/upload-artifact@v4 if: always() with: - name: food-help-verification + name: food-help-verification-${{ strategy.job-index }} path: | playwright-report/ artifacts/reports/ diff --git a/AGENTS.md b/AGENTS.md index abcc6ef..88d0713 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,10 @@ Inspection and planning are read-only until implementation is explicitly authori ## Authority -- `site/site.json` defines this deployment; `site/resources.json` holds authoritative reviewed resource facts. +- The selected `deployments//site.json` defines that deployment; its `resources.json` holds authoritative reviewed resource facts. `examples/exampleville/` is a fictional starter, not a real deployment. - `schemas/` plus shared semantic validation define the contract. `src/` is generic application behaviour. - `.generated/`, `artifacts/` and `dist/` are disposable output. Never maintain generated projections manually. -- Read `site/AGENTS.md` for narrower deployment rules. Keep private review material outside public source. +- Read the selected deployment's `AGENTS.md` for narrower rules. Independently configured `--site` folders may supply the same file. Keep private review material outside public source. Never fix a deployment-specific requirement by adding community-specific logic to generic platform code. @@ -24,6 +24,7 @@ Never fix a deployment-specific requirement by adding community-specific logic t - Use locked, justified development dependencies and no runtime package dependencies. Review dependency changes. - Preserve keyboard access, readable contrast, native controls, print and no-JavaScript access. -- Run `npm run check`. Worker changes require first visit, offline cold restart, update, failed install, rollback and stale-cache cleanup evidence. +- Run `npm run check -- --site ` for the affected deployment and `npm run check -- --all` for shared changes. Worker changes require first visit, offline cold restart, update, failed install, rollback and stale-cache cleanup evidence. +- Keep shared development on `main`; releases explicitly select one community and exact source revision. Adding a folder or merging shared code must not publish communities automatically. Retain the previous complete artifact and source availability for per-community rollback. - Before consequential changes, report exact scope, visible/data/privacy/cache effects, tests and unresolved risks, artifact identity, and whether external publication would occur. - Do not infer external authorization from credentials, prior unrelated work or passing tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8146719..a1bdd2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,13 @@ # Contributing -Read `AGENTS.md` and `site/AGENTS.md`. Changes to platform behaviour belong in generic source; community facts and choices belong in `site/`. Never solve a community requirement with a locality branch in application code. +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` 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. +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. 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. Test changes through the Exampleville and second-community fixtures. Worker changes require the full offline/update/rollback suite. New UI copy belongs in the copy pack. New categories or schema meanings require a version/migration decision, not an unannounced enum expansion in an operator file. -Contributions use the applicable existing software/documentation licence; dataset contributions require a separately stated right to redistribute. Keep review proportionate. A focused pull request and passing evidence are preferable to additional process files. +Contributions use the applicable existing software/documentation licence, MPL-2.0. Submit only material you have the right to contribute, retain upstream notices, and identify copied material and its licence. Dataset contributions require separately stated rights and factual review; a public source link alone does not authorize relicensing. You retain your copyright. No CLA or copyright transfer is required; maintainers cannot promise alternative licensing of every contribution without the relevant rights. See [licensing](docs/licensing.md). + +Keep review proportionate. A focused pull request and passing evidence are preferable to additional process files. Never place credentials, internal hosting receipts or private provider correspondence in a pull request or public repository. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index ac17866..46df8f1 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -2,10 +2,14 @@ Food Help maintains generic directory software. Each deployment operator owns local inclusion criteria, evidence review, publication decisions, correction handling, data rights, optional analytics policy and hosting approvals. -The root `AGENTS.md` defines platform boundaries. A narrower `site/AGENTS.md` may add local review and operational rules, but may not turn generated files into authority or introduce community-specific platform logic. Keep private research notes and credentials outside the public repository. +The root `AGENTS.md` defines platform boundaries. A narrower `deployments//AGENTS.md` may add local review and operational rules, but may not turn generated files into authority or introduce community-specific platform logic. A self-hosted configuration outside `deployments/` can carry the same narrower instruction file beside its `site.json`. Keep private research notes and credentials outside the public repository. The authority order is explicit user/owner direction within the task, applicable repository instructions, authoritative site facts/configuration and schema/domain contracts. Generated reports demonstrate a build; they do not override reviewed source facts or authorize external actions. Ordinary code fixes can follow normal maintainer review. Publication of provider facts, licence changes, enabling collection, deployment/DNS/domain actions and public repository creation need the appropriate operator/owner approval. A passing check is necessary engineering evidence, not that approval. Before a consequential change, hand off its exact scope, source/data difference, public and privacy effects, cache/update implications, test results, remaining limitations, artifact hash and proposed external action. Keep this in the relevant review or release record. Do not create a parallel historical “source of truth” archive inside generic source. + +`main` contains shared software and independently selectable community folders. A community release identifies one exact commit and one target. Merging a shared change or adding a folder is not approval to publish all communities. Keep a per-community release/rollback record without storing account receipts or credentials in public source. The historical `kingston` branch is retained for recovery, not ongoing development after the new release arrangement is verified. + +Contributors retain their rights under the applicable licences. Governance does not require a copyright assignment or imply that True Good Craft owns every contribution. Data grants, third-party notices and trademark rights remain separate; see [NOTICE](NOTICE.md). diff --git a/LICENSE b/LICENSE index 14e2f77..d0a1fa1 100644 --- a/LICENSE +++ b/LICENSE @@ -35,7 +35,7 @@ Mozilla Public License Version 2.0 means any form of the work other than Source Code Form. 1.7. "Larger Work" - means a work that combines Covered Software with other material, in + means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" @@ -357,7 +357,7 @@ Exhibit A - Source Code Form License Notice This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. + file, You can obtain one at https://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE diff --git a/NOTICE.md b/NOTICE.md index 8838fc4..bc8a36b 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,13 +1,17 @@ # Licensing and attribution -Food Help application source, build/test tooling and repository documentation are offered under the Mozilla Public License 2.0; see `LICENSE`. Preserve applicable notices when redistributing modified source and make covered source available when distributing an executable build. MPL does not automatically grant trademark rights. +Food Help application source, schemas, build/test tooling and supporting repository documentation are offered under the unmodified Mozilla Public License 2.0; see [LICENSE](LICENSE). Preserve applicable notices and provide the corresponding covered source when distributing executable browser code, including your deployed modifications. This notice defines the scope for project files that cannot conveniently contain a source comment. Separately identified data and third-party material retain their own terms. -The bowl/spoon visual identity, icon PNGs and base CSS were reused from the project owner’s reference implementation at their direction. The generic SVG description and application identity have been updated. These project-supplied assets accompany the source licence; users should not imply endorsement by another deployment or operator. +The bowl/spoon artwork, icon PNGs and base CSS were brought forward from the owner's Kingston Food Help reference implementation at their direction. The owner has authorized their inclusion in Food Help under MPL-2.0. The generic SVG description and application identity have been updated. This grant for the included material does not change historical releases or override another contributor's rights. No municipal mark is included. Independent operators may replace these assets; neither their use nor the software licence grants rights to another operator's name, trademarks, official domains or endorsement. -Atkinson Hyperlegible Next is Copyright 2020–2024 The Atkinson Hyperlegible Next Project Authors. Its unmodified local WOFF2 and full SIL Open Font License 1.1 are in `src/assets/fonts/`. The build distributes the font licence. No third-party font host is contacted. +Atkinson Hyperlegible Next is Copyright 2020–2024 The Atkinson Hyperlegible Next Project Authors. Its unmodified local WOFF2 and full SIL Open Font License 1.1 are in `src/assets/fonts/`. The build distributes the font licence. The font remains OFL-1.1, not MPL-2.0. No third-party font host is contacted. Source provenance and the matching legacy-file audit are recorded in [licensing](docs/licensing.md). -Exampleville and the synthetic test fixtures are newly authored fictional examples, offered under CC0-1.0 as indicated in their configuration. This does not license any real provider dataset. Operators must establish and state their own dataset rights; source URLs and public availability alone do not prove redistribution permission. Real Kingston data is not included here. +Exampleville and the synthetic test datasets are fictional examples, dedicated under [CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/) as indicated in their configurations. Test software remains MPL-2.0. These fixture grants do not cover real provider records. + +Real Kingston resource data is included in `deployments/kingston/resources.json`. Its source references, qualifications and review dates must remain with it. No separate open-data licence is asserted for the whole Kingston dataset. Original provider and third-party rights remain applicable; public visibility and a source link alone do not establish permission to relicense imported text or a database. See the deployment's `data_rights` statement and the [rights review](docs/licensing.md#real-community-data). Development tools retain their own package licences in the lockfile installation. Ajv generates self-contained schema validation code; Food Help supplies the small code-point-length and structural-equality helpers so no Ajv runtime package is shipped. No application code from the standards/prior-art projects in `docs/interoperability.md` is copied. -Before initial public repository publication, review the complete file list, attribution, actual asset rights and any contributor-supplied material. Do not import the reference repository’s Git history, operational records or private integration files. +Contributors retain their rights. There is no compulsory CLA or copyright transfer. A maintainer cannot promise alternative licensing of all contributions without the rights needed to do so. Self-hosting and commercial use of the software do not require a TGC service purchase. Third-party hosting or support is separate from this licence. + +Before each public release, review included files, source availability and attribution. Keep private operational records, credentials, correspondence and hosting receipts outside this repository. diff --git a/README.md b/README.md index 3c83a06..76f40e8 100644 --- a/README.md +++ b/README.md @@ -1,69 +1,74 @@ # Food Help -Food Help builds one community’s 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. +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. -**A new deployment requires no changes to generic application source.** Configure `site/site.json`, author `site/resources.json`, and optionally supply `site/assets/` and sanitized `site/usage.json`. +**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 included Exampleville directory is fictional and deliberately excluded from indexing. It must not be used to find real services. +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. -## Start locally +## Start independently -Use Node **24.12 or later in the Node 24 line** (the tested version is in `.node-version`). +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`). ```sh npm ci -npm run setup npx playwright install chromium firefox webkit -npm run check -npm run dev +npm run setup -- --site deployments/my-community +npm run check -- --site deployments/my-community +npm run dev -- --site deployments/my-community ``` -Open `http://127.0.0.1:4173`. Development rebuilds on changes to `src/`, `site/` and `schemas/`; reload the page or accept a waiting application update. Use a fresh browser profile when checking a particular artifact without an older worker. +Setup requires an explicit folder and creates a fictional starting configuration without inventing local facts. Finish [configuration](docs/configuration.md), replace example data with reviewed local resources, and establish your data rights before publication. In a noninteractive terminal, setup creates/validates the fictional starter rather than guessing answers. `--validate-only` leaves configuration and data unchanged; generated schema tooling may be refreshed. Existing resource data is preserved. -`setup` prompts for deployment basics without inventing resource facts. Without an interactive terminal, or with `--validate-only`, it validates the configuration. It preserves existing resource data. Finish the remaining settings in the documented configuration file and replace fictional data with reviewed resources. +Open `http://127.0.0.1:4173`. Development rebuilds the selected community; reload or accept its waiting application update. To inspect only the neutral example, use `npm run dev` without `--site`. A fresh browser profile avoids an earlier deployment's worker when comparing artifacts locally. -`npm run check` means **structurally safe and internally consistent enough to be reviewed for publication**. It validates the actual deployment, builds two community fixtures, checks generated relationships, runs unit tests, and exercises Chromium, Firefox and WebKit. It does not certify provider facts, licensing, medical suitability or live availability. First install the test browsers; no test silently substitutes a missing engine. +## Work on Kingston ```sh -npm run build # preview, noindex -npm run preview # serve dist/ with generated response headers -npm run build -- --production # after human review; refuses example content +npm run check -- --site deployments/kingston +npm run build -- --site deployments/kingston +npm run preview -- --site deployments/kingston ``` -Builds do not deploy. Upload the complete `dist/` artifact to an approved static host at a dedicated HTTPS origin. Arbitrary subdirectory hosting is outside v1. +The build above is a noindex preview at `dist/kingston/`. After editorial review, build production explicitly: -## What the build produces +```sh +npm run build -- --site deployments/kingston --production --source-revision +``` + +Production requires corresponding source availability for the exact built revision. Follow [release and hosting instructions](docs/deployment.md) before publishing. Upload the complete selected output, not the parent `dist/` folder. Builds do not deploy, create hosting, change DNS or publish other communities. Dedicated root HTTPS origins are supported; arbitrary subdirectory hosting is outside v1. -- A static directory with optional local search, categories and in-memory distance sorting. -- Useful `/resources/{stable-id}/` HTML pages, including evidence and review dates. -- `/directory/`, a printable no-JavaScript directory, and a self-contained downloadable HTML copy. -- `/data/v1/resources.json`, the current public dataset, filtered from authoritative source. +`npm run check -- --site ` means **structurally safe and internally consistent enough to be reviewed for publication**. It builds the selected deployment and independent fixtures, validates output relationships, and exercises supported browsers. `npm run check -- --all` discovers community folders for validation only. Neither command certifies provider facts, rights or live availability. Missing required test engines are failures, not silent substitutions. + +## Generated surfaces + +- A static directory with local search, category buttons and optional in-memory distance sorting. +- Useful `/resources/{stable-id}/` HTML with source evidence and review dates. +- `/directory/`, a printable no-JavaScript directory, and a self-contained HTML download. +- `/data/v1/resources.json`, filtered from the selected authoritative dataset. - Methodology, privacy, about and licensing pages; optional sanitized `/usage/`. - Canonicals, Open Graph, truthful JSON-LD, sitemap, robots and `llms.txt`. -- A generated manifest, self-hosted icons/font, native service worker and security headers. +- A manifest, self-hosted icons/font, native service worker and generated host policies. -Published schedules are never a live availability feed. Optional location is not stored. Analytics is disabled by default and requires explicit configuration; no external infrastructure is required. +Published schedules are never a live availability feed. Location is optional and not stored. Analytics is disabled in the starter and requires explicit deployment configuration and policy approval. Host-injected scripts are a separate responsibility; production verification checks actual responses. ## Source map | Location | Role | | --- | --- | -| `site/site.json`, `site/resources.json` | Deployment and reviewed fact authority | -| `site/assets/`, optional `site/usage.json` | Optional local branding and reviewed aggregates | -| `schemas/` | Two versioned schema contracts | -| `src/` | Generic domain, validation, presentation and browser behaviour | -| `src/copy/en.ts` | Centralized application copy; additive French path | -| `scripts/` | Setup, one build pipeline, preview and canonical check | -| `examples/exampleville/` | Fictional authoring example, not an authority for your deployment | -| `tests/` | Unit, output contracts, browser and offline tests; synthetic second community | -| `.generated/`, `artifacts/`, `dist/` | Disposable generated output; never hand-maintained | - -Read [configuration](docs/configuration.md), [resource authoring](docs/data.md), [architecture and offline behaviour](docs/architecture.md), [standards/reuse decisions](docs/interoperability.md), [deployment](docs/deployment.md), and [verification](docs/verification.md). +| `deployments//site.json`, `resources.json` | Community configuration and reviewed fact authority | +| 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 | -Application source and repository documentation use [MPL-2.0](LICENSE). The font retains its [OFL](src/assets/fonts/OFL.txt). Dataset rights are separate; see [NOTICE](NOTICE.md). No real provider dataset or production infrastructure is included. +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; and [verification](docs/verification.md) for automated versus physical-device evidence. -The GitHub check workflow is manual-only (`workflow_dispatch`). Repository Actions are disabled for the initial publication; a maintainer must explicitly enable and run them. There are no deployment or scheduled workflows. +Software and supporting documentation use [MPL-2.0](LICENSE). The font retains [OFL-1.1](src/assets/fonts/OFL.txt), fictional datasets retain CC0-1.0, and real Kingston data has no asserted blanket open-data licence. [NOTICE](NOTICE.md) and [licensing](docs/licensing.md) explain coverage, attribution and source availability. Self-hosting and commercial software use do not require buying TGC services. -## Current inspection snapshot +`main` is the shared development source. Community release pointers select exact reviewed commits; adding a folder or merging shared software is not an instruction to deploy every community. Validation and publication remain separate. Hosting success alone is not proof of offline readiness, analytics delivery or physical-phone reliability. -This initial repository is available for local inspection, not production sign-off. The latest local run passed type checks, 18 unit/output-contract tests and 21 browser tests. Two WebKit checks remain unresolved: optional analytics consent/event collection and corrupt-response last-good-data retention. Four browser cases are intentionally Chromium-only. Run `npm run check` to reproduce the outstanding results. Real-device installation and production-host checks are still pending. No website is deployed by publishing this repository. +For a configured host, `npm run release -- --site deployments/kingston --ref ` checks/builds an exact local release plan. Adding `--publish` explicitly advances only `release/kingston` and triggers its configured hosting build. The checkout must be clean and already at the selected revision. See [deployment](docs/deployment.md) for prerequisites and rollback. diff --git a/SECURITY.md b/SECURITY.md index abb7267..a1fc76f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ Do not put credentials, private provider records or visitor data in this repository. Everything in a deployed static artifact is public. `.gitignore` is not a secret scanner and cannot undo a prior disclosure. -Report a suspected vulnerability privately to the repository’s maintainers using private vulnerability reporting when enabled. Before the public repository exists, use the project owner’s existing private contact channel; do not open a public issue containing exploit details or secrets. Operators’ public correction addresses are for directory facts, not automatically the software security contact. +Report a suspected vulnerability privately to the repository's maintainers using private vulnerability reporting when enabled. Otherwise use an established private maintainer contact channel; do not open a public issue containing exploit details or secrets. Operators' public correction addresses are for directory facts, not automatically the software security contact. Do not assume a reporting feature is enabled without checking. Include affected versions, a minimal reproduction, impact and relevant configuration with sensitive values removed. Do not test against other communities without permission. A maintainer should acknowledge, assess and coordinate a fix before disclosure; no unsupported response-time guarantee is implied. diff --git a/deployments/kingston/AGENTS.md b/deployments/kingston/AGENTS.md new file mode 100644 index 0000000..97eab61 --- /dev/null +++ b/deployments/kingston/AGENTS.md @@ -0,0 +1,13 @@ +# Kingston deployment instructions + +This directory defines the Kingston, Ontario deployment at https://kingston.food-help.ca. + +Only publish resource facts that have completed human review. Preserve each source URL, checked date, verification state, qualification and unresolved uncertainty. A published schedule describes reviewed information and never establishes live availability. + +Changes to resource facts, analytics policy, public usage data, the canonical domain, production hosting, DNS or redirects require the operator's explicit approval. Keep private correspondence, internal operational records, deployment receipts and submitter details outside this public repository. + +Kingston requirements belong in `deployments/kingston/`. Never add Kingston-specific behaviour to generic platform source. Run `npm run check -- --site deployments/kingston` before release. A release selects Kingston explicitly; neither another community's publication nor a shared-source merge is a Kingston release approval. + +The public operator is True Good Craft; Jamie Whelan is the operator's review owner for the current listings. The existing 2026-09-04 source-review dates and 30-day interval make the next review due 2026-10-04. These are carried-forward review dates, not new verification during migration. Follow `REVIEW.md`; unresolved hours and access details must remain visible until reviewed evidence resolves them. + +No separate open-data licence is asserted for this dataset. Preserve source attribution and the configured rights statement until the operator establishes and approves a material-specific grant. Software MPL licensing does not supply provider or 211 data rights. diff --git a/deployments/kingston/REVIEW.md b/deployments/kingston/REVIEW.md new file mode 100644 index 0000000..1e70aff --- /dev/null +++ b/deployments/kingston/REVIEW.md @@ -0,0 +1,27 @@ +# Kingston editorial maintenance + +This is an operator checklist, not a replacement dataset. Authoritative provider facts, evidence and verification dates remain in `resources.json`; site policy remains in `site.json`. + +**Review owner:** Jamie Whelan, for True Good Craft. The public correction address is the address in `site.json`. Review sources sooner when a correction, closure or changed schedule is reported. Automation can flag a due review; it cannot approve a provider fact. + +All eight current listings retain their existing **2026-09-04** verification date. The configured review interval is **30 days**, so the next review target is **2026-10-04**. This calculation is a maintenance deadline, not a claim that the listings were rechecked during software consolidation. After an actual review, update the relevant evidence and resource dates and this checklist's derived deadline together; do not advance verification dates for formatting, schema migration or deployment alone. + +| Resource ID | Current review state | Next review by | +| --- | --- | --- | +| `marthas-table` | Confirmed | 2026-10-04 | +| `loretta-meal-program` | Confirmed | 2026-10-04 | +| `brit-smith-social-market` | Confirmed | 2026-10-04 | +| `partners-in-mission` | Confirmed | 2026-10-04 | +| `st-marys-drop-in` | Confirmed | 2026-10-04 | +| `lunch-by-george` | Partially confirmed | 2026-10-04 | +| `salvation-army-pantry` | Partially confirmed | 2026-10-04 | +| `storehouse-of-hope` | Confirmed | 2026-10-04 | + +Outstanding source questions remain open: + +- **Lunch by George:** changed lunch hours conflict across published sources; the complete dated change notice was not inspected in the recorded review. Preserve the warning and unknown schedule until serving times are confirmed. +- **Community Choice Pantry:** confirm food collection times and intake arrangements. Office hours must not be repurposed as food-service hours. Preserve unknown access rules where the evidence is incomplete. +- **Every record:** keep provider qualifications, eligibility and intake details with their sources. Do not turn source-reviewed schedules into live availability. Check dated programme and holiday exceptions before carrying them forward. +- **Dataset rights:** no blanket open-data licence has been established for all provider/211/City-derived material. Follow the specific review in [licensing](../../docs/licensing.md#real-community-data); keep the current rights statement meanwhile. + +Private correspondence and permission receipts belong outside this public repository. Public evidence notes should describe the finding without exposing private contact details. See [maintenance](../../docs/maintenance.md) for edit/check/release steps. diff --git a/deployments/kingston/resources.json b/deployments/kingston/resources.json new file mode 100644 index 0000000..07a2e4c --- /dev/null +++ b/deployments/kingston/resources.json @@ -0,0 +1,1430 @@ +{ + "format": "food-help", + "schema_version": 1, + "dataset_id": "774da8bd-b838-4d83-aa3b-a7ae98c7725c", + "content_language": "en", + "taxonomy_version": 1, + "organizations": [ + { + "id": "martha-s-table", + "name": "Martha’s Table" + }, + { + "id": "st-vincent-de-paul-society-of-kingston", + "name": "St. Vincent de Paul Society of Kingston" + }, + { + "id": "partners-in-mission-food-bank", + "name": "Partners in Mission Food Bank" + }, + { + "id": "st-mary-s-cathedral", + "name": "St. Mary’s Cathedral" + }, + { + "id": "outreach-st-george-s-kingston", + "name": "Outreach St. George’s Kingston" + }, + { + "id": "the-salvation-army-kingston-citadel", + "name": "The Salvation Army — Kingston Citadel" + }, + { + "id": "kingston-alliance-church", + "name": "Kingston Alliance Church" + } + ], + "resources": [ + { + "id": "marthas-table", + "organization_id": "martha-s-table", + "name": "Martha’s Table", + "summary": "Free breakfast, lunch and dinner on weekdays, with takeout lunch on weekends.", + "categories": [ + "prepared_meals" + ], + "food_access_purpose": "Free breakfast, lunch and dinner on weekdays, with takeout lunch on weekends.", + "location": { + "description": "629 Princess Street, Kingston, ON K7L 1E2", + "address": { + "street": "629 Princess Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7L 1E2", + "neighbourhood": "Princess & Nelson" + }, + "coordinates": { + "latitude": 44.237109, + "longitude": -76.5015428 + } + }, + "phone": "+16135460320", + "official_url": "https://marthastable.ca/plan-your-visit/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "Drop in during meal times. No documents required." + ] + }, + "eligibility": [ + "Everyone is welcome for meals." + ], + "limitations": [ + "Weekend service is takeout lunch only." + ], + "before_you_go": [ + "Takeout is at the rear of the building.", + "For meal delivery, call 343-580-1519 to ask about arrangements." + ], + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Weekdays: breakfast 9–11, lunch noon–2, dinner 3–5. Weekend lunch: takeout only.", + "weekly": [ + { + "day": 1, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "11:00" + }, + { + "opens": "12:00", + "closes": "14:00" + }, + { + "opens": "15:00", + "closes": "17:00" + } + ] + }, + { + "day": 2, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "11:00" + }, + { + "opens": "12:00", + "closes": "14:00" + }, + { + "opens": "15:00", + "closes": "17:00" + } + ] + }, + { + "day": 3, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "11:00" + }, + { + "opens": "12:00", + "closes": "14:00" + }, + { + "opens": "15:00", + "closes": "17:00" + } + ] + }, + { + "day": 4, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "11:00" + }, + { + "opens": "12:00", + "closes": "14:00" + }, + { + "opens": "15:00", + "closes": "17:00" + } + ] + }, + { + "day": 5, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "11:00" + }, + { + "opens": "12:00", + "closes": "14:00" + }, + { + "opens": "15:00", + "closes": "17:00" + } + ] + }, + { + "day": 6, + "state": "published", + "intervals": [ + { + "opens": "12:00", + "closes": "14:00" + } + ] + }, + { + "day": 7, + "state": "published", + "intervals": [ + { + "opens": "12:00", + "closes": "14:00" + } + ] + } + ], + "exceptions": [ + { + "date": "2026-09-07", + "confirmed": true, + "intervals": [], + "note": "Closed for Labour Day, according to the provider.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-10-12", + "confirmed": true, + "intervals": [], + "note": "Closed for Thanksgiving, according to the provider.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-12-23", + "confirmed": true, + "intervals": [ + { + "opens": "13:00", + "closes": "16:00" + } + ], + "note": "Holiday meal: dine-in 1–4 p.m.; takeout 1–2 p.m. only.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-12-24", + "confirmed": true, + "intervals": [ + { + "opens": "09:00", + "closes": "12:00" + } + ], + "note": "Holiday brunch, dine-in only.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-12-25", + "confirmed": true, + "intervals": [], + "note": "Closed for Christmas Day.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-12-26", + "confirmed": true, + "intervals": [], + "note": "Closed for Boxing Day.", + "evidence_ids": [ + "source-1" + ] + } + ], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://marthastable.ca/plan-your-visit/", + "checked_on": "2026-09-04", + "note": "Meal windows, weekend takeout, holiday closures and delivery contact.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule" + ] + }, + { + "id": "source-2", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/68954791/marthas-table-marthas-table/", + "checked_on": "2026-09-04", + "note": "No fee or documents; drop-in access. Map link supplies provider coordinates. Provider page supersedes 211’s incomplete weekend hours.", + "supports": [ + "name", + "location", + "phone", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04" + }, + { + "id": "loretta-meal-program", + "organization_id": "st-vincent-de-paul-society-of-kingston", + "name": "Loretta Meal Program", + "summary": "Free dine-in meal at St. Vincent de Paul, with coffee and other beverages.", + "categories": [ + "prepared_meals" + ], + "food_access_purpose": "Free dine-in meal at St. Vincent de Paul, with coffee and other beverages.", + "location": { + "description": "595 Bagot Street, Kingston, ON K7K 3E2", + "address": { + "street": "595 Bagot Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7K 3E2", + "neighbourhood": "Bagot Street" + }, + "coordinates": { + "latitude": 44.24254, + "longitude": -76.4912632 + } + }, + "phone": "+16137668432", + "official_url": "https://svdpkingston.com/services/loretta-meal-program/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "unknown", + "details": [ + "No registration is required.", + "The provider does not list an ID requirement for meals." + ] + }, + "eligibility": [ + "Everyone is welcome." + ], + "limitations": [ + "Dining hall hours are longer than meal service; the times above are for food." + ], + "before_you_go": [ + "Use the new location at 595 Bagot Street.", + "Call to check holiday service." + ], + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Meals: Monday–Friday, 10 a.m.–1 p.m. The hall is open 9 a.m.–2:30 p.m.", + "weekly": [ + { + "day": 1, + "state": "published", + "intervals": [ + { + "opens": "10:00", + "closes": "13:00" + } + ] + }, + { + "day": 2, + "state": "published", + "intervals": [ + { + "opens": "10:00", + "closes": "13:00" + } + ] + }, + { + "day": 3, + "state": "published", + "intervals": [ + { + "opens": "10:00", + "closes": "13:00" + } + ] + }, + { + "day": 4, + "state": "published", + "intervals": [ + { + "opens": "10:00", + "closes": "13:00" + } + ] + }, + { + "day": 5, + "state": "published", + "intervals": [ + { + "opens": "10:00", + "closes": "13:00" + } + ] + }, + { + "day": 6, + "state": "closed", + "intervals": [] + }, + { + "day": 7, + "state": "closed", + "intervals": [] + } + ], + "exceptions": [], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://svdpkingston.com/services/loretta-meal-program/", + "checked_on": "2026-09-04", + "note": "Provider confirms dine-in meals 10 a.m.–1 p.m., weekdays; no registration or charge; everyone welcome.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule", + "access.walk_in", + "access.appointment_required", + "access.registration_required" + ] + }, + { + "id": "source-2", + "source_kind": "provider", + "url": "https://svdpkingston.com/services/brit-smith-social-market/", + "checked_on": "2026-09-04", + "note": "Shared contact address and provider map: 595 Bagot Street, K7K 3E2. Older Stephen Street listings must not be used.", + "supports": [ + "name", + "location", + "phone" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04" + }, + { + "id": "brit-smith-social-market", + "organization_id": "st-vincent-de-paul-society-of-kingston", + "name": "Brit Smith Social Market", + "summary": "A registered food program offering two days of groceries each month, with household-size points.", + "categories": [ + "groceries" + ], + "food_access_purpose": "A registered food program offering two days of groceries each month, with household-size points.", + "location": { + "description": "595 Bagot Street, Kingston, ON K7K 3E2", + "address": { + "street": "595 Bagot Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7K 3E2", + "neighbourhood": "Bagot Street" + }, + "coordinates": { + "latitude": 44.24254, + "longitude": -76.4912632 + } + }, + "phone": "+16137668432", + "official_url": "https://svdpkingston.com/services/brit-smith-social-market/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "yes", + "identification_required": "yes", + "details": [ + "Register with ID for each household member and a current address.", + "No income statement or pay stub is required.", + "Call to ask how to register and whether you need a booked visit." + ] + }, + "eligibility": [ + "Community members may register; no income qualifying criteria are published." + ], + "limitations": [ + "Two days of food, once a month.", + "Food selections depend on stock and points for household size." + ], + "before_you_go": [ + "Ask about registration before making a trip.", + "Call to check holiday service." + ], + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Published service hours; this is not live availability.", + "weekly": [ + { + "day": 1, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "14:30" + } + ] + }, + { + "day": 2, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "14:30" + } + ] + }, + { + "day": 3, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "14:30" + } + ] + }, + { + "day": 4, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "14:30" + } + ] + }, + { + "day": 5, + "state": "published", + "intervals": [ + { + "opens": "09:00", + "closes": "14:30" + } + ] + }, + { + "day": 6, + "state": "closed", + "intervals": [] + }, + { + "day": 7, + "state": "closed", + "intervals": [] + } + ], + "exceptions": [], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://svdpkingston.com/services/brit-smith-social-market/", + "checked_on": "2026-09-04", + "note": "Provider confirms hours, registration, household identification, current address, monthly limit and provider map.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule", + "location", + "phone", + "access.registration_required", + "access.identification_required" + ] + }, + { + "id": "source-2", + "source_kind": "local_authority", + "url": "https://www.cityofkingston.ca/community-supports/community-supports-and-well-being/food-supports/", + "checked_on": "2026-09-04", + "note": "Confirms current Bagot Street location and public telephone.", + "supports": [ + "name", + "location", + "phone" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04" + }, + { + "id": "partners-in-mission", + "organization_id": "partners-in-mission-food-bank", + "name": "Partners in Mission Food Bank", + "summary": "Free groceries by appointment, usually enough for about five to seven days.", + "categories": [ + "food_banks_pantries" + ], + "food_access_purpose": "Free groceries by appointment, usually enough for about five to seven days.", + "location": { + "description": "4 Harvey Street, Kingston, ON K7K 5B9", + "address": { + "street": "4 Harvey Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7K 5B9", + "neighbourhood": "North Kingston" + }, + "coordinates": { + "latitude": 44.2521562, + "longitude": -76.4938664 + } + }, + "phone": "+16135444100", + "official_url": "https://www.kingstonfoodbank.ca/get-assistance/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "no", + "appointment_required": "yes", + "registration_required": "yes", + "identification_required": "yes", + "details": [ + "Call to book a first intake appointment. No referral is needed.", + "Bring ID for each household member if available; call if you do not have it.", + "No proof of income is required. Proof of address is helpful but not always required." + ] + }, + "eligibility": [ + "Kingston and the Islands, Loyalist Township, and surrounding areas described by the provider; call to confirm your address." + ], + "limitations": [ + "Once per month, by appointment.", + "In-person pickup; delivery is not currently offered." + ], + "before_you_go": [ + "Book by phone, Monday–Friday 8:30–noon or 1–4:30.", + "The displayed food hours are shopping appointments, not walk-in or phone hours." + ], + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Booked shopping visits: 9:15–11:30 a.m. and 1:15–3:30 p.m., weekdays.", + "weekly": [ + { + "day": 1, + "state": "published", + "intervals": [ + { + "opens": "09:15", + "closes": "11:30" + }, + { + "opens": "13:15", + "closes": "15:30" + } + ] + }, + { + "day": 2, + "state": "published", + "intervals": [ + { + "opens": "09:15", + "closes": "11:30" + }, + { + "opens": "13:15", + "closes": "15:30" + } + ] + }, + { + "day": 3, + "state": "published", + "intervals": [ + { + "opens": "09:15", + "closes": "11:30" + }, + { + "opens": "13:15", + "closes": "15:30" + } + ] + }, + { + "day": 4, + "state": "published", + "intervals": [ + { + "opens": "09:15", + "closes": "11:30" + }, + { + "opens": "13:15", + "closes": "15:30" + } + ] + }, + { + "day": 5, + "state": "published", + "intervals": [ + { + "opens": "09:15", + "closes": "11:30" + }, + { + "opens": "13:15", + "closes": "15:30" + } + ] + }, + { + "day": 6, + "state": "closed", + "intervals": [] + }, + { + "day": 7, + "state": "closed", + "intervals": [] + } + ], + "exceptions": [ + { + "date": "2026-09-07", + "confirmed": true, + "intervals": [], + "note": "Closed for Labour Day; provider closes on holidays.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-10-12", + "confirmed": true, + "intervals": [], + "note": "Closed for Thanksgiving; provider closes on holidays.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-12-25", + "confirmed": true, + "intervals": [], + "note": "Closed for Christmas; provider closes on holidays.", + "evidence_ids": [ + "source-1" + ] + } + ], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://www.kingstonfoodbank.ca/get-assistance/", + "checked_on": "2026-09-04", + "note": "Provider shopping appointments, intake, ID guidance, service area, monthly limit, no delivery and holiday closure; service times supersede 211.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required" + ] + }, + { + "id": "source-2", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/68953414/partners-in-mission-food-bank-food-bank/", + "checked_on": "2026-09-04", + "note": "Corroborates new 4 Harvey Street address and phone; published map coordinates.", + "supports": [ + "name", + "location", + "phone" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04" + }, + { + "id": "st-marys-drop-in", + "organization_id": "st-mary-s-cathedral", + "name": "St. Mary’s Drop-In Centre", + "summary": "Free light meals and snacks during weekday afternoon drop-in hours.", + "categories": [ + "prepared_meals" + ], + "food_access_purpose": "Free light meals and snacks during weekday afternoon drop-in hours.", + "location": { + "description": "260 Brock Street, Kingston, ON K7L 1S4", + "address": { + "street": "260 Brock Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7L 1S4", + "neighbourhood": "Brock & Clergy" + }, + "coordinates": { + "latitude": 44.23137, + "longitude": -76.48904 + } + }, + "phone": "+16135465521", + "phone_extension": "3", + "official_url": "https://stmaryscathedral.ca/dropincentre/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "Drop in during service hours. No documents required." + ] + }, + "eligibility": [ + "No eligibility restrictions are listed." + ], + "limitations": [ + "Light meals and snacks; offerings may vary." + ], + "before_you_go": [ + "Use the Parish Centre at 260 Brock Street.", + "Call extension 3 for the drop-in centre and holiday arrangements." + ], + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Published service hours; this is not live availability.", + "weekly": [ + { + "day": 1, + "state": "published", + "intervals": [ + { + "opens": "13:00", + "closes": "15:30" + } + ] + }, + { + "day": 2, + "state": "published", + "intervals": [ + { + "opens": "13:00", + "closes": "15:30" + } + ] + }, + { + "day": 3, + "state": "published", + "intervals": [ + { + "opens": "13:00", + "closes": "15:30" + } + ] + }, + { + "day": 4, + "state": "published", + "intervals": [ + { + "opens": "13:00", + "closes": "15:30" + } + ] + }, + { + "day": 5, + "state": "published", + "intervals": [ + { + "opens": "13:00", + "closes": "15:30" + } + ] + }, + { + "day": 6, + "state": "closed", + "intervals": [] + }, + { + "day": 7, + "state": "closed", + "intervals": [] + } + ], + "exceptions": [], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://stmaryscathedral.ca/dropincentre/", + "checked_on": "2026-09-04", + "note": "Current Services section confirms 1–3:30 p.m. weekdays and extension 3; older newsletter copy on the page is historical.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule" + ] + }, + { + "id": "source-2", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/68954074/st-marys-cathedral-drop-in-centre/", + "checked_on": "2026-09-04", + "note": "Corroborates hours, no fee or documents, unrestricted drop-in, address and coordinates.", + "supports": [ + "name", + "location", + "phone", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04" + }, + { + "id": "lunch-by-george", + "organization_id": "outreach-st-george-s-kingston", + "name": "Lunch by George", + "summary": "Free takeaway breakfast and lunch from St. George’s Hall.", + "categories": [ + "prepared_meals" + ], + "food_access_purpose": "Free takeaway breakfast and lunch from St. George’s Hall.", + "location": { + "description": "129 Wellington Street, Kingston, ON K7L 4W5", + "address": { + "street": "129 Wellington Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7L 4W5", + "neighbourhood": "Downtown Kingston" + }, + "coordinates": { + "latitude": 44.2296985, + "longitude": -76.4833517 + } + }, + "phone": "+16134842984", + "official_url": "https://lunchbygeorge.ca/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "Meals are takeaway only.", + "Drop-in service; no documents required according to 211." + ] + }, + "eligibility": [ + "Anyone needing a meal is welcome." + ], + "limitations": [ + "Published lunch times conflict. Contact the provider before travelling." + ], + "before_you_go": [ + "Use the Wellington Street entrance.", + "Provider lists weekday breakfast 9–10:30 a.m. and lunch noon–1 p.m.; older listings show lunch 11:30–12:30." + ], + "schedule": { + "kind": "unknown", + "confirmed": false, + "note": "Weekday breakfast and lunch; call to confirm current serving times.", + "weekly": [], + "exceptions": [], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://lunchbygeorge.ca/", + "checked_on": "2026-09-04", + "note": "Provider homepage now lists lunch noon–1 p.m., but repeats 11:30–12:30 elsewhere; provider phone is 613-484-2984.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule" + ] + }, + { + "id": "source-2", + "source_kind": "provider", + "url": "https://lunchbygeorge.ca/279307-2/", + "checked_on": "2026-09-04", + "note": "Dated August 10, 2026 notice announces changed hours; full notice could not be inspected. No lunch status inferred.", + "supports": [ + "name" + ] + }, + { + "id": "source-3", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/68953695/outreach-st-georges-kingston-lunch-by-george/", + "checked_on": "2026-09-04", + "note": "June listing confirms drop-in, no documents, address and map; old lunch hours and phone differ from provider.", + "supports": [ + "name", + "location", + "phone", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04", + "notices": [ + { + "id": "verification-notice", + "text": "Lunch hours recently changed. Conflicting published times still need confirmation.", + "starts_on": "2026-09-04", + "evidence_ids": [ + "source-1" + ] + } + ] + }, + { + "id": "salvation-army-pantry", + "organization_id": "the-salvation-army-kingston-citadel", + "name": "Community Choice Pantry", + "summary": "Emergency food assistance. Call Community & Family Services to arrange access to the pantry.", + "categories": [ + "food_banks_pantries" + ], + "food_access_purpose": "Emergency food assistance. Call Community & Family Services to arrange access to the pantry.", + "location": { + "description": "342 Patrick Street, Kingston, ON K7K 6R6", + "address": { + "street": "342 Patrick Street", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7K 6R6", + "neighbourhood": "North Kingston" + }, + "coordinates": { + "latitude": 44.2456961, + "longitude": -76.4912101 + } + }, + "phone": "+16135484411", + "official_url": "https://sacfs.ca/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "unknown", + "appointment_required": "unknown", + "registration_required": "unknown", + "identification_required": "yes", + "details": [ + "211 lists ID, income and expense statements for each household member for emergency assistance.", + "Call to ask about intake, appointments and pantry collection hours." + ] + }, + "eligibility": [ + "Eligibility varies by program; ask about food assistance." + ], + "limitations": [ + "Pantry pickup hours have not been confirmed." + ], + "before_you_go": [ + "Call before going; office opening hours do not establish food availability." + ], + "schedule": { + "kind": "unknown", + "confirmed": false, + "note": "Call for pantry collection times and intake arrangements.", + "weekly": [], + "exceptions": [], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/69035083/salvation-army-the-community-and-family-services-kingston-citadel/", + "checked_on": "2026-09-04", + "note": "Confirms pantry, contact, address/map and emergency-assistance documents. Listed hours are administration, not food collection.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule", + "location", + "phone", + "access.identification_required" + ] + }, + { + "id": "source-2", + "source_kind": "local_authority", + "url": "https://www.cityofkingston.ca/community-supports/community-supports-and-well-being/food-supports/", + "checked_on": "2026-09-04", + "note": "Corroborates food assistance at 342 Patrick Street and public phone.", + "supports": [ + "name", + "location", + "phone" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "partially_confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04", + "notices": [ + { + "id": "verification-notice", + "text": "Pantry hours need confirmation. Office hours are not shown as food-service hours.", + "starts_on": "2026-09-04", + "evidence_ids": [ + "source-1" + ] + } + ] + }, + { + "id": "storehouse-of-hope", + "organization_id": "kingston-alliance-church", + "name": "Storehouse of Hope", + "summary": "Free Monday lunch, with coffee and snacks before the meal. Fall service restarts September 14.", + "categories": [ + "prepared_meals" + ], + "food_access_purpose": "Free Monday lunch, with coffee and snacks before the meal. Fall service restarts September 14.", + "location": { + "description": "540 Palace Road, Kingston, ON K7L 4T7", + "address": { + "street": "540 Palace Road", + "locality": "Kingston", + "region": "ON", + "country": "CA", + "postal_code": "K7L 4T7", + "neighbourhood": "Palace & Bath" + }, + "coordinates": { + "latitude": 44.2399898, + "longitude": -76.5150493 + } + }, + "phone": "+16135462143", + "official_url": "https://kingstonalliance.ca/storehouse-opens-on-mon-sep-14/", + "time_zone": "America/Toronto", + "access": { + "walk_in": "yes", + "appointment_required": "no", + "registration_required": "no", + "identification_required": "no", + "details": [ + "Drop in for the Monday lunch program.", + "No documents are listed by 211." + ] + }, + "eligibility": [ + "Community food outreach; no eligibility restrictions are listed." + ], + "limitations": [ + "No service on holiday Mondays; summer arrangements differ." + ], + "before_you_go": [ + "First fall session: Monday, September 14, 2026.", + "Coffee and snacks 10:30–11:30 a.m.; lunch 11:30 a.m.–1 p.m." + ], + "schedule": { + "kind": "weekly", + "confirmed": true, + "note": "Mondays from September 14: lunch 11:30 a.m.–1 p.m. No holiday Mondays.", + "weekly": [ + { + "day": 1, + "state": "published", + "intervals": [ + { + "opens": "11:30", + "closes": "13:00" + } + ] + }, + { + "day": 2, + "state": "closed", + "intervals": [] + }, + { + "day": 3, + "state": "closed", + "intervals": [] + }, + { + "day": 4, + "state": "closed", + "intervals": [] + }, + { + "day": 5, + "state": "closed", + "intervals": [] + }, + { + "day": 6, + "state": "closed", + "intervals": [] + }, + { + "day": 7, + "state": "closed", + "intervals": [] + } + ], + "exceptions": [ + { + "date": "2026-09-07", + "confirmed": true, + "intervals": [], + "note": "No service; fall program resumes September 14.", + "evidence_ids": [ + "source-1" + ] + }, + { + "date": "2026-10-12", + "confirmed": true, + "intervals": [], + "note": "Closed on holiday Mondays.", + "evidence_ids": [ + "source-1" + ] + } + ], + "evidence_ids": [ + "source-1" + ] + }, + "evidence": [ + { + "id": "source-1", + "source_kind": "provider", + "url": "https://kingstonalliance.ca/storehouse-opens-on-mon-sep-14/", + "checked_on": "2026-09-04", + "note": "Dated fall notice updated September 2: restart September 14; coffee 10:30–11:30, lunch 11:30–1.", + "supports": [ + "name", + "summary", + "food_access_purpose", + "official_url", + "eligibility", + "limitations", + "before_you_go", + "schedule", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required" + ] + }, + { + "id": "source-2", + "source_kind": "provider", + "url": "https://kingstonalliance.ca/storehouse-of-hope/", + "checked_on": "2026-09-04", + "note": "Free food outreach; holiday Mondays excluded; summer service changes.", + "supports": [ + "name" + ] + }, + { + "id": "source-3", + "source_kind": "provider", + "url": "https://kingstonalliance.ca/contact-kac/", + "checked_on": "2026-09-04", + "note": "Street address/postal code and provider map coordinates.", + "supports": [ + "name", + "location", + "phone" + ] + }, + { + "id": "source-4", + "source_kind": "referral_directory", + "url": "https://211ontario.ca/service/68959504/kingston-alliance-church-kingston-alliance-church/", + "checked_on": "2026-09-04", + "note": "Church phone and no documents. Its old program frequency is superseded by provider notice.", + "supports": [ + "name", + "access.walk_in", + "access.appointment_required", + "access.registration_required", + "access.identification_required" + ] + } + ], + "verification": { + "reviewed_on": "2026-09-04", + "method": "multiple_sources", + "state": "confirmed" + }, + "publication_status": "published", + "service_condition": "active", + "updated_on": "2026-09-04" + } + ] +} + diff --git a/deployments/kingston/site.json b/deployments/kingston/site.json new file mode 100644 index 0000000..a902473 --- /dev/null +++ b/deployments/kingston/site.json @@ -0,0 +1,65 @@ +{ + "schema_version": 1, + "deployment_id": "kingston", + "community": { + "name": "Kingston", + "locality": "Kingston", + "region": "Ontario", + "country": "CA" + }, + "site_name": "Kingston Food Help", + "short_name": "Food Help", + "description": "Find free and low-cost food support in Kingston, Ontario. Check details and contact providers before travelling.", + "language": "en", + "locale": "en-CA", + "text_direction": "ltr", + "time_zone": "America/Toronto", + "canonical_origin": "https://kingston.food-help.ca", + "operator": { + "name": "True Good Craft", + "url": "https://truegoodcraft.ca", + "statement": "An independent community resource by True Good Craft Inc.\nNot an official City of Kingston service." + }, + "corrections": { + "email": "jamie@truegoodcraft.ca" + }, + "help_contact": { + "label": "Call 2-1-1", + "phone": "211", + "description": "Call 2-1-1 for help finding services. Free and confidential, 24 hours a day.", + "source_url": "https://211ontario.ca/", + "checked_on": "2026-09-04" + }, + "emergency_contact": { + "label": "Call 9-1-1 in an emergency", + "phone": "911", + "description": "For immediate danger or a life-threatening emergency." + }, + "review": { + "interval_days": 30 + }, + "presentation": { + "introduction": "Find a meal or groceries. See where to go, when to visit, and what to bring.", + "category_groups": [ + { "id": "meals", "label": "Meals", "categories": ["prepared_meals"] }, + { "id": "groceries", "label": "Groceries", "categories": ["groceries", "food_banks_pantries"] } + ] + }, + "location": { + "enabled": true + }, + "indexing": { + "enabled": true + }, + "analytics": { + "enabled": false + }, + "public_usage": { + "enabled": false + }, + "example_content": false, + "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" +} diff --git a/docs/analytics.md b/docs/analytics.md new file mode 100644 index 0000000..0c42646 --- /dev/null +++ b/docs/analytics.md @@ -0,0 +1,56 @@ +# Optional aggregate reporting + +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. + +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 + +The application exposes six broad event kinds: `page_start`, `call`, `help`, `directions`, `source` and `install`. `call` means a resource call; `help` means the site's general help number. The emitter receives only the kind, never the clicked element, resource, destination, search, filter or location. + +The default JSON body is the configured static `constants` plus `event`, optionally renamed with `event_names`. The default content type remains `application/json`. + +For an existing collector with a different strict contract, configure `event_payloads` instead of `event_names`. Supply all six keys; each value is a small object of static scalar fields. Constants and event fields must not overlap. For example: + +```json +{ + "constants": { "deployment": "example-community", "version": 1 }, + "event_payloads": { + "page_start": { "event_name": "directory_start" }, + "call": { "event_name": "contact", "kind": "resource" }, + "help": { "event_name": "contact", "kind": "general_help" }, + "directions": { "event_name": "outbound", "kind": "directions" }, + "source": { "event_name": "outbound", "kind": "official_source" }, + "install": { "event_name": "installation_signal" } + }, + "content_type": "text/plain;charset=UTF-8" +} +``` + +This fragment belongs within an enabled, separately reviewed `analytics` object with `endpoint`, `collection_mode` and `disclosure`. It does not enable collection by itself. No collector secret belongs in public configuration. Static fields must never contain personal information, identifiers, resource identities or private operational details. Schema checks cannot establish whether a constant is appropriate to publish. + +## Optional finite outreach labels + +The optional `attribution` policy defines `events`, `sources`, `campaigns`, `contents`, `referrers` and `internal_hosts`. It permits only fixed public campaign labels, with maximum list lengths. Installation is never attributed. + +Source selection is `src`, then `utm_source`, then a configured referrer hostname rule, then `direct_unknown`. An unregistered explicit source or external host becomes `other`. Registered `utm_campaign` and `utm_content` labels are used; unknown values become `none`. Query strings, fragments and referrer URLs are never sent or stored by the adapter. Configured referrer rules have `{ "host": "search.example.test", "source": "search", "include_subdomains": true }`; hostname boundaries are checked. The canonical host and `internal_hosts` stay direct/unknown when no explicit source or matching rule exists. + +`sources` must include `direct_unknown` and `other`; `campaigns` and `contents` must include `none`. Every referrer source must appear in `sources`. These labels remain only in page memory and clear when an opt-out or privacy/operator suppression is observed. Do not issue per-person, household, provider, private-group or sensitive-support campaign labels. Classification is a privacy boundary, not evidence that a visitor is a person or received food. + +## Choice, suppression and delivery + +`collection_mode` is an explicit operator policy choice: `opt_in` starts off; `opt_out` starts on without writing a preference on first visit. The home page's **Your privacy** section and `/privacy/` offer the same saved choice. This preference is not an identifier. `preference_key` can preserve an existing same-origin key; otherwise the key derives from the deployment ID. + +GPC, navigator/window DNT, presence of the `dev_mode` cookie, `localStorage.noAnalytics === "1"`, or unreadable/unwritable preference storage suppress collection. The application creates no cookie. Signals are rechecked before every event. Changes in another tab recheck the choice and cancel pending requests. Disabled and preview builds do not read optional analytics storage. + +The client attempts one startup and one supported installation signal per page lifetime. Broad clicks have a 750ms per-kind in-memory duplicate guard. Hidden/offline events are dropped and never replayed on return. Requests omit credentials and referrers, reject redirects and use a 1,500ms abort timer. No beacon, queue, retry, response dependency or navigation wait exists. + +The optional `click_keepalive: true` allows only a previously started visible broad click to finish when the page hides or navigates. Other pending measurements abort. Opt-out, privacy/operator suppression and offline transitions still abort all pending requests. Timers cannot guarantee cancellation after a browser freezes or destroys a page realm; delivery is best effort. This capability needs explicit policy review and is off by default. + +## Restoration and verification limits + +A preference saved under an old domain cannot be read under a new domain. Reusing its key does not transfer an old opt-out. Before default-on collection on a replacement domain, resolve this explicitly; do not silently discard existing privacy choices or invent a cross-domain identifier. + +Collector compatibility must include the deployed origin allowlist, exact payload version, privacy controls, retention and report semantics. Local tests use synthetic data and local collectors. A browser request or HTTP 204 is not proof of persistence, reporting continuity or a completed call/visit. Production verification must have an established exclusion mechanism that keeps synthetic activity out of genuine totals. If those requirements are unresolved, leave collection disabled and record the exact gap. + +Operators are responsible for collector transport logging and retention. Ordinary hosting connection processing is separate from application analytics. Unrequested host-injected beacons remain unwanted even when this adapter is enabled. diff --git a/docs/architecture.md b/docs/architecture.md index f6c08ee..47edd88 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,8 @@ # Architecture and offline behaviour -Food Help is one static community artifact. There is no tenant router, server API, database, account system or admin portal. `site/` is deployment authority; `schemas/` and shared semantic validation define the contract. Generated files are never authoring authority. +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. + +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. ## Build order @@ -8,13 +10,15 @@ Food Help is one static community artifact. There is no tenant router, server AP 1. Generate TypeScript declarations and self-contained Ajv validators from the two schemas into `.generated/`. These validators are also bundled into the browser, so an untrusted network response receives the same structural and semantic checks. 2. Read and validate source configuration, data, optional branding and optional local aggregates. Reject generated-only fields in source, bad identities/references, contradictory schedules and incomplete evidence relationships. -3. Filter published, non-closed resources and referenced organizations once. Calculate dataset and configuration-compatibility digests. Write the small generated runtime configuration module. +3. Filter published, non-closed resources and referenced organizations once. Calculate dataset and configuration-compatibility digests. Supply the selected runtime configuration to that build without a shared community-specific generated module. 4. Bundle generic TypeScript/CSS with Vite. Copy/hash reviewed local assets, preserve the font licence, and generate a small branding stylesheet. 5. Generate static pages from the public projection, then the portable HTML download, public JSON, manifest, actual indexable sitemap, robots and machine-discovery document. Public metadata uses the configured canonical origin even in previews. -6. Hash the complete cacheable application surface and worker source. Generate the worker with that release identity, exact cache entries and route map. Current data is deliberately outside the application precache. -7. Generate host header/redirect declarations and an ignored `artifacts/reports/{deployment_id}.json` handoff report containing output hashes, routes, public resource count, dataset identity, security policies and release identity. +6. Hash the complete cacheable application surface and worker source. Generate the worker with that release identity, exact cache entries and route map. Current data is deliberately outside the application precache. Crawler-only metadata is also outside installation; it is generated and verified independently and is not needed for offline browsing. +7. Generate host header/redirect declarations and an ignored handoff report below `artifacts/reports/{deployment_id}/`, keyed by output path. It contains output hashes, routes, public resource count, dataset identity, security policies and release identity. The build prints the exact report path. + +The report is for this local build, not a historical public dataset snapshot. Keep a whole approved artifact privately for deployment rollback. Default output is `dist/{deployment_id}/`; an explicit `--out` must be an approved output location. Different communities must use unique deployment IDs and separate output locations. `dist/`, `.generated/`, `artifacts/`, browser profiles and reports are ignored by Git. The pipeline never empties source community folders. Shared schema declarations are generic; checks serialize shared generation rather than letting concurrent checks rewrite it unpredictably. -The report is for this local build, not a historical public dataset snapshot. Keep a whole approved artifact privately for deployment rollback. `dist/`, `.generated/`, `artifacts/`, browser profiles and reports are ignored by Git. The pipeline empties only approved output locations within this workspace, never `site/`. +Source text copied or embedded in output is normalized to LF before hashing, including worker source, SVG branding, standalone CSS and licence/notice text. Font and icon binary bytes remain unchanged. A contract test builds isolated fictional source trees with LF and CRLF endings and compares every output file hash, so Windows checkout settings cannot silently change the deployable artifact. ## Offline lifecycle @@ -28,7 +32,7 @@ First installation activates normally. Later workers wait and display an update Offline navigation uses the cached root, resource pages, methodology and simple directory. Search/filtering uses retained validated data; visiting any enhanced page initializes data retention. Reconnection requests current data. Neither the worker nor analytics queues external events. Cache names begin `food-help-{deployment_id}-` and never contain a reference locality by convention. -Rollback means serving the previous complete artifact, including its worker and JSON. Its worker becomes a waiting update and follows the same explicit activation path. A compatible last-good dataset remains usable; incompatible data falls back to the artifact’s static content until connected validation succeeds. No service-worker unregister-and-wipe migration is necessary for the later Kingston move. +Rollback means serving the previous complete artifact for one selected community, including its worker and JSON. Its worker becomes a waiting update and follows the same explicit activation path. A compatible last-good dataset remains usable; incompatible data falls back to the artifact's static content until connected validation succeeds. A domain transition separately accounts for workers already registered on the legacy origin; absence of home-screen installs is not evidence that no worker exists. Browser eviction, unavailable storage, private browsing restrictions and device-specific installed-app lifecycle policies remain practical limits. A self-contained HTML download is an independent offline copy. A newly downloaded directory is still a dated publication, never a live feed. @@ -39,3 +43,5 @@ Every useful published resource gets one stable, crawlable HTML URL. It has a ti Only actual indexable pages enter the sitemap. Simple duplicates, downloads, usage and 404 pages are noindex. Preview builds default to noindex at both HTML and response-header levels. Robots allows crawling so crawlers can discover those noindex directives; `Disallow: /` would prevent that. Preview noindex is not confidentiality or access control. `llms.txt` points to the canonical directory, current JSON, methodology and data rights and explicitly warns about schedule uncertainty; it is a discovery hint, not an enforceable AI-use policy. The default CSP has no third-party connection origins. Enabling the explicit analytics capability adds only its endpoint origin to `connect-src`; location permission is generated separately. External provider/directions links transmit information only when a person follows them. Browser tests block unintended outbound test traffic. Hosting providers can still log requests independently of this application. + +`software_source_url` and the exact release revision make the deployed covered source discoverable. Browser source availability, dataset rights and font licensing are separate generated notices. A folder move does not change Food Help data v1 or imply implemented HSDS conformance; see [interoperability](interoperability.md). diff --git a/docs/configuration.md b/docs/configuration.md index e2435be..011a384 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,8 @@ `schemas/site.schema.json` is the exact contract. Unknown fields are errors. This page explains the choices rather than maintaining a second schema. +Use `--site deployments/kingston` for Kingston or `--site deployments/` for another community. The selected folder contains `site.json`, `resources.json`, optional `assets/`, optional `usage.json` and narrower `AGENTS.md` instructions. Setup requires the target; build/dev/preview without it use the fictional `examples/exampleville/` starter. Outputs default to `dist//`, and creating a configuration never creates hosting or publishes it. + | Field | Required | Meaning | | --- | --- | --- | | `schema_version` | Yes | `1` | @@ -21,19 +23,26 @@ | `indexing.enabled` | No | Explicitly true to allow indexing in a production build | | `location.enabled` | No | Enable optional, in-memory approximate distance sorting | | `branding` | No | Local logo, 192px/512px PNG icons, and small color-token overrides | +| `presentation` | No | Optional introduction text and category button groups; resource facts remain in the dataset | | `jurisdiction` | No | Reviewed date warnings and the date through which that list has been reviewed | | `analytics` | No | Explicit optional collection boundary, described below | | `public_usage` | No | Enable a local sanitized aggregate file; independent of collection | -| `software_source_url` | No | Public source location; supply before distributing executables | +| `software_source_url` | Required for distribution | Public covered source location; production must identify the exact built revision, including deployed modifications | V1 ships a reviewed English copy pack (`language: "en"`, `text_direction: "ltr"`); other language/direction combinations are represented in the schema but rejected by the runtime capability check until a copy pack exists. `locale` is not a claim that resource text has been translated. Resource and organization content can carry their own language. No English-only locality or country types exist. For French, add `src/copy/fr.ts` against the English pack’s key contract, select copy by language, and add translated content keyed by the same organization/resource IDs. Reserve `/fr/resources/{id}/` and corresponding French directory routes. Keep the existing English root URLs stable. Do not clone application code, automatically translate provider facts, or add hreflang without actual translated pages. This is a documented additive path, not a complete translation framework in v1. -Branding files must live in `site/assets/` and are copied with content-hashed filenames. Supply all three overrides together. Icons must be actual 192×192 and 512×512 PNGs. SVG logos must be passive and self-contained. Optional colors are six-digit hex `primary`, `background`, and `text`; the operator deployment’s accessibility test checks the result. Fonts remain local with their licence. +Branding files must live in the selected folder's `assets/` and are copied with content-hashed filenames. Supply all three overrides together. Icons must be actual 192×192 and 512×512 PNGs. SVG logos must be passive and self-contained. Optional colors are six-digit hex `primary`, `background`, and `text`; the operator deployment's accessibility test checks the result. Fonts remain local with their licence. Replace operator names and contacts as well as artwork when creating an independent community; the software does not imply TGC endorsement. Jurisdiction configuration contains `coverage_through` and `warning_dates` entries with date, note, source URL and checked date. There is no automatic national holiday library. A local warning suppresses an inferred weekly status unless a provider has a confirmed exception for that date. Expired coverage yields uncertainty. Do not assume that a public holiday means a provider is closed. +The directory title derives from `community.name`. `presentation.introduction` overrides the generic introductory sentence. Optional `presentation.category_groups` entries have a unique `id`, a short `label`, and one or more existing taxonomy `categories`. For example, a “Take-home food” button can include both `groceries` and `food_banks_pantries`. Groups without published resources are hidden. Without configured groups, buttons are generated from the categories present. The built-in “All food help” option always includes all published resources, even categories outside the configured groups; do not define an `all` group yourself. Grouping changes discovery, never resource classification or evidence. + +The compact header and help panel use `help_contact.label` and `help_contact.description`; keep the label brief. `operator.statement` supports plain-text line breaks in the footer. These are reviewed public deployment copy, not HTML templates. + +For a production build, pass `--source-revision ` with a configured GitHub `software_source_url`, or pass `--source-url ` for another source host/archive. `FOOD_HELP_SOURCE_REVISION` and the Cloudflare build's `CF_PAGES_COMMIT_SHA` are supported revision inputs. The source location must match the built covered code, including deployed modifications. A current repository homepage alone is not the corresponding source of every release. This is build provenance, not a provider verification date. + ## Optional analytics ```json @@ -49,10 +58,10 @@ Jurisdiction configuration contains `coverage_through` and `warning_dates` entri } ``` -The example is illustrative and is not shipped as an enabled default. The JSON POST body contains only configured static constants and one fixed event name (`page_start`, `call`, `directions`, `source`, `install`, optionally renamed). Do not put credentials, visitor identifiers, personal information or provider identity in constants. An endpoint on another origin must permit the deployment’s CORS request. +The example is illustrative and is not shipped as an enabled default. The default JSON POST body contains only configured static constants and one fixed event name (`page_start`, `call`, `help`, `directions`, `source`, `install`, optionally renamed). Do not put credentials, visitor identifiers, personal information or provider identity in constants. An endpoint on another origin must permit the deployment’s CORS request. Collection requires a production build on the exact canonical origin. -The adapter sends no referrer, cookies, search/filter values, page path, selected provider, location or generated visitor ID. It respects GPC/DNT, a local opt-out or opt-in preference, and offline status. Requests time out without retries, queuing, replay or unload beacons. The endpoint necessarily receives transport information such as an IP address; its logging and retention are the operator’s responsibility. Selecting `opt_out` is an explicit operator policy choice, not a legal-consent conclusion. +The adapter sends no referrer, cookies, search/filter values, page path, selected provider, location or generated visitor ID. It respects GPC/DNT, a local opt-out or opt-in preference, canonical operator suppression, storage failure and offline/background status. Requests time out without retries, queuing, replay or unload beacons. The endpoint necessarily receives transport information such as an IP address; its logging and retention are the operator’s responsibility. Selecting `opt_out` is an explicit operator policy choice, not a legal-consent conclusion. -A later Kingston deployment can map its approved collector’s event names and static deployment fields here. If the collector expects a different wire protocol, supply a separately reviewed generic adapter at this boundary. Do not copy private collector endpoints, historical receipts or collector-specific code/CSP into the platform default. Compatibility with any existing collector must be tested before enabling it. +The generic adapter also supports static `event_payloads`, explicit `content_type`, a same-origin `preference_key`, optional `click_keepalive`, and finite public outreach `attribution` rules. See [the complete analytics contract](analytics.md) for validation, suppression and navigation semantics. Defaults remain independent of any collector. Compatibility and exclusion-safe production verification must be established before enabling a deployment; changing domains does not transfer old browser opt-outs. Keep historical receipts and credentials outside public source. For public totals use `public_usage: {"enabled": true, "file": "usage.json"}`. The file contains `schema_version: 1`, matching `deployment_id`, `period_start`, `period_end`, `generated_at` (calendar dates), and `counts` with exactly `page_starts`, `call_actions`, `directions_actions`, `source_link_actions`, `installation_signals`. Counts are nonnegative safe integers or null. No extra fields, events, breakdowns or identifiers are accepted. An operator prepares and reviews the aggregate before building. The browser never fetches a remote report. `/usage/` stays noindex and out of the sitemap. diff --git a/docs/data.md b/docs/data.md index 579b027..415f866 100644 --- a/docs/data.md +++ b/docs/data.md @@ -1,6 +1,6 @@ # Reviewed resource data -`site/resources.json` is authoritative. Generated `/data/v1/resources.json` must never be edited. Food Help’s `format: "food-help"`, `schema_version: 1` distinguishes it from other projects’ version numbering. +The selected deployment's `resources.json` is authoritative (for Kingston, `deployments/kingston/resources.json`). Generated `/data/v1/resources.json` must never be edited. Food Help's `format: "food-help"`, `schema_version: 1` distinguishes it from other projects' version numbering. Moving a dataset between source folders does not change this contract. The dataset has a stable UUID `dataset_id`, `content_language`, `taxonomy_version: 1`, organizations and resources. Organizations have stable slug IDs, names and optional descriptions, official URLs and content language. A resource references `organization_id`; locations are inline to keep small-directory authoring manageable. Preserve IDs through name changes. Do not recycle withdrawn IDs for unrelated services. V1 URLs use the stable resource ID directly. @@ -36,8 +36,23 @@ Temporary notices include text, start/end dates and evidence references. Dates r Generated public fields are `deployment_id`, `dataset_version` (content digest), `compatibility_id` (deployment/configuration compatibility digest) and `data_updated_on` (latest published resource update). They are forbidden in source. Unreferenced organizations and unpublished resources never enter the public projection. The public dataset carries evidence; it is not an assertion of live availability. V1 limits its serialized size to 5 MB. -## Later Kingston conversion +## Kingston conversion and public-contract boundary Treat the verified Kingston source dataset, not a stale generated public file, as the input. Map legacy meals to `prepared_meals`; review grocery records individually for pantry versus groceries. Preserve stable IDs and evidence, group provider identity into organizations, convert known/unknown access explicitly, and split editorial/service/review status. Retain IANA timezones and explicit schedule uncertainty. Do not increase confidence during conversion. The Food Help format discriminator and new dataset ID make the public-contract transition explicit. -Conversion is a separately reviewed deployment step. The repository includes only a synthetic Kingston-like test fixture. No provider facts, production domain redirects or installed-app migration are performed by this project’s setup command. +The reviewed eight-record Kingston conversion is now included in `deployments/kingston/`; the separate synthetic Kingston-like test fixture remains fictional. Original review dates and uncertainties are preserved. The real dataset has no asserted blanket open-data licence; see [licensing](licensing.md). Setup does not invent or verify provider facts. + +The legacy and Food Help `/data/v1/resources.json` schemas are different despite sharing a path suffix. A website redirect must not silently claim wire compatibility for these machine endpoints. Treat any legacy data retention or contract retirement explicitly in the release's transition record. The [HSDS 3.3 mapping](interoperability.md) is unchanged and remains a design for a future converter, not an implemented export. + +The consolidation comparison of the retained legacy publication and current Kingston source confirms all eight stable IDs. Names, summaries, operator names, locations/coordinates, phone/extensions, official URLs, timezones, guidance, eligibility, limitations, evidence URLs/check dates/notes, review methods/dates, schedule notes/exceptions and temporary notices are preserved through explicit mappings. All verification dates remain 2026-09-04. + +The deliberate representation differences are: + +- Legacy `meals` becomes `prepared_meals`; pantry services move from broad `groceries` to `food_banks_pantries`, while Brit Smith Social Market remains `groceries`. +- Provider identity becomes an organization reference; locations, schedules and evidence become structured records with stable evidence references. +- Legacy `unconfirmed_fields` takes precedence over placeholder booleans and becomes explicit `unknown` access values. Missing information is not promoted to a confirmed yes/no. +- Unconfirmed empty weekly-hour placeholders for Lunch by George and Community Choice Pantry become an unknown schedule, not a claim of seven closed days. Their temporary warnings remain. +- Legacy `verification_due` splits into published editorial status and partially confirmed review state. It is not upgraded to confirmed information. +- The new public dataset adds the `food-help` discriminator, dataset UUID, taxonomy/compatibility identifiers and content digests. The old timestamp-style dataset version and JSON shape remain at the preserved legacy endpoint. + +The comparison is migration evidence, not a new source review or a universal converter. See [legacy transition](legacy-transition.md) for endpoint and worker treatment. diff --git a/docs/deployment.md b/docs/deployment.md index 4ab9b50..299950f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,8 +1,17 @@ # Static deployment and change control -The application artifact is `dist/`. 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. +The application artifact is the selected `dist//` 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. -Before production, review all public facts, data rights, contacts, operator statement, optional analytics policy, source-code availability and canonical origin. Set `example_content: false` only after replacing the fictional dataset. Enable indexing explicitly if appropriate. Run `npm run check`, then `npm run build -- --production`. Review `artifacts/reports/{deployment_id}.json` and retain the complete artifact for rollback. +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 +npm run check -- --site deployments/kingston +npm run build -- --site deployments/kingston --production --source-revision +``` + +Use the exact checked-out revision, not an unrelated SHA. `FOOD_HELP_SOURCE_REVISION` or Cloudflare's `CF_PAGES_COMMIT_SHA` may supply it. A production build uses the configured source repository plus that revision, or an explicit `--source-url` for the corresponding covered source on another host/archive. Verify that recipients can obtain it. See [licensing](licensing.md). + +The build prints a report path below `artifacts/reports//`, keyed by output location. Retain its identities and the complete artifact for rollback before replacing a release. `check` builds into isolated `artifacts/check/` outputs rather than overwriting production `dist/`. Optional `--out` selects a permitted isolated build location; never combine communities into one web root. ## Host requirements @@ -12,20 +21,58 @@ Before production, review all public facts, data rights, contacts, operator stat - Serve the worker as JavaScript at `/service-worker.js` with root scope and no stale CDN override. Keep data and all application files from the same complete release when deploying or rolling back. - Check the actual HTTP response for canonical metadata, CSP, noindex status, MIME types, manifest, data and resource 404 behaviour. A file named `_headers` does nothing on a host that does not understand it. -`dist/_headers` and `_redirects` are convenient declarative adapters for hosts that support that syntax. The structured local build report provides the same exact per-route policies for other hosts. The CSP for each HTML route contains hashes for its generated inline JSON-LD, and the download’s embedded stylesheet; do not replace it with `unsafe-inline`. Do not concatenate a second restrictive CSP that silently blocks those hashes. `scripts/serve.ts` exercises the generated declarations locally. +The selected artifact's `_headers` and `_redirects` are declarative adapters for hosts that support that syntax. The structured local build report provides the same exact per-route policies for other hosts. The CSP for HTML contains hashes for generated inline JSON-LD and the download's embedded stylesheet; do not replace it with `unsafe-inline`. Do not concatenate another CSP that silently blocks those hashes. `scripts/serve.ts` exercises generated declarations locally; actual production responses still require separate verification. + +The Pages adapter in `scripts/lib/headers.ts` emits one common CSP and adds `noindex` for both project and version/branch `pages.dev` aliases. It validates the emitted file against the documented 100-rule and 2,000-character-per-line limits, including indentation and header names. Script and style hashes are kept separate to avoid needless policy growth. A capacity failure requires a reviewed host-adapter change; it must never be worked around by dropping resources or truncating CSP. These limits and hostname patterns belong to the host adapter, not the resource schema or browser application. See [Cloudflare's header syntax and limits](https://developers.cloudflare.com/pages/configuration/headers/). ## Hosting options -Cloudflare Pages is the primary later manual proof path: create or select an authorized static Pages project, upload the whole artifact, verify the temporary origin as noindex, and attach the approved custom hostname/DNS only after authorization. Avoid automatic script injection and conflicting Pages Functions. Check the host’s current `_headers` limits against the generated file, especially as resource counts grow; do not silently truncate policies. No Cloudflare account is accessed by Food Help’s setup/check/build commands. +Cloudflare Pages is the primary documented host. Select an authorized static project, use one community's explicit build command/output, and verify the approved custom hostname and actual response policies. Suppress preview collection and indexing; do not expose an indexable production artifact on a competing preview origin. Avoid automatic script injection and conflicting Pages Functions. Check the host's current `_headers` limits as resource counts grow; do not silently truncate policies. No Cloudflare account is accessed by setup/check/build. Netlify can use the supplied declarations, subject to its current rule semantics. For nginx or Apache, translate the build report’s exact route policies into reviewed server configuration and use ordinary directory indexes and a real 404. For object/static hosting, set content types, response headers and index/error handling through that provider’s configuration. GitHub Pages can serve root-origin static content on a compatible custom domain or user/organization site, but it cannot supply all baseline response headers natively; use an explicitly configured header-capable front end or accept that it does not satisfy the strict-header production acceptance criterion. Repository subpath Pages hosting is outside v1. Do not claim provider-neutral files mean every host has identical security capabilities. The application is portable; host policy remains a deployment obligation. -## Approval and later automation +## Independent release arrangement + +`main` contains shared source and community folders. Each participating hosting project follows only its own explicit `release/` pointer to a reviewed exact commit, with a fixed `--site deployments/` build command and `dist/` output. Release pointers are not forks for application development. Disable automatic builds from ordinary development branches and unrelated preview branches before merging shared changes into a Git-connected repository. + +Validation workflows can discover configured communities. A release must name one community and one exact revision; it must not iterate over all folders. Editing Kingston data, adding a folder, or merging shared software does not authorize or trigger another community's release. Hosting setup and custom hostname creation remain separate explicit operations. + +For the consolidation, retain historical `kingston` and the previous complete artifact until the replacement arrangement is deployed and production-verified. Then retire `kingston` as the active release branch without deleting its history. Record actual hosting state and deployed artifact identity in the private release evidence; this operating document is not a receipt asserting an unperformed transition. + +The release command requires a clean checkout at the specified commit or tag. Preserve any local work before switching revisions. The target must be a direct `deployments/` folder with matching deployment ID and real, reviewed data. + +```sh +npm run release -- --site deployments/kingston --ref +``` + +This runs selected-community checks and a production build, reads the existing remote release pointer, and writes an ignored local release plan under `artifacts/releases/`. It does not publish. Review the printed target, source commit, artifact/report identity and prior release. With applicable publication authorization: + +```sh +npm run release -- --site deployments/kingston --ref --publish +``` + +`--publish` pushes only the selected `release/kingston` pointer using `--force-with-lease`, refusing to overwrite an unexpected remote change. An already matching pointer is a no-op. It does not create a hosting project, configure another community, change DNS or perform a Git merge. The relevant existing Pages project must watch `release/kingston`, run `npm run build -- --site deployments/kingston --production`, and publish `dist/kingston`. The build host supplies `CF_PAGES_COMMIT_SHA` so the generated source link identifies the deployed commit. Verify the resulting host revision and production responses; the push is not proof of successful deployment. + +## Rollback + +Retain one recoverable complete artifact, exact source revision, selected configuration and hosting release identifier per community. Reactivating that community's previous immutable Pages deployment is the fastest recovery when available. Alternatively, preserve local work, check out the former shared-source commit/tag, and run the explicit release command for that target/revision again. Pre-consolidation commits do not contain the new CLI or folder layout; use the retained hosting deployment/artifact for those. Never mix files from separate releases. Keep old covered source available for recipients. + +After rollback, verify actual hashes/headers, HTML, public JSON and offline installation. Existing workers detect the restored release as a controlled update; users may need to accept it. Other communities stay on their own approved releases. A domain redirect is a separate rollback concern: browser-cached permanent redirects cannot be instantly recalled. + +## Approval and hosting changes Local mechanical generation and testing follow approved implementation scope. Provider publication decisions, changing public data rights/analytics policy, external publishing, GitHub creation/push, production deploys, DNS, hostnames and redirects require the operator’s applicable explicit approval. Passing tests is evidence for review, not authorization to act externally. -After the generic artifact is manually proven on an approved host, optional Cloudflare automation may reconcile desired state: find/create the named project, upload an approved artifact, attach a hostname, create or verify authorized DNS, verify HTTPS and run post-deployment smoke tests. It must read current state first, produce a reviewable diff, avoid unrelated records/projects, and record the artifact identity. Tokens remain outside repository files. This automation is deliberately not implemented in v1. +Authorized hosting changes reconcile current state with a named project's desired state. Read the existing settings, report the intended effect, touch only the selected project/hostname, and retain recovery information. Use the narrowest supported rule scope for disabling HTML rewriting, host-managed crawler changes and unwanted analytics injection. Email-obfuscation code is a transformation, not itself analytics; identify scripts before reporting collection. Avoid unrelated zone policies, DNS/mail records and TGC services. Keep tokens and receipts outside repository files. + +## Legacy website transition + +Production verification of the new origin must precede permanent website redirects. Map legacy `/directory` and `/directory.html` to `/directory/`, preserve query strings, and verify applicable HTTP/HTTPS and `www` entry points. Other old links need useful equivalent destinations with no loops or avoidable chains. Retain the old domain and HTTPS and a recoverable legacy release. + +Inspect machine endpoints separately: the two projects' `/data/v1/resources.json` formats differ. Do not redirect the old contract to an incompatible schema while claiming continuity. Inspect existing browser worker registrations even when no one clicked a home-screen install. Give legacy worker, manifest, data and asset routes an explicit compatibility/retirement treatment; preserve old machine data where that is the approved smallest solution. Record the implemented rules and tested exceptions in the transition handoff. A general migration backend is unnecessary. + +For local and actual-origin checks, including phone procedures and analytics evidence, see [verification](verification.md). Do not call a migration complete on the strength of a successful hosting build or homepage screenshot. -The later Kingston proof supplies the real verified dataset and deployment configuration, reuses the visual assets, and optionally enables its approved analytics boundary. Production verification must precede any permanent old-domain path-preserving redirect. There are no existing installed PWA copies requiring a complex migration, so a separate installed-app migration layer is unnecessary. +The [legacy transition helper](legacy-transition.md) prepares a separate static compatibility artifact and retirement worker from an inspected baseline. It does not enable the permanent host redirect; publication remains conditional on the new production verification. diff --git a/docs/interoperability.md b/docs/interoperability.md index 3ee5cdb..0242ebc 100644 --- a/docs/interoperability.md +++ b/docs/interoperability.md @@ -2,6 +2,8 @@ Focused review completed before the v1 data contract, checked September 2026. Food Help keeps a small authoring model and documents an interoperability mapping. It does **not** claim HSDS conformance, ship an HSDS API, or reproduce a subscription taxonomy. +The shared-source/community-folder consolidation preserves this v1 contract and mapping. Select the dataset with `--site`; folder layout and independent releases do not rename fields, generate HSDS output or make the legacy Kingston JSON wire-compatible with Food Help v1. Dataset licensing remains separate from standards mapping; see [licensing](licensing.md). + | Standard or project | Decision | Applied lesson and boundary | | --- | --- | --- | | [Open Referral / HSDS](https://docs.openreferral.org/en/latest/hsds/hsds_faqs.html) | Map/interoperate | Organization, service, location, contact, eligibility, identifiers and metadata are separate concepts. Food Help groups organizations but keeps service locations inline for simpler authoring. | diff --git a/docs/legacy-transition.md b/docs/legacy-transition.md new file mode 100644 index 0000000..c0b0474 --- /dev/null +++ b/docs/legacy-transition.md @@ -0,0 +1,62 @@ +# Retiring a legacy website + +The directory application remains ordinary static software. `scripts/legacy-transition.ts` is a separate, one-time compatibility helper for the inspected legacy `sw.js`/`kfh-app-*`/`kfh-data-v1` contract. It does not configure hosting, enable redirects, publish data or create a migration service. + +## Prepare without changing production + +Preserve the exact current legacy artifact and its source/hosting identities outside public source before proceeding. Verify the new production origin first, including fresh offline installation, hashes, headers, data and source availability. Then generate a separate candidate: + +```sh +node scripts/legacy-transition.ts --from artifacts/baselines/legacy-before-redirect --to https://kingston.food-help.ca --out artifacts/legacy-transition-candidate +``` + +All three arguments are explicit. The target must be an HTTPS root origin. The output must be a separate, empty child of `artifacts/` or `dist/`; the helper refuses to delete/overwrite an existing release. Use a new candidate directory when regenerating. It reads the old precache manifest and copies only compatibility files, preserving their bytes. It never copies the private receipt, previous application HTML, correspondence or source history into the new artifact. + +The generated `/transition-licenses/legacy-provenance.txt` identifies unchanged historical assets and distinguishes their original terms from the new retirement worker's MPL licence. Maintainers preserve original source and release provenance privately. Do not claim that an inaccessible historical repository is public, publish private history, or retroactively apply the current Food Help licence to historical assets. The unchanged font keeps its OFL, and no general data reuse grant is added. The new worker is itself distributed as unminified source; current Food Help's exact covered source is linked from its own licence page. + +The adjacent `.report.json` records retained paths, every output hash, artifact identity and exact legacy-data SHA-256. It is a private handoff report outside the upload directory. Publish only the candidate directory after the new site passes its production gate and the applicable transition authorization is in place. + +## Upload to an existing Pages project + +Use an available, reviewed Wrangler v4 installation; it is an operator deployment tool, not an application dependency. Confirm its version and use account credentials already scoped for editing the existing Pages project. Do not put credentials in commands, source files or the uploaded artifact. The authenticated account must be the one that owns the old domain's project. + +After inspecting the project's current production branch and disabling unintended Git-triggered rebuilds, the upload command is: + +```sh +wrangler pages deploy artifacts/legacy-transition-candidate --project-name --branch --commit-hash --commit-message "Legacy compatibility transition" +``` + +This command changes production when the selected branch is that project's production branch. It does not create a project or alter DNS. Preserve both the original legacy source commit and the transition generator commit in the private release record; the upload's commit label is not proof that the modified worker came from the old baseline commit. Verify the new deployment URL and artifact hashes before attaching/enabling the conditional website redirect rules. + +Cloudflare documents using Wrangler uploads with an existing Git-integrated Pages project after disabling automatic deployments; there is no need to convert the project or create another one. See [Git integration](https://developers.cloudflare.com/pages/get-started/git-integration/) and [Pages deploy command](https://developers.cloudflare.com/workers/wrangler/commands/pages/#pages-deploy). Keep unrelated projects and existing hostname/mail configuration untouched. + +## Machine contracts and retained routes + +The original `/data/v1/resources.json` is copied byte for byte as a dated legacy snapshot. It is **not redirected** to Food Help's incompatible v1 representation. The response exposes a `successor-version` link to the new dataset; this is discovery, not a claim of wire compatibility. Retained old assets, fonts, icons and manifest keep their original bytes and paths so cached documents and old clients are not broken unnecessarily. The legacy manifest's old-origin start URL reaches the website redirect after reconnection. + +The host's website redirect must exclude these paths: + +- Exact: `/sw.js`, `/manifest.webmanifest`, `/favicon.svg`, `/icon-192.png`, `/icon-512.png`, `/fonts.css`, `/simple.css`. +- Prefixes: `/data/`, `/assets/`, `/fonts/`, `/transition-licenses/`. + +An absent file under an excluded prefix must return a real 404, not the new directory or an incompatible dataset. Retain the old domain and HTTPS. Do not touch unrelated DNS or mail records. + +Redirect website URLs permanently to the new origin, preserving query strings. Explicitly map `/directory` and `/directory.html` to `/directory/`; normalize `/index` and `/index.html` to `/`. Verify the relevant HTTP/HTTPS and `www` variants at the host. The generated fallback `index.html` is a simple link with no old application scripts; the external host rule supplies the actual website redirect and query preservation. It is not a copy of the old application. + +## Existing browser workers + +The new `/sw.js` is an unminified retirement worker, distributed in source form with its MPL licence. It installs immediately, claims the old origin's clients, and deletes only `kfh-app-*` plus the exact `kfh-data-v1` cache. It creates no cache and does not delete local storage, unrelated caches or another application's worker registration. Existing privacy preferences are left intact on the old origin; they do not magically transfer across origins. + +For controlled website windows it requests navigation to the same old-origin URL. The active retirement fetch handler returns a 308 to the fixed new origin, including the directory-path mapping and query. It does not depend on a direct cross-origin `WindowClient.navigate()` succeeding, and does not await the navigation inside activation because that navigation's fetch can wait for activation to finish. See the browser's [navigate API](https://developer.mozilla.org/en-US/docs/Web/API/WindowClient/navigate) and [claim API](https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim). + +Navigations to retained machine/asset paths and non-navigation requests are passed through. The retirement worker does not unregister itself: it must remain obtainable on the old URL for returning registered browsers. Keep `/sw.js` served as JavaScript with root worker scope and `Cache-Control: no-store`, without redirects or HTML transformation. A browser returning entirely offline may still show its old cached release until it reconnects and checks for an update; the domain transition cannot remotely update an offline browser. + +No tracking is introduced. Historical compatibility JavaScript is retained as an original asset, but the transition page does not load it. Preserve its applicable historical licensing/source provenance separately from the retirement worker's MPL notice. The supplied OFL continues to cover the unchanged font. + +## Verification and recovery + +Unit tests exercise path/query mapping, reserved routes, narrow cache deletion, retained bytes and refusal to overwrite an artifact. The browser test installs a synthetic legacy worker, opens two controlled tabs, updates to retirement, and verifies the new origin, unchanged old JSON, compatibility routes and preservation of unrelated caches/local storage in Chromium, Firefox and WebKit. Synthetic browser evidence does not replace checking the actual deployed legacy worker and redirect rules. + +After publishing, verify `/sw.js` status/MIME/cache headers and hash, old JSON status/bytes, manifest and representative old assets. Fetch `/transition-licenses/legacy-provenance.txt` and the retirement worker's source and MPL licence without authentication. Check fresh visitors and previously controlled browsers, useful destinations, queries and absence of loops. Confirm that the new origin can again complete fresh offline installation. Record actual results and release identities in the private transition handoff; these instructions alone are not a declaration that redirects are live. + +Retain the previous immutable hosting deployment and full legacy artifact. Reverting hosting and redirect settings can restore server behaviour, but permanent redirects cached by browsers cannot be instantly recalled. Never discard the old-domain certificate or baseline as part of this transition. diff --git a/docs/licensing.md b/docs/licensing.md new file mode 100644 index 0000000..96069f2 --- /dev/null +++ b/docs/licensing.md @@ -0,0 +1,54 @@ +# Licensing and source availability + +The coverage in [NOTICE](../NOTICE.md) and the standard [MPL-2.0](../LICENSE) applies to Food Help software and supporting project material. It does not silently license community datasets or third-party assets. + +## Using and distributing Food Help + +MPL permits use, modification, self-hosting and commercial use. Private use does not itself require public release of every modification. When covered executable code is distributed, recipients must be able to obtain its corresponding covered source and notices. Separately authored files in a larger work do not automatically acquire MPL obligations merely because they accompany covered files; copied covered code requires separate consideration. An operator's data does not become public under the software licence. See [Mozilla's licence](https://www.mozilla.org/en-US/MPL/2.0/) and [FAQ](https://www.mozilla.org/en-US/MPL/2.0/FAQ/) for the terms and file-level explanation. + +A website serves executable JavaScript to its visitors. Its source-availability link must identify that deployed version, including modifications. Pointing to a moving default branch or an older unrelated release is insufficient. Set `software_source_url` to the public source repository, preserve the release's exact commit, and verify the generated licence page links to that commit. If your covered source is not in a public Git repository, provide a corresponding source archive by reasonable means and use the supported source-link configuration. Keep private records and credentials out of that archive; the relevant covered source and build instructions still need to be available. + +Retain `LICENSE`, `NOTICE.md`, applicable SPDX identifiers, font notices and any additional upstream notices in source redistributions. Check the generated `/licenses/`, downloadable directory and distributed licence files before publishing. `package.json` and the lockfile's project metadata identify the software as `MPL-2.0`; `private: true` prevents accidental npm package publication and does not change the licence. + +There is no compulsory CLA, copyright transfer or requirement to buy TGC hosting. Contributors retain rights to their work. Future alternative licensing would require sufficient rights from affected contributors; project maintainership alone does not supply those rights. Names, branding, official domains and commercial services are separate from the software grant. + +## Real community data + +The eight Kingston listings in `deployments/kingston/resources.json` contain reviewed material referencing provider websites, the City of Kingston and 211 Ontario. The available source and local legacy records do not establish a blanket open-data grant covering all imported material. Keep the current statement: no separate open-data licence is asserted for this deployment. Publication of a directory is not a representation that every field can be relicensed. + +The outstanding rights review covers summaries, access/eligibility wording, schedule notes, evidence notes and any extracted third-party compilation material in the records below. Source URLs remain in the authoritative JSON; they are evidence references, not licence receipts. + +| Resource ID | Referenced source publishers | +| --- | --- | +| `marthas-table` | Martha's Table; 211 Ontario | +| `loretta-meal-program` | St. Vincent de Paul Society of Kingston | +| `brit-smith-social-market` | St. Vincent de Paul Society of Kingston; City of Kingston | +| `partners-in-mission` | Partners in Mission Food Bank; 211 Ontario | +| `st-marys-drop-in` | St. Mary's Cathedral; 211 Ontario | +| `lunch-by-george` | Lunch by George; 211 Ontario | +| `salvation-army-pantry` | 211 Ontario; City of Kingston | +| `storehouse-of-hope` | Kingston Alliance; 211 Ontario | + +Recommended next decision: identify operator-authored descriptions and factual arrangements separately from copied expression or licensed compilations; record permissions and applicable terms by affected material; use CC0-1.0 for material the operator can actually dedicate if they approve that grant. If imported material cannot support that grant, retain its separate terms or replace it with independently authored, provider-confirmed wording. Do not add `CC0-1.0` to the whole Kingston configuration before that review. Do not imply that facts, copyrightable descriptions and database rights have identical treatment. + +This consolidation preserves the existing publication and rights statement; it does not conduct a new provider verification or resolve the missing grant. Private permissions/correspondence should stay outside public source, with only an appropriate public attribution or rights statement included. + +## Fictional datasets + +Exampleville (`examples/exampleville`) and the synthetic Kingston-like fixture (`tests/fixtures/kingston-like`) retain their existing [CC0-1.0 dedication](https://creativecommons.org/publicdomain/zero/1.0/). Their contacts and services are fictional and must not be published as real assistance. Copying their shape is encouraged; copying their fictional facts into a real deployment is not a valid source review. + +## Font and artwork provenance + +Atkinson Hyperlegible Next is Copyright 2020–2024 The Atkinson Hyperlegible Next Project Authors. The upright variable WOFF2 (weights 200–800) is 48,188 bytes, unchanged and not subsetted. The reference project records download on 2026-09-04 from the [upstream webfont](https://github.com/googlefonts/atkinson-hyperlegible-next/blob/main/fonts/webfonts/AtkinsonHyperlegibleNext%5Bwght%5D.woff2), Git blob `626791adce3ab62b0a8deb7d778d91a069a65ccc`. + +The local consolidation audit confirmed that the Food Help and legacy font bytes match SHA-256 `abde1ad5cf78b9ac575ef90d991f2e9101eb0b3b6668bde9a00e2e1e27d99afd`, and both full OFL notices match SHA-256 `b5b7a8bc1e0e92be9b64abd8abd92659488733aa5be8d982bc872c36dac73f08`. The [OFL](../src/assets/fonts/OFL.txt) remains separate from MPL and accompanies font distribution. + +The included 192px and 512px bowl/spoon PNGs are byte-identical to the local reference assets. The SVG and base CSS carry forward the owner's design with generic identity and subsequent visual fixes. Available legacy design documentation identifies first-party artwork and the font as the third-party asset. The legacy README reserved application rights; no separate legacy application `LICENSE` or `NOTICE` was present. The owner's Food Help approval supplies the current MPL direction for their material, rather than rewriting an earlier release or assuming rights to third-party contributions. No municipal marks or photography are incorporated. + +The audit examined the present repository, available local reference README/design/font documentation and source assets. It does not claim access to unavailable conversations, private rights contracts or every historical distribution. Preserve any applicable prior grants or notices discovered later. + +## Dependencies and release review + +Locked development tools retain their own licences; do not relabel them MPL. No runtime package dependency is required. Re-check bundled output and upstream notices when adding a dependency or copying code. The [interoperability review](interoperability.md) records design influences; it is not permission to copy those projects' code or data. + +Release review checks the exact source revision, covered source availability, MPL/OFL notices, deployment data-rights statement and absence of private files. A successful schema/build check cannot determine ownership or grant redistribution permission. diff --git a/docs/maintenance.md b/docs/maintenance.md new file mode 100644 index 0000000..aae3771 --- /dev/null +++ b/docs/maintenance.md @@ -0,0 +1,50 @@ +# Maintaining a community + +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. + +## 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. +2. Edit `resources.json`. For a new programme, use a stable unused resource ID and the appropriate organization ID; preserve IDs when renaming an existing service. Use a draft until facts are reviewed. Record category, food-access purpose, practical access, eligibility, limitations, schedules, explicit unknowns and evidence. See [data authoring](data.md). +3. Record actual source-check and verification dates. A migration or software release is not a new verification. Keep the date chronology valid, retain unresolved qualifications, and use a temporary notice when a consequential change is supported. Do not substitute office hours for food-service hours. +4. Review the right to publish contributed descriptions and any imported material. Keep private correspondence and permission receipts outside the repository; publish only appropriate evidence notes and rights statements. +5. Run `npm run check -- --site deployments/kingston` (substitute your folder). Build and preview the same selection. Inspect the affected card, static resource page, simple/print directory, download and JSON. Review practical wording with JavaScript disabled as well. +6. Have the operator approve publication of the reviewed facts. Commit the source changes, then explicitly release that community and revision using [deployment](deployment.md). A commit, a successful check or another community's release does not publish the change. + +Use `withdrawn` or `closed` according to the documented distinction when a service leaves the public directory. Generated pages are removed and unknown routes return a genuine 404. Offline/downloaded copies cannot be reliably recalled; issue a corrected release promptly and keep uncertainty visible. + +For Kingston, [the current review checklist](../deployments/kingston/REVIEW.md) identifies the owner, carried-forward dates and unresolved questions. The review interval in configuration drives overdue warnings; it is not an automatic re-verification or publication schedule. + +## Prepare a new community + +```sh +npm ci +npm run setup -- --site deployments/my-community +npm run check -- --site deployments/my-community +npm run dev -- --site deployments/my-community +``` + +Setup starts from Exampleville. Choose a unique deployment ID and dataset UUID, an intended canonical origin, local timezone/locale, operator and public correction contact. Replace example contacts and records with reviewed local data; do not copy Kingston's facts as a starter. Set optional help numbers, jurisdiction warnings, branding and presentation from reviewed community choices. Keep analytics disabled unless its distinct policy and adapter have been approved. Replace names/assets where needed without implying TGC or another community endorses the deployment. + +Create a concise local `AGENTS.md` with operator publication rules. Document review ownership and a review deadline derived from actual source dates. Establish the dataset's rights statement. Clear `example_content` only after fictional content has been replaced; enable indexing separately when production publication is appropriate. + +Adding the folder creates no public site, DNS record or analytics integration. The production artifact is independent and must be explicitly selected. A synthetic second community is enough to test separation; it is not necessary to create another real public deployment. + +## Build and inspect one artifact + +```sh +npm run build -- --site deployments/my-community +npm run preview -- --site deployments/my-community +``` + +The default output is `dist//`. Preview uses that same folder; do not serve all of `dist/` as one website. A build prints the exact report path. Keep source revision, artifact identity, dataset identity, capability policy and test evidence together in a private release record. See [verification](verification.md) before a production release. + +Use different origins/ports or fresh browser profiles when switching communities locally. Browser storage is origin-scoped in addition to deployment-prefixed cache names. A previously controlled local origin can otherwise show its previous worker until an update is accepted. + +## Upgrade shared software without publishing everyone + +Shared changes go through review on `main` and `npm run check -- --all`. Each operator chooses when to move their community's release pointer to an exact passing source commit. Do not automatically move other communities when one advances. Avoid maintaining divergent application code on community branches; put local choices in their folder and keep release branches as pointers to reviewed shared-source revisions. + +Before each release, preserve the previous complete artifact and source commit. Rollback selects that community's previous complete release, including its worker and dataset; never mix old HTML with new assets. Test and verify the rollback at the deployed origin. It does not rewind another community. Keep the covered source of distributed older releases available. + +Dependency changes require justification, a reviewed lockfile change and the applicable browser/offline checks. Schema or wire-contract changes require an explicit version/migration decision. The documented HSDS mapping is not a shipping exporter and must not be presented as formal interoperability. diff --git a/docs/verification.md b/docs/verification.md index 83b6893..f06da05 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,21 +1,24 @@ # Verification and acceptance -`npm run check` generates every prerequisite, builds the actual `site/` deployment and independent fixtures, type-checks application/scripts and worker, runs Node unit/output tests, then Playwright. It fails if a required engine or assertion fails. Install engines with `npx playwright install chromium firefox webkit` (Linux CI additionally uses `--with-deps`). +`npm run check -- --site deployments/kingston` generates prerequisites, builds the selected deployment and independent fixtures, type-checks application/scripts and worker, runs Node unit/output tests, then Playwright. Substitute another folder to validate it. `npm run check -- --all` discovers configured communities for validation only; it never creates or releases sites. It fails if a required engine or assertion fails. Install engines with `npx playwright install chromium firefox webkit` (Linux CI additionally uses `--with-deps`). The fixtures deliberately differ in community, country, address format, locale and timezone. Exampleville output and generic source are scanned for reference-locality and private-infrastructure leakage. Tests compare configuration and source relationships rather than freezing Kingston strings. Fictional production-mode tests use local generated directories and reserved example hostnames; they are never deployed. +GitHub checks run for pull requests, pushes to `main` and manual dispatch. Change selection limits community-data changes to the relevant configuration; shared changes validate configured communities alongside the neutral example. Checks have no publication step. A release pointer advances only through the explicit target/revision release action, not this validation workflow. + Coverage includes schema strictness, evidence/ID relationships, dates, schedules, DST, overnight intervals, jurisdiction uncertainty, public filtering, source-matched resource pages and JSON-LD, canonical/Open Graph/sitemap relationships, manifest, robots, discovery files, internal links, portable download and unpublished-data exclusion. 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. -Inspect generated screenshots in `artifacts/screenshots/`, Playwright’s HTML report, retained failure traces and `artifacts/reports/{deployment_id}.json`. Browser profiles, reports and all generated output are private disposable artifacts and excluded from source control. +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. ## Definition of done for the generic repository -- An operator can change only `site/`, run setup/check/build, and obtain the complete static deployment. +- An operator can change only a selected community folder, run setup/check/build, and obtain the complete static deployment. - A distinct community passes the same contracts without source edits or locality leakage. +- Independently built outputs and runtime configuration contain only their selected community. A synthetic second community proves that releasing one target does not advance another; no second real public site is needed. - Public facts, resource pages and discovery outputs derive from one reviewed source; no draft/closed sentinel escapes. - Useful HTML remains when JavaScript, network or persistent storage fails. - Offline cold restart, safe updates and rollback have executable evidence. @@ -23,4 +26,29 @@ Automated accessibility checks cannot establish complete accessibility. Before a - Source and data rights are separated, font notices survive, and documentation explains review/deployment obligations. - Public repository/hosting actions are separately approved and manual device/host checks are honestly recorded. -The implementation can meet the local generic acceptance criteria before the separate Kingston production proof. Do not describe an unperformed deployment, phone test or external collector compatibility check as passed. +## Actual-origin release checks + +Keep the exact production artifact and report made by the release command, then run: + +```sh +npm run verify -- --site deployments/kingston +``` + +An explicit `--out` uses that output's report; `--report ` selects an independently retained report. The verifier compares the actual worker and its installation entries with the selected artifact, checks host policies, runs fresh and offline browser stages, and writes private evidence under `artifacts/verification/`. Set `FOOD_HELP_VERIFY_ALIASES` to comma-separated HTTPS Pages aliases when verifying their `noindex` headers. Optional collection is suppressed with browser privacy signals; any external request attempt is reported as a failure. Run this command with browser execution permitted on the operator machine. + +After deployment, verify exact built HTML/assets/worker bytes and MIME types at the approved origin, fresh offline installation and validated last-good data. Test canonical and normalized routes, real 404 status, effective CSP/security headers, worker revalidation policy, crawler files, sitemap and source links. More than one CSP can combine restrictively; inspecting `_headers` alone cannot establish the actual response policy. + +Distinguish host-injected code by purpose. Email obfuscation is an HTML transformation, not itself analytics. Separately observe whether collection code is injected, attempts a request, is blocked, or successfully persists a record. Use only identified, excluded synthetic records when a production collector test is authorized. + +For domain transitions, verify HTTP/HTTPS and applicable `www` variants, useful final destinations, query preservation and no redirect loops. Inspect old worker/manifest/data/asset routes separately; identical JSON path names do not establish identical formats. Keep the old artifact recoverable before a permanent redirect, and record that cached permanent redirects cannot be instantly recalled. + +## Physical-phone procedure + +Physical-device evidence remains pending until an operator records actual results. Desktop mobile sizes and WebKit tests are not a phone installation test. + +1. On an actual Android phone in Chrome, or iPhone in Safari, visit the production origin online in a fresh site context. Wait for the application offline-ready message and visible reviewed listings. Record phone/OS/browser version and date. +2. Install/Add to Home Screen. Open from the new icon, then close both the browser and app, enable airplane mode and launch from the icon again. Confirm the directory, resource detail and simple directory open; exercise a filter. Calls/maps/external sources can require connectivity. +3. Reconnect and refresh. Confirm the directory updates without losing navigation. When a new approved release is available, accept its update and repeat cold offline launch. +4. Inspect enlarged text, keyboard/focus where available and the device's screen-reader announcements. Report exactly what passed or failed; do not advance source-review dates as part of this test. + +The implementation can meet local generic acceptance criteria before production/device proof. Do not describe an unperformed deployment, phone test or external collector reporting check as passed. diff --git a/package.json b/package.json index 5ca1d53..b637d9f 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,10 @@ "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", - "test:e2e": "playwright test", - "check": "node scripts/check.ts" + "test:e2e": "node scripts/browser.ts", + "check": "node scripts/check.ts", + "release": "node scripts/release.ts", + "verify": "node scripts/verify-release.mjs" }, "devDependencies": { "@playwright/test": "1.62.1", diff --git a/schemas/site.schema.json b/schemas/site.schema.json index 57c77f9..883cb9f 100644 --- a/schemas/site.schema.json +++ b/schemas/site.schema.json @@ -160,6 +160,33 @@ } } }, + "presentation": { + "type": "object", + "additionalProperties": false, + "properties": { + "introduction": { "$ref": "#/$defs/text" }, + "category_groups": { + "type": "array", + "minItems": 1, + "maxItems": 12, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "label", "categories"], + "properties": { + "id": { "$ref": "#/$defs/slug" }, + "label": { "type": "string", "minLength": 1, "maxLength": 80 }, + "categories": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z]+(?:_[a-z]+)*$", "maxLength": 80 } + } + } + } + } + } + }, "branding": { "type": "object", "additionalProperties": false, @@ -274,6 +301,9 @@ "call": { "$ref": "#/$defs/text" }, + "help": { + "$ref": "#/$defs/text" + }, "directions": { "$ref": "#/$defs/text" }, @@ -284,6 +314,38 @@ "$ref": "#/$defs/text" } } + }, + "preference_key": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$" }, + "content_type": { "enum": ["application/json", "text/plain;charset=UTF-8"], "type": "string" }, + "click_keepalive": { "type": "boolean" }, + "event_payloads": { + "type": "object", "additionalProperties": false, + "properties": { + "page_start": { "$ref": "#/$defs/eventPayload" }, + "call": { "$ref": "#/$defs/eventPayload" }, + "help": { "$ref": "#/$defs/eventPayload" }, + "directions": { "$ref": "#/$defs/eventPayload" }, + "source": { "$ref": "#/$defs/eventPayload" }, + "install": { "$ref": "#/$defs/eventPayload" } + } + }, + "attribution": { + "type": "object", "additionalProperties": false, + "required": ["events", "sources", "campaigns", "contents", "referrers", "internal_hosts"], + "properties": { + "events": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["page_start", "call", "help", "directions", "source"] } }, + "sources": { "$ref": "#/$defs/analyticsLabels" }, + "campaigns": { "$ref": "#/$defs/analyticsLabels" }, + "contents": { "$ref": "#/$defs/analyticsLabels" }, + "internal_hosts": { "type": "array", "maxItems": 12, "uniqueItems": true, "items": { "$ref": "#/$defs/analyticsHost" } }, + "referrers": { + "type": "array", "maxItems": 24, + "items": { + "type": "object", "additionalProperties": false, "required": ["host", "source"], + "properties": { "host": { "$ref": "#/$defs/analyticsHost" }, "source": { "$ref": "#/$defs/analyticsLabel" }, "include_subdomains": { "type": "boolean" } } + } + } + } } } }, @@ -340,6 +402,10 @@ } }, "$defs": { + "analyticsLabel": { "type": "string", "pattern": "^[a-z0-9_]{1,80}$" }, + "analyticsLabels": { "type": "array", "minItems": 1, "maxItems": 24, "uniqueItems": true, "items": { "$ref": "#/$defs/analyticsLabel" } }, + "analyticsHost": { "type": "string", "pattern": "^[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$" }, + "eventPayload": { "type": "object", "minProperties": 1, "maxProperties": 8, "propertyNames": { "pattern": "^[a-z][a-z0-9_]{0,79}$" }, "additionalProperties": { "type": ["string", "number", "boolean"], "maxLength": 200 } }, "text": { "type": "string", "minLength": 1, diff --git a/scripts/browser.ts b/scripts/browser.ts new file mode 100644 index 0000000..cfe9e50 --- /dev/null +++ b/scripts/browser.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MPL-2.0 +import { spawn } from 'node:child_process'; +import { argumentsFor, selection } from './lib/selection.ts'; +const selected = await selection(argumentsFor()); +const args = process.argv.slice(2).filter((_arg, index, all) => !['--site', '--out'].includes(all[index]!) && !['--site', '--out'].includes(all[index - 1]!)); +const child = spawn(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', ...args], { stdio: 'inherit', env: { ...process.env, FOOD_HELP_TEST_DEPLOYMENTS: JSON.stringify([{ directory: selected.outDir, sourceDir: selected.siteDir, production: false }]) } }); +child.on('error', error => { throw error; }); +child.on('exit', code => { process.exitCode = code ?? 1; }); diff --git a/scripts/build.ts b/scripts/build.ts index 7002f8b..417e711 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -import { readFile, writeFile, mkdir, readdir, rm, copyFile } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, readdir, rm } from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { build as viteBuild } from 'vite'; @@ -7,21 +7,40 @@ import { stripTypeScriptTypes } from 'node:module'; import { generateContracts } from './lib/contracts.ts'; import { digest, pages, document, securityHeaders, type Assets } from './lib/web.ts'; import type { Site, Dataset, PublicDataset, Usage } from '../src/types.ts'; +import { escape as escapeHTML } from '../src/presentation.ts'; +import { argumentsFor, selection } from './lib/selection.ts'; +import { renderHeaders } from './lib/headers.ts'; +import { readText } from './lib/text.ts'; -export type BuildOptions = { siteDir?: string; outDir?: string; production?: boolean }; +export type BuildOptions = { siteDir?: string; outDir?: string; production?: boolean; sourceRevision?: string; sourceUrl?: string }; export async function files(directory: string): Promise { return (await Promise.all((await readdir(directory, { withFileTypes: true })).map(async entry => entry.isDirectory() ? (await files(path.join(directory, entry.name))).map(file => `${entry.name}/${file}`) : [entry.name]))).flat().sort(); } -export async function build(options: BuildOptions = {}): Promise<{ release: string; outDir: string; data: PublicDataset }> { - const root = process.cwd(), siteDir = path.resolve(options.siteDir ?? 'site'), outDir = path.resolve(options.outDir ?? 'dist'), production = options.production ?? false; +export async function build(options: BuildOptions = {}) { + await mkdir('.generated', { recursive: true }); + const lock = path.resolve('.generated/build.lock'); + try { await mkdir(lock); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Another Food Help build is active. Builds are explicitly serialized. If a process crashed, confirm no build is running before removing .generated/build.lock.'); throw error; } + try { return await buildSelected(options); } finally { await rm(lock, { recursive: true, force: true }); } +} +async function buildSelected(options: BuildOptions): Promise<{ release: string; outDir: string; reportPath: string; data: PublicDataset }> { + const root = process.cwd(), { siteDir, outDir } = await selection(options), production = options.production ?? false; // Destructive work is restricted to known generated directories inside this workspace. - if (!(outDir === path.join(root, 'dist') || outDir.startsWith(path.join(root, 'artifacts') + path.sep) || outDir.startsWith(path.join(root, '.generated') + path.sep))) throw new Error('Output must be dist/ or a child of artifacts/ or .generated/'); + if (!['dist', 'artifacts', '.generated'].some(directory => outDir.startsWith(path.join(root, directory) + path.sep)) || outDir === siteDir || siteDir.startsWith(outDir + path.sep) || outDir === path.join(root, '.generated/build.lock')) throw new Error('Output must be a child of dist/, artifacts/ or .generated/ and must not contain source configuration'); await generateContracts(); const validation: typeof import('../src/validation.ts') = await import('../src/validation.ts'); const site: unknown = JSON.parse(await readFile(path.join(siteDir, 'site.json'), 'utf8')); const source: unknown = JSON.parse(await readFile(path.join(siteDir, 'resources.json'), 'utf8')); validation.assertSite(site); validation.assertDataset(source); if (production && (site.example_content || new URL(site.canonical_origin).hostname.endsWith('.invalid'))) throw new Error('Production build refuses fictional example content or an .invalid origin'); + const sourceRevision = options.sourceRevision ?? process.env.FOOD_HELP_SOURCE_REVISION ?? process.env.CF_PAGES_COMMIT_SHA; + if (sourceRevision && !/^[0-9a-f]{40}$/i.test(sourceRevision)) throw new Error('Source revision must be an exact 40-character Git commit SHA'); + let sourceUrl = options.sourceUrl; + if (!sourceUrl && sourceRevision && site.software_source_url) { + const repository = new URL(site.software_source_url); + if (repository.hostname === 'github.com' && /^\/[^/]+\/[^/]+\/?$/.test(repository.pathname)) sourceUrl = `${repository.origin}${repository.pathname.replace(/\/$/, '')}/tree/${sourceRevision}`; + } + if (production && !sourceUrl) throw new Error('Production requires --source-url for the corresponding covered source, or --source-revision with a configured GitHub repository URL'); + if (sourceUrl) { if (new URL(sourceUrl).protocol !== 'https:') throw new Error('Covered source URL must use HTTPS'); site.software_source_url = sourceUrl; } const resources = source.resources.filter(validation.isPublished); const compatibility = digest(JSON.stringify({ deployment_id: site.deployment_id, dataset_id: source.dataset_id, contract: 1, taxonomy: 1, language: site.language, time_zone: site.time_zone, review: site.review, jurisdiction: site.jurisdiction ?? null })); const data: PublicDataset = { ...source, organizations: source.organizations.filter(org => resources.some(r => r.organization_id === org.id)), resources, deployment_id: site.deployment_id, compatibility_id: compatibility, dataset_version: '', data_updated_on: resources.map(r => r.updated_on).sort().at(-1) ?? null }; @@ -29,13 +48,12 @@ export async function build(options: BuildOptions = {}): Promise<{ release: stri if (JSON.stringify(data).length > 5_000_000) throw new Error('v1 supports at most 5 MB of public resource JSON'); let usage: Usage | undefined; if (site.public_usage?.enabled) { const value: unknown = JSON.parse(await readFile(path.join(siteDir, 'usage.json'), 'utf8')); validation.assertUsage(value, site); usage = value; } - await mkdir('.generated', { recursive: true }); - await writeFile('.generated/runtime-config.json', JSON.stringify({ site, compatibility_id: compatibility, data_url: '/data/v1/resources.json' })); + const runtimeConfig = JSON.stringify({ site, compatibility_id: compatibility, data_url: '/data/v1/resources.json', production }); await rm(outDir, { recursive: true, force: true }); await mkdir(outDir, { recursive: true }); - await viteBuild({ configFile: false, publicDir: false, logLevel: 'warn', build: { outDir, emptyOutDir: false, manifest: true, sourcemap: false, rollupOptions: { input: path.join(root, 'src/main.ts'), output: { entryFileNames: 'assets/app-[hash].js', assetFileNames: 'assets/[name]-[hash][extname]' } } } }); + await viteBuild({ configFile: false, publicDir: false, logLevel: 'warn', plugins: [{ name: 'food-help-selected-config', resolveId(id) { if (id === 'virtual:food-help-config') return '\0food-help-config'; }, load(id) { if (id === '\0food-help-config') return `export default ${runtimeConfig};`; } }], build: { outDir, emptyOutDir: false, manifest: true, sourcemap: false, rollupOptions: { input: path.join(root, 'src/main.ts'), output: { entryFileNames: 'assets/app-[hash].js', assetFileNames: 'assets/[name]-[hash][extname]' } } } }); const manifest = JSON.parse(await readFile(path.join(outDir, '.vite/manifest.json'), 'utf8')) as Record; const entry = Object.values(manifest).find(item => item.isEntry)!; - const asset = async (sourceFile: string, name: string): Promise => { const content = await readFile(sourceFile); const file = `/assets/${name}-${digest(content).slice(0, 16)}${path.extname(sourceFile)}`; await writeFile(path.join(outDir, file), content); return file; }; + const asset = async (sourceFile: string, name: string): Promise => { const content = sourceFile.endsWith('.svg') ? Buffer.from(await readText(sourceFile)) : await readFile(sourceFile); const file = `/assets/${name}-${digest(content).slice(0, 16)}${path.extname(sourceFile)}`; await writeFile(path.join(outDir, file), content); return file; }; const branding = site.branding; if (branding?.logo) { for (const [file, size] of [[branding.icon_192!, 192], [branding.icon_512!, 512]] as const) { @@ -56,12 +74,15 @@ export async function build(options: BuildOptions = {}): Promise<{ release: stri if (!(production && !site.example_content && site.indexing?.enabled && page.indexable)) headers[page.path]!['X-Robots-Tag'] = 'noindex, follow'; routes[page.path] = file; routes[file] = file; headers[file] = headers[page.path]!; - if (page.path !== '/' && page.path.endsWith('/')) routes[page.path.slice(0, -1)] = file; + if (page.path !== '/' && page.path.endsWith('/')) { routes[page.path.slice(0, -1)] = file; headers[page.path.slice(0, -1)] = headers[page.path]!; } + if (page.path.endsWith('.html')) { routes[page.path.slice(0, -5)] = file; headers[page.path.slice(0, -5)] = headers[page.path]!; } } const simple = outputPages.find(page => page.path === '/directory/')!; - const standaloneCSS = (await readFile('src/simple.css', 'utf8')) + `\n@font-face{font-family:"Atkinson Hyperlegible Next";src:url(data:font/woff2;base64,${(await readFile('src/assets/fonts/atkinson-hyperlegible-next.woff2')).toString('base64')}) format("woff2");font-weight:200 800;font-display:swap}.site-footer{margin-top:2rem}.skip-link{display:none}[hidden]{display:none}`; - const download = document(simple, site, assets, false, standaloneCSS).replace(/href="\/(?!\/)/g, `href="${site.canonical_origin}/`).replace(/]*>/g, ''); + const standaloneCSS = (await readText('src/simple.css')) + `\n@font-face{font-family:"Atkinson Hyperlegible Next";src:url(data:font/woff2;base64,${(await readFile('src/assets/fonts/atkinson-hyperlegible-next.woff2')).toString('base64')}) format("woff2");font-weight:200 800;font-display:swap}.site-footer{margin-top:2rem}.skip-link{display:none}[hidden]{display:none}.font-license pre{white-space:pre-wrap;overflow-wrap:anywhere}@media print{.font-license{display:none}}`; + const licenseAppendix = `
Embedded font licence
${escapeHTML(await readText('src/assets/fonts/OFL.txt'))}
`; + const download = document(simple, site, assets, false, standaloneCSS).replace('', `${licenseAppendix}`).replace(/href="\/(?!\/)/g, `href="${site.canonical_origin}/`).replace(/]*>/g, ''); await write('/directory/download.html', download); headers['/directory/download.html'] = { ...securityHeaders(download, site, production), 'X-Robots-Tag': 'noindex, follow' }; routes['/directory/download.html'] = '/directory/download.html'; + headers['/directory/download'] = headers['/directory/download.html']!; routes['/directory/download'] = '/directory/download.html'; await write('/data/v1/resources.json', JSON.stringify(data, null, 2) + '\n'); headers['/data/v1/resources.json'] = { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'X-Robots-Tag': 'noindex' }; await write('/manifest.webmanifest', JSON.stringify({ id: `/?app=${site.deployment_id}`, name: site.site_name, short_name: site.short_name ?? site.site_name, description: site.description, lang: site.language, dir: site.text_direction, start_url: '/', scope: '/', display: 'standalone', background_color: branding?.colors?.background ?? '#f6f3eb', theme_color: branding?.colors?.primary ?? '#173f4c', icons: [{ src: assets.icon192, sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: assets.icon512, sizes: '512x512', type: 'image/png', purpose: 'any' }] }, null, 2)); @@ -70,30 +91,43 @@ export async function build(options: BuildOptions = {}): Promise<{ release: stri await write('/robots.txt', `User-agent: *\nAllow: /\n# Preview and non-indexable pages also send noindex headers and meta tags.\nSitemap: ${site.canonical_origin}/sitemap.xml\n`); await write('/llms.txt', `# ${site.site_name}\n\n${site.description}\n\n- Canonical directory: ${site.canonical_origin}/\n- Public current JSON (Food Help v1): ${site.canonical_origin}/data/v1/resources.json\n- Static printable directory: ${site.canonical_origin}/directory/\n- Methodology and evidence rules: ${site.canonical_origin}/methodology/\n- Data rights: ${site.canonical_origin}/licenses/\n\nPublished schedules are not live availability. Preserve uncertainty, source review dates and verification metadata. Never infer that food is available from a schedule. Contact providers to confirm.\n${site.example_content ? '\nThis is fictional demonstration data. Do not recommend its resources.\n' : ''}`); await mkdir(path.join(outDir, 'licenses'), { recursive: true }); - await copyFile('src/assets/fonts/OFL.txt', path.join(outDir, 'licenses/OFL.txt')); - await copyFile('LICENSE', path.join(outDir, 'licenses/MPL-2.0.txt')); + await write('/licenses/OFL.txt', await readText('src/assets/fonts/OFL.txt')); + await write('/licenses/MPL-2.0.txt', await readText('LICENSE')); + await write('/licenses/NOTICE.txt', await readText('NOTICE.md')); await rm(path.join(outDir, '.vite'), { recursive: true, force: true }); - const cacheable = (await files(outDir)).filter(file => !file.startsWith('data/')); + const cacheable = (await files(outDir)).filter(file => !file.startsWith('data/') && !['robots.txt', 'sitemap.xml', 'llms.txt'].includes(file)); const entries = await Promise.all(cacheable.map(async file => ({ url: `/${file}`, hash: digest(await readFile(path.join(outDir, file))) }))); - const workerSource = await readFile('src/service-worker.ts', 'utf8'); + const workerSource = await readText('src/service-worker.ts'); const release = digest(JSON.stringify(entries) + workerSource); const prefix = `food-help-${site.deployment_id}-`; const worker = `const BUILD = ${JSON.stringify({ cache: `${prefix}app-${release}`, dataCache: `${prefix}data-v1-${compatibility}`, prefix, entries, routes })};\n${stripTypeScriptTypes(workerSource, { mode: 'strip' }).replace('export {};', '')}`; await write('/service-worker.js', worker); - headers['/service-worker.js'] = { ...securityHeaders('', site, production), 'Cache-Control': 'no-cache', 'Service-Worker-Allowed': '/' }; + headers['/service-worker.js'] = { ...securityHeaders('', site, production), 'Cache-Control': 'no-store, max-age=0', 'Service-Worker-Allowed': '/' }; const common = securityHeaders('', site, production); - const headerText = ['/*', ...Object.entries(common).filter(([k]) => k !== 'Content-Security-Policy').map(([k, v]) => ` ${k}: ${v}`), ...(production && site.indexing?.enabled ? [] : [' X-Robots-Tag: noindex, follow']), '', ...Object.entries(headers).flatMap(([route, values]) => [route, ...Object.entries(values).filter(([key, value]) => key === 'Content-Security-Policy' || common[key] !== value).map(([key, value]) => ` ${key}: ${value}`), ''])].join('\n'); - // Exact HTML routes own CSP; host adapters must replace, never concatenate policy values. + // One policy covers canonical URLs, normalized paths and unknown/error routes. + // Only hashes from generated inline content are added; no unsafe-inline or conflicting CSP rules. + const directives = new Map>(); + for (const policy of [common['Content-Security-Policy']!, ...Object.values(headers).map(values => values['Content-Security-Policy']).filter((policy): policy is string => Boolean(policy))]) { + for (const directive of policy.split(';').map(value => value.trim()).filter(Boolean)) { const [name, ...values] = directive.split(/\s+/); const allowed = directives.get(name!) ?? new Set(); values.forEach(value => allowed.add(value)); directives.set(name!, allowed); } + } + common['Content-Security-Policy'] = [...directives].map(([name, values]) => `${name} ${[...values].join(' ')}`).join('; '); + common['Cache-Control'] = 'no-cache'; + if (!(production && site.indexing?.enabled)) common['X-Robots-Tag'] = 'noindex, follow'; + for (const values of Object.values(headers)) values['Content-Security-Policy'] = common['Content-Security-Policy']; + const headerText = renderHeaders(common, headers); await write('/_headers', headerText); await write('/_redirects', outputPages.filter(page => page.path !== '/' && page.path.endsWith('/')).map(page => `${page.path.slice(0, -1)} ${page.path} 301`).join('\n') + '\n'); - const reportDir = path.resolve('artifacts/reports'); await mkdir(reportDir, { recursive: true }); - const report = { deployment_id: site.deployment_id, release, dataset_version: data.dataset_version, compatibility_id: compatibility, production, resources: data.resources.length, routes, indexable: indexable.map(page => page.path), headers, common_headers: common, output: outDir, file_hashes: Object.fromEntries(await Promise.all((await files(outDir)).map(async file => [file, digest(await readFile(path.join(outDir, file)))]))) }; - await writeFile(path.join(reportDir, `${site.deployment_id}.json`), JSON.stringify(report, null, 2)); + const reportDir = path.resolve('artifacts/reports', site.deployment_id); await mkdir(reportDir, { recursive: true }); + const reportPath = path.join(reportDir, `${digest(outDir).slice(0, 16)}.json`); + const report = { deployment_id: site.deployment_id, site_directory: siteDir, source_revision: sourceRevision ?? null, source_url: site.software_source_url ?? null, release, dataset_version: data.dataset_version, compatibility_id: compatibility, production, resources: data.resources.length, routes, indexable: indexable.map(page => page.path), headers, common_headers: common, output: outDir, file_hashes: Object.fromEntries(await Promise.all((await files(outDir)).map(async file => [file, digest(await readFile(path.join(outDir, file)))]))) }; + await writeFile(reportPath, JSON.stringify(report, null, 2)); console.log(`Built ${site.deployment_id}: ${resources.length} resources, release ${release.slice(0, 16)} → ${outDir}`); - return { release, outDir, data }; + console.log(`Build report: ${reportPath}`); + return { release, outDir, reportPath, data }; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const arg = (name: string) => { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; }; - await build({ siteDir: arg('--site'), outDir: arg('--out'), production: process.argv.includes('--production') }); - if (process.argv.includes('--dev')) { const { serve } = await import('./serve.ts'); await serve({ watch: true }); } + const options = argumentsFor(); + if (process.argv.includes('--dev') && options.production) throw new Error('Development rebuilds are previews. Use build --production for a release artifact.'); + const result = await build(options); + if (process.argv.includes('--dev')) { const { serve } = await import('./serve.ts'); await serve({ root: result.outDir, siteDir: options.siteDir, watch: true, port: options.port ? Number(options.port) : undefined }); } } diff --git a/scripts/check.ts b/scripts/check.ts index a7df371..8d958f1 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: MPL-2.0 import { spawn } from 'node:child_process'; -import { readFile, writeFile, mkdir, copyFile } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, copyFile, readdir, rm } from 'node:fs/promises'; import { build } from './build.ts'; +import { digest } from './lib/web.ts'; +import { argumentsFor, starterSite } from './lib/selection.ts'; +const options = argumentsFor(); +if (process.argv.includes('--all') && options.siteDir) throw new Error('Use either --all or --site, not both'); +if (options.outDir || options.production) throw new Error('Check makes isolated preview outputs; use build for production or an explicit output path'); 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})`))); @@ -11,18 +16,34 @@ async function variant(folder: string, patch: Record) { const site = { ...JSON.parse(await readFile('examples/exampleville/site.json', 'utf8')), ...patch }; await writeFile(`${folder}/site.json`, JSON.stringify(site, null, 2)); await copyFile('examples/exampleville/resources.json', `${folder}/resources.json`); } -await build({ siteDir: 'examples/exampleville', outDir: 'artifacts/exampleville' }); +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 untouchedFiles = JSON.parse(await readFile(independent.reportPath, 'utf8')).file_hashes as Record; await build({ siteDir: 'tests/fixtures/kingston-like', outDir: 'artifacts/kingston-like' }); await variant('artifacts/production-site', { example_content: false, canonical_origin: 'https://directory.example.test', indexing: { enabled: true } }); -await build({ siteDir: 'artifacts/production-site', outDir: 'artifacts/production', production: true }); +await build({ siteDir: 'artifacts/production-site', outDir: 'artifacts/production', production: true, sourceUrl: 'https://source.example.test/fixture/' }); await variant('artifacts/release-b-site', { site_name: 'Exampleville Food Help updated' }); await build({ siteDir: 'artifacts/release-b-site', outDir: 'artifacts/release-b' }); -await variant('artifacts/analytics-site', { analytics: { enabled: true, endpoint: 'https://metrics.example.invalid/events', collection_mode: 'opt_in', disclosure: 'Fictional test endpoint, used only by automated tests.', constants: { deployment: 'exampleville' } }, public_usage: { enabled: true, file: 'usage.json' } }); +await variant('artifacts/analytics-site', { example_content: false, canonical_origin: 'https://analytics.example.test', analytics: { enabled: true, endpoint: 'https://metrics.example.invalid/events', collection_mode: 'opt_in', disclosure: 'Fictional test endpoint, used only by automated tests.', constants: { deployment: 'exampleville' } }, public_usage: { enabled: true, file: 'usage.json' } }); await writeFile('artifacts/analytics-site/usage.json', JSON.stringify({ schema_version: 1, deployment_id: 'exampleville', period_start: '2026-08-01', period_end: '2026-08-31', generated_at: '2026-09-01', counts: { page_starts: 10, call_actions: null, directions_actions: 2, source_link_actions: 3, installation_signals: 1 } })); -await build({ siteDir: 'artifacts/analytics-site', outDir: 'artifacts/analytics' }); -await build(); +await build({ siteDir: 'artifacts/analytics-site', outDir: 'artifacts/analytics', production: true, sourceUrl: 'https://source.example.test/fixture/' }); +const sites = process.argv.includes('--all') ? [starterSite, ...(await readdir('deployments', { withFileTypes: true }).catch(() => [])).filter(entry => entry.isDirectory()).map(entry => `deployments/${entry.name}`)].sort() : [options.siteDir ?? starterSite]; +const selected = [], ids = new Set(); +for (const [index, siteDir] of sites.entries()) { + const result = await build({ siteDir, outDir: `artifacts/check/selected-${index}` }); + if (ids.has(result.data.deployment_id)) throw new Error(`Duplicate deployment_id in selected communities: ${result.data.deployment_id}`); + ids.add(result.data.deployment_id); + selected.push({ directory: result.outDir, sourceDir: siteDir, production: false }); +} +process.env.FOOD_HELP_TEST_DEPLOYMENTS = JSON.stringify(selected); +for (const [file, expected] of Object.entries(untouchedFiles)) { + if (digest(await readFile(`${independent.outDir}/${file}`)) !== expected) throw new Error(`A different deployment build contaminated the independent Exampleville artifact: ${file}`); +} await run('node_modules/typescript/bin/tsc', ['--noEmit']); await run('node_modules/typescript/bin/tsc', ['-p', 'tsconfig.worker.json']); await run('--test', ['tests/unit/*.test.ts', 'tests/contracts/*.test.ts']); await run('node_modules/@playwright/test/cli.js', ['test']); console.log('PASS: structurally safe and internally consistent enough for publication review. Provider facts, rights and deployment still require human review.'); +} finally { await rm('.generated/check.lock', { recursive: true, force: true }); } diff --git a/scripts/legacy-transition.ts b/scripts/legacy-transition.ts new file mode 100644 index 0000000..cc38e9b --- /dev/null +++ b/scripts/legacy-transition.ts @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// One-time static compatibility helper for the legacy directory contract, not application routing. +import { createHash } from 'node:crypto'; +import { lstat, mkdir, readFile, readdir, realpath, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const legacyFiles = ['/sw.js', '/manifest.webmanifest', '/favicon.svg', '/icon-192.png', '/icon-512.png', '/fonts.css', '/simple.css']; +export const legacyPrefixes = ['/data/', '/assets/', '/fonts/', '/transition-licenses/']; +export function retainedLegacyPath(pathname: string): boolean { + return legacyFiles.includes(pathname) || legacyPrefixes.some(prefix => pathname.startsWith(prefix)); +} +export function transitionDestination(oldUrl: string, toOrigin: string): string | null { + const source = new URL(oldUrl); + if (retainedLegacyPath(source.pathname)) return null; + const target = new URL(toOrigin); + target.pathname = ['/directory', '/directory.html'].includes(source.pathname) ? '/directory/' : ['/index', '/index.html'].includes(source.pathname) ? '/' : source.pathname; + target.search = source.search; target.hash = source.hash; + return target.href; +} +export function retirementWorker(toOrigin: string): string { + return `// SPDX-License-Identifier: MPL-2.0 +// Source is this unminified worker; licence: /transition-licenses/MPL-2.0.txt. +// Retained compatibility bundle provenance: /transition-licenses/legacy-provenance.txt. +// Retires only the legacy application's caches. It creates no cache and collects nothing. +const TO_ORIGIN = ${JSON.stringify(toOrigin)}; +const RETAINED_FILES = ${JSON.stringify(legacyFiles)}; +const RETAINED_PREFIXES = ${JSON.stringify(legacyPrefixes)}; +const retained = pathname => RETAINED_FILES.includes(pathname) || RETAINED_PREFIXES.some(prefix => pathname.startsWith(prefix)); +function destination(url) { + const next = new URL(TO_ORIGIN); + next.pathname = ['/directory', '/directory.html'].includes(url.pathname) ? '/directory/' : ['/index', '/index.html'].includes(url.pathname) ? '/' : url.pathname; + next.search = url.search; next.hash = url.hash; + return next.href; +} +self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); +self.addEventListener('activate', event => event.waitUntil((async () => { + await Promise.all((await caches.keys()).filter(key => key.startsWith('kfh-app-') || key === 'kfh-data-v1').map(key => caches.delete(key))); + await self.clients.claim(); + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + // Navigate within the old origin first; the new active fetch handler supplies the redirect. + // WindowClient.navigate must not rely on a direct cross-origin navigation succeeding. + windows.filter(client => { + const url = new URL(client.url); + return url.origin === self.location.origin && !retained(url.pathname); + }).forEach(client => { client.navigate(client.url).catch(() => {}); }); + // Do not await navigation in activate: fetch may wait for activation to finish. +})())); +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + if (event.request.method !== 'GET' || event.request.mode !== 'navigate' || url.origin !== self.location.origin || retained(url.pathname)) return; + event.respondWith(Promise.resolve(Response.redirect(destination(url), 308))); +}); +`; +} + +const hash = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex'); +const within = (child: string, parent: string) => child.startsWith(parent + path.sep); +async function noSymlinkPath(target: string, boundary: string) { + let current = boundary; + for (const segment of path.relative(boundary, target).split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + try { if ((await lstat(current)).isSymbolicLink()) throw new Error('Transition paths must not contain symlinks'); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + } +} +export async function buildLegacyTransition(options: { fromDir: string; toOrigin: string; outDir: string }) { + const target = new URL(options.toOrigin); + if (target.protocol !== 'https:' || target.username || target.password || target.pathname !== '/' || target.search || target.hash) throw new Error('Transition destination must be a dedicated HTTPS origin'); + const workspace = await realpath(process.cwd()), fromDir = path.resolve(options.fromDir), outDir = path.resolve(options.outDir); + if (!['artifacts', 'dist'].some(parent => within(outDir, path.join(workspace, parent))) || outDir === fromDir || within(outDir, fromDir) || within(fromDir, outDir)) throw new Error('Transition output must be a separate child of artifacts/ or dist/'); + await noSymlinkPath(outDir, workspace); + const sourceRoot = await realpath(fromDir); + const worker = await readFile(path.join(sourceRoot, 'sw.js'), 'utf8'); + if (!worker.includes('kfh-app-') || !worker.includes('kfh-data-v1')) throw new Error('Source is not the recognized legacy worker/cache contract'); + const manifestMatch = worker.match(/const PRECACHE_URLS\s*=\s*(\[[\s\S]*?\]);/); + if (!manifestMatch) throw new Error('The preserved legacy precache list is required'); + const precache: unknown = JSON.parse(manifestMatch[1]!); + if (!Array.isArray(precache) || precache.some(value => typeof value !== 'string')) throw new Error('Invalid legacy precache list'); + const dataBytes = await readFile(path.join(sourceRoot, 'data/v1/resources.json')); + const legacyData = JSON.parse(dataBytes.toString('utf8')) as { schema_version?: number; format?: string; resources?: unknown[] }; + if (legacyData.schema_version !== 1 || legacyData.format === 'food-help' || !Array.isArray(legacyData.resources)) throw new Error('Preserve the legacy JSON contract, not a Food Help projection'); + const copyPaths = [...new Set(['/data/v1/resources.json', ...legacyFiles.filter(file => file !== '/sw.js'), ...(precache as string[]).filter(retainedLegacyPath)])].sort(); + const content = new Map(); + for (const url of copyPaths) { + if (url.includes('\\') || url.includes('?') || url.includes('#') || url.split('/').some(segment => segment === '..' || segment === '.')) throw new Error('Unsafe legacy asset path'); + const source = path.resolve(sourceRoot, `.${url}`); + if (!within(source, sourceRoot)) throw new Error('Legacy asset escapes source artifact'); + await noSymlinkPath(source, sourceRoot); + content.set(url, await readFile(source)); + } + // Refuse a nonempty destination rather than deleting a baseline or mixing two releases. + try { if ((await readdir(outDir)).length) throw new Error('Transition output must be empty; choose a new artifact directory'); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + await mkdir(outDir, { recursive: true }); + const write = async (url: string, bytes: string | Uint8Array) => { const file = path.join(outDir, url); await mkdir(path.dirname(file), { recursive: true }); await writeFile(file, bytes, { flag: 'wx' }); }; + for (const [url, bytes] of content) await write(url, bytes); + await write('/sw.js', retirementWorker(target.origin)); + const escape = (text: string) => text.replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!); + const fallback = `Directory moved

This directory has moved

Open the current food directory.

The old JSON endpoint is a retained legacy snapshot with a different format. It is not a current availability feed.

Transition software licence · Retained legacy asset notices · Current source and data notices

`; + await write('/index.html', fallback); await write('/404.html', fallback); + await write('/robots.txt', `User-agent: *\nAllow: /\nSitemap: ${target.origin}/sitemap.xml\n`); + for (const [source, destination] of [['LICENSE', 'MPL-2.0.txt'], ['NOTICE.md', 'NOTICE.txt'], ['src/assets/fonts/OFL.txt', 'OFL.txt']]) await write(`/transition-licenses/${destination}`, await readFile(source!)); + await write('/transition-licenses/legacy-provenance.txt', `Retained legacy asset provenance\n\nThe compatibility JavaScript, styles, icons, manifest and resource JSON are an\nunchanged historical snapshot retained for existing browser clients. The current\nFood Help MPL-2.0 licence does not relicense these historical assets or rewrite\ntheir original terms. Existing copyright and third-party rights remain applicable.\nThe original source and release provenance are retained privately by maintainers;\nthis notice makes no claim that the historical source is publicly available.\n\nThe unchanged bundled font remains covered by /transition-licenses/OFL.txt.\nNo general reuse licence is granted for provider material or the resource data.\nThe new retirement worker is separately served as unminified source at /sw.js\nunder MPL-2.0; its licence is /transition-licenses/MPL-2.0.txt. Current Food Help\nsource and data notices are available at ${target.origin}/licenses/.\n`); + await write('/_headers', `/*\n Cache-Control: no-store\n X-Content-Type-Options: nosniff\n Referrer-Policy: no-referrer\n X-Frame-Options: DENY\n X-Robots-Tag: noindex, follow\n Content-Security-Policy: default-src 'none'; script-src 'self'; worker-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'\n/sw.js\n Service-Worker-Allowed: /\n/data/v1/resources.json\n Access-Control-Allow-Origin: *\n Link: <${target.origin}/data/v1/resources.json>; rel="successor-version"\n`); + const artifactFiles: string[] = []; + const walk = async (directory: string) => { for (const entry of await readdir(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) await walk(file); else artifactFiles.push(path.relative(outDir, file).split(path.sep).join('/')); } }; + await walk(outDir); artifactFiles.sort(); + const fileHashes = Object.fromEntries(await Promise.all(artifactFiles.map(async file => [file, hash(await readFile(path.join(outDir, file)))]))); + const report = { to_origin: target.origin, legacy_data_sha256: hash(dataBytes), legacy_resource_count: legacyData.resources.length, retained_paths: copyPaths, excluded_from_website_redirects: { exact: legacyFiles, prefixes: legacyPrefixes }, worker_cache_deletions: { prefixes: ['kfh-app-'], exact: ['kfh-data-v1'] }, file_hashes: fileHashes, artifact_sha256: hash(JSON.stringify(fileHashes)), requires_external_website_redirect: true }; + const reportPath = `${outDir}.report.json`; + await writeFile(reportPath, JSON.stringify(report, null, 2) + '\n', { flag: 'wx' }); + return { outDir, reportPath, ...report }; +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const value = (flag: string) => { const index = process.argv.indexOf(flag), result = index < 0 ? undefined : process.argv[index + 1]; if (!result || result.startsWith('--')) throw new Error(`Explicit ${flag} is required`); return result; }; + console.log(JSON.stringify(await buildLegacyTransition({ fromDir: value('--from'), toOrigin: value('--to'), outDir: value('--out') }), null, 2)); +} diff --git a/scripts/lib/contracts.ts b/scripts/lib/contracts.ts index 32ca2b8..28952c0 100644 --- a/scripts/lib/contracts.ts +++ b/scripts/lib/contracts.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, rename } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import Ajv from 'ajv/dist/2020.js'; import standaloneCode from 'ajv/dist/standalone/index.js'; @@ -7,6 +8,7 @@ import { compile } from 'json-schema-to-typescript'; export async function generateContracts(): Promise { await mkdir('.generated', { recursive: true }); + const atomicWrite = async (file: string, content: string) => { const temporary = `${file}.${randomUUID()}.tmp`; await writeFile(temporary, content); await rename(temporary, file); }; const site = JSON.parse(await readFile('schemas/site.schema.json', 'utf8')); const data = JSON.parse(await readFile('schemas/resources.schema.json', 'utf8')); const ajv = new Ajv({ strict: true, allowUnionTypes: true, allErrors: true, code: { source: true, esm: true }, inlineRefs: true }); @@ -19,9 +21,9 @@ export async function generateContracts(): Promise { const standalone = code.replaceAll('require("ajv/dist/runtime/ucs2length").default', '((value) => Array.from(value).length)') .replaceAll('require("ajv/dist/runtime/equal").default', '(function equal(a, b) { if (a === b) return true; if (!a || !b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a) !== Array.isArray(b)) return false; const keys = Object.keys(a); return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && equal(a[key], b[key])); })'); if (standalone.includes('require(')) throw new Error(`New schema runtime helper needs explicit bundling review: ${standalone.match(/require\([^)]*\)/g)?.join(', ')}`); - await writeFile('.generated/validators.js', standalone); - await writeFile('.generated/validators.d.ts', 'type Validator = ((value: unknown) => boolean) & { errors?: unknown };\nexport const validateSite: Validator, validateSource: Validator, validatePublic: Validator, validateUsage: Validator;\n'); - await writeFile('.generated/site.d.ts', await compile(site, 'Site', { bannerComment: '// Generated from site.schema.json; do not edit.' })); - await writeFile('.generated/dataset.d.ts', await compile(data, 'Dataset', { bannerComment: '// Generated from resources.schema.json; do not edit.' })); + await atomicWrite('.generated/validators.js', standalone); + await atomicWrite('.generated/validators.d.ts', 'type Validator = ((value: unknown) => boolean) & { errors?: unknown };\nexport const validateSite: Validator, validateSource: Validator, validatePublic: Validator, validateUsage: Validator;\n'); + await atomicWrite('.generated/site.d.ts', await compile(site, 'Site', { bannerComment: '// Generated from site.schema.json; do not edit.' })); + await atomicWrite('.generated/dataset.d.ts', await compile(data, 'Dataset', { bannerComment: '// Generated from resources.schema.json; do not edit.' })); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await generateContracts(); diff --git a/scripts/lib/headers.ts b/scripts/lib/headers.ts new file mode 100644 index 0000000..a4add3c --- /dev/null +++ b/scripts/lib/headers.ts @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: MPL-2.0 +// This file is the static-host adapter; application code has no provider dependency. +// Verified against https://developers.cloudflare.com/pages/configuration/headers/. +export const aliasRules = ['https://:project.pages.dev/*', 'https://:version.:project.pages.dev/*'] as const; + +export function assertPagesHeaders(text: string): void { + const lines = text.split(/\r?\n/); + const rules = lines.filter(line => line.trim() && !/^\s|#/.test(line)); + if (rules.length > 100) throw new Error(`Cloudflare Pages _headers supports at most 100 rules; generated ${rules.length}. Review the host adapter rather than dropping resource facts or security policy.`); + for (const [index, line] of lines.entries()) { + if ([...line].length > 2000) throw new Error(`Cloudflare Pages _headers line ${index + 1} exceeds 2000 characters (${[...line].length}). Review the host adapter for this directory size; do not truncate CSP or omit resources.`); + } +} + +export function renderHeaders(common: Record, headers: Record>): string { + const lines = ['/*', ...Object.entries(common).map(([key, value]) => ` ${key}: ${value}`), '']; + for (const [route, values] of Object.entries(headers)) { + const specific = Object.entries(values).filter(([key, value]) => key !== 'Content-Security-Policy' && common[key] !== value); + if (specific.length) lines.push(route, ...specific.map(([key, value]) => ` ${key}: ${value}`), ''); + } + // Provider aliases must not compete with the configured canonical community origin. + for (const pattern of aliasRules) lines.push(pattern, ' X-Robots-Tag: noindex', ''); + const text = lines.join('\n'); assertPagesHeaders(text); return text; +} + +function partMatches(pattern: string, value: string, host: boolean): boolean { + let expression = ''; + for (let index = 0; index < pattern.length;) { + const character = pattern[index]!; + if (character === '*') { expression += '.*'; index++; continue; } + const placeholder = pattern.slice(index).match(/^:[A-Za-z]\w*/); + if (placeholder) { expression += host ? '[^./]+' : '[^/]+'; index += placeholder[0].length; continue; } + expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); index++; + } + return new RegExp(`^${expression}$`).test(value); +} + +export function headerPatternMatches(pattern: string, url: URL): boolean { + if (!pattern.startsWith('https://')) return partMatches(pattern, url.pathname, false); + const authorityAndPath = pattern.slice('https://'.length), slash = authorityAndPath.indexOf('/'); + const host = slash < 0 ? authorityAndPath : authorityAndPath.slice(0, slash), pathname = slash < 0 ? '/' : authorityAndPath.slice(slash); + // Pages absolute patterns deliberately ignore the incoming protocol and port. + return partMatches(host, url.hostname, true) && partMatches(pathname, url.pathname, false); +} + +export function responseHeaders(text: string, url: URL): Record { + const headers: Record = {}; let active = false; + for (const line of text.split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith('#')) continue; + if (!/^\s/.test(line)) { active = headerPatternMatches(line, url); continue; } + if (!active) continue; + if (line.trimStart().startsWith('! ')) { delete headers[line.trim().slice(2)]; continue; } + const split = line.indexOf(':'); if (split < 0) continue; + const key = line.slice(0, split).trim(), value = line.slice(split + 1).trim(); + headers[key] = headers[key] ? `${headers[key]}, ${value}` : value; + } + return headers; +} diff --git a/scripts/lib/release-plan.ts b/scripts/lib/release-plan.ts new file mode 100644 index 0000000..7926ba5 --- /dev/null +++ b/scripts/lib/release-plan.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MPL-2.0 +export function releasePlan(input: { deployment: string; revision: string; currentRevision: string; previousRevision?: string; remote?: string }) { + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(input.deployment)) throw new Error('Invalid deployment ID'); + if (!/^[a-f0-9]{40}$/.test(input.revision) || input.revision !== input.currentRevision) throw new Error('Release the exact currently checked-out commit; checkout the chosen revision first'); + if (input.previousRevision && !/^[a-f0-9]{40}$/.test(input.previousRevision)) throw new Error('Invalid previous release revision'); + const remote = input.remote ?? 'origin'; + if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(remote)) throw new Error('Use a configured Git remote name, not a URL'); + const branch = `release/${input.deployment}`, ref = `refs/heads/${branch}`; + return { deployment: input.deployment, revision: input.revision, previousRevision: input.previousRevision ?? null, remote, branch, changed: input.revision !== input.previousRevision, + pushArguments: ['push', `--force-with-lease=${ref}:${input.previousRevision ?? ''}`, remote, `${input.revision}:${ref}`] }; +} diff --git a/scripts/lib/selection.ts b/scripts/lib/selection.ts new file mode 100644 index 0000000..0d3e93e --- /dev/null +++ b/scripts/lib/selection.ts @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MPL-2.0 +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +export const starterSite = 'examples/exampleville'; +export function argumentsFor(args = process.argv.slice(2)) { + const value = (name: string): string | undefined => { + const index = args.indexOf(name); + if (index < 0) return undefined; + const result = args[index + 1]; + if (!result || result.startsWith('--')) throw new Error(`${name} requires a value`); + return result; + }; + return { siteDir: value('--site'), outDir: value('--out'), production: args.includes('--production'), sourceRevision: value('--source-revision'), sourceUrl: value('--source-url'), port: value('--port') }; +} +export async function selection(options: { siteDir?: string; outDir?: string } = {}) { + const siteDir = path.resolve(options.siteDir ?? starterSite); + const site = JSON.parse(await readFile(path.join(siteDir, 'site.json'), 'utf8')) as { deployment_id?: unknown }; + if (typeof site.deployment_id !== 'string' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(site.deployment_id)) throw new Error('Select a site with a valid deployment_id'); + if (path.dirname(siteDir) === path.resolve('deployments') && path.basename(siteDir) !== site.deployment_id) throw new Error('deployment_id must match its deployments/ folder name so default outputs remain independent'); + return { siteDir, outDir: path.resolve(options.outDir ?? path.join('dist', site.deployment_id)), deploymentId: site.deployment_id }; +} diff --git a/scripts/lib/text.ts b/scripts/lib/text.ts new file mode 100644 index 0000000..98f2a28 --- /dev/null +++ b/scripts/lib/text.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MPL-2.0 +import { readFile } from 'node:fs/promises'; + +/** Normalize source-text line endings so Windows and Linux emit identical bytes. */ +export async function readText(file: string | URL): Promise { + return (await readFile(file, 'utf8')).replace(/\r\n?/g, '\n'); +} diff --git a/scripts/lib/web.ts b/scripts/lib/web.ts index b022970..b26700d 100644 --- a/scripts/lib/web.ts +++ b/scripts/lib/web.ts @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 import { createHash } from 'node:crypto'; import { copy as c } from '../../src/copy/en.ts'; -import { card, escape as e, resourcePath } from '../../src/presentation.ts'; +import { card, escape as e, resourcePath, formatDate } from '../../src/presentation.ts'; +import { categoryGroups } from '../../src/domain.ts'; import type { PublicDataset, Site, Usage } from '../../src/types.ts'; export const digest = (input: string | Uint8Array): string => createHash('sha256').update(input).digest('hex'); export type Page = { path: string; title: string; description: string; body: string; indexable: boolean; jsonld?: unknown }; @@ -11,32 +12,73 @@ export function pages(site: Site, data: PublicDataset, usage?: Usage): Page[] { const warning = `

${c.warning}

`; const resources = data.resources.map(r => card(r, data, site)).join(''); const listData = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: site.site_name, url: `${site.canonical_origin}/`, description: site.description, mainEntity: { '@type': 'ItemList', itemListElement: data.resources.map((r, index) => ({ '@type': 'ListItem', position: index + 1, url: site.canonical_origin + resourcePath(r.id), name: r.name })) } }; - const output: Page[] = [{ path: '/', title: site.site_name, description: site.description, indexable: true, jsonld: listData, body: `

${c.eyebrow} · ${e(site.community.name)}

${c.headline}

${c.introduction}

${cta}

${c.directory}

${c.resourceCount(data.resources.length)}

${resources}
` }]; + const groups = categoryGroups(site, data.resources); + const output: Page[] = [{ + path: '/', title: site.site_name, description: site.description, indexable: true, jsonld: listData, + body: `

${e(c.headline(site.community.name))}

${e(site.presentation?.introduction ?? c.introduction)}

+ ${site.help_contact ? `` : ''}
+
+

${c.directory}

${data.data_updated_on ? `${c.listingsUpdated} · ${formatDate(data.data_updated_on, site)}` : ''}

+ +

${c.scheduleDisclaimer} ${c.notLive} ${c.callBefore}

+

${c.resourceCount(data.resources.length)}

+
${resources}
+ ${aboutSection(site)}` + }]; for (const r of data.resources) { const organization = data.organizations.find(o => o.id === r.organization_id)!; const url = site.canonical_origin + resourcePath(r.id); const service = { '@type': 'Service', '@id': `${url}#service`, name: r.name, description: r.summary, url, serviceType: r.categories.map(cat => c.categories[cat]), provider: { '@type': 'Organization', name: organization.name, ...(organization.url ? { url: organization.url } : {}) }, ...(r.service_area ? { areaServed: r.service_area } : {}), ...(r.location?.address ? { availableChannel: { '@type': 'ServiceChannel', serviceLocation: { '@type': 'Place', name: r.location.description, address: { '@type': 'PostalAddress', streetAddress: r.location.address.street, addressLocality: r.location.address.locality, addressRegion: r.location.address.region, addressCountry: r.location.address.country, ...(r.location.address.postal_code ? { postalCode: r.location.address.postal_code } : {}) } } } } : {}) }; output.push({ path: resourcePath(r.id), title: `${r.name} | ${site.site_name}`, description: r.summary, indexable: true, body: warning + card(r, data, site, true) + cta, jsonld: { '@context': 'https://schema.org', '@type': 'WebPage', name: r.name, description: r.summary, url, inLanguage: r.content_language ?? data.content_language, dateModified: r.updated_on, mainEntity: service, citation: r.evidence.flatMap(item => item.url ? [item.url] : []) } }); } - output.push({ path: '/directory/', title: `${c.simple} | ${site.site_name}`, description: site.description, indexable: false, body: `

${c.simple}

${warning}${cta}

${c.downloadNote}

${data.resources.map(r => card(r, data, site, false, 2).replace('
', '
')).join('')}` }); + output.push({ path: '/directory/', title: `${c.simple} | ${site.site_name}`, description: site.description, indexable: false, body: `

${c.simple}

${warning}${cta}

${c.downloadNote}

${data.resources.map(r => card(r, data, site, false, 2).replace(/])/, '
output.push({ path, title: `${title} | ${site.site_name}`, description: `${title}. ${site.description}`, body: `

${title}

${body}`, indexable }); page('/methodology/', c.methodology, `

${c.methodologyText}

${c.methodologyScope}

${c.reviewInterval(site.review.interval_days)}

${warning}

${c.holidayText}

${site.jurisdiction ? `

${c.holidayCoverage}: ${e(site.jurisdiction.coverage_through)}

    ${site.jurisdiction.warning_dates.map(item => `
  • ${e(item.date)}: ${e(item.note)} ${c.source} (${c.checked}: ${e(item.checked_on)})
  • `).join('')}
` : ''}

${c.json}

`); - page('/privacy/', c.privacy, `

${c.privacyText}

${c.privacyOffline}

${site.analytics?.enabled ? `

${c.analyticsOn}

${e(site.analytics.disclosure)}

${c.analyticsExplanation}

${e(new URL(site.analytics.endpoint!).origin)}

${c.analyticsPreferenceNote}

` : `

${c.analyticsOff}

`}`); + page('/privacy/', c.privacy, `

${c.privacyText}

${c.privacyOffline}

${site.analytics?.enabled ? `

${c.analyticsOn}

${e(site.analytics.disclosure)}

${c.analyticsExplanation}

${e(new URL(site.analytics.endpoint!).origin)}

${analyticsChoice()}` : `

${c.analyticsOff}

`}`); page('/about/', c.about, `

${e(site.description)}

${c.maintained}: ${e(site.operator.name)}

${e(site.operator.statement ?? '')}

${c.report}

${c.software}

${site.software_source_url ? `

${e(site.software_source_url)}

` : ''}`); - page('/licenses/', c.licenses, `

${c.softwareLicense} MPL-2.0

${c.fontLicense} OFL

${c.dataLicense}

${e(site.data_rights.statement)}

${site.data_rights.url ? `${e(site.data_rights.license ?? site.data_rights.url)}` : ''}`); + page('/licenses/', c.licenses, `

${c.softwareLicense} MPL-2.0

${sourceLink(site)}

${c.attribution}

${c.fontLicense} OFL

${c.dataLicense}

${e(site.data_rights.statement)}

${site.data_rights.url ? `${e(site.data_rights.license ?? site.data_rights.url)}` : ''}`); if (usage) page('/usage/', c.usage, `

${c.usageIntro}

${c.usagePeriod}: ${e(usage.period_start)} – ${e(usage.period_end)}

${c.usageGenerated}: ${e(usage.generated_at)}

${Object.entries(usage.counts).map(([key, value]) => `
${c.usageCounts[key as keyof typeof c.usageCounts]}
${value === null ? c.usageMissing : new Intl.NumberFormat(site.locale).format(value)}
`).join('')}
`, false); page('/404.html', c.notFound, `

${c.notFoundText}

`, false); return output; } + +function analyticsChoice(): string { + return `

${c.analyticsPreferenceNote}

`; +} +function sourceLink(site: Site): string { return site.software_source_url ? `

${c.coveredSource}

` : ''; } +function aboutSection(site: Site): string { + return `
+

${c.about}

${c.aboutShort}

${c.report}
+
+
${c.saveDirectory}

${c.installHelp}

${c.privacyOffline}

${c.simple} · ${c.download}

+
${c.yourPrivacy}

${c.privacyText}

${site.analytics?.enabled ? e(site.analytics.disclosure) : c.analyticsOff}

${site.analytics?.enabled ? analyticsChoice() : ''}

${c.privacy}

+
${c.sourcesData}

${c.methodologyText}

${c.methodology} · ${c.json} · ${c.licenses}

+
`; +} export function document(page: Page, site: Site, assets: Assets, production: boolean, standaloneCSS?: string): string { const index = production && !site.example_content && site.indexing?.enabled === true && page.indexable; const json = page.jsonld ? JSON.stringify(page.jsonld).replace(/${e(page.title)}${standalone ? `` : `${[...assets.css, assets.brand].map(css => ``).join('')}`}${json ? `` : ''}
${site.example_content ? `

${c.example}

` : ''}${page.body}

${c.report}

${site.emergency_contact ? `

${e(site.emergency_contact.label)} ${e(site.emergency_contact.description ?? '')}

` : ''}${standalone ? '' : `

${c.installHelp}

`}
`; + const standalone = standaloneCSS !== undefined, home = page.path === '/'; + const links = [['/', c.home], ['/directory/', c.simple], ['/methodology/', c.methodology], ['/privacy/', c.privacy], ['/about/', c.about], ['/licenses/', c.licenses], ['/data/v1/resources.json', c.json], ...(site.public_usage?.enabled ? [['/usage/', c.usage]] : [])]; + return `${e(page.title)}${standalone ? `` : `${[...assets.css, assets.brand].map(css => ``).join('')}`}${json ? `` : ''} + + +
${!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 { - const hashes = [...html.matchAll(/<(script|style)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g)].filter(item => item[2]).map(item => `'sha256-${createHash('sha256').update(item[2]!).digest('base64')}'`); + const blocks = [...html.matchAll(/<(script|style)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g)].filter(item => item[2]); + const hashes = (tag: string) => blocks.filter(item => item[1] === tag).map(item => `'sha256-${createHash('sha256').update(item[2]!).digest('base64')}'`).join(' '); const endpoint = site.analytics?.enabled ? ` ${new URL(site.analytics.endpoint!).origin}` : ''; - const policy = `default-src 'none'; script-src 'self' ${hashes.join(' ')}; style-src 'self' ${hashes.join(' ')}; img-src 'self' data:; font-src 'self'${html.includes('data:font/woff2;base64,') ? ' data:' : ''}; connect-src 'self'${endpoint}; manifest-src 'self'; worker-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'`; + const policy = `default-src 'none'; script-src 'self' ${hashes('script')}; style-src 'self' ${hashes('style')}; img-src 'self' data:; font-src 'self'${html.includes('data:font/woff2;base64,') ? ' data:' : ''}; connect-src 'self'${endpoint}; manifest-src 'self'; worker-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'none'`; return { 'Content-Security-Policy': policy, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'Permissions-Policy': `geolocation=${site.location?.enabled ? '(self)' : '()'}, camera=(), microphone=(), payment=()`, ...(production ? { 'Strict-Transport-Security': 'max-age=31536000' } : {}), 'Cache-Control': 'no-cache' }; } diff --git a/scripts/release.ts b/scripts/release.ts new file mode 100644 index 0000000..57e3641 --- /dev/null +++ b/scripts/release.ts @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MPL-2.0 +import { execFileSync, spawnSync } from 'node:child_process'; +import { readFile, mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { build } from './build.ts'; +import { releasePlan } from './lib/release-plan.ts'; + +const value = (name: string) => { const i = process.argv.indexOf(name); if (i < 0) return undefined; const next = process.argv[i + 1]; if (!next || next.startsWith('--')) throw new Error(`${name} needs a value`); return next; }; +const siteDir = value('--site'), ref = value('--ref'), remote = value('--remote') ?? 'origin'; +if (!siteDir || !ref) throw new Error('Explicit target and revision required: npm run release -- --site deployments/ --ref [--publish]'); +if (ref.startsWith('-')) throw new Error('Invalid source reference'); +const relative = path.relative(process.cwd(), path.resolve(siteDir)).split(path.sep).join('/'); +if (!/^deployments\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(relative)) throw new Error('Release a direct deployments/ folder; examples are never production targets'); +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'); +const site = JSON.parse(await readFile(path.join(siteDir, 'site.json'), 'utf8')) as { deployment_id: string; example_content: boolean; canonical_origin: string }; +if (site.example_content || path.basename(relative) !== site.deployment_id) throw new Error('Release folder and deployment ID must agree, and fictional content cannot be published'); +releasePlan({ deployment: site.deployment_id, 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 r = spawnSync(process.execPath, [script, ...args], { stdio: 'inherit' }); if (r.error) throw r.error; if (r.status !== 0) throw new Error(`${script} failed; nothing was published`); }; +run('scripts/check.ts', ['--site', siteDir]); +verifiedHead(); +const output = await build({ siteDir, production: true, sourceRevision: revision, sourceUrl: value('--source-url') }); +verifiedHead(); +const previousRevision = git('ls-remote', remote, `refs/heads/release/${site.deployment_id}`).split(/\s+/)[0] || undefined; +const plan = releasePlan({ deployment: site.deployment_id, revision, currentRevision: verifiedHead(), previousRevision, remote }); +const receipt = { ...plan, artifact: output.outDir, report: output.reportPath, release: output.release, canonical_origin: site.canonical_origin, publication_requested: process.argv.includes('--publish') }; +await mkdir('artifacts/releases', { recursive: true }); +await writeFile(`artifacts/releases/${site.deployment_id}-${revision}.json`, JSON.stringify(receipt, null, 2) + '\n'); +console.log(JSON.stringify(receipt, null, 2)); +if (!process.argv.includes('--publish')) console.log('Validated local release plan only. Add --publish to advance this community release pointer and trigger its configured host.'); +else if (!plan.changed) console.log('The selected community 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 host reports ${revision} and run the production verifier before declaring the release complete.`); +} diff --git a/scripts/select-checks.ts b/scripts/select-checks.ts new file mode 100644 index 0000000..94b2594 --- /dev/null +++ b/scripts/select-checks.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MPL-2.0 +import { execFileSync } from 'node:child_process'; +import { readdir, appendFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +export function selectedChecks(changed: string[], deployments: string[]): string[] { + const shared = changed.some(file => !file.startsWith('deployments/')); + return shared ? ['examples/exampleville', ...deployments].sort() : deployments.filter(folder => changed.some(file => file.startsWith(`${folder}/`))).sort(); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const folders = (await readdir('deployments', { withFileTypes: true }).catch(() => [])).filter(entry => entry.isDirectory()).map(entry => `deployments/${entry.name}`); + const base = process.env.FOOD_HELP_CHECK_BASE, head = process.env.FOOD_HELP_CHECK_HEAD ?? 'HEAD'; + const changed = base && !/^0+$/.test(base) ? execFileSync('git', ['diff', '--name-only', base, head], { encoding: 'utf8' }).trim().split('\n').filter(Boolean) : ['src/']; + const sites = selectedChecks(changed, folders); + const matrix = JSON.stringify({ site: sites.length ? sites : ['examples/exampleville'] }); + console.log(matrix); + if (process.env.GITHUB_OUTPUT) await appendFile(process.env.GITHUB_OUTPUT, `matrix=${matrix}\n`); +} diff --git a/scripts/serve.ts b/scripts/serve.ts index 25091c2..d8e1e64 100644 --- a/scripts/serve.ts +++ b/scripts/serve.ts @@ -4,33 +4,33 @@ import { readFile, stat } from 'node:fs/promises'; import { watch } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -export async function serve(options: { root?: string | (() => string); port?: number; watch?: boolean; fail?: (route: string) => boolean } = {}) { +import { argumentsFor, selection, starterSite } from './lib/selection.ts'; +import { responseHeaders } from './lib/headers.ts'; +export async function serve(options: { root?: string | (() => string); siteDir?: string; port?: number; watch?: boolean; fail?: (route: string) => boolean } = {}) { const port = options.port ?? 4173; + const fallbackRoot = options.root ? undefined : (await selection({ siteDir: options.siteDir })).outDir; const mime: Record = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' }; const server = createServer(async (request, response) => { try { - const url = new URL(request.url ?? '/', `http://localhost:${port}`); let route = decodeURIComponent(url.pathname); - const root = path.resolve(typeof options.root === 'function' ? options.root() : options.root ?? 'dist'); + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `localhost:${port}`}`); let route = decodeURIComponent(url.pathname); + const root = path.resolve(typeof options.root === 'function' ? options.root() : options.root ?? fallbackRoot!); if (options.fail?.(route)) { response.writeHead(503).end(); return; } if (route.includes('\\') || route.split('/').includes('..') || route.includes('\0') || route.split('/').some(part => part.startsWith('_') || part.startsWith('.'))) { response.writeHead(404).end(); return; } let target = path.resolve(root, `.${route}`); if (!(target === root || target.startsWith(root + path.sep))) { response.writeHead(404).end(); return; } let code = 200; + // Static hosts normalize .html URLs; exercise the same useful destination locally. + if (!path.extname(target)) { try { if ((await stat(`${target}.html`)).isFile()) target += '.html'; } catch {} } try { if ((await stat(target)).isDirectory()) { if (!route.endsWith('/')) { response.writeHead(301, { Location: `${route}/${url.search}` }).end(); return; } target = path.join(target, 'index.html'); } } catch { target = path.join(root, '404.html'); code = 404; } const headerFile = await readFile(path.join(root, '_headers'), 'utf8'); - const headers: Record = {}; let active = false; - for (const line of headerFile.split('\n')) { - if (!line.trim()) continue; - if (!line.startsWith(' ')) { active = line === '/*' || line === route || line === route.replace(/index\.html$/, ''); continue; } - if (active) { const split = line.indexOf(':'); headers[line.slice(0, split).trim()] = line.slice(split + 1).trim(); } - } + const headers = responseHeaders(headerFile, url); response.writeHead(code, { ...headers, 'Content-Type': mime[path.extname(target)] ?? 'application/octet-stream' }); response.end(await readFile(target)); } catch { response.writeHead(500).end('Static preview error'); } }); await new Promise(resolve => server.listen(port, '127.0.0.1', resolve)); console.log(`Food Help static preview: http://127.0.0.1:${port}`); if (options.watch) { let timer: ReturnType, rebuilding = false; - for (const directory of ['src', 'site', 'schemas']) watch(directory, { recursive: true }, () => { clearTimeout(timer); timer = setTimeout(async () => { if (rebuilding) return; rebuilding = true; try { const { build } = await import('./build.ts'); await build(); } catch (error) { console.error(error); } finally { rebuilding = false; } }, 300); }); + for (const directory of ['src', options.siteDir ?? starterSite, 'schemas', 'scripts']) watch(directory, { recursive: true }, () => { clearTimeout(timer); timer = setTimeout(async () => { if (rebuilding) return; rebuilding = true; try { const { build } = await import('./build.ts'); await build({ siteDir: options.siteDir, outDir: typeof options.root === 'string' ? options.root : fallbackRoot }); } catch (error) { console.error(error); } finally { rebuilding = false; } }, 300); }); } return server; } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await serve({ root: process.env.FOOD_HELP_PREVIEW_ROOT, port: Number(process.env.FOOD_HELP_PORT ?? 4173) }); +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const options = argumentsFor(); const selected = options.siteDir || options.outDir || !process.env.FOOD_HELP_PREVIEW_ROOT ? await selection(options) : undefined; await serve({ root: selected?.outDir ?? process.env.FOOD_HELP_PREVIEW_ROOT, siteDir: options.siteDir, port: Number(options.port ?? process.env.FOOD_HELP_PORT ?? 4173) }); } diff --git a/scripts/setup.ts b/scripts/setup.ts index b70e87e..bf747d2 100644 --- a/scripts/setup.ts +++ b/scripts/setup.ts @@ -2,13 +2,32 @@ import { readFile, writeFile, mkdir, access, copyFile } from 'node:fs/promises'; import { createInterface } from 'node:readline/promises'; import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import { generateContracts } from './lib/contracts.ts'; +import { argumentsFor } from './lib/selection.ts'; + +const options = argumentsFor(); +if (!options.siteDir) throw new Error('Setup requires an explicit target: npm run setup -- --site deployments/my-community'); +const siteDir = path.resolve(options.siteDir), validateOnly = process.argv.includes('--validate-only'); +if (!validateOnly && (!siteDir.startsWith(path.resolve('deployments') + path.sep) || path.dirname(siteDir) !== path.resolve('deployments'))) throw new Error('Create a community in a direct deployments// folder'); await generateContracts(); const validation: typeof import('../src/validation.ts') = await import('../src/validation.ts'); -await mkdir('site/assets', { recursive: true }); -for (const file of ['site.json', 'resources.json']) { try { await access(`site/${file}`); } catch { await copyFile(`examples/exampleville/${file}`, `site/${file}`); } } -const site = JSON.parse(await readFile('site/site.json', 'utf8')); -if (process.stdin.isTTY && !process.argv.includes('--validate-only')) { +if (!validateOnly) { + await mkdir(path.join(siteDir, 'assets'), { recursive: true }); + for (const file of ['site.json', 'resources.json']) { + try { await access(path.join(siteDir, file)); } + catch { + if (file === 'site.json') { + const starter = JSON.parse(await readFile('examples/exampleville/site.json', 'utf8')); + starter.deployment_id = path.basename(siteDir); + validation.assertSite(starter); + await writeFile(path.join(siteDir, file), JSON.stringify(starter, null, 2) + '\n', { flag: 'wx' }); + } else await copyFile(`examples/exampleville/${file}`, path.join(siteDir, file)); + } + } +} +const site = JSON.parse(await readFile(path.join(siteDir, 'site.json'), 'utf8')); +if (process.stdin.isTTY && !validateOnly) { const input = createInterface({ input: process.stdin, output: process.stdout }); const ask = async (label: string, current: string) => (await input.question(`${label} [${current}]: `)).trim() || current; try { @@ -24,9 +43,9 @@ if (process.stdin.isTTY && !process.argv.includes('--validate-only')) { site.operator.name = await ask('Operator name', site.operator.name); site.corrections.email = await ask('Corrections email', site.corrections.email); validation.assertSite(site); - await writeFile('site/site.json', JSON.stringify(site, null, 2) + '\n'); + await writeFile(path.join(siteDir, 'site.json'), JSON.stringify(site, null, 2) + '\n'); console.log(`Saved configuration. When replacing the fictional dataset, use a new dataset_id, for example ${randomUUID()}.`); } finally { input.close(); } } -validation.assertSite(site); validation.assertDataset(JSON.parse(await readFile('site/resources.json', 'utf8'))); -console.log('Site and resource contracts are valid. Review the example-content flag, contacts, evidence and data rights before production. See docs/data.md.'); +validation.assertSite(site); validation.assertDataset(JSON.parse(await readFile(path.join(siteDir, 'resources.json'), 'utf8'))); +console.log(`Validated ${siteDir}. Review fictional content, contacts, evidence and data rights before production. See docs/data.md.`); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..884df3f --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: MPL-2.0 +// Read-only host/browser verification. Browser privacy signals suppress optional collection. +import assert from 'node:assert/strict'; +import { readFile, writeFile, mkdir, mkdtemp } from 'node:fs/promises'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { chromium, expect } from '@playwright/test'; +import axe from 'axe-core'; +import { argumentsFor, selection } from './lib/selection.ts'; +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +const args = argumentsFor(); +if (!args.siteDir) throw new Error('Explicit --site required for production verification'); +const selected = await selection(args), site = JSON.parse(await readFile(path.join(selected.siteDir, 'site.json'), 'utf8')); +const reportArg = process.argv.indexOf('--report'); +const reportPath = reportArg < 0 ? `artifacts/reports/${site.deployment_id}/${hash(selected.outDir).slice(0, 16)}.json` : process.argv[reportArg + 1]; +const built = JSON.parse(await readFile(reportPath, 'utf8')); +assert.equal(built.production, true, 'Use the exact production build report'); +assert.equal(built.deployment_id, site.deployment_id); +const origin = site.canonical_origin; +const evidenceDir = `artifacts/verification/${site.deployment_id}-${built.release.slice(0, 16)}`; +await mkdir(evidenceDir, { recursive: true }); +const result = { origin, checked_at: new Date().toISOString(), source_revision: built.source_revision, release: built.release, dataset_version: built.dataset_version, checks: [], issues: [] }; +async function check(name, task) { + try { await task(); result.checks.push(name); console.log(`PASS ${name}`); } + catch (error) { result.issues.push({ check: name, message: error.message }); console.log(`FAIL ${name}: ${error.message}`); } +} +async function get(route) { + const response = await fetch(origin + route, { cache: 'no-store', signal: AbortSignal.timeout(30000) }); + const bytes = Buffer.from(await response.arrayBuffer()); + return { response, bytes, text: bytes.toString('utf8') }; +} +let data, workerBuild; +await check('production identity and HTTP policies', async () => { + const [home, worker, json, manifest, sitemap, robots, error] = await Promise.all(['/', '/service-worker.js', '/data/v1/resources.json', '/manifest.webmanifest', '/sitemap.xml', '/robots.txt', '/verification-unknown-route/'].map(get)); + for (const response of [home, worker, json, manifest, sitemap, robots]) assert.equal(response.response.status, 200); + assert.equal(error.response.status, 404); + assert.equal(error.response.headers.get('content-security-policy'), built.common_headers['Content-Security-Policy']); + assert.equal(home.response.headers.get('content-security-policy'), built.common_headers['Content-Security-Policy']); + assert.equal(home.response.headers.get('x-content-type-options'), 'nosniff'); + assert.equal(home.response.headers.get('referrer-policy'), 'no-referrer'); + assert.match(home.response.headers.get('strict-transport-security'), /max-age=/); + assert.match(home.text, //); + assert.equal(home.response.headers.get('x-robots-tag'), null); + assert.match(worker.response.headers.get('cache-control'), /no-store|no-cache/); + assert.match(worker.response.headers.get('content-type'), /javascript/); + assert.equal(worker.response.headers.get('service-worker-allowed'), '/'); + assert.match(json.response.headers.get('cache-control'), /no-store/); + assert.equal(json.response.headers.get('access-control-allow-origin'), '*'); + assert.equal(hash(json.bytes), built.file_hashes['data/v1/resources.json']); + assert.equal(hash(worker.bytes), built.file_hashes['service-worker.js'], 'Served worker must match the exact built artifact'); + data = JSON.parse(json.text); assert.equal(data.dataset_version, built.dataset_version); + assert.equal(data.resources.length, built.resources); + const app = JSON.parse(manifest.text); assert.equal(app.name, site.site_name); assert.equal(app.scope, '/'); assert.equal(app.start_url, '/'); + assert.ok(robots.text.includes(`Sitemap: ${origin}/sitemap.xml`)); + for (const resource of data.resources) assert.ok(sitemap.text.includes(`${origin}/resources/${resource.id}/`)); + workerBuild = JSON.parse(worker.text.match(/^const BUILD = (.+);/)[1]); + assert.equal(workerBuild.cache, `food-help-${site.deployment_id}-app-${built.release}`); + assert.equal(workerBuild.dataCache, `food-help-${site.deployment_id}-data-v1-${built.compatibility_id}`); +}); +const aliases = process.env.FOOD_HELP_VERIFY_ALIASES?.split(',').map(value => value.trim()).filter(Boolean) ?? []; +for (const alias of aliases) await check(`provider alias is noindex: ${alias}`, async () => { + const url = new URL(alias); assert.equal(url.protocol, 'https:'); assert.ok(url.hostname.endsWith('.pages.dev')); + const response = await fetch(url, { signal: AbortSignal.timeout(30000) }); assert.equal(response.status, 200); + assert.match(response.headers.get('x-robots-tag'), /noindex/); +}); +if (workerBuild) { + const pending = [...workerBuild.entries]; + await Promise.all(Array.from({ length: 4 }, async () => { + for (let entry; (entry = pending.shift());) await check(`integrity ${entry.url}`, async () => { + assert.ok(!['/robots.txt', '/sitemap.xml', '/llms.txt'].includes(entry.url)); + const r = await get(entry.url); assert.equal(r.response.status, 200); assert.equal(hash(r.bytes), entry.hash); + if (entry.url.endsWith('.html')) { + assert.equal(r.response.headers.get('content-security-policy'), built.common_headers['Content-Security-Policy']); + assert.ok(!/email-decode|\/cdn-cgi\/l\/email-protection|static\.cloudflareinsights\.com/.test(r.text), 'Unexpected host injection'); + } + }); + })); +} +await check('normalized HTML routes and corresponding source', async () => { + for (const route of ['/404', '/directory/download', '/directory/download.html']) { + const r = await get(route); assert.equal(r.response.status, 200); assert.equal(r.response.headers.get('content-security-policy'), built.common_headers['Content-Security-Policy']); + } + const licenses = await get('/licenses/'); assert.ok(licenses.text.includes(built.source_url)); + const download = await get('/directory/download'); assert.ok(download.text.includes(built.source_url)); assert.ok(download.text.includes('SIL OPEN FONT LICENSE')); +}); +let context; +await mkdir('artifacts/profiles', { recursive: true }); +const profile = await mkdtemp(path.resolve('artifacts/profiles', 'verify-')); +const browserFailures = [], outsideRequests = [], runtimeErrors = []; +async function privacy(browserContext) { + await browserContext.addInitScript(() => { Object.defineProperty(navigator, 'globalPrivacyControl', { get: () => true }); Object.defineProperty(navigator, 'doNotTrack', { get: () => '1' }); }); + browserContext.on('request', request => { if (new URL(request.url()).origin !== origin) outsideRequests.push({ url: request.url(), method: request.method() }); }); + browserContext.on('requestfailed', request => browserFailures.push({ url: request.url(), method: request.method(), error: request.failure()?.errorText })); + browserContext.on('page', page => page.on('pageerror', error => runtimeErrors.push(error.message))); +} +try { + context = await chromium.launchPersistentContext(profile, { headless: true, viewport: { width: 1920, height: 912 } }); + await privacy(context); + const page = await context.newPage(); + await check('live interface, accessibility, mobile and optional-collection suppression', async () => { + await page.goto(origin); await expect(page.locator('.brand')).toHaveText(site.site_name); await expect(page.locator('#filters')).toBeVisible(); + await expect(page.locator('#resource-list article')).toHaveCount(built.resources); + for (const group of site.presentation?.category_groups ?? []) { + await page.locator(`[data-category="${group.id}"]`).click(); + await expect(page.locator('#resource-list article')).toHaveCount(data.resources.filter(r => r.categories.some(c => group.categories.includes(c))).length); + } + await page.locator('[data-category="all"]').click(); + await page.locator('#search').fill(data.resources[0].name); await expect(page.locator('#resource-list article')).toHaveCount(1); await page.locator('#search').fill(''); + await page.locator('h1').click(); + await page.evaluate(axe.source); + const accessibility = await page.evaluate(() => window.axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] } })); + assert.deepEqual(accessibility.violations.map(v => v.id), []); + await page.screenshot({ path: `${evidenceDir}/desktop.png` }); await page.locator('.site-footer').scrollIntoViewIfNeeded(); await page.screenshot({ path: `${evidenceDir}/footer.png` }); + for (const width of [768, 390, 320]) { await page.setViewportSize({ width, height: 844 }); assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)); } + await page.evaluate(() => scrollTo(0, 0)); await page.screenshot({ path: `${evidenceDir}/mobile.png` }); + assert.deepEqual(runtimeErrors, []); assert.deepEqual(outsideRequests, []); + }); + await check('first connected visit installs a complete validated offline release', async () => { + await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)), { timeout: 60000 }).toBe(true); + const keys = await page.evaluate(() => caches.keys()); assert.ok(keys.includes(workerBuild.cache)); assert.ok(keys.includes(workerBuild.dataCache)); + }); + await context.close(); context = undefined; + await check('offline cold browser restart, resource navigation and reconnection', async () => { + context = await chromium.launchPersistentContext(profile, { headless: true, offline: true }); await privacy(context); + const cold = await context.newPage(); await cold.goto(origin); + await expect(cold.locator('#resource-list article')).toHaveCount(built.resources); await expect(cold.locator('#data-retention-status')).toContainText('last saved validated'); + await cold.getByRole('link', { name: data.resources[0].name, exact: true }).click(); await expect(cold.locator('h1')).toHaveText(data.resources[0].name); + await context.setOffline(false); await cold.goto(origin); await expect(cold.locator('#data-status')).toContainText('Listings updated'); + await context.close(); context = undefined; + }); + await check('no-JavaScript directory, print and standalone download', async () => { + context = await chromium.launchPersistentContext(await mkdtemp(path.resolve('artifacts/profiles', 'nojs-')), { headless: true, javaScriptEnabled: false }); + await privacy(context); + const simple = await context.newPage(); await simple.goto(origin); await expect(simple.locator('#resource-list article')).toHaveCount(built.resources); + await simple.goto(`${origin}/directory/`); await expect(simple.locator('article')).toHaveCount(built.resources); await simple.emulateMedia({ media: 'print' }); await simple.pdf({ path: `${evidenceDir}/directory.pdf` }); + await simple.goto(`${origin}/directory/download`); await expect(simple.locator('article')).toHaveCount(built.resources); + await context.close(); context = undefined; + }); +} finally { + await context?.close(); + await check('all browsing stages remain free of external requests and runtime errors', async () => { + assert.deepEqual(outsideRequests, []); assert.deepEqual(runtimeErrors, []); + }); + result.browser_failures = browserFailures; result.external_requests = outsideRequests; result.runtime_errors = runtimeErrors; + result.status = result.issues.length ? 'failed' : 'passed'; + await writeFile(`${evidenceDir}/verification.json`, JSON.stringify(result, null, 2) + '\n'); + console.log(`Evidence: ${evidenceDir}/verification.json`); +} +if (result.issues.length) throw new Error(`${result.issues.length} production checks failed; the release is not verified`); diff --git a/site/AGENTS.md b/site/AGENTS.md deleted file mode 100644 index 1958c3a..0000000 --- a/site/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ -# Deployment instructions - -This initial deployment is fictional Exampleville demonstration content. Do not present it as verified real-world help or enable production indexing. Replace it with human-reviewed local data before a real deployment. - -Publication, changes to resource facts, analytics enablement and external hosting actions require the operator's explicit approval. Preserve source dates and unresolved questions. No private correspondence or submitter details belong here, including draft records in a public repository. diff --git a/site/resources.json b/site/resources.json deleted file mode 100644 index 1833fd9..0000000 --- a/site/resources.json +++ /dev/null @@ -1,841 +0,0 @@ -{ - "format": "food-help", - "schema_version": 1, - "dataset_id": "b0e85423-f924-4e79-9495-1d9f9c13252b", - "content_language": "en", - "taxonomy_version": 1, - "organizations": [ - { - "id": "example-community", - "name": "Example Community Volunteers" - } - ], - "resources": [ - { - "id": "community-table", - "organization_id": "example-community", - "name": "Example Community Table", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "prepared_meals" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "location": { - "description": "Fictional location; do not travel here.", - "address": { - "street": "1 Example Lane", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB", - "postal_code": "EX1 1EX" - }, - "coordinates": { - "latitude": 52.1, - "longitude": -1.1 - } - }, - "official_url": "https://providers.example.invalid/community-table", - "access": { - "walk_in": "yes", - "appointment_required": "no", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "weekly", - "confirmed": true, - "note": "Example service hours; not live availability.", - "weekly": [ - { - "day": 1, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 2, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 3, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 4, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 5, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 6, - "state": "unknown", - "intervals": [] - }, - { - "day": 7, - "state": "unknown", - "intervals": [] - } - ], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "location", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "confirmed" - }, - "publication_status": "published", - "service_condition": "active", - "updated_on": "2026-09-01" - }, - { - "id": "neighbourhood-pantry", - "organization_id": "example-community", - "name": "Example Neighbourhood Pantry", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "food_banks_pantries" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "location": { - "description": "Fictional location; do not travel here.", - "address": { - "street": "1 Example Lane", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB", - "postal_code": "EX1 1EX" - }, - "coordinates": { - "latitude": 52.1, - "longitude": -1.1 - } - }, - "official_url": "https://providers.example.invalid/neighbourhood-pantry", - "access": { - "walk_in": "no", - "appointment_required": "yes", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "weekly", - "confirmed": true, - "note": "Example service hours; not live availability.", - "weekly": [ - { - "day": 1, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 2, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 3, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 4, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 5, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 6, - "state": "unknown", - "intervals": [] - }, - { - "day": 7, - "state": "unknown", - "intervals": [] - } - ], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "location", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "confirmed" - }, - "publication_status": "published", - "service_condition": "active", - "updated_on": "2026-09-01" - }, - { - "id": "sharing-fridge", - "organization_id": "example-community", - "name": "Example Sharing Fridge", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "community_fridges" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "location": { - "description": "Fictional location; do not travel here.", - "address": { - "street": "1 Example Lane", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB", - "postal_code": "EX1 1EX" - }, - "coordinates": { - "latitude": 52.1, - "longitude": -1.1 - } - }, - "official_url": "https://providers.example.invalid/sharing-fridge", - "access": { - "walk_in": "yes", - "appointment_required": "no", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "unknown", - "confirmed": false, - "note": "Ask the operator about arrangements.", - "weekly": [], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "location", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "partially_confirmed" - }, - "publication_status": "published", - "service_condition": "active", - "updated_on": "2026-09-01" - }, - { - "id": "garden-support", - "organization_id": "example-community", - "name": "Example Garden Support", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "community_gardens" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "location": { - "description": "Fictional location; do not travel here.", - "address": { - "street": "1 Example Lane", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB", - "postal_code": "EX1 1EX" - }, - "coordinates": { - "latitude": 52.1, - "longitude": -1.1 - } - }, - "official_url": "https://providers.example.invalid/garden-support", - "access": { - "walk_in": "yes", - "appointment_required": "no", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "by_arrangement", - "confirmed": false, - "note": "Ask the operator about arrangements.", - "weekly": [], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "location", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "partially_confirmed" - }, - "publication_status": "published", - "service_condition": "active", - "updated_on": "2026-09-01" - }, - { - "id": "delivery-support", - "organization_id": "example-community", - "name": "Example Delivery Support", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "food_delivery" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "official_url": "https://providers.example.invalid/delivery-support", - "access": { - "walk_in": "yes", - "appointment_required": "no", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "by_arrangement", - "confirmed": false, - "note": "Ask the operator about arrangements.", - "weekly": [], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "service_area", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "partially_confirmed" - }, - "publication_status": "published", - "service_condition": "active", - "updated_on": "2026-09-01", - "service_area": "Exampleville area; arrangements must be confirmed." - }, - { - "id": "draft-example", - "organization_id": "example-community", - "name": "PRIVATE DRAFT SENTINEL", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "groceries" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "location": { - "description": "Fictional location; do not travel here.", - "address": { - "street": "1 Example Lane", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB", - "postal_code": "EX1 1EX" - }, - "coordinates": { - "latitude": 52.1, - "longitude": -1.1 - } - }, - "official_url": "https://providers.example.invalid/draft-example", - "access": { - "walk_in": "yes", - "appointment_required": "no", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "weekly", - "confirmed": true, - "note": "Example service hours; not live availability.", - "weekly": [ - { - "day": 1, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 2, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 3, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 4, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 5, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 6, - "state": "unknown", - "intervals": [] - }, - { - "day": 7, - "state": "unknown", - "intervals": [] - } - ], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "location", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "confirmed" - }, - "publication_status": "draft", - "service_condition": "active", - "updated_on": "2026-09-01" - }, - { - "id": "closed-example", - "organization_id": "example-community", - "name": "CLOSED SENTINEL", - "summary": "Fictional example: practical food support from a community-run program.", - "categories": [ - "groceries" - ], - "food_access_purpose": "Demonstrates a reviewed food-access service, not an ordinary commercial retailer.", - "location": { - "description": "Fictional location; do not travel here.", - "address": { - "street": "1 Example Lane", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB", - "postal_code": "EX1 1EX" - }, - "coordinates": { - "latitude": 52.1, - "longitude": -1.1 - } - }, - "official_url": "https://providers.example.invalid/closed-example", - "access": { - "walk_in": "yes", - "appointment_required": "no", - "registration_required": "unknown", - "identification_required": "unknown", - "details": [ - "This is example information. Check access arrangements with the operator." - ] - }, - "eligibility": [ - "Example: community residents." - ], - "limitations": [ - "Availability depends on the provider; these records are fictional." - ], - "before_you_go": [ - "Do not use this fictional listing to seek food." - ], - "cost": { - "state": "free", - "description": "Fictional example of a free service." - }, - "schedule": { - "kind": "weekly", - "confirmed": true, - "note": "Example service hours; not live availability.", - "weekly": [ - { - "day": 1, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 2, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 3, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 4, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 5, - "state": "published", - "intervals": [ - { - "opens": "09:00", - "closes": "17:00" - } - ] - }, - { - "day": 6, - "state": "unknown", - "intervals": [] - }, - { - "day": 7, - "state": "unknown", - "intervals": [] - } - ], - "exceptions": [], - "evidence_ids": [ - "example-source" - ] - }, - "evidence": [ - { - "id": "example-source", - "source_kind": "provider", - "url": "https://providers.example.invalid/about", - "checked_on": "2026-09-01", - "note": "Fictional evidence illustrating the data contract; no real provider verification is claimed.", - "supports": [ - "name", - "summary", - "food_access_purpose", - "location", - "access.walk_in", - "access.appointment_required", - "eligibility", - "cost", - "schedule" - ] - } - ], - "verification": { - "reviewed_on": "2026-09-01", - "method": "provider_source", - "state": "confirmed" - }, - "publication_status": "published", - "service_condition": "closed", - "updated_on": "2026-09-01" - } - ] -} diff --git a/site/site.json b/site/site.json deleted file mode 100644 index 9ec4741..0000000 --- a/site/site.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "schema_version": 1, - "deployment_id": "exampleville", - "community": { - "name": "Exampleville", - "locality": "Exampleville", - "region": "Exampleshire", - "country": "GB" - }, - "site_name": "Exampleville Food Help", - "short_name": "Food Help", - "description": "A fictional demonstration of a local food-access directory. These example services do not exist.", - "language": "en", - "locale": "en-GB", - "text_direction": "ltr", - "time_zone": "Europe/London", - "canonical_origin": "https://exampleville.example.invalid", - "operator": { - "name": "Example Community Volunteers", - "statement": "A fictional community directory demonstrating Food Help." - }, - "corrections": { - "email": "corrections@example.invalid" - }, - "help_contact": null, - "emergency_contact": null, - "review": { - "interval_days": 30 - }, - "location": { - "enabled": true - }, - "indexing": { - "enabled": false - }, - "analytics": { - "enabled": false - }, - "public_usage": { - "enabled": false - }, - "example_content": true, - "data_rights": { - "statement": "Fictional example data created for Food Help; dedicated to the public domain under CC0.", - "license": "CC0-1.0", - "url": "https://creativecommons.org/publicdomain/zero/1.0/" - } -} diff --git a/src/analytics.ts b/src/analytics.ts index 13c10c9..023b347 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -1,14 +1,119 @@ // SPDX-License-Identifier: MPL-2.0 import type { Site } from './types.ts'; -export type EventKind = 'page_start' | 'call' | 'directions' | 'source' | 'install'; -export function analytics(site: Site): { emit: (kind: EventKind) => void; allowed: () => boolean; setAllowed: (value: boolean) => void } { - const config = site.analytics, key = `food-help-${site.deployment_id}-analytics-preference`; - let preference = config?.collection_mode === 'opt_out'; - try { const saved = localStorage.getItem(key); if (saved !== null) preference = saved === 'yes'; } catch { /* Optional preference. */ } - const allowed = () => Boolean(config?.enabled && preference && navigator.doNotTrack !== '1' && !(navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl); - return { allowed, setAllowed(value) { preference = value; try { localStorage.setItem(key, value ? 'yes' : 'no'); } catch { /* In-memory preference remains effective. */ } }, emit(kind) { - if (!allowed() || !config?.endpoint || !navigator.onLine) return; - const body = JSON.stringify({ ...config.constants, event: config.event_names?.[kind] ?? kind }); - void fetch(config.endpoint, { method: 'POST', mode: 'cors', credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store', headers: { 'Content-Type': 'application/json' }, body, signal: AbortSignal.timeout(2500) }).catch(() => {}); - } }; + +export type EventKind = 'page_start' | 'call' | 'help' | 'directions' | 'source' | 'install'; +type Suppression = 'disabled' | 'preview' | 'privacy' | 'operator' | 'storage' | null; +type Attribution = { source: string; campaign: string; content: string }; +type AttributionConfig = NonNullable['attribution']>; +export interface AnalyticsEnvironment { + origin: string; url: string; referrer: string; + online(): boolean; visible(): boolean; privacySignal(): boolean; cookies(): string; + storage: Pick; + fetch: typeof fetch; now(): number; +} +const kinds: readonly EventKind[] = ['page_start', 'call', 'help', 'directions', 'source', 'install']; +const emptyAttribution = (): Attribution => ({ source: 'direct_unknown', campaign: 'none', content: 'none' }); + +/** Reduce public outreach labels in memory. No raw context leaves this boundary. */ +export function classifyAttribution(url: string, referrer: string, origin: string, policy?: AttributionConfig): Attribution { + const result = emptyAttribution(); + if (!policy) return result; + try { + const params = new URL(url).searchParams, source = params.get('src') ?? params.get('utm_source'); + if (source) result.source = policy.sources.includes(source) ? source : 'other'; + else if (referrer) { + const host = new URL(referrer).hostname; + const match = policy.referrers.find(item => host === item.host || (item.include_subdomains && host.endsWith(`.${item.host}`))); + if (match) result.source = match.source; + else if (host !== new URL(origin).hostname && !policy.internal_hosts.some(value => value === host)) result.source = 'other'; + } + const campaign = params.get('utm_campaign'), content = params.get('utm_content'); + if (campaign && policy.campaigns.includes(campaign)) result.campaign = campaign; + if (content && policy.contents.includes(content)) result.content = content; + } catch { /* Malformed optional context stays bounded. */ } + return result; +} + +/** The only event input is a fixed broad kind, never an element, provider or URL. */ +export function createAnalytics(site: Site, production: boolean, env: AnalyticsEnvironment) { + const config = site.analytics, key = config?.preference_key ?? `food-help-${site.deployment_id}-analytics-preference`; + let attribution = classifyAttribution(env.url, env.referrer, site.canonical_origin, config?.attribution); + let pageAttempted = false, installAttempted = false, storageFailed = false; + const lastAction = new Map(); + const pending = new Map }>(); + function pause(backgroundOnly = false): void { + for (const [controller, request] of pending) { + if (backgroundOnly && request.action && config?.click_keepalive) continue; + controller.abort(); clearTimeout(request.timer); pending.delete(controller); + } + } + function state(): { choice: boolean; suppression: Suppression } { + let choice = config?.collection_mode === 'opt_out', suppression: Suppression = null; + if (!config?.enabled) suppression = 'disabled'; + else if (!production || env.origin !== site.canonical_origin) suppression = 'preview'; + else try { + if (storageFailed) throw new Error('Preference unavailable'); + const saved = env.storage.getItem(key); + if (saved === 'yes' || saved === 'no') choice = saved === 'yes'; + if (env.storage.getItem('noAnalytics') === '1' || env.cookies().split(';').some(cookie => /^\s*dev_mode=/.test(cookie))) suppression = 'operator'; + if (env.privacySignal()) suppression = 'privacy'; + } catch { suppression = 'storage'; } + if (!choice || suppression) { attribution = emptyAttribution(); pause(); } + return { choice, suppression }; + } + const allowed = () => { const current = state(); return current.choice && !current.suppression; }; + function emit(kind: EventKind): void { + if (!kinds.includes(kind)) return; + if (kind === 'page_start') { + if (pageAttempted || !state().choice) return; + pageAttempted = true; + } else if (kind === 'install') { + if (installAttempted) return; + installAttempted = true; + } else { + const now = env.now(); + if (now - (lastAction.get(kind) ?? -Infinity) < 750) return; + lastAction.set(kind, now); + } + if (!allowed() || !config?.endpoint || !env.online() || !env.visible()) return; + // Directory startup/actions only; no resource-page browsing or other page history. + try { if (new URL(env.url).pathname !== '/') return; } catch { return; } + const event = config.event_payloads ? config.event_payloads[kind] : { event: config.event_names?.[kind] ?? kind }; + if (!event) return; + const body = JSON.stringify({ ...config.constants, ...event, ...(config.attribution?.events.includes(kind as Exclude) ? attribution : {}) }); + const action = kind !== 'page_start' && kind !== 'install', controller = new AbortController(); + const timer = setTimeout(() => { controller.abort(); pending.delete(controller); }, 1500); + pending.set(controller, { action, timer }); + try { + // One attempt; no response dependency, beacon, navigation wait, queue or retry. + void env.fetch(config.endpoint, { + method: 'POST', mode: 'cors', credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store', redirect: 'error', + headers: { 'Content-Type': config.content_type ?? 'application/json' }, body, signal: controller.signal, + ...(action && config.click_keepalive ? { keepalive: true } : {}), + }).catch(() => {}).finally(() => { clearTimeout(timer); pending.delete(controller); }); + } catch { clearTimeout(timer); pending.delete(controller); } + } + function setAllowed(value: boolean): void { + if (!value) { attribution = emptyAttribution(); pause(); } + try { env.storage.setItem(key, value ? 'yes' : 'no'); } catch { storageFailed = true; pause(); return; } + if (value) emit('page_start'); + } + function background(): void { state(); pause(true); } + return { emit, allowed, setAllowed, state, pause, background }; +} + +export function analytics(site: Site, production: boolean) { + const collection = createAnalytics(site, production, { + origin: location.origin, url: location.href, referrer: document.referrer, + online: () => navigator.onLine, visible: () => !document.hidden, + privacySignal: () => (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl === true || navigator.doNotTrack === '1' || (window as Window & { doNotTrack?: string }).doNotTrack === '1', + cookies: () => document.cookie, + storage: { getItem: key => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value) }, + fetch: window.fetch.bind(window), now: () => performance.now(), + }); + window.addEventListener('offline', () => collection.pause()); + window.addEventListener('pagehide', collection.background); + window.addEventListener('storage', () => collection.state()); + document.addEventListener('visibilitychange', () => { if (document.hidden) collection.background(); else collection.state(); }); + return collection; } diff --git a/src/assets/brand/logo.svg b/src/assets/brand/logo.svg index 3bb4c90..379d5b1 100644 --- a/src/assets/brand/logo.svg +++ b/src/assets/brand/logo.svg @@ -1 +1 @@ - + diff --git a/src/copy/en.ts b/src/copy/en.ts index 69d18b7..1bd9e1d 100644 --- a/src/copy/en.ts +++ b/src/copy/en.ts @@ -1,15 +1,19 @@ // SPDX-License-Identifier: MPL-2.0 export const copy = { - skip: 'Skip to directory', home: 'Food directory', eyebrow: 'Local food access', headline: 'Find food. Know what to expect.', - introduction: 'Food-access services, practical details, and published schedules in your community.', + skip: 'Skip to directory', home: 'Food directory', headline: (community: string) => `Find food help in ${community}`, + introduction: 'Find local food support. See where to go, when to visit, and what to bring.', + needHelp: 'Need help?', helpChoosing: 'Need help choosing?', warning: 'Published schedules do not mean live availability. Food, spaces and opening times can change. Check with the provider before travelling.', example: 'Fictional example — these services do not exist. This site demonstrates Food Help.', - directory: 'Find a food resource', all: 'All resources', search: 'Search by name, service or area', category: 'Type of support', - scheduledFilter: 'Open by published schedule', location: 'Sort using my location', clearLocation: 'Forget my location', + directory: 'What would help?', all: 'All food help', search: 'Search the directory', searchPlaceholder: 'Name, street or neighbourhood', category: 'Type of food help', + scheduledFilter: 'Open now or later today', location: 'Use my location', clearLocation: 'Forget my location', + locationOptional: 'Location is optional. Browse all listings without it.', + scheduleDisclaimer: 'Times are based on published schedules,', notLive: 'not live availability.', callBefore: 'Call before travelling when you can.', locationNotice: 'Location stays in memory on this device. Distance is straight-line, not a travel route.', locationFailed: 'Location was unavailable. You can still browse every resource.', - nearest: 'Approximate distance', resourceCount: (n: number) => `${n} resource${n === 1 ? '' : 's'}`, noResults: 'No resources match. Try another search or type of support.', - provider: 'Provided by', address: 'Location', area: 'Service area', phone: 'Phone', eligibility: 'Who can use this', limitations: 'Limitations', before: 'Before you go', - access: 'Access requirements', details: 'Details and sources', schedule: 'Published schedule', timezone: 'Local timezone', cost: 'Cost', languages: 'Service languages', + nearest: 'Approximate distance', resourceCount: (n: number) => `${n} reviewed ${n === 1 ? 'option' : 'options'} shown.`, noResults: 'No options match. Clear the search, choose All food help, or turn off the schedule filter.', + provider: 'Operated by', address: 'Where', today: 'Today', quickAccess: 'Access', area: 'Service area', phone: 'Phone', eligibility: 'Who can use this', limitations: 'Limitations', before: 'Before you go', + access: 'Access requirements', details: 'Before you go & weekly hours', schedule: 'Published schedule', timezone: 'Local timezone', cost: 'Cost', languages: 'Service languages', + foodPurpose: 'Food support provided', staticHours: 'See published hours below; confirm before travelling.', accessUnknown: 'Check access arrangements before travelling.', dropIn: 'Drop-in access', appointmentNeeded: 'Appointment required', registrationNeeded: 'Registration required', unknown: 'Not confirmed', yes: 'Yes', no: 'No', walkIn: 'Walk-ins accepted', appointment: 'Appointment required', registration: 'Registration required', identification: 'Identification required', conditions: { active: 'Operating service', temporarily_changed: 'Service temporarily changed', temporarily_unavailable: 'Service temporarily unavailable', closed: 'Closed' }, verification: { confirmed: 'Reviewed and confirmed', partially_confirmed: 'Some information unconfirmed', needs_review: 'Needs review' }, @@ -17,10 +21,12 @@ export const copy = { categories: { prepared_meals: 'Prepared meals', food_banks_pantries: 'Food banks and pantries', groceries: 'Groceries', community_fridges: 'Community fridges', community_markets: 'Community markets', community_gardens: 'Community gardens', food_delivery: 'Food delivery', food_access_support: 'Food-access support', other_food_access: 'Other food-access services' }, weekdays: ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], closed: 'No service by published schedule', nextDay: '(next day)', scheduled: { scheduled_now: 'Open by published schedule', not_scheduled_now: 'Outside published hours or currently unavailable', unknown: 'Check hours with the provider' }, + scheduleLabels: { now: 'Open by schedule', later: 'Later today', ended: 'No more hours today', none: 'No published hours today', unknown: 'Check hours before going', unavailable: 'Service temporarily unavailable' }, + appointmentHours: 'Appointment hours', appointmentLater: 'Appointments later today', bookingRequired: 'Booking required', until: 'Until', publishedOpening: 'Published opening', seeWeekly: 'See the weekly hours below.', scheduleKinds: { weekly: 'Weekly schedule', dated: 'Dated schedule', by_arrangement: 'Contact provider to arrange', unknown: 'Hours not confirmed' }, validity: 'Schedule date range', exceptions: 'Date-specific changes', unconfirmed: 'Unconfirmed', reviewed: 'Last reviewed', updated: 'Information updated', overdue: 'Review overdue — confirm details before travelling.', - source: 'Official website', sources: 'Evidence and provenance', checked: 'Source checked', supports: 'Supports', call: 'Call provider', directions: 'Directions', resourcePage: 'Resource page', - notices: 'Temporary notices', noticePeriod: 'Notice dates', report: 'Suggest a correction', noDetails: 'Not specified. Check with the provider.', + source: 'Official details', sources: 'Evidence and provenance', checked: 'Source checked', sourcesChecked: 'Sources checked', supports: 'Reviewed information', call: 'Call', directions: 'Directions', resourcePage: 'Resource page', + notices: 'Temporary notices', noticePeriod: 'Notice dates', report: 'Report a correction', noDetails: 'Not specified. Check with the provider.', simple: 'Simple and printable directory', download: 'Download the directory', downloadNote: 'Save this HTML file for a self-contained offline copy. It contains the information and source dates at download time.', methodology: 'How information is reviewed', methodologyText: 'Resources are published only after operator review. Evidence records identify sources, review dates and the facts they support. Unknown access rules remain unknown. Verification records describe a review, not a promise that service is available today.', methodologyScope: 'Food Help includes food-access and support services. An ordinary commercial retailer is included only for a reviewed food-access programme. Automated validation checks structure and consistency; a person must approve the facts and publication.', @@ -29,13 +35,17 @@ export const copy = { privacy: 'Privacy', privacyText: 'Browsing requires no account or cookies. Fonts and application assets are hosted here. Search and filters stay on your device. Optional location is used in memory for sorting and is not stored or sent by Food Help. Following a provider, phone or directions link opens the service you choose.', privacyOffline: 'This browser may retain the application and its most recent validated public dataset for offline use. Browser settings can clear this storage. Browsers can also evict it, so offline availability cannot be guaranteed indefinitely.', analyticsOff: 'Analytics collection is disabled.', analyticsOn: 'Optional usage collection', analyticsExplanation: 'Only configured aggregate event types are sent. No search text, filter choices, provider identity, location, visitor identifier or activity history is included. Network requests necessarily reach the configured operator endpoint; the operator is responsible for its logging and retention.', - analyticsPreference: 'Allow anonymous usage events', analyticsPreferenceNote: 'This preference is stored locally. Global Privacy Control and Do Not Track always disable collection.', + 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', + 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.', savingFailed: 'The current directory is available, but this browser could not save it for offline use.', appReady: 'Application ready for offline use.', appFailed: 'Offline setup was unavailable. Basic browsing still works.', offline: 'You are offline. Check review dates; details may have changed.', install: 'Install Food Help', installHelp: 'Use your browser’s Install app or Add to Home Screen option. Visit online once to save the directory, then check the offline-ready message.', updateReady: 'An application update is ready. Apply it when you are ready to reload this page.', update: 'Apply update', updateFailed: 'The update could not be applied. Your current directory is still available.', - refresh: 'Refresh directory', usage: 'Public usage', usageIntro: 'Sanitized totals for this deployment. These are event counts, not unique people or measures of demand. Missing totals are shown as unavailable.', + refresh: 'Refresh listings', usage: 'Public usage', usageIntro: 'Sanitized totals for this deployment. These are event counts, not unique people or measures of demand. Missing totals are shown as unavailable.', usagePeriod: 'Reporting period', usageGenerated: 'Aggregate prepared', usageMissing: 'Unavailable', usageCounts: { page_starts: 'Page starts', call_actions: 'Call actions', directions_actions: 'Directions actions', source_link_actions: 'Source-link actions', installation_signals: 'Installation signals' }, notFound: 'Page not found', notFoundText: 'Return to the directory to find a published resource.', diff --git a/src/domain.ts b/src/domain.ts index 2d50d3f..9ac200d 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -9,13 +9,16 @@ export function localTime(now: Date, zone: string): { date: string; day: number; export function reviewOverdue(resource: Resource, site: Site, now: Date): boolean { return (Date.parse(localTime(now, resource.time_zone ?? site.time_zone).date) - Date.parse(resource.verification.reviewed_on)) / 86400000 > site.review.interval_days; } -export function scheduledStatus(resource: Resource, site: Site, now: Date): 'scheduled_now' | 'not_scheduled_now' | 'unknown' { +type Interval = Resource['schedule']['weekly'][number]['intervals'][number]; +export type ScheduleView = { kind: 'now' | 'later' | 'ended' | 'none' | 'unknown' | 'unavailable'; intervals: Interval[]; current?: Interval; next?: Interval; note?: string }; +export function publishedSchedule(resource: Resource, site: Site, now: Date): ScheduleView { const s = resource.schedule, today = localTime(now, resource.time_zone ?? site.time_zone); - if (resource.service_condition === 'temporarily_unavailable' || resource.service_condition === 'closed') return 'not_scheduled_now'; - if (!s.confirmed || reviewOverdue(resource, site, now) || resource.verification.state === 'needs_review' || (s.valid_from && today.date < s.valid_from) || (s.valid_to && today.date > s.valid_to)) return 'unknown'; + if (resource.service_condition === 'temporarily_unavailable' || resource.service_condition === 'closed') return { kind: 'unavailable', intervals: [] }; + if (!s.confirmed || reviewOverdue(resource, site, now) || resource.verification.state === 'needs_review' || (s.valid_from && today.date < s.valid_from) || (s.valid_to && today.date > s.valid_to)) return { kind: 'unknown', intervals: [] }; const jurisdiction = site.jurisdiction; const previous = new Date(Date.parse(today.date) - 86400000).toISOString().slice(0, 10); const select = (date: string, day: number) => { + if ((s.valid_from && date < s.valid_from) || (s.valid_to && date > s.valid_to)) return null; const exception = s.exceptions.find(item => item.date === date); if (exception) return exception.confirmed ? exception.intervals : null; if (jurisdiction && (date > jurisdiction.coverage_through || jurisdiction.warning_dates.some(item => item.date === date))) return null; @@ -26,9 +29,29 @@ export function scheduledStatus(resource: Resource, site: Site, now: Date): 'sch const current = select(today.date, today.day), before = select(previous, (today.day + 5) % 7 + 1); // A date exception or jurisdiction warning applies to the whole local day, including overnight carry-in. const override = s.exceptions.some(item => item.date === today.date) || jurisdiction?.warning_dates.some(item => item.date === today.date); - if (current?.some(item => today.time >= item.opens && (item.closes_next_day || today.time < item.closes))) return 'scheduled_now'; - if (!override && before?.some(item => item.closes_next_day && today.time < item.closes)) return 'scheduled_now'; - return current === null ? 'unknown' : 'not_scheduled_now'; + const carried = !override ? before?.find(item => item.closes_next_day && today.time < item.closes) : undefined; + const ordered = [...(current ?? [])].sort((a, b) => a.opens.localeCompare(b.opens)); + const active = ordered.find(item => today.time >= item.opens && (item.closes_next_day || today.time < item.closes)); + const note = s.exceptions.find(item => item.date === today.date)?.note; + // Carry-in ends today; display only its remaining part in today's hours. + const intervals = carried ? [{ opens: '00:00', closes: carried.closes }, ...ordered] : ordered; + if (active || carried) return { kind: 'now', intervals, current: active ?? { ...carried!, closes_next_day: false }, note }; + if (current === null) return { kind: 'unknown', intervals: [], note }; + const next = ordered.find(item => item.opens > today.time); + return { kind: next ? 'later' : ordered.length ? 'ended' : 'none', intervals, next, note }; +} +export function scheduledStatus(resource: Resource, site: Site, now: Date): 'scheduled_now' | 'not_scheduled_now' | 'unknown' { + const view = publishedSchedule(resource, site, now); + return view.kind === 'now' ? 'scheduled_now' : view.kind === 'unknown' ? 'unknown' : 'not_scheduled_now'; +} +export function scheduledToday(resource: Resource, site: Site, now: Date): boolean { + return ['now', 'later'].includes(publishedSchedule(resource, site, now).kind); +} +export function categoryGroups(site: Site, resources: Resource[]): { id: string; label: string; categories: string[] }[] { + const present = new Set(resources.flatMap(resource => resource.categories)); + const configured = site.presentation?.category_groups; + if (configured) return configured.filter(group => group.categories.some(category => present.has(category as Resource['categories'][number]))); + return [...present].map(category => ({ id: category, label: category, categories: [category] })); } export function distanceKm(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }): number { const rad = (n: number) => n * Math.PI / 180, dlat = rad(b.latitude - a.latitude), dlon = rad(b.longitude - a.longitude); diff --git a/src/main.ts b/src/main.ts index b1a7daf..1f4cb74 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,58 +1,90 @@ // SPDX-License-Identifier: MPL-2.0 import './styles.css'; -import configuration from '../.generated/runtime-config.json'; +import configuration from 'virtual:food-help-config'; import type { PublicDataset, RuntimeConfig } from './types.ts'; import { copy as c } from './copy/en.ts'; -import { card } from './presentation.ts'; -import { distanceKm, reviewOverdue, scheduledStatus, searchable } from './domain.ts'; +import { card, formatDate } from './presentation.ts'; +import { distanceKm, scheduledToday, searchable, categoryGroups } from './domain.ts'; import { loadData } from './data-loader.ts'; import { analytics, type EventKind } from './analytics.ts'; const config = configuration as RuntimeConfig, site = config.site; const byId = (id: string) => document.getElementById(id) as T | null; const status = (id: string, text: string) => { const element = byId(id); if (element) element.textContent = text; }; -const collection = analytics(site); -let dataset: PublicDataset | null = null, position: { latitude: number; longitude: number } | null = null, loading = false; -const results = byId('resource-list'), search = byId('search'), category = byId('category'), scheduled = byId('scheduled'); +const collection = analytics(site, config.production); +let dataset: PublicDataset | null = null, position: { latitude: number; longitude: number } | null = null, loading = false, selectedCategory = 'all'; +const results = byId('resource-list'), search = byId('search'), scheduled = byId('scheduled'); function render(): void { if (!dataset || !results) return; const now = new Date(), query = (search?.value ?? '').normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase().trim(); - let resources = dataset.resources.filter(r => (!query || searchable(r, dataset!.organizations.find(o => o.id === r.organization_id)!.name).includes(query)) && (!category?.value || r.categories.includes(category.value as typeof r.categories[number])) && (!scheduled?.checked || scheduledStatus(r, site, now) === 'scheduled_now')); + const groups = categoryGroups(site, dataset.resources); + // Retained or refreshed data may add/remove categories; keep every resource reachable. + const buttons = document.querySelector('.segmented-control'); + if (buttons) { + const ids = ['all', ...groups.map(group => group.id)]; + if (!ids.includes(selectedCategory)) selectedCategory = 'all'; + if (Array.from(buttons.querySelectorAll('[data-category]')).map(button => button.dataset.category).join('|') !== ids.join('|')) { + buttons.replaceChildren(...[{ id: 'all', label: c.all }, ...groups.map(group => ({ id: group.id, label: site.presentation?.category_groups ? group.label : c.categories[group.id as keyof typeof c.categories] }))].map(group => { + const button = document.createElement('button'); button.type = 'button'; button.dataset.category = group.id; button.textContent = group.label; return button; + })); + } + buttons.querySelectorAll('[data-category]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.category === selectedCategory))); + } + const categories = groups.find(group => group.id === selectedCategory)?.categories; + let resources = dataset.resources.filter(r => (!query || searchable(r, dataset!.organizations.find(o => o.id === r.organization_id)!.name).includes(query)) && (!categories || r.categories.some(category => categories.includes(category))) && (!scheduled?.checked || scheduledToday(r, site, now))); if (position) resources = [...resources].sort((a, b) => (a.location?.coordinates ? distanceKm(position!, a.location.coordinates) : Infinity) - (b.location?.coordinates ? distanceKm(position!, b.location.coordinates) : Infinity)); const opened = new Set(Array.from(results.querySelectorAll('article:has(details[open])')).map(el => el.getAttribute('data-resource-id'))); - results.innerHTML = resources.map(r => card(r, dataset!, site)).join('') || `

${c.noResults}

`; + const focused = document.activeElement instanceof HTMLElement && results.contains(document.activeElement) ? { id: document.activeElement.closest('[data-resource-id]')?.getAttribute('data-resource-id'), index: Array.from(document.activeElement.closest('article')!.querySelectorAll('a,button,summary')).indexOf(document.activeElement) } : null; + results.innerHTML = resources.map(r => card(r, dataset!, site, false, 3, now)).join('') || `

${c.noResults}

`; for (const resource of resources) { const element = results.querySelector(`[data-resource-id="${resource.id}"]`)!; - const schedule = element.querySelector('[data-schedule-status]')!; - schedule.textContent = c.scheduled[scheduledStatus(resource, site, now)]; - if (position && resource.location?.coordinates) schedule.textContent += ` · ${c.nearest}: ${new Intl.NumberFormat(site.locale, { maximumFractionDigits: 1 }).format(distanceKm(position, resource.location.coordinates))} km`; - if (reviewOverdue(resource, site, now)) element.querySelector('[data-review-warning]')!.textContent = c.overdue; + if (position && resource.location?.coordinates) { + const location = element.querySelector('[data-location]')!; + location.textContent = `${c.nearest}: ${new Intl.NumberFormat(site.locale, { maximumFractionDigits: 1 }).format(distanceKm(position, resource.location.coordinates))} km · ${location.textContent}`; + } if (opened.has(resource.id)) element.querySelector('details')!.open = true; } + if (focused) results.querySelector(`[data-resource-id="${focused.id}"]`)?.querySelectorAll('a,button,summary')[focused.index]?.focus({ preventScroll: true }); status('result-status', c.resourceCount(resources.length)); } async function refresh(): Promise { if (loading) return; loading = true; const result = await loadData(config); - if (result.data) { dataset = result.data; render(); byId('filters')?.removeAttribute('hidden'); } - status('data-status', result.source === 'network' && !result.retained ? c.savingFailed : c[result.source]); + if (result.data) { + dataset = result.data; render(); byId('filters')?.removeAttribute('hidden'); byId('refresh')?.removeAttribute('hidden'); + status('data-status', dataset.data_updated_on ? `${result.source === 'cached' ? c.savedCopy : c.listingsUpdated} · ${formatDate(dataset.data_updated_on, site)}` : ''); + } + status('data-retention-status', result.source === 'network' && result.retained ? '' : result.source === 'network' ? c.savingFailed : c[result.source]); loading = false; } -search?.addEventListener('input', render); category?.addEventListener('change', render); scheduled?.addEventListener('change', render); +search?.addEventListener('input', render); scheduled?.addEventListener('change', render); +document.querySelector('.segmented-control')?.addEventListener('click', event => { + const button = event.target instanceof Element ? event.target.closest('[data-category]') : null; + if (button) { selectedCategory = button.dataset.category!; render(); } +}); byId('refresh')?.addEventListener('click', () => void refresh()); +byId('refresh')?.removeAttribute('hidden'); byId('locate')?.addEventListener('click', () => { if (!site.location?.enabled || !navigator.geolocation) { status('location-status', c.locationFailed); return; } navigator.geolocation.getCurrentPosition(result => { position = { latitude: result.coords.latitude, longitude: result.coords.longitude }; render(); byId('forget-location')?.removeAttribute('hidden'); status('location-status', c.locationNotice); }, () => status('location-status', c.locationFailed), { enableHighAccuracy: false, timeout: 8000, maximumAge: 0 }); }); -byId('forget-location')?.addEventListener('click', () => { position = null; render(); byId('forget-location')?.setAttribute('hidden', ''); status('location-status', ''); }); +byId('forget-location')?.addEventListener('click', () => { position = null; render(); byId('forget-location')?.setAttribute('hidden', ''); status('location-status', c.locationOptional); byId('locate')?.focus(); }); if (site.analytics?.enabled) { const preference = byId('analytics-preference'); - if (preference) { preference.disabled = false; preference.checked = collection.allowed(); preference.addEventListener('change', () => { collection.setAllowed(preference.checked); preference.checked = collection.allowed(); }); } + function renderPreference(): void { + const current = collection.state(); + if (preference) { preference.disabled = Boolean(current.suppression); preference.checked = collection.allowed(); } + status('analytics-status', current.suppression ? c.analyticsSuppressed[current.suppression] : collection.allowed() ? c.analyticsEnabled : c.analyticsDisabled); + } + if (preference) { preference.addEventListener('change', () => { collection.setAllowed(preference.checked); renderPreference(); }); } + window.addEventListener('storage', renderPreference); + document.addEventListener('visibilitychange', renderPreference); + renderPreference(); } -document.addEventListener('click', event => { const target = event.target instanceof Element ? event.target.closest('[data-event]') : null; const kind = target?.dataset.event; if (['call', 'directions', 'source'].includes(kind ?? '')) collection.emit(kind as EventKind); }); +document.addEventListener('click', event => { const target = event.target instanceof Element ? event.target.closest('[data-event]') : null; const kind = target?.dataset.event; if (['call', 'help', 'directions', 'source'].includes(kind ?? '')) collection.emit(kind as EventKind); }); collection.emit('page_start'); -window.addEventListener('online', () => { status('connection-status', ''); void refresh(); }); +window.addEventListener('online', () => { status('connection-status', c.online); void refresh(); }); window.addEventListener('offline', () => status('connection-status', c.offline)); -if (!navigator.onLine) status('connection-status', c.offline); +status('connection-status', navigator.onLine ? c.online : c.offline); void refresh(); // Refresh schedule-derived wording while browsing, without collecting interactions or making network requests. setInterval(() => { if (!document.hidden && !results?.contains(document.activeElement)) render(); }, 60_000); diff --git a/src/presentation.ts b/src/presentation.ts index 8d74136..6fd8226 100644 --- a/src/presentation.ts +++ b/src/presentation.ts @@ -1,19 +1,62 @@ // SPDX-License-Identifier: MPL-2.0 import { copy as c } from './copy/en.ts'; +import { localTime, publishedSchedule, reviewOverdue } from './domain.ts'; import type { Resource, PublicDataset, Site } from './types.ts'; export const escape = (value: unknown): string => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!); export const resourcePath = (id: string): string => `/resources/${id}/`; export const list = (items: readonly string[]): string => items.length ? `
    ${items.map(item => `
  • ${escape(item)}
  • `).join('')}
` : `

${c.noDetails}

`; -export function address(resource: Resource): string { return resource.location?.address ? Object.values(resource.location.address).join(', ') : resource.location?.description ?? resource.service_area ?? c.unknown; } +export function formatDate(value: string, site: Site): string { + return new Intl.DateTimeFormat(site.locale, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${value}T12:00:00Z`)); +} +export function formatTime(value: string, site: Site): string { + return new Intl.DateTimeFormat(site.locale, { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(new Date(`2000-01-01T${value}:00Z`)); +} +export function address(resource: Resource, compact = false): string { + const a = resource.location?.address; + if (!a) return resource.location?.description ?? resource.service_area ?? c.unknown; + if (compact) return [a.street, a.neighbourhood && !a.street.includes(a.neighbourhood) ? a.neighbourhood : a.locality].join(', '); + return [a.street, a.locality, a.region, a.postal_code, a.country].filter(Boolean).join(', '); +} +const times = (items: Resource['schedule']['weekly'][number]['intervals'], site: Site) => items.map(item => `${formatTime(item.opens, site)}–${formatTime(item.closes, site)}${item.closes_next_day ? ` ${c.nextDay}` : ''}`).join(', '); export function schedule(resource: Resource, site: Site, headingLevel = 5): string { const s = resource.schedule; - const times = (items: typeof s.weekly[number]['intervals']) => items.map(item => `${escape(item.opens)}–${escape(item.closes)} ${item.closes_next_day ? c.nextDay : ''}`).join(', '); - return `

${c.scheduleKinds[s.kind]}${s.confirmed ? '' : ` — ${c.unconfirmed}`}. ${escape(s.note)}

${c.timezone}: ${escape(resource.time_zone ?? site.time_zone)}

${s.valid_from || s.valid_to ? `

${c.validity}: ${escape(s.valid_from ?? c.unknown)} – ${escape(s.valid_to ?? c.unknown)}

` : ''}${s.weekly.length ? `
    ${s.weekly.map(day => `
  • ${c.weekdays[day.day - 1]}: ${day.state === 'unknown' ? c.unknown : day.state === 'closed' ? c.closed : times(day.intervals)}
  • `).join('')}
` : ''}${s.exceptions.length ? `${c.exceptions}
    ${s.exceptions.map(item => `
  • ${escape(item.date)}: ${item.confirmed ? (times(item.intervals) || c.closed) : c.unconfirmed}. ${escape(item.note)}
  • `).join('')}
` : ''}`; + return `

${c.scheduleKinds[s.kind]}${s.confirmed ? '' : ` — ${c.unconfirmed}`}. ${escape(s.note)}

${c.timezone}: ${escape(resource.time_zone ?? site.time_zone)}

${s.valid_from || s.valid_to ? `

${c.validity}: ${s.valid_from ? formatDate(s.valid_from, site) : c.unknown} – ${s.valid_to ? formatDate(s.valid_to, site) : c.unknown}

` : ''}${s.weekly.length ? `
    ${s.weekly.map(day => `
  • ${c.weekdays[day.day - 1]}: ${day.state === 'unknown' ? c.unknown : day.state === 'closed' ? c.closed : times(day.intervals, site)}
  • `).join('')}
` : ''}${s.exceptions.length ? `${c.exceptions}
    ${s.exceptions.map(item => `
  • ${formatDate(item.date, site)}: ${item.confirmed ? (times(item.intervals, site) || c.closed) : c.unconfirmed}. ${escape(item.note)}
  • `).join('')}
` : ''}`; +} +export function accessSummary(r: Resource): string { + if (r.access.appointment_required === 'yes') return c.appointmentNeeded; + if (r.access.registration_required === 'yes') return r.access.details[0] ?? c.registrationNeeded; + if (r.access.walk_in === 'yes') return c.dropIn; + return r.access.details[0] ?? c.accessUnknown; +} +export function scheduleSummary(r: Resource, site: Site, now?: Date): { kind: string; label: string; detail: string; today: string } { + if (!now) return { kind: 'unknown', label: r.schedule.confirmed ? c.schedule : c.scheduleLabels.unknown, detail: c.callBefore, today: r.schedule.confirmed ? c.staticHours : r.schedule.note }; + const view = publishedSchedule(r, site, now), appointment = r.access.appointment_required === 'yes'; + const label = view.kind === 'now' && appointment ? c.appointmentHours : view.kind === 'later' && appointment ? c.appointmentLater : c.scheduleLabels[view.kind]; + const detail = view.current ? `${appointment ? `${c.bookingRequired} · ` : ''}${c.until} ${formatTime(view.current.closes, site)}${view.current.closes_next_day ? ` ${c.nextDay}` : ''} · ${c.callBefore}` : view.next ? `${c.publishedOpening} ${formatTime(view.next.opens, site)}${appointment ? ` · ${c.bookingRequired}` : ''}` : view.kind === 'unknown' ? (reviewOverdue(r, site, now) ? c.overdue : c.callBefore) : c.seeWeekly; + const today = view.kind === 'unknown' ? (reviewOverdue(r, site, now) ? c.overdue : r.schedule.note) : view.kind === 'unavailable' ? c.conditions[r.service_condition] : `${times(view.intervals, site) || c.closed}${view.note ? ` · ${view.note}` : ''}`; + return { kind: view.kind, label, detail, today }; +} +function evidenceField(field: string): string { + const labels: Record = { name: 'Service name', summary: 'Service description', food_access_purpose: c.foodPurpose, official_url: c.source, location: 'Location', phone: c.phone, eligibility: c.eligibility, limitations: c.limitations, before_you_go: c.before, schedule: c.schedule, cost: c.cost, service_area: c.area, 'access.walk_in': c.walkIn, 'access.appointment_required': c.appointment, 'access.registration_required': c.registration, 'access.identification_required': c.identification }; + return labels[field] ?? field.replaceAll('_', ' ').replaceAll('.', ': '); } -export function card(resource: Resource, data: PublicDataset, site: Site, full = false, headingLevel = full ? 1 : 3): string { +export function card(resource: Resource, data: PublicDataset, site: Site, full = false, headingLevel = full ? 1 : 3, now?: Date): string { const r = resource, org = data.organizations.find(item => item.id === r.organization_id)!; const field = (title: string, value: string) => `
${title}${value}
`; - const detail = `${field(c.schedule, schedule(r, site, headingLevel + 2))}${field(c.access, `
${(['walk_in', 'appointment_required', 'registration_required', 'identification_required'] as const).map((key, i) => `
${[c.walkIn, c.appointment, c.registration, c.identification][i]}
${c[r.access[key]]}
`).join('')}
${list(r.access.details)}`)}${field(c.eligibility, list(r.eligibility))}${field(c.limitations, list(r.limitations))}${field(c.before, list(r.before_you_go))}${r.cost ? field(c.cost, `

${escape(r.cost.description)}

`) : ''}${r.service_languages?.length ? field(c.languages, list(r.service_languages)) : ''}${r.notices?.length ? field(c.notices, list(r.notices.map(item => `${item.text} (${c.noticePeriod}: ${item.starts_on} – ${item.ends_on ?? c.unknown})`))) : ''}${field(c.sources, `
    ${r.evidence.map(e => `
  • ${e.url ? `${escape(e.url)}` : escape(c.methods.direct_confirmation)}

    ${c.checked}: . ${escape(e.note)}

    ${c.supports}: ${escape(e.supports.join(', '))}

  • `).join('')}

${c.methods[r.verification.method]}. ${c.verification[r.verification.state]}.

`)}`; - const level = `h${headingLevel}`; - return `
${r.categories.map(cat => `${c.categories[cat]}`).join('')}
<${level}>${full ? escape(r.name) : `${escape(r.name)}`}

${c.provider} ${escape(org.name)}

${escape(r.summary)}

${escape(r.food_access_purpose)}

${c.warning}

${c.conditions[r.service_condition]}

${c.address}
${escape(address(r))}
${r.phone ? `
${c.phone}
${escape(r.phone)}${r.phone_extension ? ` ${escape(r.phone_extension)}` : ''}
` : ''}
${full ? `
${detail}
` : `
${c.details}
${detail}
`}
${r.phone ? `${c.call}` : ''}${r.location?.address ? `${c.directions}` : ''}${r.official_url ? `${c.source}` : ''}

${c.reviewed}: · ${c.updated}:

`; + const facts = `${field(c.address, `

${escape(address(r))}

${r.phone ? `

${c.phone}: ${escape(r.phone)}${r.phone_extension ? ` · ext ${escape(r.phone_extension)}` : ''}

` : ''}`)}${field(c.access, `
${(['walk_in', 'appointment_required', 'registration_required', 'identification_required'] as const).map((key, i) => `
${[c.walkIn, c.appointment, c.registration, c.identification][i]}
${c[r.access[key]]}
`).join('')}
${list(r.access.details)}`)}${field(c.eligibility, list(r.eligibility))}${field(c.limitations, list(r.limitations))}${field(c.before, list(r.before_you_go))}${field(c.schedule, schedule(r, site, headingLevel + 2))}`; + const evidence = `

${c.methods[r.verification.method]}. ${c.verification[r.verification.state]}.

`; + const detail = `${facts}${r.food_access_purpose !== r.summary ? field(c.foodPurpose, `

${escape(r.food_access_purpose)}

`) : ''}${r.cost ? field(c.cost, `

${escape(r.cost.description)}

`) : ''}${r.service_languages?.length ? field(c.languages, list(r.service_languages)) : ''}${r.notices?.length ? field(c.notices, list(r.notices.map(item => `${item.text} (${formatDate(item.starts_on, site)}${item.ends_on ? ` – ${formatDate(item.ends_on, site)}` : ''})`))) : ''}${field(c.sources, evidence)}`; + const status = scheduleSummary(r, site, now), level = `h${headingLevel}`; + const today = now ? localTime(now, r.time_zone ?? site.time_zone).date : undefined; + const notices = (r.notices ?? []).filter(item => !today || (item.starts_on <= today && (!item.ends_on || item.ends_on >= today))); + const quickFact = (name: string, value: string, attr = '') => `
${name}
${escape(value)}
`; + const link = (url: string, text: string, style: string, event: string) => `${escape(text)}`; + return `
+
${r.categories.map(cat => `${c.categories[cat]}`).join('')}
<${level}>${full ? escape(r.name) : `${escape(r.name)}`}

${c.provider} ${escape(org.name)}

+
${escape(status.label)}${escape(status.detail)}
+ ${r.service_condition !== 'active' ? `

${c.conditions[r.service_condition]}

` : ''}${notices.map(notice => `

${escape(notice.text)}

`).join('')} +

${escape(r.summary)}

${quickFact(c.address, address(r, !full), ' data-location')}${quickFact(c.today, status.today)}${quickFact(c.quickAccess, accessSummary(r))}
+ ${full ? `
${detail}
` : `
${c.details}
${detail}
`} +
${r.phone ? link(`tel:${r.phone}${r.phone_extension ? `;ext=${r.phone_extension}` : ''}`, `${c.call}${r.phone_extension ? ` · ext ${r.phone_extension}` : ''}`, 'button-primary', 'call') : ''}${r.location?.address ? link(`https://www.openstreetmap.org/search?query=${encodeURIComponent(address(r))}`, c.directions, 'button-secondary', 'directions') : ''}${r.official_url ? link(r.official_url, c.source, 'button-quiet', 'source') : ''}
+

${c.sourcesChecked} ${full && r.updated_on !== r.verification.reviewed_on ? ` · ${c.updated} ${formatDate(r.updated_on, site)}` : ''}

${now && reviewOverdue(r, site, now) ? c.overdue : ''}

`; } diff --git a/src/runtime-config.d.ts b/src/runtime-config.d.ts new file mode 100644 index 0000000..e3f3759 --- /dev/null +++ b/src/runtime-config.d.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MPL-2.0 +declare module 'virtual:food-help-config' { + const config: import('./types.ts').RuntimeConfig; + export default config; +} diff --git a/src/styles.css b/src/styles.css index 042070e..160833f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -15,14 +15,34 @@ --caution:#faecd9; color-scheme:light; } -select {font:inherit;max-width:100%;padding:.7rem;border:1px solid var(--line);border-radius:6px;background:var(--white);color:inherit} .notice{padding:1rem;background:var(--caution);border-left:4px solid var(--brick);line-height:1.5} .example-notice{font-weight:650} -.filters>label:not(.search-control):not(.check-control){display:grid;gap:.4rem} -.site-footer{max-width:1120px;margin:3rem auto;padding:1.5rem} .detail-grid dl{margin:0}.detail-grid dt{font-weight:650}.detail-grid dd{margin:0 0 .5rem} .resource-card h1{font-size:clamp(1.8rem,5vw,2.6rem)} -.resource-card h3 a{color:inherit} +.resource-card h3 a{color:inherit;text-decoration:none} +.resource-card h3 a:hover,.resource-card h3 a:focus-visible{text-decoration:underline} +.hero h1{max-width:none} +.content-page{padding-top:1.5rem} +.page-navigation{margin:0 0 1.5rem} +.content-page>h1{max-width:32ch;margin-bottom:1.5rem} +.content-page>p{max-width:75ch} +.location-feedback{display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem 1rem} +.review-warning{font-size:.9rem;color:var(--brick)} +.source-links{gap:.8rem} +.source-links li{min-width:0;max-width:100%} +.source-links a{display:inline-block;min-height:44px;padding:.5rem 0} +.operator-statement{white-space:pre-line} +.footer-inner,.footer-navigation{width:min(1120px,calc(100% - 3rem));margin:auto} +.footer-inner{display:flex;justify-content:space-between;align-items:center;gap:2rem} +.footer-inner>div{flex:1;min-width:0} +.emergency-contact{flex:0 1 auto;max-width:28rem} +.footer-navigation{display:flex;flex-wrap:wrap;gap:.25rem 1.25rem;padding-top:1rem} +.footer-navigation a{display:inline-flex;align-items:center;min-height:44px} +.offline-status span:empty{display:none} +.offline-status #data-retention-status{max-width:65ch} +.page-install:has(button[hidden]){display:none} +.card-actions>.button:only-child{grid-column:1/-1} +.home-page:focus,.content-page:focus{outline:none} @media print {details>*{display:block!important}details>summary{display:none!important}.filters,#install,.update-notice{display:none!important}} * { box-sizing:border-box; @@ -140,6 +160,7 @@ a,button,input,summary { gap:3rem; padding:2rem 0 1.75rem; } +.hero:not(:has(.help-panel)){grid-template-columns:1fr} .eyebrow { margin:0 0 .8rem; color:var(--brick); @@ -438,6 +459,9 @@ h1 em { .availability-available_now,.availability-opening_soon { border-color:var(--teal); } +.availability-now,.availability-later{border-color:var(--teal)} +.availability-now strong{color:#225b3c} +.availability-unknown,.availability-unavailable{border-color:var(--brick)} .availability-available_now strong { color:#225b3c; } @@ -634,11 +658,9 @@ h1 em { background:var(--brick); } .site-footer { - display:flex; - justify-content:space-between; - align-items:center; - gap:2rem; - padding:1.8rem max(1.5rem,calc((100% - 1120px)/2)); + width:100%; + margin:0; + padding:1.8rem 0; background:var(--navy); color:var(--white); font-size:.9rem; @@ -800,11 +822,12 @@ h1 em { .about-intro h2 { font-size:1.7rem; } - .site-footer { + .footer-inner { display:block; - padding:1.5rem 1rem; } - .site-footer>p { + .footer-inner,.footer-navigation{width:calc(100% - 2rem)} + .site-footer{padding:1.5rem 0} + .footer-inner>p { margin-top:1.2rem; } .update-notice { diff --git a/src/types.ts b/src/types.ts index f11e071..e13f6b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,4 +6,4 @@ export type Resource = Dataset['resources'][number]; export type Organization = Dataset['organizations'][number]; export type PublicDataset = Dataset & Required>; export type Usage = { schema_version: 1; deployment_id: string; period_start: string; period_end: string; generated_at: string; counts: Record<'page_starts' | 'call_actions' | 'directions_actions' | 'source_link_actions' | 'installation_signals', number | null> }; -export type RuntimeConfig = { site: Site; compatibility_id: string; data_url: string }; +export type RuntimeConfig = { site: Site; compatibility_id: string; data_url: string; production: boolean }; diff --git a/src/validation.ts b/src/validation.ts index 6c080a8..3fa1940 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 import { validateSite, validateSource, validatePublic, validateUsage } from '../.generated/validators.js'; import type { Site, Dataset, PublicDataset, Resource, Usage } from './types.ts'; +import { copy } from './copy/en.ts'; function requireValid(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } export function validDate(value: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value; } @@ -23,10 +24,33 @@ export function assertSite(value: unknown): asserts value is Site { requireValid(new URL(site.canonical_origin).origin === site.canonical_origin, 'canonical_origin must be an HTTPS origin without a path or trailing slash'); requireValid(site.language === 'en' && site.text_direction === 'ltr', 'v1 ships English UI; add a reviewed copy pack before enabling another UI language'); new Intl.DateTimeFormat(site.locale).format(); + if (site.presentation?.category_groups) { + unique(site.presentation.category_groups.map(group => group.id), 'category group IDs'); + for (const group of site.presentation.category_groups) { + requireValid(group.id !== 'all', 'The all category group is built in'); + requireValid(group.categories.every(category => Object.hasOwn(copy.categories, category)), 'Unknown category in presentation group'); + } + } if (site.analytics?.enabled) { requireValid(site.analytics.endpoint && site.analytics.collection_mode && site.analytics.disclosure, 'Enabled analytics requires endpoint, collection_mode and disclosure'); const endpoint = new URL(site.analytics.endpoint); requireValid(!endpoint.search && !endpoint.hash, 'Analytics endpoint cannot contain query or fragment'); - } else requireValid(!site.analytics?.endpoint && !site.analytics?.constants && !site.analytics?.event_names, 'Disabled analytics must not configure collection'); + const analytics = site.analytics; + if (analytics.event_payloads) { + requireValid(!analytics.event_names, 'Choose static event payloads or event names, not both'); + requireValid(['page_start', 'call', 'help', 'directions', 'source', 'install'].every(event => Object.hasOwn(analytics.event_payloads!, event)), 'Static event payloads must explicitly cover every supported event'); + for (const payload of Object.values(analytics.event_payloads)) requireValid(!Object.keys(payload).some(key => Object.hasOwn(analytics.constants ?? {}, key)), 'Event payloads cannot override shared constants'); + } else requireValid(!Object.hasOwn(analytics.constants ?? {}, 'event'), 'Constants cannot override the event name'); + if (analytics.attribution) { + const attribution = analytics.attribution; + requireValid(attribution.sources.includes('direct_unknown') && attribution.sources.includes('other') && attribution.campaigns.includes('none') && attribution.contents.includes('none'), 'Attribution requires bounded fallback labels'); + unique(attribution.referrers.map(item => item.host), 'attribution referrer hosts'); + const hosts = [...attribution.internal_hosts, ...attribution.referrers.map(item => item.host)]; + requireValid(hosts.every(host => new URL(`https://${host}`).hostname === host && !host.includes('..')), 'Attribution requires normalized hostnames'); + requireValid(attribution.referrers.every(item => attribution.sources.includes(item.source)), 'Referrer source must be allowlisted'); + const reserved = ['source', 'campaign', 'content']; + requireValid(!reserved.some(key => Object.hasOwn(analytics.constants ?? {}, key)) && Object.values(analytics.event_payloads ?? {}).every(payload => !reserved.some(key => Object.hasOwn(payload, key))), 'Static fields cannot override bounded attribution'); + } + } else requireValid(Object.keys(site.analytics ?? {}).every(key => key === 'enabled'), 'Disabled analytics must not configure collection'); if (site.public_usage?.enabled) requireValid(site.public_usage.file === 'usage.json', 'Public usage needs site/usage.json'); const branding = site.branding; if (branding?.logo || branding?.icon_192 || branding?.icon_512) requireValid(branding.logo && branding.icon_192 && branding.icon_512, 'Branding requires logo and both sized icons'); diff --git a/tests/contracts/determinism.test.ts b/tests/contracts/determinism.test.ts new file mode 100644 index 0000000..cf9b812 --- /dev/null +++ b/tests/contracts/determinism.test.ts @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MPL-2.0 +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { cp, mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { build, files } from '../../scripts/build.ts'; + +it('complete LF and CRLF fictional source trees produce byte-identical deployment artifacts', async () => { + const workspace = process.cwd(); + await mkdir('artifacts/eol-fixtures', { recursive: true }); + const fixture = await mkdtemp(path.resolve('artifacts/eol-fixtures/compare-')); + const reports: { release: string; file_hashes: Record }[] = []; + for (const variant of ['lf', 'crlf']) { + const root = path.join(fixture, variant); await mkdir(root); + for (const folder of ['src', 'schemas', 'examples']) await cp(path.join(workspace, folder), path.join(root, folder), { recursive: true }); + for (const filename of ['LICENSE', 'NOTICE.md']) await cp(path.join(workspace, filename), path.join(root, filename)); + for (const filename of await files(root)) { + if (!/\.(ts|css|svg|txt|md|json)$/.test(filename) && filename !== 'LICENSE') continue; + const absolute = path.join(root, filename), text = (await readFile(absolute, 'utf8')).replace(/\r\n?/g, '\n'); + await writeFile(absolute, variant === 'crlf' ? text.replaceAll('\n', '\r\n') : text); + } + process.chdir(root); + try { const result = await build({ siteDir: 'examples/exampleville' }); reports.push(JSON.parse(await readFile(result.reportPath, 'utf8'))); } + finally { process.chdir(workspace); } + } + assert.deepEqual(reports[0]!.file_hashes, reports[1]!.file_hashes); + assert.equal(reports[0]!.release, reports[1]!.release); +}); diff --git a/tests/contracts/hosting.test.ts b/tests/contracts/hosting.test.ts new file mode 100644 index 0000000..b9ac01c --- /dev/null +++ b/tests/contracts/hosting.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { serve } from '../../scripts/serve.ts'; +import { selectedBuilds } from '../selected.ts'; +import { assertPagesHeaders, responseHeaders } from '../../scripts/lib/headers.ts'; + +for (const selected of selectedBuilds) it(`${selected.sourceDir}: normalized pages and real errors retain one strict CSP; crawler metadata is outside atomic offline installation`, async () => { + const server = await serve({ root: selected.directory, port: 0 }); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const declaration = await readFile(`${selected.directory}/_headers`, 'utf8'); + assertPagesHeaders(declaration); + assert.equal((declaration.match(/Content-Security-Policy:/g) ?? []).length, 1); + const responses = await Promise.all(['/', '/about/', '/about/index.html', '/directory/download', '/404', '/missing-resource-testing', '/service-worker.js'].map(route => fetch(`${origin}${route}`))); + const csp = responses[0]!.headers.get('content-security-policy'); + assert.ok(csp); assert.ok(csp.includes("default-src 'none'")); + assert.ok(!csp.includes("'unsafe-inline'")); + for (const response of responses) assert.equal(response.headers.get('content-security-policy'), csp); + assert.equal(responses[5]!.status, 404); + assert.match(responses[6]!.headers.get('cache-control') ?? '', /no-store/); + const download = await responses[3]!.text(); + assert.match(download, /data:font\/woff2;base64/); + const worker = await responses[6]!.text(); + const build = JSON.parse(worker.match(/^const BUILD = (.*);\n/)![1]!); + for (const filename of ['robots.txt', 'sitemap.xml', 'llms.txt']) assert.ok(!build.entries.some((entry: { url: string }) => entry.url === `/${filename}`)); + assert.ok(build.entries.some((entry: { url: string }) => entry.url === '/directory/download.html')); + assert.equal(build.routes['/directory/download'], '/directory/download.html'); + } finally { await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); } +}); + +it('production provider aliases send noindex while the canonical home remains indexable', async () => { + const declaration = await readFile('artifacts/production/_headers', 'utf8'); + assertPagesHeaders(declaration); + assert.equal(responseHeaders(declaration, new URL('https://directory.example.test/'))['X-Robots-Tag'], undefined); + for (const alias of ['https://directory.pages.dev/', 'https://review.directory.pages.dev/']) assert.equal(responseHeaders(declaration, new URL(alias))['X-Robots-Tag'], 'noindex'); +}); diff --git a/tests/contracts/output.test.ts b/tests/contracts/output.test.ts index f4b9d9c..18c8e32 100644 --- a/tests/contracts/output.test.ts +++ b/tests/contracts/output.test.ts @@ -6,11 +6,12 @@ import { parse } from 'parse5'; import { files } from '../../scripts/build.ts'; import { assertDataset } from '../../src/validation.ts'; import type { PublicDataset, Site } from '../../src/types.ts'; +import { selectedBuilds } from '../selected.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, key: string) => node.attrs?.find(item => item.name === key)?.value; const text = (node: Node): string => node.value ?? (node.childNodes ?? []).map(text).join(''); -for (const [directory, sourceDir, production] of [['dist', 'site', false], ['artifacts/exampleville', 'examples/exampleville', false], ['artifacts/kingston-like', 'tests/fixtures/kingston-like', false], ['artifacts/production', 'artifacts/production-site', true]] as const) describe(directory, () => { +for (const { directory, sourceDir, production } of [...selectedBuilds, { directory: 'artifacts/exampleville', sourceDir: 'examples/exampleville', production: false }, { directory: 'artifacts/kingston-like', sourceDir: 'tests/fixtures/kingston-like', production: false }, { directory: 'artifacts/production', sourceDir: 'artifacts/production-site', production: true }]) describe(directory, () => { it('derives public facts, resource pages, metadata, sitemap and internal links consistently', async () => { const data = JSON.parse(await readFile(`${directory}/data/v1/resources.json`, 'utf8')) as PublicDataset; assertDataset(data, 'public'); const site = JSON.parse(await readFile(`${sourceDir}/site.json`, 'utf8')) as Site; @@ -49,11 +50,15 @@ for (const [directory, sourceDir, production] of [['dist', 'site', false], ['art expect(await readFile(`${directory}/robots.txt`, 'utf8')).toContain(`Sitemap: ${site.canonical_origin}/sitemap.xml`); expect(await readFile(`${directory}/llms.txt`, 'utf8')).toContain(`${site.canonical_origin}/data/v1/resources.json`); const download = await readFile(`${directory}/directory/download.html`, 'utf8'); expect(download).toContain('data:font/woff2;base64,'); expect(download).not.toContain('type="module"'); + const downloadNodes = nodes(parse(download) as unknown as Node); + expect(text(downloadNodes.find(n => n.nodeName === 'pre')!)).toBe((await readFile('src/assets/fonts/OFL.txt', 'utf8')).replace(/\r\n?/g, '\n')); }); }); it('Exampleville and generic source contain no reference deployment or private infrastructure strings', async () => { for (const directory of ['src', 'schemas', 'artifacts/exampleville']) for (const file of await files(directory)) { if (/\.(png|woff2)$/.test(file)) continue; + // Source attribution must preserve the legacy work's names; it is not deployment data. + if (directory === 'artifacts/exampleville' && file === 'licenses/NOTICE.txt') { expect(await readFile(path.join(directory, file), 'utf8')).toBe((await readFile('NOTICE.md', 'utf8')).replace(/\r\n?/g, '\n')); continue; } expect(await readFile(path.join(directory, file), 'utf8'), `${directory}/${file}`).not.toMatch(/kingston|kfh-|buscore|true good craft|lighthouse|agent smith/i); } }); diff --git a/tests/e2e/analytics.spec.ts b/tests/e2e/analytics.spec.ts new file mode 100644 index 0000000..230667f --- /dev/null +++ b/tests/e2e/analytics.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from '@playwright/test'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { stripTypeScriptTypes } from 'node:module'; +import siteJSON from '../../examples/exampleville/site.json' with { type: 'json' }; + +test('local collector receives a single broad click through native navigation, with no replay or raw context', async ({ browser }) => { + const module = stripTypeScriptTypes(await readFile('src/analytics.ts', 'utf8')); + const received: { body: Record; referrer?: string; cookie?: string }[] = []; + let origin = ''; + const server = createServer((request, response) => { + if (request.url === '/module.js') { response.writeHead(200, { 'Content-Type': 'application/javascript' }); response.end(module); return; } + if (request.url === '/metrics' && request.method === 'POST') { + let body = ''; request.on('data', chunk => { body += String(chunk); }); request.on('end', () => { + received.push({ body: JSON.parse(body), referrer: request.headers.referer, cookie: request.headers.cookie }); + setTimeout(() => { response.writeHead(204); response.end(); }, 250); + }); return; + } + response.writeHead(200, { 'Content-Type': 'text/html' }); + if (request.url === '/next') { response.end('Destination

Navigation completed

'); return; } + const site = { ...siteJSON, canonical_origin: origin, analytics: { enabled: true, endpoint: `${origin}/metrics`, collection_mode: 'opt_out', click_keepalive: true, constants: { deployment: 'fictional-collector-test' } } }; + response.end(`Local collector testSame tabNew tab`); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const context = await browser.newContext(); + try { + // All URLs are loopback. Do not intercept transport: that changes keepalive + // behaviour in WebKit and would test the harness instead of native delivery. + const page = await context.newPage(); await page.goto(`${origin}/?query=secret&provider=private#location`); await page.waitForFunction(() => (window as unknown as { ready: boolean }).ready); + await expect.poll(() => received.length).toBe(1); + await page.locator('#same').click(); await expect(page).toHaveURL(`${origin}/next`); await expect.poll(() => received.length).toBe(2); + expect(received[1]!.body).toEqual({ deployment: 'fictional-collector-test', event: 'directions' }); + await page.goto(origin); await page.waitForFunction(() => (window as unknown as { ready: boolean }).ready); await expect.poll(() => received.length).toBe(3); + const popupPromise = context.waitForEvent('page'); await page.locator('#new').click(); const popup = await popupPromise; await popup.waitForLoadState(); await expect.poll(() => received.length).toBe(4); await popup.close(); + await page.bringToFront(); await page.locator('#revoke').click(); await page.locator('#same').click(); expect(received).toHaveLength(4); + expect(received.every(event => !event.referrer && !event.cookie)).toBe(true); + expect(JSON.stringify(received)).not.toMatch(/secret|private|provider|location/); + } finally { await context.close(); await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); } +}); + +for (const signal of ['navigator-dnt', 'window-dnt', 'gpc', 'operator', 'unreadable-storage'] as const) test(`browser suppression: ${signal}`, async ({ browser }) => { + const context = await browser.newContext({ serviceWorkers: 'block' }); + try { + const module = stripTypeScriptTypes(await readFile('src/analytics.ts', 'utf8')); + await context.route('https://suppression.example.test/**', route => route.fulfill({ body: 'Local suppression test' })); + const page = await context.newPage(); await page.goto('https://suppression.example.test/'); + const result = await page.evaluate(async ({ module, siteJSON, signal }) => { + // A module URL generated in this browser contains the actual transpiled adapter. + const url = URL.createObjectURL(new Blob([module], { type: 'application/javascript' })); + const { analytics } = await import(url); URL.revokeObjectURL(url); + if (signal === 'navigator-dnt') Object.defineProperty(navigator, 'doNotTrack', { get: () => '1' }); + if (signal === 'window-dnt') Object.defineProperty(window, 'doNotTrack', { get: () => '1' }); + if (signal === 'gpc') Object.defineProperty(navigator, 'globalPrivacyControl', { get: () => true }); + if (signal === 'operator') document.cookie = 'dev_mode=0'; + if (signal === 'unreadable-storage') Object.defineProperty(window, 'localStorage', { get: () => { throw new Error('blocked'); } }); + let attempts = 0; window.fetch = async () => { attempts += 1; return new Response(null, { status: 204 }); }; + const collection = analytics({ ...siteJSON, canonical_origin: location.origin, analytics: { enabled: true, endpoint: 'https://metrics.example.invalid/events', collection_mode: 'opt_out' } }, true); + collection.emit('page_start'); collection.emit('directions'); return { attempts, state: collection.state() }; + }, { module, siteJSON, signal }); + expect(result.attempts).toBe(0); expect(result.state.suppression).toBe(signal === 'operator' ? 'operator' : signal === 'unreadable-storage' ? 'storage' : 'privacy'); + } finally { await context.close(); } +}); diff --git a/tests/e2e/browsing.spec.ts b/tests/e2e/browsing.spec.ts index c34087a..94fb27e 100644 --- a/tests/e2e/browsing.spec.ts +++ b/tests/e2e/browsing.spec.ts @@ -6,8 +6,8 @@ test('accessible search, categories, static pages and private browsing', async ( page.on('pageerror', error => errors.push(error.message)); await page.goto('/'); await expect(page.locator('#filters')).toBeVisible(); await page.locator('#search').fill('pantry'); await expect(page.locator('#resource-list article')).toHaveCount(1); - await page.locator('#search').fill(''); await page.locator('#category').selectOption('community_fridges'); await expect(page.locator('#resource-list article')).toHaveCount(1); - await page.locator('#category').selectOption(''); await expect(page.locator('#resource-list article')).toHaveCount(5); + await page.locator('#search').fill(''); await page.locator('[data-category="community_fridges"]').click(); await expect(page.locator('#resource-list article')).toHaveCount(1); + await page.locator('[data-category="all"]').click(); await expect(page.locator('#resource-list article')).toHaveCount(5); await page.locator('details').first().locator('summary').click(); await page.evaluate(axe.source); const result = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] } })); expect(result.violations.map(item => ({ id: item.id, nodes: item.nodes.map(node => node.target) }))).toEqual([]); @@ -27,3 +27,12 @@ test('small screens and keyboard access remain usable', async ({ page }) => { expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: `artifacts/screenshots/${test.info().project.name}-mobile.png`, fullPage: true }); }); +test('the today filter includes future hours and preserves keyboard category selection', async ({ page }) => { + await page.clock.install({ time: new Date('2026-09-07T07:00:00Z') }); + await page.goto('/'); await expect(page.locator('#filters')).toBeVisible(); + await page.locator('#scheduled').check(); await expect(page.locator('#resource-list article')).toHaveCount(2); + await expect(page.locator('[data-schedule-status]').first()).toContainText('Later today'); + const meals = page.locator('[data-category="prepared_meals"]'); await meals.focus(); await page.keyboard.press('Space'); + await expect(meals).toHaveAttribute('aria-pressed', 'true'); await expect(meals).toBeFocused(); await expect(page.locator('#resource-list article')).toHaveCount(1); + await page.clock.setSystemTime(new Date('2026-09-07T18:00:00Z')); await page.locator('[data-category="all"]').click(); await expect(page.locator('#resource-list article')).toHaveCount(0); +}); diff --git a/tests/e2e/capabilities.spec.ts b/tests/e2e/capabilities.spec.ts index 052c4d6..e0d310f 100644 --- a/tests/e2e/capabilities.spec.ts +++ b/tests/e2e/capabilities.spec.ts @@ -2,29 +2,34 @@ import { test, expect } from '@playwright/test'; import { readFile } from 'node:fs/promises'; import { serve } from '../../scripts/serve.ts'; import axe from 'axe-core'; -test('the operator deployment itself passes browsing and accessibility checks', async ({ browser }) => { - const server = await serve({ root: 'dist', port: 0 }); const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; +import { selectedBuilds } from '../selected.ts'; +for (const selected of selectedBuilds) test(`${selected.sourceDir}: the operator deployment itself passes browsing and accessibility checks`, async ({ browser }) => { + const server = await serve({ root: selected.directory, port: 0 }); const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; const context = await browser.newContext(); try { await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); - const data = JSON.parse(await readFile('dist/data/v1/resources.json', 'utf8')), site = JSON.parse(await readFile('site/site.json', 'utf8')); + const data = JSON.parse(await readFile(`${selected.directory}/data/v1/resources.json`, 'utf8')), site = JSON.parse(await readFile(`${selected.sourceDir}/site.json`, 'utf8')); const page = await context.newPage(); await page.goto(origin); await expect(page.locator('.brand')).toHaveText(site.site_name); await expect(page.locator('#filters')).toBeVisible(); await expect(page.locator('#resource-list article')).toHaveCount(data.resources.length); await page.evaluate(axe.source); const result = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] } })); expect(result.violations.map(item => ({ id: item.id, nodes: item.nodes.map(node => node.target) }))).toEqual([]); - await page.screenshot({ path: `artifacts/screenshots/${test.info().project.name}-desktop.png`, fullPage: true }); + await page.screenshot({ path: `artifacts/screenshots/${site.deployment_id}/${test.info().project.name}-desktop.png`, fullPage: true }); } finally { await context.close(); await new Promise(resolve => server.close(() => resolve())); } }); test('analytics adapter is opt-in, context-free, and independent of public aggregates', async ({ browser }) => { - const server = await serve({ root: 'artifacts/analytics', port: 0 }); const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; - const context = await browser.newContext(), events: unknown[] = []; + const server = await serve({ root: 'artifacts/analytics', port: 0 }); const local = `http://127.0.0.1:${(server.address() as { port: number }).port}`, origin = 'https://analytics.example.test'; + // WebKit does not reliably intercept requests made from worker-controlled pages. + // Exercise the adapter with a blocked worker; its lifecycle is covered separately. + const context = await browser.newContext({ serviceWorkers: 'block' }), events: unknown[] = []; try { + // The production-origin gate is exercised without opening a network path to it. + await context.route(`${origin}/**`, async route => { const url = new URL(route.request().url()); const response = await route.fetch({ url: local + url.pathname + url.search }); await route.fulfill({ response }); }); await context.route('https://metrics.example.invalid/**', async route => { if (route.request().method() === 'POST') events.push(route.request().postDataJSON()); await route.fulfill({ status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'content-type' } }); }); const page = await context.newPage(); await page.goto(`${origin}/privacy/`); expect(events).toEqual([]); await page.locator('#analytics-preference').check(); await page.goto(origin); await expect.poll(() => events.length).toBe(1); expect(events[0]).toEqual({ deployment: 'exampleville', event: 'page_start' }); - await page.locator('#search').fill('sensitive search'); await page.locator('#category').selectOption('community_fridges'); expect(events).toHaveLength(1); + await page.locator('#search').fill('sensitive search'); await page.locator('[data-category="community_fridges"]').click(); expect(events).toHaveLength(1); await page.goto(`${origin}/usage/`); await expect(page.locator('main')).toContainText('Page starts'); await expect(page.locator('main')).toContainText('Unavailable'); await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', 'noindex,follow'); await page.goto(`${origin}/privacy/`); await page.locator('#analytics-preference').uncheck(); const count = events.length; await page.goto(origin); expect(events).toHaveLength(count); await context.addInitScript(() => { Object.defineProperty(navigator, 'globalPrivacyControl', { get: () => true }); }); - await page.goto(`${origin}/privacy/`); await page.locator('#analytics-preference').check().catch(() => {}); await page.goto(origin); expect(events).toHaveLength(count); + await page.goto(`${origin}/privacy/`); await expect(page.locator('#analytics-preference')).toBeDisabled(); await expect(page.locator('#analytics-status')).toContainText('browser requests privacy'); await page.goto(origin); expect(events).toHaveLength(count); } finally { await context.close(); await new Promise(resolve => server.close(() => resolve())); } }); diff --git a/tests/e2e/layout.spec.ts b/tests/e2e/layout.spec.ts new file mode 100644 index 0000000..9108f6d --- /dev/null +++ b/tests/e2e/layout.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; +import { serve } from '../../scripts/serve.ts'; +import axe from 'axe-core'; +import { selectedBuilds } from '../selected.ts'; +for (const selected of selectedBuilds) test(`${selected.sourceDir}: operator layout remains readable at desktop, mobile and enlarged-text widths`, async ({ browser }, info) => { + const server = await serve({ root: selected.directory, port: 0 }); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + const context = await browser.newContext(); + try { + await context.route('**/*', route => new URL(route.request().url()).origin === origin ? route.continue() : route.abort()); + const site = JSON.parse(await readFile(`${selected.sourceDir}/site.json`, 'utf8')); + const data = JSON.parse(await readFile(`${selected.directory}/data/v1/resources.json`, 'utf8')); + const page = await context.newPage(); await page.clock.install({ time: new Date('2026-09-14T19:00:00Z') }); + await page.setViewportSize({ width: 1920, height: 912 }); await page.goto(origin); await expect(page.locator('#filters')).toBeVisible(); await page.evaluate(() => document.fonts.ready); + await expect(page.locator('h1')).toHaveText(`Find food help in ${site.community.name}`); + if (site.presentation?.category_groups) { + for (const group of site.presentation.category_groups) { + await page.getByRole('button', { name: group.label, exact: true }).click(); + await expect(page.locator('#resource-list article')).toHaveCount(data.resources.filter((r: { categories: string[] }) => r.categories.some(category => group.categories.includes(category))).length); + } + await page.getByRole('button', { name: 'All food help', exact: true }).click(); + } + const footer = await page.locator('.site-footer').boundingBox(); + expect(footer!.width).toBe(await page.evaluate(() => document.documentElement.clientWidth)); expect(footer!.height).toBeLessThan(220); + const search = await page.locator('#search').boundingBox(), locate = await page.locator('#locate').boundingBox(); + if (locate) { expect(locate.width).toBeLessThan(250); expect(Math.abs(locate.y - search!.y)).toBeLessThan(2); } + for (const record of data.resources) { + const article = page.locator(`[data-resource-id="${record.id}"]`); + await expect(article.locator('.summary')).toHaveText(record.summary); + if (record.summary === record.food_access_purpose) expect(await article.locator('p').evaluateAll((nodes, summary) => nodes.filter(node => node.textContent === summary).length, record.summary)).toBe(1); + } + await page.screenshot({ path: `artifacts/screenshots/${site.deployment_id}/${info.project.name}-restored-desktop.png`, fullPage: true }); + await page.screenshot({ path: `artifacts/screenshots/${site.deployment_id}/${info.project.name}-restored-top.png` }); + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await page.screenshot({ path: `artifacts/screenshots/${site.deployment_id}/${info.project.name}-restored-footer.png` }); + await page.evaluate(() => window.scrollTo(0, 0)); + for (const width of [768, 390, 320]) { + await page.setViewportSize({ width, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + if (site.help_contact) { await expect(page.locator('.header-help strong')).toBeVisible(); expect(await page.locator('.header-help strong').evaluate(el => parseFloat(getComputedStyle(el).fontSize))).toBeGreaterThanOrEqual(14); } + const inner = await page.locator('.footer-inner').boundingBox(); expect(inner!.width).toBeGreaterThan(width - 60); + } + await page.screenshot({ path: `artifacts/screenshots/${site.deployment_id}/${info.project.name}-restored-mobile.png`, fullPage: true }); + await page.screenshot({ path: `artifacts/screenshots/${site.deployment_id}/${info.project.name}-restored-mobile-top.png` }); + await page.evaluate(() => { document.documentElement.style.fontSize = '200%'; }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.getByText('Your privacy', { exact: true }).click(); + await page.evaluate(axe.source); + const result = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] } })); + expect(result.violations.map(item => item.id)).toEqual([]); + } finally { await context.close(); await new Promise(resolve => server.close(() => resolve())); } +}); diff --git a/tests/e2e/legacy-transition.spec.ts b/tests/e2e/legacy-transition.spec.ts new file mode 100644 index 0000000..8a11608 --- /dev/null +++ b/tests/e2e/legacy-transition.spec.ts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +import { createServer, type Server } from 'node:http'; +import { test, expect } from '@playwright/test'; +import { retirementWorker, retainedLegacyPath, transitionDestination } from '../../scripts/legacy-transition.ts'; + +const listen = async (server: Server) => { await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); return `http://127.0.0.1:${(server.address() as { port: number }).port}`; }; +const close = async (server: Server) => new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); +test('existing legacy workers retire into the new origin without changing old JSON or unrelated storage', async ({ browser }) => { + let transitioned = false; + const fresh = createServer((request, response) => response.writeHead(200, { 'Content-Type': 'text/html' }).end(`

New directory

${request.url}

`)); + const target = await listen(fresh); + const legacyData = '{"schema_version":1,"dataset_version":"old-test","resources":[]}\n'; + const oldWorker = `self.addEventListener('install', e => e.waitUntil((async () => { const c=await caches.open('kfh-app-synthetic');await c.add('/');await self.skipWaiting(); })()));self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(e.request.mode==='navigate'&&!new URL(e.request.url).pathname.startsWith('/data/'))e.respondWith(caches.open('kfh-app-synthetic').then(c=>c.match('/')));});`; + const oldHTML = '

Old directory

'; + const old = createServer((request, response) => { + const url = new URL(request.url!, 'http://old.example'); response.setHeader('Cache-Control', 'no-store'); + if (url.pathname === '/sw.js') { response.writeHead(200, { 'Content-Type': 'text/javascript' }).end(transitioned ? retirementWorker(target) : oldWorker); return; } + if (url.pathname === '/data/v1/resources.json') { response.writeHead(200, { 'Content-Type': 'application/json' }).end(legacyData); return; } + if (retainedLegacyPath(url.pathname)) { response.writeHead(200, { 'Content-Type': 'text/plain' }).end('preserved compatibility asset'); return; } + if (transitioned) { response.writeHead(308, { Location: transitionDestination(url.href, target)! }).end(); return; } + response.writeHead(200, { 'Content-Type': 'text/html' }).end(oldHTML); + }); + const origin = await listen(old), context = await browser.newContext(); + try { + const first = await context.newPage(); await first.goto(origin + '/?kept=first'); + await expect.poll(() => first.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + const second = await context.newPage(); await second.goto(origin + '/directory.html?kept=second'); + await expect(second.locator('h1')).toHaveText('Old directory'); + await first.evaluate(async () => { for (const key of ['kfh-data-v1', 'kfh-data-v10', 'kfh-other', 'unrelated']) await caches.open(key); localStorage.setItem('privacy-choice', 'off'); }); + transitioned = true; + await first.evaluate(() => { void navigator.serviceWorker.getRegistration().then(registration => registration!.update()); }); + await expect(first).toHaveURL(target + '/?kept=first'); await expect(second).toHaveURL(target + '/directory/?kept=second'); + await expect(first.locator('h1')).toHaveText('New directory'); + const inspection = await context.newPage(); const response = await inspection.goto(origin + '/data/v1/resources.json'); + expect(response!.status()).toBe(200); expect(await response!.text()).toBe(legacyData); expect(inspection.url()).toBe(origin + '/data/v1/resources.json'); + await expect.poll(() => inspection.evaluate(() => caches.keys())).toEqual(['kfh-data-v10', 'kfh-other', 'unrelated']); + expect(await inspection.evaluate(() => localStorage.getItem('privacy-choice'))).toBe('off'); + expect((await context.request.get(origin + '/manifest.webmanifest')).status()).toBe(200); + expect((await context.request.get(origin + '/assets/old.js')).status()).toBe(200); + const freshVisitor = await browser.newContext(); + try { const page = await freshVisitor.newPage(); await page.goto(origin + '/directory?from=fresh'); await expect(page).toHaveURL(target + '/directory/?from=fresh'); } finally { await freshVisitor.close(); } + } finally { await context.close(); await close(old); await close(fresh); } +}); diff --git a/tests/e2e/offline.spec.ts b/tests/e2e/offline.spec.ts index 6dfd97a..80268ee 100644 --- a/tests/e2e/offline.spec.ts +++ b/tests/e2e/offline.spec.ts @@ -1,5 +1,6 @@ import { test, expect, chromium, type Page } from '@playwright/test'; import { serve } from '../../scripts/serve.ts'; +import { cp, writeFile } from 'node:fs/promises'; const ready = async (page: Page) => { await expect(page.locator('#filters')).toBeVisible(); await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller)), { timeout: 20_000 }).toBe(true); @@ -12,25 +13,32 @@ test('offline launch and validated last-good fallback', async ({ page }) => { const origin = 'http://127.0.0.1:' + port; try { await page.goto(origin); await ready(page); - await new Promise(resolve => server.close(() => resolve())); + // Stop listening and close browser keep-alive sockets to simulate a dead origin. + await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); await page.reload(); - await expect(page.locator('#resource-list article')).toHaveCount(5); await expect(page.locator('#data-status')).toContainText('last saved validated'); + await expect(page.locator('#resource-list article')).toHaveCount(5); await expect(page.locator('#data-retention-status')).toContainText('last saved validated'); await page.locator('#search').fill('pantry'); await expect(page.locator('#resource-list article')).toHaveCount(1); await page.getByRole('link', { name: 'Example Neighbourhood Pantry', exact: true }).click(); await expect(page.locator('h1')).toHaveText('Example Neighbourhood Pantry'); server = await serve({ root: 'artifacts/exampleville', port }); - await page.goto(origin); await expect(page.locator('#data-status')).toContainText('latest validated'); + await page.goto(origin); await expect(page.locator('#data-status')).toContainText('Listings updated'); } finally { await new Promise(resolve => server.close(() => resolve())); } }); -test('bad response never overwrites last-good data; absent storage leaves static HTML', async ({ browser }) => { - const context = await browser.newContext({ serviceWorkers: 'block' }); const page = await context.newPage(); +test('bad response never overwrites last-good data; absent storage leaves static HTML', async ({ browser }, info) => { + const context = await browser.newContext(); const page = await context.newPage(); let corrupt = false; - // Configure interception before storing data; changing WebKit interception settings clears its caches. - await page.route('**/data/v1/resources.json', route => corrupt ? route.fulfill({ contentType: 'application/json', body: '{"format":"corrupt"}' }) : route.continue()); - await page.goto('http://127.0.0.1:4173/'); await expect(page.locator('#filters')).toBeVisible(); - corrupt = true; - await page.reload(); await expect(page.locator('#data-status')).toContainText('last saved validated'); await expect(page.locator('#resource-list article')).toHaveCount(5); - await page.evaluate(async () => { await Promise.all((await caches.keys()).map(key => caches.delete(key))); }); - await page.reload(); await expect(page.locator('#data-status')).toContainText('published page remains'); await expect(page.locator('#resource-list article')).toHaveCount(5); await expect(page.locator('#filters')).toBeHidden(); await context.close(); + // Use a real origin and normal worker: blocked-worker WebKit contexts evict cached entries on reload. + const corruptRoot = info.outputPath('corrupt-site'); + await cp('artifacts/exampleville', corruptRoot, { recursive: true }); + await writeFile(`${corruptRoot}/data/v1/resources.json`, '{"format":"corrupt"}'); + const server = await serve({ root: () => corrupt ? corruptRoot : 'artifacts/exampleville', port: 0 }); + const origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + await page.goto(origin); await ready(page); + corrupt = true; + await page.reload(); await expect(page.locator('#data-retention-status')).toContainText('last saved validated'); await expect(page.locator('#resource-list article')).toHaveCount(5); + await page.evaluate(async () => { await Promise.all((await caches.keys()).map(key => caches.delete(key))); }); + await page.reload(); await expect(page.locator('#data-retention-status')).toContainText('published page remains'); await expect(page.locator('#resource-list article')).toHaveCount(5); 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.skip(browserName !== 'chromium', 'Persistent browser-process restart is exercised in Chromium; all engines exercise offline navigation.'); @@ -39,7 +47,7 @@ test('cold restart uses the persisted worker and data with no network', async ({ try { const page = await context.newPage(); await page.goto('http://127.0.0.1:4173/'); await ready(page); await context.close(); context = await chromium.launchPersistentContext(profile, { headless: true, offline: true }); - const cold = await context.newPage(); await cold.goto('http://127.0.0.1:4173/'); await expect(cold.locator('#filters')).toBeVisible(); await expect(cold.locator('#data-status')).toContainText('last saved validated'); + const cold = await context.newPage(); await cold.goto('http://127.0.0.1:4173/'); await expect(cold.locator('#filters')).toBeVisible(); await expect(cold.locator('#data-retention-status')).toContainText('last saved validated'); await cold.locator('#search').fill('garden'); await expect(cold.locator('#resource-list article')).toHaveCount(1); } finally { await context.close(); } }); diff --git a/tests/e2e/selected-offline.spec.ts b/tests/e2e/selected-offline.spec.ts new file mode 100644 index 0000000..1965704 --- /dev/null +++ b/tests/e2e/selected-offline.spec.ts @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MPL-2.0 +import { test, expect, chromium } from '@playwright/test'; +import { readFile, mkdir, mkdtemp } from 'node:fs/promises'; +import path from 'node:path'; +import { selectedBuilds } from '../selected.ts'; +import { serve } from '../../scripts/serve.ts'; + +for (const selected of selectedBuilds) { + test(`${selected.sourceDir}: actual selected data survives offline navigation and reconnects`, async ({ page }) => { + const data = JSON.parse(await readFile(`${selected.directory}/data/v1/resources.json`, 'utf8')); + let server = await serve({ root: selected.directory, port: 0 }); + const port = (server.address() as { port: number }).port, origin = `http://127.0.0.1:${port}`; + try { + await page.goto(origin); await expect(page.locator('#filters')).toBeVisible(); + await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + await expect.poll(() => page.evaluate(async () => (await caches.keys()).some(key => key.includes('-data-v1-')))).toBe(true); + await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); + await page.reload(); await expect(page.locator('#data-retention-status')).toContainText('last saved validated'); + await expect(page.locator('#resource-list article')).toHaveCount(data.resources.length); + const resource = data.resources[0]; + if (resource) { await page.goto(`${origin}/resources/${resource.id}/`); await expect(page.locator('h1')).toHaveText(resource.name); } + await page.goto(`${origin}/directory/`); await expect(page.locator('main article')).toHaveCount(data.resources.length); + server = await serve({ root: selected.directory, port }); + await page.goto(origin); await expect(page.locator('#data-status')).toContainText('Listings updated'); + } finally { await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); } + }); + test(`${selected.sourceDir}: actual selected community survives a browser-process cold restart`, async ({ browserName }) => { + test.skip(browserName !== 'chromium', 'Browser-process cold restart is exercised in Chromium; all engines test offline navigation.'); + const data = JSON.parse(await readFile(`${selected.directory}/data/v1/resources.json`, 'utf8')); + const server = await serve({ root: selected.directory, port: 0 }), origin = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + // 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-')); + let context = await chromium.launchPersistentContext(profile, { headless: true }); + try { + let page = await context.newPage(); await page.goto(origin); await expect(page.locator('#filters')).toBeVisible(); + await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); + await expect.poll(() => page.evaluate(async () => (await caches.keys()).some(key => key.includes('-data-v1-')))).toBe(true); + await context.close(); + context = await chromium.launchPersistentContext(profile, { headless: true, offline: true }); + page = await context.newPage(); await page.goto(origin); await expect(page.locator('#data-retention-status')).toContainText('last saved validated'); + await expect(page.locator('#resource-list article')).toHaveCount(data.resources.length); + } finally { await context.close(); await new Promise(resolve => { server.close(() => resolve()); server.closeAllConnections(); }); } + }); +} diff --git a/tests/selected.ts b/tests/selected.ts new file mode 100644 index 0000000..922ab31 --- /dev/null +++ b/tests/selected.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: MPL-2.0 +export type SelectedBuild = { directory: string; sourceDir: string; production: boolean }; +export const selectedBuilds: SelectedBuild[] = process.env.FOOD_HELP_TEST_DEPLOYMENTS + ? JSON.parse(process.env.FOOD_HELP_TEST_DEPLOYMENTS) + : [{ directory: 'dist/exampleville', sourceDir: 'examples/exampleville', production: false }]; diff --git a/tests/unit/analytics.test.ts b/tests/unit/analytics.test.ts new file mode 100644 index 0000000..39ff908 --- /dev/null +++ b/tests/unit/analytics.test.ts @@ -0,0 +1,139 @@ +import { describe, it } from 'node:test'; +import { expect } from '@playwright/test'; +import siteJSON from '../../examples/exampleville/site.json' with { type: 'json' }; +import { createAnalytics, classifyAttribution, type AnalyticsEnvironment, type EventKind } from '../../src/analytics.ts'; +import { assertSite } from '../../src/validation.ts'; +import type { Site } from '../../src/types.ts'; + +function fixture() { + const site = structuredClone(siteJSON) as Site; + site.canonical_origin = 'https://analytics.example.test'; + site.analytics = { + enabled: true, endpoint: 'https://metrics.example.invalid/events', collection_mode: 'opt_out', disclosure: 'Fictional aggregate collector used only by local tests.', + preference_key: 'example-choice', content_type: 'text/plain;charset=UTF-8', click_keepalive: true, + constants: { site_key: 'example_directory', contract_version: 3, collection_mode: 'opt_out', page: 'directory' }, + event_payloads: { + page_start: { event_name: 'page_view' }, call: { event_name: 'contact_click', event_value: 'resource_call' }, + help: { event_name: 'contact_click', event_value: 'help_211' }, directions: { event_name: 'outbound_click', event_value: 'directions' }, + source: { event_name: 'outbound_click', event_value: 'official_source' }, install: { event_name: 'pwa_install' }, + }, + attribution: { + events: ['page_start', 'call', 'help', 'directions', 'source'], sources: ['direct_unknown', 'community', 'search', 'other'], + campaigns: ['none', 'example_outreach'], contents: ['none', 'example_poster'], internal_hosts: ['former.example.test'], + referrers: [{ host: 'search.example.test', source: 'search', include_subdomains: true }], + }, + }; + const values = new Map(), requests: RequestInit[] = []; + const env: AnalyticsEnvironment = { + origin: site.canonical_origin, url: `${site.canonical_origin}/?src=community&utm_campaign=example_outreach&utm_content=example_poster&search=secret&fbclid=private#location`, referrer: 'https://search.example.test/private?q=secret', + online: () => true, visible: () => true, privacySignal: () => false, cookies: () => '', + storage: { getItem: key => values.get(key) ?? null, setItem: (key, value) => { values.set(key, value); } }, + fetch: async (_url, init) => { requests.push(init!); return new Response(null, { status: 204 }); }, now: () => 1000, + }; + const collection = createAnalytics(site, true, env); + return { site, env, values, requests, collection }; +} +describe('optional aggregate collection', () => { + it('sends exact static payloads and only finite public labels, without identity or provider context', () => { + const { site, collection, values, requests } = fixture(); assertSite(site); + for (const kind of ['page_start', 'call', 'help', 'directions', 'source', 'install'] as EventKind[]) collection.emit(kind); + expect(values.size).toBe(0); expect(requests).toHaveLength(6); + for (const [index, kind] of (['page_start', 'call', 'help', 'directions', 'source', 'install'] as EventKind[]).entries()) { + expect(JSON.parse(requests[index]!.body as string)).toEqual({ ...site.analytics!.constants, ...site.analytics!.event_payloads![kind], ...(kind === 'install' ? {} : { source: 'community', campaign: 'example_outreach', content: 'example_poster' }) }); + expect(requests[index]).toMatchObject({ method: 'POST', mode: 'cors', credentials: 'omit', cache: 'no-store', redirect: 'error', referrerPolicy: 'no-referrer', headers: { 'Content-Type': 'text/plain;charset=UTF-8' } }); + } + expect(JSON.stringify(requests.map(request => request.body))).not.toMatch(/secret|private|fbclid|location/); + expect(requests[0]).not.toHaveProperty('keepalive'); expect(requests[1]!.keepalive).toBe(true); expect(requests[5]).not.toHaveProperty('keepalive'); collection.pause(); + }); + it('keeps the original simple JSON adapter compatible and opt-in', () => { + const { site, env, requests } = fixture(); + site.analytics = { enabled: true, endpoint: 'https://metrics.example.invalid/events', collection_mode: 'opt_in', disclosure: 'Example only.', constants: { deployment: 'exampleville' } }; + const collection = createAnalytics(site, true, env); collection.emit('page_start'); expect(requests).toHaveLength(0); + collection.setAllowed(true); expect(JSON.parse(requests[0]!.body as string)).toEqual({ deployment: 'exampleville', event: 'page_start' }); + expect(requests[0]!.headers).toEqual({ 'Content-Type': 'application/json' }); collection.pause(); + }); + it('suppresses disabled builds, previews and every origin except the configured canonical HTTPS origin', () => { + for (const origin of ['http://localhost:4174', 'https://preview.example.test', 'http://analytics.example.test', 'https://analytics.example.test.evil.invalid']) { + const { site, env, requests } = fixture(); env.origin = origin; const collection = createAnalytics(site, true, env); + collection.emit('page_start'); collection.emit('directions'); expect(requests).toHaveLength(0); expect(collection.state().suppression).toBe('preview'); + } + const { site, env, requests } = fixture(); createAnalytics(site, false, env).emit('page_start'); + site.analytics = { enabled: false }; createAnalytics(site, true, env).emit('page_start'); expect(requests).toHaveLength(0); + }); + it('does not measure static resource, simple-directory, privacy or other page history', () => { + for (const path of ['/resources/example-service/', '/directory/', '/privacy/', '/about/']) { + const { site, env, requests } = fixture(); env.url = site.canonical_origin + path; + const collection = createAnalytics(site, true, env); collection.emit('page_start'); collection.emit('directions'); expect(requests).toHaveLength(0); + } + }); + it('retains opt-out and rechecks privacy and canonical operator suppression before every event', () => { + for (const setup of [ + (f: ReturnType) => f.values.set('example-choice', 'no'), + (f: ReturnType) => { f.env.privacySignal = () => true; }, + (f: ReturnType) => f.values.set('noAnalytics', '1'), + (f: ReturnType) => { f.env.cookies = () => 'ordinary=value; dev_mode=0'; }, + (f: ReturnType) => { f.env.cookies = () => 'dev_mode='; }, + ]) { const f = fixture(); setup(f); f.collection.emit('page_start'); f.collection.emit('help'); expect(f.requests).toHaveLength(0); } + const { collection, env, requests } = fixture(); env.cookies = () => 'not_dev_mode=1'; collection.emit('page_start'); expect(requests).toHaveLength(1); + env.privacySignal = () => true; collection.emit('source'); expect(requests).toHaveLength(1); collection.pause(); + }); + it('fails closed when preferences cannot be read or written', () => { + const { collection, env, requests } = fixture(); env.storage.getItem = () => { throw new Error('blocked'); }; + collection.emit('page_start'); expect(collection.state().suppression).toBe('storage'); expect(requests).toHaveLength(0); + env.storage.getItem = () => null; env.storage.setItem = () => { throw new Error('blocked'); }; + collection.setAllowed(true); collection.emit('help'); expect(collection.allowed()).toBe(false); expect(requests).toHaveLength(0); + }); + it('deduplicates startup, install and rapid broad actions only in memory and rejects unknown events', () => { + const { collection, env, requests } = fixture(); + collection.emit('page_start'); collection.emit('page_start'); collection.emit('install'); collection.emit('install'); + collection.emit('directions'); collection.emit('directions'); collection.emit('provider-secret' as EventKind); expect(requests).toHaveLength(3); + env.now = () => 1750; collection.emit('directions'); expect(requests).toHaveLength(4); collection.pause(); + }); + it('drops offline/hidden attempts with no reconnection replay', () => { + for (const mode of ['online', 'visible'] as const) { + const { collection, env, requests } = fixture(); env[mode] = () => false; + collection.emit('page_start'); collection.emit('directions'); collection.emit('install'); env[mode] = () => true; + collection.emit('page_start'); collection.emit('install'); expect(requests).toHaveLength(0); + env.now = () => 1750; collection.emit('directions'); expect(requests).toHaveLength(1); collection.pause(); + } + }); + it('aborts all pending deliveries and clears attribution on cross-tab revocation', () => { + const { collection, env, values, requests } = fixture(); env.fetch = (_url, init) => { requests.push(init!); return new Promise(() => {}); }; + collection.emit('page_start'); collection.emit('directions'); values.set('example-choice', 'no'); collection.state(); + expect(requests.every(request => request.signal!.aborted)).toBe(true); collection.setAllowed(true); env.now = () => 1750; collection.emit('help'); + expect(JSON.parse(requests[2]!.body as string)).toMatchObject({ source: 'direct_unknown', campaign: 'none', content: 'none' }); collection.pause(); + }); + it('lets already-started clicks finish on backgrounding only when configured; privacy still aborts them', () => { + const { collection, env, requests } = fixture(); env.fetch = (_url, init) => { requests.push(init!); return new Promise(() => {}); }; + collection.emit('page_start'); collection.emit('directions'); collection.background(); + expect(requests[0]!.signal!.aborted).toBe(true); expect(requests[1]!.signal!.aborted).toBe(false); + env.privacySignal = () => true; collection.background(); expect(requests[1]!.signal!.aborted).toBe(true); + }); + it('bounds hanging requests to 1500ms while the realm runs; failed sends are never retried', t => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { collection, env, requests } = fixture(); env.fetch = (_url, init) => { requests.push(init!); return new Promise(() => {}); }; + collection.emit('page_start'); t.mock.timers.tick(1500); expect(requests[0]!.signal!.aborted).toBe(true); expect(requests).toHaveLength(1); + env.fetch = () => { throw new Error('network'); }; expect(() => collection.emit('directions')).not.toThrow(); + env.fetch = async () => { throw new Error('network'); }; expect(() => collection.emit('help')).not.toThrow(); t.mock.timers.tick(10_000); expect(requests).toHaveLength(1); collection.pause(); + }); + it('maps unregistered attribution to fallback labels and matches referrer host boundaries', () => { + const { site } = fixture(), policy = site.analytics!.attribution!; + expect(classifyAttribution(`${site.canonical_origin}/?src=person_123&utm_campaign=private&utm_content=private`, '', site.canonical_origin, policy)).toEqual({ source: 'other', campaign: 'none', content: 'none' }); + expect(classifyAttribution(site.canonical_origin, 'https://sub.search.example.test/?q=private', site.canonical_origin, policy).source).toBe('search'); + expect(classifyAttribution(site.canonical_origin, 'https://search.example.test.evil.invalid/', site.canonical_origin, policy).source).toBe('other'); + expect(classifyAttribution(site.canonical_origin, 'https://former.example.test/private', site.canonical_origin, policy).source).toBe('direct_unknown'); + }); + it('rejects ambiguous payload contracts, unknown dynamic fields and unbounded attribution policies', () => { + const { site } = fixture(); assertSite(site); + for (const change of [ + (s: Site) => { s.analytics!.event_names = { call: 'also-call' }; }, + (s: Site) => { delete s.analytics!.event_payloads!.help; }, + (s: Site) => { s.analytics!.constants!.source = 'override'; }, + (s: Site) => { s.analytics!.event_payloads!.call!.site_key = 'override'; }, + (s: Site) => { s.analytics!.attribution!.sources = ['unbounded']; }, + (s: Site) => { s.analytics!.attribution!.referrers[0]!.source = 'unknown'; }, + (s: Site) => { s.analytics!.attribution!.referrers[0]!.host = 'https://bad.example'; }, + (s: Site) => { (s.analytics!.attribution as unknown as Record).provider = true; }, + ]) { const candidate = structuredClone(site); change(candidate); expect(() => assertSite(candidate)).toThrow(); } + }); +}); diff --git a/tests/unit/headers.test.ts b/tests/unit/headers.test.ts new file mode 100644 index 0000000..3c0776c --- /dev/null +++ b/tests/unit/headers.test.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MPL-2.0 +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { assertPagesHeaders, headerPatternMatches, responseHeaders, renderHeaders } from '../../scripts/lib/headers.ts'; +import { securityHeaders } from '../../scripts/lib/web.ts'; +import siteJSON from '../../examples/exampleville/site.json' with { type: 'json' }; +import type { Site } from '../../src/types.ts'; + +it('the Pages adapter rejects oversized lines and excess rules at the documented boundaries', () => { + assert.doesNotThrow(() => assertPagesHeaders(`/*\n X: ${'a'.repeat(1995)}`)); + assert.throws(() => assertPagesHeaders(`/*\n X: ${'a'.repeat(1996)}`), /exceeds 2000/); + const rules = Array.from({ length: 100 }, (_, index) => `/page-${index}\n X-Test: value\n`).join('\n'); + assert.doesNotThrow(() => assertPagesHeaders(rules)); + assert.throws(() => assertPagesHeaders(rules + '/one-too-many\n X-Test: value'), /at most 100/); +}); + +it('provider host patterns suppress aliases without matching a custom domain or a deceptive suffix', () => { + const text = renderHeaders({ 'Content-Security-Policy': "default-src 'none'" }, {}); + for (const origin of ['https://directory.pages.dev', 'https://abc123.directory.pages.dev', 'http://staging.directory.pages.dev:4173']) { + assert.equal(responseHeaders(text, new URL(`${origin}/resources/a/`))['X-Robots-Tag'], 'noindex'); + } + for (const origin of ['https://directory.example.test', 'https://directory.pages.dev.example.test', 'https://too.many.parts.pages.dev']) assert.equal(responseHeaders(text, new URL(`${origin}/`))['X-Robots-Tag'], undefined); + assert.equal(headerPatternMatches('/resources/:id/', new URL('https://example.test/resources/one/')), true); + assert.equal(headerPatternMatches('/resources/:id/', new URL('https://example.test/resources/one/two/')), false); +}); + +it('the local adapter models repeated headers by joining them, exposing conflicting policy rules', () => { + const text = '/*\n Content-Security-Policy: first\n/\n Content-Security-Policy: second'; + assert.equal(responseHeaders(text, new URL('https://example.test/'))['Content-Security-Policy'], 'first, second'); +}); + +it('script and style hashes remain in their own CSP directives without duplicating allowed content', () => { + const script = '{"@type":"WebPage"}', style = 'body{color:#173f4c}'; + const hash = (value: string) => `'sha256-${createHash('sha256').update(value).digest('base64')}'`; + const policy = securityHeaders(``, siteJSON as Site, false)['Content-Security-Policy']!; + const scripts = policy.split(';').find(value => value.trim().startsWith('script-src'))!, styles = policy.split(';').find(value => value.trim().startsWith('style-src'))!; + assert.ok(scripts.includes(hash(script))); assert.ok(!scripts.includes(hash(style))); + assert.ok(styles.includes(hash(style))); assert.ok(!styles.includes(hash(script))); +}); diff --git a/tests/unit/legacy-transition.test.ts b/tests/unit/legacy-transition.test.ts new file mode 100644 index 0000000..e70ca5e --- /dev/null +++ b/tests/unit/legacy-transition.test.ts @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MPL-2.0 +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import vm from 'node:vm'; +import { buildLegacyTransition, legacyFiles, retirementWorker, transitionDestination } from '../../scripts/legacy-transition.ts'; + +describe('legacy static transition', () => { + it('maps website paths and queries but leaves machine and compatibility paths alone', () => { + for (const entry of ['/directory', '/directory.html']) assert.equal(transitionDestination(`https://old.example${entry}?x=1%202&x=three#section`, 'https://new.example'), 'https://new.example/directory/?x=1%202&x=three#section'); + assert.equal(transitionDestination('https://old.example/index.html?q=1', 'https://new.example'), 'https://new.example/?q=1'); + assert.equal(transitionDestination('https://old.example/resources/a/?q=1', 'https://new.example'), 'https://new.example/resources/a/?q=1'); + for (const route of [...legacyFiles, '/data/v1/resources.json', '/assets/file.js', '/fonts/font.woff2', '/transition-licenses/OFL.txt']) assert.equal(transitionDestination('https://old.example' + route, 'https://new.example'), null); + }); + it('retirement deletes only owned legacy caches and reloads website windows on the old origin', async () => { + const handlers = new Map void>(), removed: string[] = [], navigated: string[] = []; + let claimed = false, skipped = false; + const urls = ['https://old.example/directory.html?from=bookmark', 'https://old.example/data/v1/resources.json', 'https://unrelated.example/']; + vm.runInNewContext(retirementWorker('https://new.example'), { + URL, Response, caches: { keys: async () => ['kfh-app-one', 'kfh-app-two', 'kfh-data-v1', 'kfh-data-v10', 'kfh-other', 'food-help-example-app-one', 'unrelated'], delete: async (name: string) => { removed.push(name); return true; } }, + self: { location: { origin: 'https://old.example' }, addEventListener: (type: string, listener: (event: unknown) => void) => handlers.set(type, listener), skipWaiting: async () => { skipped = true; }, clients: { claim: async () => { claimed = true; }, matchAll: async () => urls.map(url => ({ url, navigate: async (next: string) => { navigated.push(next); } })) } } + }); + let pending: Promise | undefined; + handlers.get('install')!({ waitUntil: (value: Promise) => { pending = value; } }); await pending; assert.equal(skipped, true); + handlers.get('activate')!({ waitUntil: (value: Promise) => { pending = value; } }); await pending; + assert.equal(claimed, true); assert.deepEqual(removed.sort(), ['kfh-app-one', 'kfh-app-two', 'kfh-data-v1']); assert.deepEqual(navigated, [urls[0]]); + let response: Promise | undefined; + handlers.get('fetch')!({ request: { method: 'GET', mode: 'navigate', url: urls[0] }, respondWith: (value: Promise) => { response = value; } }); + assert.equal((await response)!.status, 308); assert.equal((await response)!.headers.get('location'), 'https://new.example/directory/?from=bookmark'); + response = undefined; + handlers.get('fetch')!({ request: { method: 'GET', mode: 'navigate', url: urls[1] }, respondWith: (value: Promise) => { response = value; } }); assert.equal(response, undefined); + }); + it('copies exact legacy bytes without private receipts or old HTML and refuses destructive destinations', async () => { + await mkdir('artifacts/legacy-tests', { recursive: true }); const root = await mkdtemp(path.resolve('artifacts/legacy-tests/build-')), source = path.join(root, 'source'), output = path.join(root, 'output'); + await mkdir(path.join(source, 'data/v1'), { recursive: true }); await mkdir(path.join(source, 'assets')); + const data = '{\n "schema_version": 1, "resources": [{"id":"synthetic"}]\n}\n'; + await writeFile(path.join(source, 'data/v1/resources.json'), data); + for (const file of legacyFiles.filter(file => file !== '/sw.js')) await writeFile(path.join(source, file), file + '\r\n'); + await writeFile(path.join(source, 'assets/old.js'), '/* preserved synthetic code */'); + await writeFile(path.join(source, 'sw.js'), 'const APP_CACHE="kfh-app-old";const DATA_CACHE="kfh-data-v1";const PRECACHE_URLS = ["/index.html","/assets/old.js"];'); + await writeFile(path.join(source, 'index.html'), 'Old application with private marker'); await writeFile(path.join(source, 'receipt.json'), '{"private":"receipt"}'); + const options = { fromDir: source, toOrigin: 'https://new.example', outDir: output }; + const result = await buildLegacyTransition(options); + assert.equal(await readFile(path.join(output, 'data/v1/resources.json'), 'utf8'), data); assert.equal(await readFile(path.join(output, 'assets/old.js'), 'utf8'), '/* preserved synthetic code */'); + assert.equal((await readdir(output)).includes('receipt.json'), false); assert.equal((await readFile(path.join(output, 'index.html'), 'utf8')).includes('private marker'), false); + assert.equal(result.legacy_resource_count, 1); assert.equal(result.requires_external_website_redirect, true); + const provenance = await readFile(path.join(output, 'transition-licenses/legacy-provenance.txt'), 'utf8'); + assert.match(provenance, /does not relicense these historical assets/); + assert.match(provenance, /no claim that the historical source is publicly available/); + assert.match(provenance, /https:\/\/new\.example\/licenses\//); + assert.equal(provenance.includes('github.com'), false); + assert.match(await readFile(path.join(output, 'index.html'), 'utf8'), /href="\/transition-licenses\/legacy-provenance.txt"/); + assert.match(await readFile(path.join(output, '_headers'), 'utf8'), /Cache-Control: no-store/); + await assert.rejects(buildLegacyTransition(options), /must be empty/); + await assert.rejects(buildLegacyTransition({ ...options, outDir: source }), /separate child/); + await assert.rejects(buildLegacyTransition({ ...options, toOrigin: 'http://new.example', outDir: path.join(root, 'unsafe') }), /HTTPS origin/); + }); +}); diff --git a/tests/unit/presentation.test.ts b/tests/unit/presentation.test.ts new file mode 100644 index 0000000..0025f4f --- /dev/null +++ b/tests/unit/presentation.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from 'node:test'; +import { expect } from '@playwright/test'; +import siteJSON from '../../examples/exampleville/site.json' with { type: 'json' }; +import sourceJSON from '../../examples/exampleville/resources.json' with { type: 'json' }; +import { categoryGroups, publishedSchedule, scheduledToday } from '../../src/domain.ts'; +import { card, scheduleSummary } from '../../src/presentation.ts'; +import { assertSite } from '../../src/validation.ts'; +import type { Site, Dataset, PublicDataset } from '../../src/types.ts'; +const site = siteJSON as Site, source = sourceJSON as unknown as Dataset; +const resource = () => structuredClone(source.resources[0]!); +describe('directory presentation and schedule decisions', () => { + it('includes later-today hours, excludes ended hours, and labels appointments clearly', () => { + const r = resource(), early = new Date('2026-09-07T07:00:00Z'), end = new Date('2026-09-07T16:00:00Z'); + expect(publishedSchedule(r, site, early).kind).toBe('later'); expect(scheduledToday(r, site, early)).toBe(true); + expect(publishedSchedule(r, site, end).kind).toBe('ended'); expect(scheduledToday(r, site, end)).toBe(false); + r.access.appointment_required = 'yes'; + expect(scheduleSummary(r, site, early).label).toBe('Appointments later today'); + expect(scheduleSummary(r, site, new Date('2026-09-07T10:00:00Z')).label).toBe('Appointment hours'); + }); + it('keeps unconfirmed, overdue, interrupted, and out-of-range services out of the today filter', () => { + const now = new Date('2026-09-07T10:00:00Z'); + for (const change of [ + (r: ReturnType) => { r.schedule.confirmed = false; }, + (r: ReturnType) => { r.verification.reviewed_on = '2025-01-01'; }, + (r: ReturnType) => { r.service_condition = 'temporarily_unavailable'; }, + (r: ReturnType) => { r.schedule.valid_from = '2026-09-08'; }, + (r: ReturnType) => { r.verification.state = 'needs_review'; }, + ]) { const r = resource(); change(r); expect(scheduledToday(r, site, now)).toBe(false); } + }); + it('honours local warning dates and provider exceptions for future hours', () => { + const s = structuredClone(site); s.jurisdiction = { coverage_through: '2026-09-30', warning_dates: [{ date: '2026-09-07', note: 'Confirm service', source_url: 'https://example.invalid/notice', checked_on: '2026-09-01' }] }; + const r = resource(), now = new Date('2026-09-07T07:00:00Z'); + expect(publishedSchedule(r, s, now).kind).toBe('unknown'); + r.schedule.exceptions = [{ date: '2026-09-07', confirmed: true, intervals: [{ opens: '14:00', closes: '16:00' }], note: 'Special hours', evidence_ids: ['example-source'] }]; + expect(publishedSchedule(r, s, now).kind).toBe('later'); + r.schedule.exceptions[0]!.intervals = []; expect(scheduledToday(r, s, now)).toBe(false); + r.schedule.exceptions[0]!.confirmed = false; expect(publishedSchedule(r, s, now).kind).toBe('unknown'); + }); + it('retains overnight carry-in but does not import hours from before validity begins', () => { + const r = resource(); r.schedule.weekly = [{ day: 1, state: 'published', intervals: [{ opens: '22:00', closes: '02:00', closes_next_day: true }] }]; + const now = new Date('2026-09-08T00:00:00Z'); expect(publishedSchedule(r, site, now).kind).toBe('now'); + r.schedule.valid_from = '2026-09-08'; expect(publishedSchedule(r, site, now).kind).toBe('unknown'); + }); + it('groups categories without changing facts and rejects ambiguous configuration', () => { + const s = structuredClone(site); s.presentation = { category_groups: [{ id: 'take-home', label: 'Take-home food', categories: ['groceries', 'food_banks_pantries'] }] }; + expect(() => assertSite(s)).not.toThrow(); expect(categoryGroups(s, source.resources)).toHaveLength(1); + s.presentation.category_groups!.push(s.presentation.category_groups![0]!); expect(() => assertSite(s)).toThrow(/Duplicate/); + s.presentation.category_groups = [{ id: 'all', label: 'All', categories: ['groceries'] }]; expect(() => assertSite(s)).toThrow(/built in/); + s.presentation.category_groups = [{ id: 'unknown', label: 'Unknown', categories: ['invented_category'] }]; expect(() => assertSite(s)).toThrow(/Unknown category/); + }); + it('renders a repeated purpose once, keeps links and warnings, and escapes author text', () => { + const r = resource(); r.summary = 'A unique description'; r.food_access_purpose = r.summary; + r.notices = [{ id: 'warning', text: 'Call to confirm access', starts_on: '2026-09-01', evidence_ids: ['example-source'] }]; + const html = card(r, source as PublicDataset, site); + expect(html.match(/A unique <support> description/g)).toHaveLength(1); + expect(html).toContain('class="temporary-notice"'); expect(html).toContain(`/resources/${r.id}/`); + expect(html).not.toContain('access.walk_in'); expect(html).toContain('Not confirmed'); + }); +}); diff --git a/tests/unit/release-plan.test.ts b/tests/unit/release-plan.test.ts new file mode 100644 index 0000000..aa350b1 --- /dev/null +++ b/tests/unit/release-plan.test.ts @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { releasePlan } from '../../scripts/lib/release-plan.ts'; +import { selectedChecks } from '../../scripts/select-checks.ts'; +const revision = 'a'.repeat(40), previousRevision = 'b'.repeat(40); +test('publication selects exactly one independent community pointer and uses a lease', () => { + const first = releasePlan({ deployment: 'one-community', revision, currentRevision: revision, previousRevision }); + const second = releasePlan({ deployment: 'other-community', revision, currentRevision: revision }); + assert.deepEqual(first.pushArguments, ['push', `--force-with-lease=refs/heads/release/one-community:${previousRevision}`, 'origin', `${revision}:refs/heads/release/one-community`]); + assert.equal(second.branch, 'release/other-community'); + assert.ok(!first.pushArguments.join(' ').includes(second.branch)); + 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/); +}); +test('shared changes validate communities; a data change selects only its community', () => { + const folders = ['deployments/one-community', 'deployments/other-community']; + assert.deepEqual(selectedChecks(['deployments/one-community/resources.json'], folders), ['deployments/one-community']); + assert.deepEqual(selectedChecks(['src/main.ts'], folders), [...folders, 'examples/exampleville']); + assert.deepEqual(selectedChecks(['deployments/other-community/site.json'], folders), ['deployments/other-community']); +}); diff --git a/tests/unit/selection.test.ts b/tests/unit/selection.test.ts new file mode 100644 index 0000000..b305c4f --- /dev/null +++ b/tests/unit/selection.test.ts @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MPL-2.0 +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { argumentsFor, selection } from '../../scripts/lib/selection.ts'; + +it('default selection is fictional and each deployment owns its own output', async () => { + const neutral = await selection(), second = await selection({ siteDir: 'tests/fixtures/kingston-like' }); + assert.equal(neutral.siteDir, path.resolve('examples/exampleville')); + assert.equal(neutral.outDir, path.resolve('dist/exampleville')); + assert.equal(second.outDir, path.resolve('dist/kingston-like')); + assert.notEqual(neutral.outDir, second.outDir); + assert.equal((await selection({ siteDir: 'tests/fixtures/kingston-like', outDir: 'artifacts/my-review' })).outDir, path.resolve('artifacts/my-review')); +}); +it('selection flags require values rather than silently selecting another deployment', () => { + assert.throws(() => argumentsFor(['--site', '--production']), /requires a value/); + assert.throws(() => argumentsFor(['--out']), /requires a value/); + assert.equal(argumentsFor(['--site', 'deployments/example', '--production']).siteDir, 'deployments/example'); +}); diff --git a/tests/unit/text.test.ts b/tests/unit/text.test.ts new file mode 100644 index 0000000..ab24fb4 --- /dev/null +++ b/tests/unit/text.test.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +import { it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { readText } from '../../scripts/lib/text.ts'; +import { digest } from '../../scripts/lib/web.ts'; + +it('LF, CRLF and CR source files generate the same text bytes and digest without changing Unicode', async () => { + await mkdir('artifacts/text-fixtures', { recursive: true }); + const directory = await mkdtemp(path.resolve('artifacts/text-fixtures/newlines-')); + const source = 'First line: café.\nSecond line: Ω.\n'; + for (const [name, newline] of [['lf', '\n'], ['crlf', '\r\n'], ['cr', '\r']] as const) { + const file = path.join(directory, `${name}.txt`); await writeFile(file, source.replaceAll('\n', newline)); + assert.equal(await readText(file), source); + assert.equal(digest(await readText(file)), digest(source)); + } +});