diff --git a/README.md b/README.md index 5157207..6b5d115 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,10 @@ This repo holds the source of the site at ## What the site holds - A [playground](https://gatepost-dev.github.io/docs/playground/) where you check a postcode in your browser. +- A page on the postcode format, with a regex, and a page that validates a postcode in TypeScript, PHP and an HTML form. - Guides for the TypeScript core, the TypeScript client, the PHP package, the HTML field, the React field and the WooCommerce plugin. - An API reference that the build writes from the TSDoc comments and the PHP doc comments. -- The spec pages: the grammar, the client contract, the field and the glossary. +- The spec pages: the grammar, the client contract, the field, the glossary and the state codes. - A [readiness tracker](https://gatepost-dev.github.io/docs/readiness/) of common tools that reject the new postcode. ## Run it on your computer diff --git a/astro.config.mjs b/astro.config.mjs index 954e2e5..104c0f7 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -109,7 +109,7 @@ export default defineConfig({ reactTypeDoc(typeDocOf('react')), ], sidebar: [ - { label: 'Start', items: ['start', 'playground'] }, + { label: 'Start', items: ['start', 'postcode-format', 'validate-postcode', 'playground'] }, { label: 'Guides', items: [{ autogenerate: { directory: 'guides' } }] }, { label: 'Spec', items: [{ autogenerate: { directory: 'spec' } }] }, { diff --git a/scripts/spec-pages.ts b/scripts/spec-pages.ts index e7b7c28..698e54a 100644 --- a/scripts/spec-pages.ts +++ b/scripts/spec-pages.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: 2026 The Gatepost authors // SPDX-License-Identifier: Apache-2.0 // Writes the spec pages of the site from the spec submodule, so that the site shows the grammar, -// the client contract, the field and the glossary that the SDKs implement, word for word. +// the client contract, the field and the glossary that the SDKs implement, word for word. The page +// of state codes comes from the data file that the SDKs read, so it cannot drift from them. import { execFileSync } from 'node:child_process'; import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -94,6 +95,59 @@ export function specPages(specDir: string, commit: string): readonly SpecPage[] }); } +interface StateData { + readonly source: string; + readonly states: readonly { + readonly code: string; + readonly name: string; + readonly iso: string; + }[]; +} + +/** + * Builds the page that lists the state codes, from `data/states.json` of a spec folder. People + * search for these codes, so the page has a title and a description of its own. + * + * @param specDir - The spec folder, such as the `spec` submodule. + * @param commit - The commit of the spec folder, for the link to the data file. + * @returns The page, with its front matter. + */ +export function stateCodesPage(specDir: string, commit: string): SpecPage { + const version = readFileSync(join(specDir, 'VERSION'), 'utf8').trim(); + const file = 'data/states.json'; + const { source, states } = JSON.parse(readFileSync(join(specDir, file), 'utf8')) as StateData; + const url = `https://github.com/gatepost-dev/spec/blob/${commit}/${file}`; + const description = + `The ${String(states.length)} two-letter state codes that start a Nigerian postcode, ` + + 'with the name and the ISO 3166-2:NG code of each state.'; + const lines = [ + '---', + 'title: Nigerian state codes for postcodes', + `description: "${description}"`, + 'editUrl: false', + `sidebar: { label: State codes, order: ${String(SOURCES.length + 1)} }`, + '---', + '', + `:::note\nThis page shows [\`${file}\`](${url}) from spec ${version}.\n:::`, + '', + 'The first segment of a postcode is the state. It is a code of two letters.', + 'The table lists the code of each state and of the Federal Capital Territory.', + '`parse` gives `unknown_state` for any other two letters.', + '', + '## Codes and names', + '', + '| Code | State | ISO 3166-2 |', + '|---|---|---|', + ...states.map(({ code, name, iso }) => `| \`${code}\` | ${name} | \`${iso}\` |`), + '', + '## Source', + '', + source, + '', + ]; + return { slug: 'state-codes', text: wrapTables(lines.join('\n')) }; +} + /** * Writes the spec pages into the docs collection. The build calls it before it reads the pages. * @@ -104,7 +158,8 @@ export function writeSpecPages(root: string): void { const commit = execFileSync('git', ['-C', specDir, 'rev-parse', 'HEAD'], { encoding: 'utf8' }); const folder = join(root, 'src/content/docs/spec'); mkdirSync(folder, { recursive: true }); - for (const { slug, text } of specPages(specDir, commit.trim())) { + const pages = [...specPages(specDir, commit.trim()), stateCodesPage(specDir, commit.trim())]; + for (const { slug, text } of pages) { writeFileSync(join(folder, `${slug}.md`), text); } } diff --git a/src/content/docs/guides/html.md b/src/content/docs/guides/html.md index d0e79b9..3f530fe 100644 --- a/src/content/docs/guides/html.md +++ b/src/content/docs/guides/html.md @@ -1,6 +1,6 @@ --- -title: Add a postcode field to a web form -description: Use the gatepost-postcode-field element in any HTML form, with or without a framework. +title: Add a Nigerian postcode field to an HTML form +description: Add a Nigerian postcode field to any HTML form with the gatepost-postcode-field element. It checks the format while the user types. sidebar: label: HTML field order: 4 diff --git a/src/content/docs/guides/php.md b/src/content/docs/guides/php.md index a2b12b7..481a1cb 100644 --- a/src/content/docs/guides/php.md +++ b/src/content/docs/guides/php.md @@ -1,6 +1,6 @@ --- -title: Check and look up postcodes in PHP -description: Read Nigeria's digital postcodes offline and query NIPOST's gateway with gatepost/postcode. +title: Validate and look up Nigerian postcodes in PHP +description: Validate Nigeria's digital postcodes offline, and call the NIPOST postcode API, in PHP with the gatepost/postcode Composer package. sidebar: label: PHP order: 3 diff --git a/src/content/docs/guides/react.md b/src/content/docs/guides/react.md index b259dde..69ff3d7 100644 --- a/src/content/docs/guides/react.md +++ b/src/content/docs/guides/react.md @@ -1,6 +1,6 @@ --- -title: Add a postcode field to a React form -description: Use the PostcodeField component of @gatepost/react in React 18 or 19, and in Next.js. +title: Add a Nigerian postcode field to a React form +description: Add a Nigerian postcode field to a React 18 or 19 form, or to Next.js, with the PostcodeField component of @gatepost/react. sidebar: label: React order: 5 diff --git a/src/content/docs/guides/typescript-client.md b/src/content/docs/guides/typescript-client.md index bc36e15..25758aa 100644 --- a/src/content/docs/guides/typescript-client.md +++ b/src/content/docs/guides/typescript-client.md @@ -1,6 +1,6 @@ --- -title: Look up postcodes in TypeScript -description: Ask NIPOST's gateway about a postcode, a place or a typed code with @gatepost/client. +title: Use the NIPOST postcode API from TypeScript +description: Look up a Nigerian postcode with NIPOST's API and @gatepost/client. Find the postcode of a place, and complete a postcode as the user types. sidebar: label: TypeScript client order: 2 diff --git a/src/content/docs/guides/typescript.md b/src/content/docs/guides/typescript.md index 180eae0..17b800b 100644 --- a/src/content/docs/guides/typescript.md +++ b/src/content/docs/guides/typescript.md @@ -1,6 +1,6 @@ --- -title: Check postcodes in TypeScript -description: Read, clean and format Nigeria's digital postcodes with @gatepost/core, with no network call. +title: Check Nigerian postcodes in TypeScript +description: Parse and validate Nigeria's digital postcodes in TypeScript or JavaScript with @gatepost/core. It works offline and needs no API key. sidebar: label: TypeScript order: 1 diff --git a/src/content/docs/guides/woocommerce.md b/src/content/docs/guides/woocommerce.md index 198b359..965e07d 100644 --- a/src/content/docs/guides/woocommerce.md +++ b/src/content/docs/guides/woocommerce.md @@ -1,6 +1,6 @@ --- -title: Add the postcode to a WooCommerce checkout -description: Install Gatepost Postcode for WooCommerce, and choose how it checks each postcode. +title: Add the Nigerian postcode to a WooCommerce checkout +description: Show the Nigerian postcode field in the WooCommerce checkout, and check each postcode. The plugin can ask NIPOST whether a postcode exists. sidebar: label: WooCommerce order: 6 diff --git a/src/content/docs/index.mdx b/src/content/docs/index.mdx index da37196..a3a1c32 100644 --- a/src/content/docs/index.mdx +++ b/src/content/docs/index.mdx @@ -1,6 +1,39 @@ --- -title: Tools for Nigeria's digital postcode -description: Unofficial, open-source developer tools for Nigeria's digital postcode. +title: Tools for Nigeria's NIPOST digital postcode +description: Unofficial open-source tools that check, format and look up Nigeria's NIPOST digital postcode in TypeScript, PHP, HTML forms and WooCommerce. +head: + - tag: meta + attrs: + property: og:type + content: website + # Search engines read this data to name the site and its software. It loads nothing. + - tag: script + attrs: + type: application/ld+json + content: | + { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebSite", + "@id": "https://gatepost-dev.github.io/docs/#website", + "name": "Gatepost", + "url": "https://gatepost-dev.github.io/docs/", + "description": "Unofficial open-source developer tools for Nigeria's digital postcode.", + "inLanguage": "en-GB" + }, + { + "@type": "SoftwareSourceCode", + "@id": "https://gatepost-dev.github.io/docs/#software", + "name": "Gatepost", + "description": "Open-source tools that check, format and look up Nigeria's NIPOST digital postcode. Unofficial. Not made or endorsed by NIPOST.", + "url": "https://gatepost-dev.github.io/docs/", + "codeRepository": "https://github.com/gatepost-dev", + "programmingLanguage": ["TypeScript", "PHP"], + "license": "https://www.apache.org/licenses/LICENSE-2.0" + } + ] + } template: splash hero: title: Gatepost @@ -14,7 +47,7 @@ hero: variant: minimal --- -On 1 October 2026, NIPOST launched a digital postcode for addressable buildings and locations in Nigeria. A postcode such as `EK-01-A03-FK-01` has five segments. From large to small, they are the state, the LGA, the district, the area and the unit. The unit is one building. +On 1 October 2026, NIPOST launched a digital postcode for addressable buildings and locations in Nigeria. A postcode such as `EK-01-A03-FK-01` has five segments. From large to small, they are the state, the LGA, the district, the area and the unit. The unit is one building. The [postcode format](postcode-format/) page explains each segment, and the [state codes](spec/state-codes/) page lists each state. Gatepost helps apps, websites and online stores accept these postcodes. @@ -24,7 +57,7 @@ Gatepost helps apps, websites and online stores accept these postcodes. - Recognise an old 6-digit postcode. - Ask NIPOST's gateway about a postcode with your own API key. -Only NIPOST's gateway can say whether a building has a given postcode. Gatepost checks the form first, so your app calls the gateway less often and with clean input. +Only NIPOST's gateway can say whether a building has a given postcode. Gatepost checks the form first, so your app calls the gateway less often and with clean input. [Validate a Nigerian postcode](validate-postcode/) shows the check in TypeScript, PHP and an HTML form. Gatepost is independent. NIPOST did not make it and does not support it. For a problem with NIPOST's own app, data or API, contact NIPOST through its official website. diff --git a/src/content/docs/playground.mdx b/src/content/docs/playground.mdx index 51b6ea5..dc192f2 100644 --- a/src/content/docs/playground.mdx +++ b/src/content/docs/playground.mdx @@ -1,6 +1,8 @@ --- -title: Playground -description: Try the postcode parser of @gatepost/core in your browser, with no network call. +title: Nigerian postcode checker +description: Check the format of a Nigerian postcode in your browser, and see its segments, its state and its three forms. Nothing you type leaves the page. +sidebar: + label: Playground --- import ClientSamples from '../../components/ClientSamples.astro'; diff --git a/src/content/docs/postcode-format.md b/src/content/docs/postcode-format.md new file mode 100644 index 0000000..ddb93c6 --- /dev/null +++ b/src/content/docs/postcode-format.md @@ -0,0 +1,99 @@ +--- +title: Nigeria's digital postcode format +description: "The format of Nigeria's NIPOST postcode: five segments, three written forms, the characters of each segment and a regex for the format check." +sidebar: + label: Postcode format +--- + +On 1 October 2026, NIPOST launched a digital postcode for addressable buildings and locations in Nigeria. A postcode has 11 letters and digits in five segments, such as `EK-01-A03-FK-01`. This page describes the format. The [postcode grammar](../spec/grammar/) holds the full rules that every Gatepost SDK follows. + +## The five segments + +From large to small, the segments are the state, the LGA, the district, the area and the unit. The unit names one building or delivery point. + +
+ +| Segment | Example | Length | Characters | Rule | +| -------- | ------- | ------ | ----------------- | ------------------------------------------------- | +| State | `EK` | 2 | letters | one of the 37 [state codes](../spec/state-codes/) | +| LGA | `01` | 2 | digits | `01` to `99` | +| District | `A03` | 3 | letters or digits | any combination of letters and digits | +| Area | `FK` | 2 | letters | any two letters | +| Unit | `01` | 2 | digits | `01` to `99` | + +
+ +The letters are A to Z, and the digits are 0 to 9. The LGA is a local government area within the state. The district is within the LGA. The area is the smallest polygon in the system. + +`00` is never a valid LGA or unit. Both segments run from `01` to `99`, so an area holds at most 99 units. `parse` rejects `EK-01-A03-FK-00` with the error `bad_segment`. + +## Partial postcodes + +A partial postcode stops after the state, the LGA, the district or the area. It has 2, 4, 7 or 9 letters and digits, such as `EK-01-A03` for a district. `parse` accepts a partial postcode only with the option `allowPartial`. + +## Written forms + +Gatepost writes a postcode in three forms. All three name the same postcode. + +
+ +| Form | Example | Use | +| --------- | ----------------- | ---------------------- | +| Canonical | `EK-01-A03-FK-01` | APIs and logs | +| Display | `EK 01 A03 FK 01` | screens and print | +| Compact | `EK01A03FK01` | storage and comparison | + +
+ +People type postcodes in many ways. Before Gatepost checks a postcode, it normalises the text in three steps: + +1. It applies Unicode NFKC. This changes full-width letters and digits to ASCII. +2. It removes each separator: white space, hyphens and dashes, the full stop and zero-width characters. +3. It changes the letters a to z to upper case. + +So `ek 01 a03 fk 01`, `EK.01.A03.FK.01` and `EK01A03FK01` are the same postcode. + +## Old 6-digit postcodes + +Before the digital postcode, NIPOST used postcodes of 6 digits. Many records still hold such a legacy postcode. + +
+ +| Detail | Legacy postcode | Digital postcode | +| ---------- | --------------- | ------------------------ | +| Example | `900108` | `EK-01-A03-FK-01` | +| Length | 6 | 11 | +| Characters | digits | letters and digits | +| Names | an area | one building or location | + +
+ +`parse` gives the error `legacy_code` for exactly 6 digits, and `isLegacy` recognises a legacy postcode. Your form can then ask for the new postcode. + +## A regex for the format check + +`parse` is the full check. Use a regex where no Gatepost SDK runs, such as in a database rule. This regex accepts the three forms in upper case. It checks the state code and the range of the LGA and the unit. + +```ts +const POSTCODE = + /^(?:AB|AD|AK|AN|BA|BE|BR|BY|CR|DE|EB|ED|EK|EN|FC|GM|IM|JI|KD|KE|KG|KN|KT|KW|LA|NA|NI|OG|ON|OS|OY|PL|RI|SK|TA|YB|ZA)[ -]?(?:0[1-9]|[1-9][0-9])[ -]?[A-Z0-9]{3}[ -]?[A-Z]{2}[ -]?(?:0[1-9]|[1-9][0-9])$/; + +POSTCODE.test('EK-01-A03-FK-01'); // true +POSTCODE.test('EK 01 A03 FK 01'); // true +POSTCODE.test('EK01A03FK01'); // true +POSTCODE.test('EK-01-A03-FK-00'); // false +POSTCODE.test('XX-01-A03-FK-01'); // false +POSTCODE.test('900108'); // false +``` + +The regex does less than `parse`. It does not normalise the text. Remove the white space at the start and the end, and change the letters to upper case first. The regex gives no reason for a failure and no suggestion for a typo. + +:::note +A postcode in the right format can still be one that NIPOST did not assign. Only NIPOST's gateway can say whether a postcode exists. [Look up a postcode](../guides/typescript-client/) to ask it. +::: + +## Next steps + +- [Validate a postcode](../validate-postcode/) in TypeScript, PHP or an HTML form. +- [Find the code of a state](../spec/state-codes/). +- [Check a postcode in the playground](../playground/). diff --git a/src/content/docs/privacy.md b/src/content/docs/privacy.md index e4616c8..0fbff4e 100644 --- a/src/content/docs/privacy.md +++ b/src/content/docs/privacy.md @@ -1,6 +1,8 @@ --- -title: Privacy -description: What this site keeps, what it sends and what GitHub logs. +title: Privacy of the Gatepost docs +description: This site has no analytics, no tracking and no cookies. Read what your browser keeps, what the site sends and what GitHub logs. +sidebar: + label: Privacy --- This site has no analytics, no tracking and no cookies. Every script, font, image and style comes from this site itself. No page loads anything from another website. diff --git a/src/content/docs/readiness.md b/src/content/docs/readiness.md index 115d2cf..5657ebe 100644 --- a/src/content/docs/readiness.md +++ b/src/content/docs/readiness.md @@ -1,6 +1,8 @@ --- -title: Readiness tracker -description: Common tools that reject or hide Nigeria's new postcode, and the state of the fix for each one. +title: Readiness tracker for Nigeria's new postcode +description: Common tools, such as Shopify worldwide and WooCommerce, that reject or hide Nigeria's new postcode, and the state of the fix for each one. +sidebar: + label: Readiness tracker --- On 1 October 2026, NIPOST launched a postcode of 11 letters and digits, as [its docs](https://docs.postcode.gov.ng) describe. Some common tools still accept only the old 6-digit postcode, or hide the postcode field for Nigeria. Gatepost has sent a fix to three of the five tools below and has not sent a fix to the other two. This page shows the state on 3 October 2026. diff --git a/src/content/docs/start.md b/src/content/docs/start.md index d3e499e..2463d69 100644 --- a/src/content/docs/start.md +++ b/src/content/docs/start.md @@ -1,6 +1,8 @@ --- -title: Choose a tool -description: Find the Gatepost package that fits your app, and learn what each one needs. +title: Choose a tool for Nigerian postcodes +description: Find the Gatepost package that checks or looks up Nigerian postcodes in TypeScript, PHP, an HTML or React form, or a WooCommerce shop. +sidebar: + label: Choose a tool --- Each Gatepost tool follows the same spec, so each one reads a postcode in the same way. Choose by the language of your app and by what it must know. diff --git a/src/content/docs/validate-postcode.md b/src/content/docs/validate-postcode.md new file mode 100644 index 0000000..561c022 --- /dev/null +++ b/src/content/docs/validate-postcode.md @@ -0,0 +1,86 @@ +--- +title: Validate a Nigerian postcode +description: Validate a Nigerian postcode in TypeScript, PHP or an HTML form. Check the format offline, then ask NIPOST's gateway whether it exists. +sidebar: + label: Validate a postcode +--- + +A check of a postcode has two steps. + +1. Check the format. Gatepost does this offline, with no API key and no network call. The check finds typos, old 6-digit postcodes and unknown state codes. +2. Ask NIPOST's gateway whether the postcode exists. Only the gateway knows, and it needs your own API key. + +Each example below checks the format. It accepts spaces, hyphens and any letter case, so `ek 01 a03 fk 01` gives `EK-01-A03-FK-01`. The [postcode format](../postcode-format/) page explains the rules. + +## TypeScript or JavaScript + +`parse` from `@gatepost/core` takes the text that a user typed. It returns a result, and it never throws. The package runs in Node 22 or later and in current browsers. + +```sh +pnpm add @gatepost/core +``` + +The first alpha is not published yet. This page will say when it is. + +```ts +import { parse } from '@gatepost/core'; + +const result = parse('ek 01 a03 fk 01'); +if (result.ok) { + result.value.canonical; // 'EK-01-A03-FK-01' +} + +const typo = parse('EK-O1-A03-FK-01'); +if (!typo.ok) { + typo.error.code; // 'bad_segment' + typo.error.suggestion; // 'EK-01-A03-FK-01' +} +``` + +When `ok` is false, `error.code` names the first problem. The [TypeScript guide](../guides/typescript/#tell-the-user-what-is-wrong) lists each code. Show a suggestion as a question, and let the user accept it. + +## PHP + +`Postcode::parse` from `gatepost/postcode` works in the same way. It needs PHP 8.1 or later. The [PHP guide](../guides/php/#install) shows how to install it with Composer. + +```php +isOk()) { + echo $result->value->canonical, "\n"; // EK-01-A03-FK-01 +} + +$typo = Postcode::parse('EK-O1-A03-FK-01'); +echo $typo->error?->code->value, "\n"; // bad_segment +echo $typo->error?->suggestion, "\n"; // EK-01-A03-FK-01 +``` + +## An HTML form + +`@gatepost/field` adds a postcode field to any HTML form. It checks the format while the user types. When the text is a postcode, the form sends its canonical form. + +```html + +
+ + +
+``` + +A user who types `fc 01 z99 zz 01` sends `postcode=FC-01-Z99-ZZ-01`. With no key, the field checks the format offline and sends no request. The [HTML field guide](../guides/html/) shows how to install the field, and it explains each setting. + +## Ask NIPOST whether a postcode exists + +A postcode in the right format can still be one that NIPOST did not assign. A lookup asks NIPOST's gateway about the postcode. [Get a key](../start/#get-a-key) from NIPOST, and then follow the guide for your language. + +- [Look up a postcode in TypeScript](../guides/typescript-client/). +- [Look up a postcode in PHP](../guides/php/#look-up-a-postcode). + +With a publishable key, the HTML field also asks the gateway about each whole postcode. + +## A WooCommerce shop + +Gatepost Postcode for WooCommerce shows the postcode field for Nigerian addresses in the checkout, and it checks each postcode. Read the [WooCommerce guide](../guides/woocommerce/). diff --git a/test/site/pages.test.ts b/test/site/pages.test.ts index 4d46d9e..878f2fb 100644 --- a/test/site/pages.test.ts +++ b/test/site/pages.test.ts @@ -50,6 +50,60 @@ describe('the preview of a shared link', () => { expect(page?.text).toMatch(/[^<|]*\| Gatepost<\/title>/); expect(page?.text).not.toContain('<title>Gatepost | Gatepost'); }); + + it('calls the home page a website and every other page an article', () => { + for (const { path, text } of PAGES) { + const type = path === 'index.html' ? 'website' : 'article'; + expect(text, path).toContain(` { + it.each([ + ['index.html', "Nigeria's NIPOST digital postcode"], + ['postcode-format/index.html', "Nigeria's digital postcode format"], + ['validate-postcode/index.html', 'Validate a Nigerian postcode'], + ['spec/state-codes/index.html', 'Nigerian state codes'], + ['playground/index.html', 'Nigerian postcode checker'], + ['guides/typescript-client/index.html', 'NIPOST postcode API'], + ['guides/php/index.html', 'Nigerian postcodes in PHP'], + ['guides/woocommerce/index.html', 'Nigerian postcode to a WooCommerce checkout'], + ])('%s names "%s"', (path, words) => { + const title = /([^<]*)<\/title>/.exec( + PAGES.find((page) => page.path === path)?.text ?? '', + )?.[1]; + expect(title?.replaceAll(''', "'")).toContain(words); + }); +}); + +// Search engines read the structured data of the home page to name the site and its software. +describe('the structured data of the home page', () => { + const home = PAGES.find(({ path }) => path === 'index.html')?.text ?? ''; + const blocks = Array.from( + home.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g), + ([, json]) => JSON.parse(json ?? '') as { '@context': string; '@graph': { '@type': string }[] }, + ); + const items = blocks.flatMap((block) => block['@graph']); + const canonical = /<link rel="canonical" href="([^"]+)"/.exec(home)?.[1]; + + it('describes one website and one piece of source code, in the vocabulary of schema.org', () => { + expect(blocks.map((block) => block['@context'])).toEqual(['https://schema.org']); + expect(items.map((item) => item['@type']).sort()).toEqual(['SoftwareSourceCode', 'WebSite']); + }); + + it('gives the website the address of the home page', () => { + const site = items.find((item) => item['@type'] === 'WebSite') as { url?: string } | undefined; + expect(canonical).toBe('https://gatepost-dev.github.io/docs/'); + expect(site?.url).toBe(canonical); + }); + + it('says that the software is unofficial', () => { + const code = items.find((item) => item['@type'] === 'SoftwareSourceCode') as + { description?: string } | undefined; + expect(code?.description).toContain('Unofficial. Not made or endorsed by NIPOST.'); + }); }); describe('every built style sheet', () => { diff --git a/test/unit/page-descriptions.test.ts b/test/unit/page-descriptions.test.ts new file mode 100644 index 0000000..1e2c8a2 --- /dev/null +++ b/test/unit/page-descriptions.test.ts @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; +import { trackedPages } from '../../scripts/check-prose.ts'; + +// A search result cuts a longer description, so the reader never sees its end. +const MAX_DESCRIPTION = 150; + +function frontMatterOf(page: string): { title?: unknown; description?: unknown } { + const yaml = /^---\n([\s\S]*?)\n---\n/.exec(readFileSync(page, 'utf8'))?.[1] ?? ''; + return parse(yaml) as { title?: unknown; description?: unknown }; +} + +describe('every hand-written page', () => { + it.each(trackedPages())( + 'has a title and a description that a search result shows: %s', + (page) => { + const { title, description } = frontMatterOf(page); + expect(title).toEqual(expect.stringMatching(/\w/)); + expect(description).toEqual(expect.stringMatching(/\w/)); + expect(String(description).length).toBeLessThanOrEqual(MAX_DESCRIPTION); + }, + ); +}); diff --git a/test/unit/postcode-format.test.ts b/test/unit/postcode-format.test.ts new file mode 100644 index 0000000..760bbe3 --- /dev/null +++ b/test/unit/postcode-format.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 The Gatepost authors +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { examplesOf } from '../support/examples.ts'; + +// Readers copy the regex of the format page into their own code, so it must agree with the spec. +const PAGE = 'src/content/docs/postcode-format.md'; +const BLOCK = examplesOf(PAGE, readFileSync(PAGE, 'utf8')).find(({ source }) => + source.includes('const POSTCODE'), +); +const PATTERN = /^\s*\/(\^.+\$)\/;$/m.exec(BLOCK?.source ?? '')?.[1] ?? ''; + +interface ParseCase { + readonly id: string; + readonly input: string; + readonly options: { readonly allowPartial?: boolean }; + readonly expect: { readonly ok: boolean }; +} + +function readJson(path: string): unknown { + return JSON.parse(readFileSync(path, 'utf8')); +} + +const STATE_CODES = ( + readJson('spec/data/states.json') as { states: { code: string }[] } +).states.map(({ code }) => code); + +// The regex does not normalise, so the page asks the reader to trim the text and to change it to +// upper case first. The regex can judge only such text: upper-case ASCII with at most one space or +// hyphen between two characters. parse judges every other input. +const CASES = ['parse', 'parse-segments', 'parse-states'] + .flatMap((name) => (readJson(`spec/vectors/${name}.json`) as { cases: ParseCase[] }).cases) + .filter( + ({ input, options }) => + options.allowPartial !== true && /^(?:[A-Z0-9]+(?:[ -][A-Z0-9]+)*)?$/.test(input), + ); + +describe('the regex on the format page', () => { + it('exists on the page', () => { + expect(PATTERN).not.toBe(''); + }); + + it('lists each state code in spec/data/states.json, and no other', () => { + const listed = /^\^\(\?:([A-Z|]+)\)/.exec(PATTERN)?.[1]?.split('|') ?? []; + expect([...listed].sort()).toEqual([...STATE_CODES].sort()); + }); + + it('has enough vectors to compare, so the check below cannot pass on none', () => { + expect(CASES.length).toBeGreaterThan(50); + }); + + it.each(CASES.map((vector) => [vector.id, vector] as const))( + 'agrees with parse on %s', + (_, { input, expect: expected }) => { + expect(new RegExp(PATTERN).test(input)).toBe(expected.ok); + }, + ); +}); diff --git a/test/unit/spec-pages.test.ts b/test/unit/spec-pages.test.ts index 2a3eb91..47cfdc4 100644 --- a/test/unit/spec-pages.test.ts +++ b/test/unit/spec-pages.test.ts @@ -4,10 +4,15 @@ import { readFileSync } from 'node:fs'; import { SPEC_VERSION as CLIENT_SPEC_VERSION } from '@gatepost/client'; import { SPEC_VERSION as CORE_SPEC_VERSION } from '@gatepost/core'; import { describe, expect, it } from 'vitest'; -import { specPages, wrapTables } from '../../scripts/spec-pages.ts'; +import { checkProse } from '../../scripts/check-prose.ts'; +import { specPages, stateCodesPage, wrapTables } from '../../scripts/spec-pages.ts'; const SPEC_VERSION = readFileSync('spec/VERSION', 'utf8').trim(); const PAGES = specPages('spec', 'abc123'); +const STATE_DATA = JSON.parse(readFileSync('spec/data/states.json', 'utf8')) as { + source: string; + states: readonly unknown[]; +}; describe('specPages', () => { it('builds the grammar, the client contract, the field and the glossary', () => { @@ -28,6 +33,40 @@ describe('specPages', () => { }); }); +describe('stateCodesPage', () => { + const { slug, text } = stateCodesPage('spec', 'abc123'); + const rows = text.split('\n').filter((line) => /^\| `[A-Z]{2}` \|/.test(line)); + + it('builds the state codes page, with a title that names the subject', () => { + expect(slug).toBe('state-codes'); + expect(text).toMatch(/^---\ntitle: Nigerian state codes for postcodes\n/); + }); + + it('gives one row to each state in data/states.json', () => { + expect(rows).toHaveLength(STATE_DATA.states.length); + expect(rows).toContain('| `FC` | Federal Capital Territory | `NG-FC` |'); + }); + + it('shows the ISO code where NIPOST uses another code', () => { + expect(rows).toContain('| `BR` | Borno | `NG-BO` |'); + }); + + it('quotes the source of the codes, and links the data file at its commit', () => { + expect(text).toContain(STATE_DATA.source); + expect(text).toContain( + `[\`data/states.json\`](https://github.com/gatepost-dev/spec/blob/abc123/data/states.json) from spec ${SPEC_VERSION}.`, + ); + }); + + it('puts the table in a box with a focus stop', () => { + expect(text).toContain('<div class="table-scroll" role="region" tabindex="0"'); + }); + + it('passes the prose check of the hand-written pages', () => { + expect(checkProse(text)).toEqual([]); + }); +}); + describe('wrapTables', () => { it('puts each table in a labelled box with a focus stop, named after the heading above', () => { const text = '## Errors\n\nIntro.\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\nAfter.\n';