diff --git a/examples/api.ht/README.md b/examples/api.ht/README.md index 76ffc6f..81879a8 100644 --- a/examples/api.ht/README.md +++ b/examples/api.ht/README.md @@ -28,11 +28,25 @@ the `/callback` key page — each of which links straight into live JSON views. | `phone` | phone number (vanity letters OK) | offline dataset (**DEMO** — labeled) | | `asn` | ASN (`13335` or `AS13335`) | RIPEstat as-overview (live) | | `entity` | slug (`cloudflare`, `united-states`) | Wikipedia REST (live) — entity stub (**DEMO** — labeled) | +| `shapes` | `/` (`jobs/4266196009`, `classes/Q5`; `?shape=a\|b\|c`) | embedded static data (53 Job records + 20 Wikidata classes, zero request-time fetches) | `asn` and `entity` are the entity-link layer: `ip → asn → entity → dns → whois → dns → ip` is a closed browsable loop. Anything mocked or heuristic says so in its `source` field. +`shapes` is the live link-shape comparison for +[dot-do/data#36](https://github.com/dot-do/data/issues/36) (feeding the +[#13](https://github.com/dot-do/data/issues/13) per-brand link-contract +ruling): the SAME entity in three candidate link shapes, switched by +`?shape=` — (a) typed camelCase edges (`postedBy`, `locatedIn`, `sameAs`, +`potentialAction`) as the envelope `links` block, (b) label-keyed maps as the +recovered workers did, (c) = (a) plus a `variations` block of real +representation alternates (for classes: Wikidata JSON/TTL/HTML). Every +response carries a `shapes` hop map to the same page in the other shapes; a +miss is a page too (404 + `suggestions` as the current shape's link map — +try `shapes.api.ht/jobs/staff-engineer`). The `shapes.api.ht/` root is a +clickable index explaining all three. + ## The graph ``` diff --git a/examples/api.ht/src/data/shapes-classes.ts b/examples/api.ht/src/data/shapes-classes.ts new file mode 100644 index 0000000..a8ee1e5 --- /dev/null +++ b/examples/api.ht/src/data/shapes-classes.ts @@ -0,0 +1,107 @@ +// Embedded dataset for the shapes tool (link-shape comparison, dot-do/data#36). +// Top-20 head Wikidata classes by direct-P31 instance count, from the committed +// catalog packages/wiki-sources/catalog-data/wikidata-classes.json (snapshot 2026-08-23, CC0-1.0). +export interface WikidataClass { qid: string; label: string; instanceCount: number } +export const CLASSES: WikidataClass[] = [ + { + "qid": "13442814", + "label": "scholarly article", + "instanceCount": 45882453 + }, + { + "qid": "5", + "label": "human", + "instanceCount": 13645558 + }, + { + "qid": "4167836", + "label": "Wikimedia category", + "instanceCount": 5831849 + }, + { + "qid": "16521", + "label": "taxon", + "instanceCount": 3844128 + }, + { + "qid": "523", + "label": "star", + "instanceCount": 3281394 + }, + { + "qid": "318", + "label": "galaxy", + "instanceCount": 2094346 + }, + { + "qid": "4167410", + "label": "Wikimedia disambiguation page", + "instanceCount": 1528202 + }, + { + "qid": "113145171", + "label": "type of chemical entity", + "instanceCount": 1280245 + }, + { + "qid": "7187", + "label": "gene", + "instanceCount": 1225241 + }, + { + "qid": "3305213", + "label": "painting", + "instanceCount": 1081643 + }, + { + "qid": "8054", + "label": "protein", + "instanceCount": 1020498 + }, + { + "qid": "3331189", + "label": "version, edition or translation", + "instanceCount": 845018 + }, + { + "qid": "11266439", + "label": "Wikimedia template", + "instanceCount": 830148 + }, + { + "qid": "79007", + "label": "street", + "instanceCount": 725984 + }, + { + "qid": "101352", + "label": "family name", + "instanceCount": 694227 + }, + { + "qid": "13433827", + "label": "encyclopedia article", + "instanceCount": 688505 + }, + { + "qid": "486972", + "label": "human settlement", + "instanceCount": 607263 + }, + { + "qid": "13100073", + "label": "village of the People's Republic of China", + "instanceCount": 592642 + }, + { + "qid": "43229", + "label": "organization", + "instanceCount": 529166 + }, + { + "qid": "7725634", + "label": "literary work", + "instanceCount": 518642 + } +] +export const CLASSES_SNAPSHOT = "2026-08-23" diff --git a/examples/api.ht/src/data/shapes-jobs.ts b/examples/api.ht/src/data/shapes-jobs.ts new file mode 100644 index 0000000..04680bf --- /dev/null +++ b/examples/api.ht/src/data/shapes-jobs.ts @@ -0,0 +1,484 @@ +// Embedded dataset for the shapes tool (link-shape comparison, dot-do/data#36). +// 53 canonical Job records from the #34 adapter PoC +// (git show origin/poc/adapter-two-paths:poc/adapter-two-paths/out/batch.ndjson), +// id/company parsed from the Greenhouse sourceUrl. Provenance: ingest/stated/posted. +export interface Job { id: string; title: string; company: string; location: string; department: string | null; postedAt: string; sourceUrl: string } +export const JOBS: Job[] = [ + { + "id": "4266196009", + "title": "AI Red Teamer, Cyber", + "company": "10alabs", + "location": "Washington DC", + "department": null, + "postedAt": "2026-06-01T16:00:58.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4266196009" + }, + { + "id": "4336077009", + "title": "Compliance Manager", + "company": "10alabs", + "location": "Remote", + "department": null, + "postedAt": "2026-07-28T18:28:15.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4336077009" + }, + { + "id": "4330885009", + "title": "Cybersecurity Engineer", + "company": "10alabs", + "location": "Remote", + "department": null, + "postedAt": "2026-07-28T21:38:23.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4330885009" + }, + { + "id": "4203095009", + "title": "Engineering Fellowship", + "company": "10alabs", + "location": "Washington D.C. ", + "department": null, + "postedAt": "2026-03-27T18:16:06.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4203095009" + }, + { + "id": "4336263009", + "title": "Finance Manager - Accounting & FP&A", + "company": "10alabs", + "location": "Remote", + "department": null, + "postedAt": "2026-07-28T20:47:48.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4336263009" + }, + { + "id": "4003000009", + "title": "General Candidate Application ", + "company": "10alabs", + "location": "New York City ", + "department": null, + "postedAt": "2025-06-03T00:49:28.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4003000009" + }, + { + "id": "4292584009", + "title": "Junior Cyber Investigator", + "company": "10alabs", + "location": "Washington DC", + "department": null, + "postedAt": "2026-06-22T22:00:27.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4292584009" + }, + { + "id": "4273194009", + "title": "Machine Learning Engineer ", + "company": "10alabs", + "location": "Washington D.C. ", + "department": null, + "postedAt": "2026-06-03T22:36:00.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4273194009" + }, + { + "id": "4297133009", + "title": "Principal Cyber Investigator", + "company": "10alabs", + "location": "Washington DC", + "department": null, + "postedAt": "2026-06-24T22:34:27.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4297133009" + }, + { + "id": "4088517009", + "title": "Protection Scientist Engineer", + "company": "10alabs", + "location": "London, UK", + "department": null, + "postedAt": "2025-12-23T16:03:00.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4088517009" + }, + { + "id": "4273684009", + "title": "Red Teaming Fellowship", + "company": "10alabs", + "location": "Washington D.C. ", + "department": null, + "postedAt": "2026-06-04T16:42:53.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4273684009" + }, + { + "id": "4007350009", + "title": "Research Fellow", + "company": "10alabs", + "location": "Washington D.C.", + "department": null, + "postedAt": "2025-07-07T19:22:19.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4007350009" + }, + { + "id": "4292581009", + "title": "Senior Cyber Investigator", + "company": "10alabs", + "location": "Washington DC", + "department": null, + "postedAt": "2026-06-22T21:55:35.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4292581009" + }, + { + "id": "4331389009", + "title": "Software Engineer, Infrastructure & Platform", + "company": "10alabs", + "location": "Remote", + "department": null, + "postedAt": "2026-08-17T22:38:05.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4331389009" + }, + { + "id": "4120642009", + "title": "Threat Investigator - APAC", + "company": "10alabs", + "location": "Sydney, Australia", + "department": null, + "postedAt": "2026-02-09T17:54:32.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4120642009" + }, + { + "id": "4112665009", + "title": "Threat Investigator - EMEA", + "company": "10alabs", + "location": "London, UK", + "department": null, + "postedAt": "2026-01-27T18:11:47.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4112665009" + }, + { + "id": "4002458009", + "title": "Threat Investigator US ", + "company": "10alabs", + "location": "Remote - Pacific Time ", + "department": null, + "postedAt": "2025-05-22T15:01:54.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/10alabs/jobs/4002458009" + }, + { + "id": "8136607", + "title": "Benefits Specialist", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-08-20T17:06:29.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8136607" + }, + { + "id": "8097027", + "title": "Digital Analytics & Engineering Manager", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-07-30T20:44:35.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8097027" + }, + { + "id": "6330097", + "title": "Distribution Center Associate (Customer Care)", + "company": "1800contacts", + "location": "Salt Lake City, UT", + "department": null, + "postedAt": "2024-10-30T20:56:17.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/6330097" + }, + { + "id": "7481286", + "title": "Distribution Center Associate (Shipping)", + "company": "1800contacts", + "location": "Coppell, TX", + "department": null, + "postedAt": "2026-05-08T23:25:40.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/7481286" + }, + { + "id": "7481340", + "title": "Distribution Center Associate (Shipping)", + "company": "1800contacts", + "location": "Salt Lake City, UT", + "department": null, + "postedAt": "2026-05-28T14:36:22.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/7481340" + }, + { + "id": "6271338", + "title": "General Call Center Positions", + "company": "1800contacts", + "location": "Draper, UT (Remote)", + "department": null, + "postedAt": "2024-09-24T20:27:59.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/6271338" + }, + { + "id": "6437930", + "title": "Optical Manufacturing Technician", + "company": "1800contacts", + "location": "Salt Lake City, UT", + "department": null, + "postedAt": "2024-11-27T18:10:57.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/6437930" + }, + { + "id": "8146009", + "title": "Optical Styling & Customer Experience Consultant", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-08-21T02:24:03.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8146009" + }, + { + "id": "8109007", + "title": "Paid Social Manager", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-08-05T15:53:04.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8109007" + }, + { + "id": "8120364", + "title": "Software Engineer II", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-08-10T20:16:51.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8120364" + }, + { + "id": "8142670", + "title": "Workforce Planning & Scheduling Specialist", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-08-20T17:27:46.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8142670" + }, + { + "id": "8142682", + "title": "Workforce Real-Time Coordinator", + "company": "1800contacts", + "location": "Draper, UT (Hybrid)", + "department": null, + "postedAt": "2026-08-20T17:32:04.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/1800contacts/jobs/8142682" + }, + { + "id": "6015958004", + "title": "App Development Architect(.Net)", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-06-08T07:06:41.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6015958004" + }, + { + "id": "5736177004", + "title": "Application Development Architect (Java)", + "company": "66degrees", + "location": "Remote, United States", + "department": null, + "postedAt": "2026-08-12T21:04:21.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5736177004" + }, + { + "id": "6011634004", + "title": "Client Outcome Architect", + "company": "66degrees", + "location": "Remote, United States", + "department": null, + "postedAt": "2026-06-02T19:03:44.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6011634004" + }, + { + "id": "6146088004", + "title": "Cloud Architect", + "company": "66degrees", + "location": "India", + "department": null, + "postedAt": "2026-08-18T12:58:26.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6146088004" + }, + { + "id": "6113501004", + "title": "Cloud Engineer", + "company": "66degrees", + "location": "India", + "department": null, + "postedAt": "2026-07-10T08:03:45.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6113501004" + }, + { + "id": "6141079004", + "title": "Cloud Engineer, Contract", + "company": "66degrees", + "location": "Remote, United States", + "department": null, + "postedAt": "2026-08-11T15:24:17.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6141079004" + }, + { + "id": "5973284004", + "title": "Enterprise Account Executive, Atlanta", + "company": "66degrees", + "location": "Atlanta, GA", + "department": null, + "postedAt": "2026-06-18T17:29:11.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5973284004" + }, + { + "id": "5746837004", + "title": "Enterprise Account Executive, West", + "company": "66degrees", + "location": "San Francisco, US", + "department": null, + "postedAt": "2026-01-09T16:52:24.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5746837004" + }, + { + "id": "6015084004", + "title": "Forward Deployed Engineer", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-06-05T12:04:43.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6015084004" + }, + { + "id": "5805595004", + "title": "GCP Data Engineer", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-02-17T06:12:13.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5805595004" + }, + { + "id": "6128485004", + "title": "Google Site Community Manager", + "company": "66degrees", + "location": "India", + "department": null, + "postedAt": "2026-07-28T06:21:13.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6128485004" + }, + { + "id": "6127315004", + "title": "IT Service Desk Intern", + "company": "66degrees", + "location": "Bangalore", + "department": null, + "postedAt": "2026-07-27T06:27:48.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6127315004" + }, + { + "id": "6139546004", + "title": "Lead SRE", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-08-10T10:16:40.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6139546004" + }, + { + "id": "6130886004", + "title": "M365 Architect, Contract", + "company": "66degrees", + "location": "Remote, United States", + "department": null, + "postedAt": "2026-08-05T11:24:36.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6130886004" + }, + { + "id": "5962162004", + "title": "Microstrategy Administrator", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-04-13T06:07:22.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5962162004" + }, + { + "id": "6150358004", + "title": "Player-Coach Sales Leader ", + "company": "66degrees", + "location": "Bangalore", + "department": null, + "postedAt": "2026-08-22T02:37:31.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6150358004" + }, + { + "id": "6100304004", + "title": "Resource Management Assistant", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-06-24T05:47:57.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6100304004" + }, + { + "id": "6105984004", + "title": "Security Operations AI Engineer, Contract", + "company": "66degrees", + "location": "Remote, United States", + "department": null, + "postedAt": "2026-07-02T11:09:10.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6105984004" + }, + { + "id": "5826403004", + "title": "Security Operations Manager", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-03-13T06:09:42.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5826403004" + }, + { + "id": "6093525004", + "title": "Senior Delivery Manager", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-06-18T13:13:48.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6093525004" + }, + { + "id": "5999655004", + "title": "Software Engineer ", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-05-19T08:37:15.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5999655004" + }, + { + "id": "6018453004", + "title": "Solution Consultant(Managed Services)", + "company": "66degrees", + "location": "Bengaluru, Pune", + "department": null, + "postedAt": "2026-06-10T13:59:58.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6018453004" + }, + { + "id": "6008169004", + "title": "Talent Development Specialist", + "company": "66degrees", + "location": "Chicago, IL", + "department": null, + "postedAt": "2026-06-17T18:49:30.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/6008169004" + }, + { + "id": "5746817004", + "title": "Workspace Architect", + "company": "66degrees", + "location": "Remote, United States", + "department": null, + "postedAt": "2026-04-02T15:45:38.000Z", + "sourceUrl": "https://job-boards.greenhouse.io/66degrees/jobs/5746817004" + } +] diff --git a/examples/api.ht/src/hypertext.ts b/examples/api.ht/src/hypertext.ts index 5e098d6..ff7c232 100644 --- a/examples/api.ht/src/hypertext.ts +++ b/examples/api.ht/src/hypertext.ts @@ -68,7 +68,7 @@ export function mountHypertext(app: Hono, deps: HypertextDeps = {}): voi }) const renderToolRoot = (c: Context, links: LinkContext, tool: HypertextTool) => { - if (wantsHtml(c)) return c.html(toolLandingHtml(tool, links)) + if (wantsHtml(c)) return c.html(tool.landingHtml ? tool.landingHtml(links) : toolLandingHtml(tool, links)) return c.var.respond({ data: toolDescriptor(tool, links), links: { docs: links.apex('/docs'), home: links.apex('/') }, diff --git a/examples/api.ht/src/landing.ts b/examples/api.ht/src/landing.ts index e940671..f581adc 100644 --- a/examples/api.ht/src/landing.ts +++ b/examples/api.ht/src/landing.ts @@ -15,9 +15,10 @@ import type { LinkContext } from './links' import type { HypertextTool, ToolRegistry } from './registry' import type { CallbackResult } from './auth' -const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +export const esc = (s: string) => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -function page(title: string, body: string): string { +/** Shared HTML shell — exported so tools with a custom landing keep the property's one design. */ +export function page(title: string, body: string): string { return ` diff --git a/examples/api.ht/src/links.ts b/examples/api.ht/src/links.ts index bef252c..80453a2 100644 --- a/examples/api.ht/src/links.ts +++ b/examples/api.ht/src/links.ts @@ -38,6 +38,7 @@ export function encodeValue(value: string): string { .replace(/%3A/gi, ':') // IPv6 — ':' is a legal pchar .replace(/%2B/gi, '+') // phone numbers — '+' is a legal pchar .replace(/%40/gi, '@') // emails, future tools + .replace(/%2F/gi, '/') // multi-segment values (shapes: jobs/4266196009) — the router decodes the whole path } export function resolveLinkContext(requestUrl: string, apexZone = 'api.ht'): LinkContext { diff --git a/examples/api.ht/src/registry.ts b/examples/api.ht/src/registry.ts index 47d93d4..4960844 100644 --- a/examples/api.ht/src/registry.ts +++ b/examples/api.ht/src/registry.ts @@ -43,6 +43,8 @@ export interface HypertextTool { /** where the data comes from — surfaced verbatim in responses */ source: string lookup: (value: string, ctx: ToolContext) => Promise + /** optional custom HTML face for the tool root; defaults to the generic toolLandingHtml */ + landingHtml?: (links: LinkContext) => string } export type ToolRegistry = Record diff --git a/examples/api.ht/src/tools/index.ts b/examples/api.ht/src/tools/index.ts index 1b1f1da..d1c9d3f 100644 --- a/examples/api.ht/src/tools/index.ts +++ b/examples/api.ht/src/tools/index.ts @@ -14,6 +14,7 @@ import { asnTool } from './asn' import { whoisTool } from './whois' import { phoneTool } from './phone' import { entityTool } from './entity' +import { shapesTool } from './shapes' export function createTools(): ToolRegistry { return { @@ -23,5 +24,6 @@ export function createTools(): ToolRegistry { phone: phoneTool, asn: asnTool, entity: entityTool, + shapes: shapesTool, } } diff --git a/examples/api.ht/src/tools/shapes.ts b/examples/api.ht/src/tools/shapes.ts new file mode 100644 index 0000000..823b4f6 --- /dev/null +++ b/examples/api.ht/src/tools/shapes.ts @@ -0,0 +1,481 @@ +/** + * shapes.api.ht/{dataset}/{id}?shape=a|b|c — the link-shape comparison. + * + * dot-do/data #36 (feeds the #13 per-brand link-contract ruling): the founder + * compares candidate link shapes on LIVE pages, not in prose. The SAME entity + * renders in three candidate shapes, switched by `?shape=`: + * + * ?shape=a typed camelCase edges — envelope `links` keyed by verb-register + * edge names (postedBy, locatedIn, sameAs, potentialAction); the + * edge name carries the TYPE of the relation, the URL carries identity + * ?shape=b label-keyed maps — envelope `links` keyed by arbitrary human + * labels, as the original recovered workers did; no machine edge type + * ?shape=c shape (a) + a `variations` block in data holding REAL + * representation alternates of this page's subject (for classes: + * Wikidata JSON / TTL / HTML) — relation edges and representation + * alternates never share a namespace + * + * Conventions kept from this worker (no second envelope): pages go through + * `c.var.respond` — the shape-under-comparison IS the envelope's `links` block; + * `data.shapes` holds the hop map to the same page in the other shapes. + * A miss is a page too (recovered design): 404 status, `data.suggestions` as a + * link map in the current shape. + * + * Data embedded at build time, zero request-time fetches: + * - jobs: 53 canonical Job records (#34 adapter PoC, 3 Greenhouse boards) + * - classes: top-20 head Wikidata classes (committed wiki-sources catalog) + * Plus two Views over jobs (a partition is a View, not a Dataset): + * companies/{name}, locations/{slug}. + */ + +import type { HypertextTool, ToolContext, ToolResult } from '../registry' +import { badValue } from '../registry' +import type { LinkContext } from '../links' +import { page, esc } from '../landing' +import { JOBS, type Job } from '../data/shapes-jobs' +import { CLASSES, CLASSES_SNAPSHOT, type WikidataClass } from '../data/shapes-classes' + +type Shape = 'a' | 'b' | 'c' +const SHAPES: Shape[] = ['a', 'b', 'c'] + +const SHAPE_INFO: Record = { + a: { + name: 'typed camelCase edges', + rule: 'links keyed by verb-register edge names (postedBy, locatedIn, sameAs, potentialAction) — the edge name carries the TYPE of the relation; the URL carries identity', + }, + b: { + name: 'label-keyed link maps', + rule: 'links keyed by arbitrary human labels, as the original recovered workers did — labels carry meaning; URLs carry identity; no machine-readable edge type', + }, + c: { + name: 'typed edges + variations', + rule: 'shape (a) links, plus a `variations` block holding format/representation alternates of THIS page (Wikidata JSON/TTL/HTML, Greenhouse JSON) — relation edges and representation alternates never share a namespace', + }, +} + +// --------------------------------------------------------------------------- +// helpers + +const slugify = (s: string) => + s + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + +const locSlug = (j: Job) => slugify(j.location) + +const uniq = (xs: T[]) => [...new Set(xs)] + +/** absolute URL into the shapes tool: {tool root}/{path}?shape={s} */ +const at = (links: LinkContext, path: string, shape: Shape): string => `${links.href('shapes', path)}?shape=${shape}` + +/** hop map to the SAME page in every shape — lives in data, never in links */ +const hops = (links: LinkContext, path: string) => + Object.fromEntries(SHAPES.map((s) => [`${s} — ${SHAPE_INFO[s].name}`, at(links, path, s)])) + +interface PageResult { + data: Record + links: Record + status?: number +} + +const ok = (links: LinkContext, path: string, data: Record, linkMap: Record): PageResult => ({ + data: { ...data, shapes: hops(links, path) }, + links: linkMap, +}) + +/** a miss is a page: 404 + suggestions rendered as the current shape's link map */ +const miss = ( + links: LinkContext, + path: string, + message: string, + suggestions: Record, + linkMap: Record, +): PageResult => ({ + data: { + type: 'Miss', + miss: { message, code: 'NOT_FOUND', status: 404 }, + suggestions, + shapes: hops(links, path), + }, + links: linkMap, + status: 404, +}) + +// --------------------------------------------------------------------------- +// jobs — one entity, three shapes + +const JOB_SOURCE = 'Greenhouse board API via the dot-do/data#34 adapter PoC (canonical records; ingest/stated/posted) — embedded, zero request-time fetches' + +function jobFacts(j: Job) { + return { + type: 'Job', + id: j.id, + title: j.title, + company: j.company, + location: j.location, + department: j.department, + postedAt: j.postedAt, + source: JOB_SOURCE, + } +} + +function jobPage(links: LinkContext, shape: Shape, j: Job): PageResult { + const path = `jobs/${j.id}` + if (shape === 'b') { + // (b) original label-keyed maps — arbitrary labels, exactly as the old workers did + return ok(links, path, jobFacts(j), { + [j.company]: at(links, `companies/${j.company}`, shape), + [j.location.trim()]: at(links, `locations/${locSlug(j)}`, shape), + 'All jobs': at(links, 'jobs', shape), + 'View on Greenhouse': j.sourceUrl, + Apply: `${j.sourceUrl}#app`, + }) + } + // (a) and (c): typed camelCase verb-register edges + const data: Record = jobFacts(j) + if (shape === 'c') { + // real representation alternates of this job, not relation edges + data.variations = { + 'source-html': j.sourceUrl, + 'source-json': `https://boards-api.greenhouse.io/v1/boards/${j.company}/jobs/${j.id}`, + } + } + return ok(links, path, data, { + memberOf: at(links, 'jobs', shape), + postedBy: at(links, `companies/${j.company}`, shape), + locatedIn: at(links, `locations/${locSlug(j)}`, shape), + sameAs: j.sourceUrl, + potentialAction: `${j.sourceUrl}#app`, + }) +} + +function jobsIndex(links: LinkContext, shape: Shape): PageResult { + const byKey = + shape === 'b' + ? Object.fromEntries(JOBS.map((j) => [`${j.title.trim()} — ${j.company} (${j.location.trim()})`, at(links, `jobs/${j.id}`, shape)])) + : Object.fromEntries(JOBS.map((j) => [j.id, at(links, `jobs/${j.id}`, shape)])) + return ok( + links, + 'jobs', + { + type: 'Dataset', + name: 'jobs', + description: '53 canonical Job records from the dot-do/data#34 adapter PoC (3 Greenhouse boards → one canonical shape)', + count: JOBS.length, + companies: Object.fromEntries(uniq(JOBS.map((j) => j.company)).map((co) => [co, at(links, `companies/${co}`, shape)])), + locations: Object.fromEntries(uniq(JOBS.map(locSlug)).map((l) => [l, at(links, `locations/${l}`, shape)])), + jobs: byKey, + }, + {}, + ) +} + +function jobMiss(links: LinkContext, shape: Shape, value: string): PageResult { + const scored = JOBS.map((j) => { + const hay = `${j.title} ${j.company} ${j.location}`.toLowerCase() + let score = 0 + for (const word of value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) if (hay.includes(word)) score++ + return { j, score } + }) + .filter((s) => s.score > 0) + .sort((x, y) => y.score - x.score) + .slice(0, 8) + const pool = scored.length ? scored.map((s) => s.j) : JOBS.slice(0, 8) + const suggestions = + shape === 'b' + ? Object.fromEntries(pool.map((j) => [`${j.title.trim()} — ${j.company}`, at(links, `jobs/${j.id}`, shape)])) + : Object.fromEntries(pool.map((j) => [j.id, at(links, `jobs/${j.id}`, shape)])) + return miss(links, `jobs/${value}`, `No job '${value}' in this dataset`, suggestions, { + dataset: at(links, 'jobs', shape), + }) +} + +// Views over jobs (a partition is a View, never a Dataset) + +function companyPage(links: LinkContext, shape: Shape, co: string): PageResult { + const jobs = JOBS.filter((j) => j.company === co) + if (!jobs.length) { + return miss( + links, + `companies/${co}`, + `No company '${co}'`, + Object.fromEntries(uniq(JOBS.map((j) => j.company)).map((c) => [c, at(links, `companies/${c}`, shape)])), + { dataset: at(links, 'jobs', shape) }, + ) + } + const board = `https://job-boards.greenhouse.io/${co}` + const posts = + shape === 'b' + ? Object.fromEntries(jobs.map((j) => [`${j.title.trim()} (${j.location.trim()})`, at(links, `jobs/${j.id}`, shape)])) + : Object.fromEntries(jobs.map((j) => [j.id, at(links, `jobs/${j.id}`, shape)])) + const data: Record = { type: 'View', name: `Jobs at ${co}`, of: 'jobs', company: co, count: jobs.length, posts } + if (shape === 'c') data.variations = { 'board-html': board, 'board-json': `https://boards-api.greenhouse.io/v1/boards/${co}/jobs` } + return ok( + links, + `companies/${co}`, + data, + shape === 'b' + ? { 'All jobs': at(links, 'jobs', shape), 'Board on Greenhouse': board } + : { partOf: at(links, 'jobs', shape), sameAs: board }, + ) +} + +function locationPage(links: LinkContext, shape: Shape, l: string): PageResult { + const jobs = JOBS.filter((j) => locSlug(j) === l) + if (!jobs.length) { + return miss( + links, + `locations/${l}`, + `No location '${l}'`, + Object.fromEntries(uniq(JOBS.map(locSlug)).map((x) => [x, at(links, `locations/${x}`, shape)])), + { dataset: at(links, 'jobs', shape) }, + ) + } + const label = (jobs[0] as Job).location.trim() + const posts = + shape === 'b' + ? Object.fromEntries(jobs.map((j) => [`${j.title.trim()} — ${j.company}`, at(links, `jobs/${j.id}`, shape)])) + : Object.fromEntries(jobs.map((j) => [j.id, at(links, `jobs/${j.id}`, shape)])) + return ok( + links, + `locations/${l}`, + { type: 'View', name: `Jobs in ${label}`, of: 'jobs', location: label, count: jobs.length, posts }, + shape === 'b' ? { 'All jobs': at(links, 'jobs', shape) } : { partOf: at(links, 'jobs', shape) }, + ) +} + +// --------------------------------------------------------------------------- +// classes — one entity, three shapes + +function classPage(links: LinkContext, shape: Shape, cls: WikidataClass): PageResult { + const q = `Q${cls.qid}` + const path = `classes/${q}` + const facts = { + type: 'Class', + qid: q, + label: cls.label, + instanceCount: cls.instanceCount, + source: `Wikidata direct-P31 instance counts via QLever, committed wiki-sources catalog snapshot ${CLASSES_SNAPSHOT} (CC0-1.0) — embedded, zero request-time fetches`, + } + if (shape === 'b') { + return ok(links, path, facts, { + [`${q} on Wikidata`]: `https://www.wikidata.org/wiki/${q}`, + 'Entity data (JSON)': `https://www.wikidata.org/wiki/Special:EntityData/${q}.json`, + 'All classes': at(links, 'classes', shape), + 'Query instances (SPARQL)': `https://query.wikidata.org/#SELECT%20%3Fx%20WHERE%20%7B%20%3Fx%20wdt%3AP31%20wd%3A${q}%20%7D%20LIMIT%2010`, + }) + } + const data: Record = { ...facts } + if (shape === 'c') { + // real representation alternates: the same class as Wikidata JSON / TTL / HTML + data.variations = { + 'wikidata-json': `https://www.wikidata.org/wiki/Special:EntityData/${q}.json`, + 'wikidata-ttl': `https://www.wikidata.org/wiki/Special:EntityData/${q}.ttl`, + 'wikidata-html': `https://www.wikidata.org/wiki/${q}`, + } + } + return ok(links, path, data, { + memberOf: at(links, 'classes', shape), + sameAs: `http://www.wikidata.org/entity/${q}`, + describedBy: `https://www.wikidata.org/wiki/Special:EntityData/${q}.json`, + subjectOf: `https://www.wikidata.org/wiki/${q}`, + }) +} + +function classesIndex(links: LinkContext, shape: Shape): PageResult { + const byKey = + shape === 'b' + ? Object.fromEntries( + CLASSES.map((c) => [`${c.label} (${c.instanceCount.toLocaleString('en-US')} instances)`, at(links, `classes/Q${c.qid}`, shape)]), + ) + : Object.fromEntries(CLASSES.map((c) => [`Q${c.qid}`, at(links, `classes/Q${c.qid}`, shape)])) + return ok( + links, + 'classes', + { + type: 'Dataset', + name: 'classes', + description: `Top-20 head Wikidata classes by direct-P31 instance count (of 8,975 live-tier classes in the committed catalog, snapshot ${CLASSES_SNAPSHOT})`, + count: CLASSES.length, + classes: byKey, + }, + {}, + ) +} + +function classMiss(links: LinkContext, shape: Shape, value: string): PageResult { + const needle = value.toLowerCase().replace(/^q/, '') + const scored = CLASSES.map((c) => { + let score = 0 + if (c.qid === needle) score += 10 + for (const word of value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) if (c.label.toLowerCase().includes(word)) score++ + return { c, score } + }) + .filter((s) => s.score > 0) + .sort((x, y) => y.score - x.score) + .slice(0, 8) + const pool = scored.length ? scored.map((s) => s.c) : CLASSES.slice(0, 8) + const suggestions = + shape === 'b' + ? Object.fromEntries(pool.map((c) => [c.label, at(links, `classes/Q${c.qid}`, shape)])) + : Object.fromEntries(pool.map((c) => [`Q${c.qid}`, at(links, `classes/Q${c.qid}`, shape)])) + return miss(links, `classes/${value}`, `No class '${value}' in this demo slice (top-20 of 8,975 catalog classes)`, suggestions, { + dataset: at(links, 'classes', shape), + wikidataLive: /^q\d+$/i.test(value) + ? `https://www.wikidata.org/wiki/${value.toUpperCase()}` + : `https://www.wikidata.org/w/index.php?search=${encodeURIComponent(value)}`, + }) +} + +// --------------------------------------------------------------------------- +// the custom landing — a clickable index explaining the three shapes + +function shapesLandingHtml(links: LinkContext): string { + const j = JOBS[0] as Job + const li = (u: string, label: string, missRow = false) => + `
  • ${esc(label)}
  • ` + const col = (s: Shape) => ` +

    ?shape=${s} — ${esc(SHAPE_INFO[s].name)}

    +

    ${esc(SHAPE_INFO[s].rule)}

    +` + + return page('shapes.api.ht', ` +

    shapes.api.ht

    +

    The same entity, three candidate link shapes — the live comparison for +dot-do/data#36, feeding the +#13 per-brand link-contract ruling.

    + +

    Usage

    +
    shapes.api.ht/{dataset}/{id}?shape=a|b|c
    +

    Datasets: jobs, classes (+ companies/locations +Views over jobs). Omitting ?shape= defaults to a. Every JSON response carries a +shapes hop map to the same page in the other two shapes, so you can compare in place. +A miss is a page too: 404 with suggestions as a link map in the current shape.

    +${SHAPES.map(col).join('\n')} + +

    Compare the same page across shapes

    + + +

    From a terminal or an agent

    +
    curl ${esc(at(links, `jobs/${j.id}`, 'a'))}
    + +
    Data embedded at build time, zero request-time fetches: 53 canonical Job records +(dot-do/data#34 adapter PoC, 3 Greenhouse boards) + top-20 head Wikidata classes +(committed wiki-sources catalog, snapshot ${esc(CLASSES_SNAPSHOT)}, CC0-1.0). +docs · api.ht
    +`) +} + +// --------------------------------------------------------------------------- +// the tool + +export const shapesTool: HypertextTool = { + name: 'shapes', + description: 'Link-shape comparison — the same entity in three candidate link shapes (dot-do/data#36)', + valueSyntax: '/ (jobs/4266196009, classes/Q5; ?shape=a|b|c)', + examples: ['jobs/4266196009', 'classes/Q5', 'jobs', 'classes', 'jobs/staff-engineer'], + source: + 'Embedded static data: 53 canonical Job records (dot-do/data#34 adapter PoC, Greenhouse) + top-20 head Wikidata classes (wiki-sources catalog, CC0-1.0). Zero request-time fetches.', + landingHtml: shapesLandingHtml, + + async lookup(value: string, ctx: ToolContext): Promise { + const links = ctx.links + const rawShape = (ctx.query.shape ?? 'a').toLowerCase() + if (!(SHAPES as string[]).includes(rawShape)) { + return badValue(`Unknown shape '${rawShape}' — pick one of ?shape=a (typed camelCase edges), ?shape=b (label-keyed maps), ?shape=c (typed + variations)`) + } + const shape = rawShape as Shape + + const trimmed = value.replace(/^\/+|\/+$/g, '') + const slash = trimmed.indexOf('/') + const dataset = slash === -1 ? trimmed : trimmed.slice(0, slash) + const id = slash === -1 ? '' : trimmed.slice(slash + 1) + + let result: PageResult + switch (dataset) { + case 'jobs': { + if (!id) result = jobsIndex(links, shape) + else { + const j = JOBS.find((x) => x.id === id) + result = j ? jobPage(links, shape, j) : jobMiss(links, shape, id) + } + break + } + case 'companies': { + if (!id) + result = ok( + links, + 'companies', + { + type: 'Index', + name: 'companies (Views over jobs)', + companies: Object.fromEntries(uniq(JOBS.map((x) => x.company)).map((co) => [co, at(links, `companies/${co}`, shape)])), + }, + { dataset: at(links, 'jobs', shape) }, + ) + else result = companyPage(links, shape, id) + break + } + case 'locations': { + if (!id) + result = ok( + links, + 'locations', + { + type: 'Index', + name: 'locations (Views over jobs)', + locations: Object.fromEntries(uniq(JOBS.map(locSlug)).map((l) => [l, at(links, `locations/${l}`, shape)])), + }, + { dataset: at(links, 'jobs', shape) }, + ) + else result = locationPage(links, shape, slugify(id)) + break + } + case 'classes': { + if (!id) result = classesIndex(links, shape) + else { + const qid = id.replace(/^Q/i, '') + const cls = CLASSES.find((c) => c.qid === qid) + result = cls ? classPage(links, shape, cls) : classMiss(links, shape, id) + } + break + } + default: + result = miss( + links, + trimmed, + `Unknown dataset '${dataset}'`, + { + jobs: at(links, 'jobs', shape), + classes: at(links, 'classes', shape), + companies: at(links, 'companies', shape), + locations: at(links, 'locations', shape), + }, + {}, + ) + } + + return { + data: { + shape: { [shape]: SHAPE_INFO[shape].name, rule: SHAPE_INFO[shape].rule }, + ...result.data, + }, + links: result.links, + status: result.status, + } + }, +} diff --git a/tests/api-ht.test.ts b/tests/api-ht.test.ts index 9a9127a..c6e237e 100644 --- a/tests/api-ht.test.ts +++ b/tests/api-ht.test.ts @@ -291,3 +291,107 @@ describe('auth flow (stubbed)', () => { expect(body.data.note).toContain('DEMO') }) }) + +// --------------------------------------------------------------------------- +// shapes.api.ht — the link-shape comparison (dot-do/data #36, feeds #13) +// Fully offline: both datasets are embedded, zero request-time fetches. +// --------------------------------------------------------------------------- + +describe('shapes.api.ht (link-shape comparison)', () => { + it('keeps multi-segment values readable in links', () => { + expect(encodeValue('jobs/4266196009')).toBe('jobs/4266196009') + }) + + it('(a) renders typed camelCase edges in the envelope link map', async () => { + const { res, body } = await getJson('https://shapes.api.ht/jobs/4266196009?shape=a') + expect(res.status).toBe(200) + expect(body.data.type).toBe('Job') + expect(body.data.title).toBe('AI Red Teamer, Cyber') + expect(body.links.postedBy).toBe('https://shapes.api.ht/companies/10alabs?shape=a') + expect(body.links.locatedIn).toBe('https://shapes.api.ht/locations/washington-dc?shape=a') + expect(body.links.sameAs).toContain('greenhouse.io') + expect(body.links.potentialAction).toContain('#app') + expect(body.data.variations).toBeUndefined() + }) + + it('(b) renders label-keyed maps with no machine edge types', async () => { + const { body } = await getJson('https://shapes.api.ht/jobs/4266196009?shape=b') + expect(body.links['10alabs']).toBe('https://shapes.api.ht/companies/10alabs?shape=b') + expect(body.links['View on Greenhouse']).toContain('greenhouse.io') + expect(body.links['Apply']).toContain('#app') + expect(body.links.postedBy).toBeUndefined() + }) + + it('(c) adds a variations block of real representation alternates', async () => { + const job = await getJson('https://shapes.api.ht/jobs/4266196009?shape=c') + expect(job.body.links.postedBy).toBeDefined() + expect(job.body.data.variations['source-json']).toContain('boards-api.greenhouse.io') + + const cls = await getJson('https://shapes.api.ht/classes/Q5?shape=c') + expect(cls.body.data.variations['wikidata-json']).toBe('https://www.wikidata.org/wiki/Special:EntityData/Q5.json') + expect(cls.body.data.variations['wikidata-ttl']).toBe('https://www.wikidata.org/wiki/Special:EntityData/Q5.ttl') + expect(cls.body.data.variations['wikidata-html']).toBe('https://www.wikidata.org/wiki/Q5') + }) + + it('defaults to shape a and carries a hop map to the other shapes', async () => { + const { body } = await getJson('https://shapes.api.ht/classes/Q5') + expect(body.links.memberOf).toBe('https://shapes.api.ht/classes?shape=a') + expect(body.data.shapes['b — label-keyed link maps']).toBe('https://shapes.api.ht/classes/Q5?shape=b') + expect(body.data.shapes['c — typed edges + variations']).toBe('https://shapes.api.ht/classes/Q5?shape=c') + }) + + it('renders a miss as a page: 404 + suggestions as the shape link map', async () => { + const a = await getJson('https://shapes.api.ht/jobs/staff-engineer?shape=a') + expect(a.res.status).toBe(404) + expect(a.body.data.miss.code).toBe('NOT_FOUND') + expect(Object.keys(a.body.data.suggestions).length).toBeGreaterThan(0) + for (const url of Object.values(a.body.data.suggestions)) expect(url).toContain('shapes.api.ht/jobs/') + + const b = await getJson('https://shapes.api.ht/classes/Q42?shape=b') + expect(b.res.status).toBe(404) + // shape b keys suggestions by label, not by Q-id + expect(Object.keys(b.body.data.suggestions).some((k) => /^Q\d+$/.test(k))).toBe(false) + }) + + it('rejects unknown shapes and unknown datasets navigably', async () => { + const bad = await getJson('https://shapes.api.ht/jobs/4266196009?shape=z') + expect(bad.res.status).toBe(400) + expect(bad.body.error.code).toBe('INVALID_VALUE') + + const ds = await getJson('https://shapes.api.ht/nope/1?shape=a') + expect(ds.res.status).toBe(404) + expect(ds.body.data.suggestions.jobs).toBe('https://shapes.api.ht/jobs?shape=a') + }) + + it('serves dataset indexes and Views over jobs', async () => { + const jobs = await getJson('https://shapes.api.ht/jobs?shape=a') + expect(jobs.body.data.count).toBe(53) + expect(jobs.body.data.jobs['4266196009']).toBe('https://shapes.api.ht/jobs/4266196009?shape=a') + + const co = await getJson('https://shapes.api.ht/companies/10alabs?shape=c') + expect(co.body.data.type).toBe('View') + expect(co.body.links.partOf).toBe('https://shapes.api.ht/jobs?shape=c') + expect(co.body.data.variations['board-json']).toContain('boards-api.greenhouse.io') + + const classes = await getJson('https://shapes.api.ht/classes?shape=b') + expect(classes.body.data.count).toBe(20) + expect(Object.keys(classes.body.data.classes)[0]).toContain('instances') + }) + + it('works in path mode for local dev', async () => { + const { body } = await getJson('http://localhost:8787/shapes/classes/Q5?shape=b') + expect(body.links['All classes']).toBe('http://localhost:8787/shapes/classes?shape=b') + }) + + it('serves a clickable HTML index explaining the three shapes at the tool root', async () => { + const res = await app.request('https://shapes.api.ht/', { headers: { accept: 'text/html' } }) + expect(res.headers.get('content-type')).toContain('text/html') + const html = await res.text() + expect(html).toContain('?shape=a — typed camelCase edges') + expect(html).toContain('?shape=b — label-keyed link maps') + expect(html).toContain('?shape=c — typed edges + variations') + expect(html).toContain('https://shapes.api.ht/jobs/4266196009?shape=a') + expect(html).toContain('jobs/staff-engineer') + expect(html).toContain('github.com/dot-do/data/issues/36') + }) +})