# @sonordev/contracts

The rules Sonor's sites, APIs and dashboard have to agree on, as one package.
Pure functions and types: no dependencies, no DOM, no network.

```bash
pnpm add @sonordev/contracts
```

| Entry                                     | What it decides                                                                                                                                                                                                | Who reads it                                                                                                                            |
| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `@sonordev/contracts/website`             | A site's design tokens and what a popup is made of (blocks, limits, the text sanitizer)                                                                                                                        | site-kit renders them, the Sonor API validates every write, the dashboard previews them, Signal's agents send them                      |
| `@sonordev/contracts/color`               | Reading a CSS color, WCAG luminance and contrast                                                                                                                                                               | `website`, site-kit's brand extraction                                                                                                  |
| `@sonordev/contracts/sites`               | The canonical shape of a site host (the multi-site `site` dimension)                                                                                                                                           | every ingest and read that filters by site                                                                                              |
| `@sonordev/contracts/seo-pages`           | Which `seo_pages` row a path resolves to on a multi-site project                                                                                                                                               | the Sonor API, Signal, site-kit's sitemap sync                                                                                          |
| `@sonordev/contracts/seo-meta`            | Whether a title or description is fit to ship                                                                                                                                                                  | site-kit, Signal's SEO gate                                                                                                             |
| `@sonordev/contracts/llms`                | llms.txt sanitizers, the word-safe cut they use (`clipAtWordBoundary`), and the contract version                                                                                                               | site-kit, the Sonor API, Signal's schema validation                                                                                     |
| `@sonordev/contracts/forms`               | The honeypot policy                                                                                                                                                                                            | site-kit forms, the Sonor API's form intake                                                                                             |
| `@sonordev/contracts/fleet`               | The fleet heartbeat's wire shape                                                                                                                                                                               | site-kit, the Sonor API                                                                                                                 |
| `@sonordev/contracts/slots`               | Slot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releases                                                                                                         | the Sonor API, site-kit on the server                                                                                                   |
| `@sonordev/contracts/slot-content`        | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it                                                                             | the Sonor API, site-kit, the dashboard editor                                                                                           |
| `@sonordev/contracts/site-cache`          | The cache tags a site's Sonor fetches carry and the revalidation route's path, payload and ping                                                                                                                | site-kit tags and serves the route, the Sonor API calls it                                                                              |
| `@sonordev/contracts/site-edit`           | Edit on page: the messages between a framed site and the dashboard, trusted origins, how a slot is found on the page                                                                                           | site-kit's overlay, the dashboard's visual editor                                                                                       |
| `@sonordev/contracts/portfolio`           | Where a portfolio number comes from and which may headline; the live-frame message                                                                                                                             | the Sonor API, Signal, agency-site-kit                                                                                                  |
| `@sonordev/contracts/proposal-sitemap`    | How a proposal's site plan counts its pages, when its before-and-after comparison may show, and whether the page counts stated elsewhere in the proposal add up                                                | the Sonor API, Signal's proposal writer, the dashboard's proposal pages                                                                 |
| `@sonordev/contracts/voice`               | The house voice's contraction rule. `findUncontracted` and `contractText` find and apply full forms that should be contractions; `findContracted` finds the contractions a question-form heading should expand | anything that checks or rewrites copy: Sonor's APIs and dashboard                                                                       |
| `@sonordev/contracts/error-page`          | Whether stored text is a rendered error page rather than a page's content, and whether generated copy says the page itself is missing                                                                          | anything that stores page text or writes page copy from it: the Sonor API's page-view and sitemap writers, Signal's metadata generators |
| `@sonordev/contracts/schema-placeholders` | Whether stored JSON-LD carries a template's unfilled slots (`example.com` URLs, an `Organization` named "Example", a placeholder phone, `[Resident Name]`, `{plan.name}`), and the value without them          | site-kit's schema output, the Sonor API's schema reads and writes                                                                       |

## Schema placeholders

`schema-placeholders` finds the parts of a JSON-LD value that a template left
unfilled, so a site never publishes them as the business's identity. Schema
extracted from a site's source, where the base URL was a variable, comes back
as `https://example.com`; a template filled in by hand or by an AI can leave
"Example", `+1-000-000-0000`, `[Resident Name]`, `{plan.name}`, or an object
that's only a note about what goes there.

```ts
import { withoutSchemaPlaceholders, describeSchemaPlaceholder } from '@sonordev/contracts/schema-placeholders'

const { value, dropped } = withoutSchemaPlaceholders(storedJsonLd)
// value: the JSON-LD without its placeholder nodes (the same reference when
// nothing was found, null when nothing real is left)
dropped.map(describeSchemaPlaceholder)
// ['Organization at $: name "Example" is a placeholder name']
```

- **The unit is the node.** The innermost object with an `@type` or `@id` (or
  a top-level or `@graph` member) whose own values hold the placeholder is
  dropped whole. Its real siblings, and a real parent it hangs off, stay. A
  node left with nothing but JSON-LD keywords once its placeholder children are
  gone goes too.
- **Conservative on purpose.** A false positive removes real schema from a live
  site, so names match whole values only ("Example Plumbing Co" is a real
  name), domains are only the reserved example domains, phones are only
  all-zero, sequential, `XXX` and the 555-0100 to 555-0199 fiction range, and a
  URI template's `{search_term_string}` is how schema.org spells a
  `SearchAction`, never a finding.
- **Notes.** An AI's `note` key on a real node isn't schema.org vocabulary: it's
  stripped and the node stays. An object that's only a note is dropped.
- **Pure.** The input is never mutated.

## The voice contract

`voice` is the contraction rule for written copy, so every app that checks or
rewrites it agrees. It's the rule Sonor's article checker has always applied,
moved here so it exists once.

- **What it finds.** Full forms that should be contractions: "do not" becomes
  "don't", "it is" becomes "it's", "let us look" becomes "let's look". The
  first letter's case is kept ("DO NOT" gives "Don't"), and the suggestion
  always uses a straight apostrophe. There are 36 pairs, and a negation wins
  an overlap ("we would not" gives "we wouldn't", not "we'd not").
- **What it leaves alone.** A pronoun and verb ("it is", "we are", "you will")
  at the end of a clause ("until it is."), or before "and", "or", "but" or
  "nor" ("what it is and why"). Negations skip that guard: "We do not." still
  becomes "We don't.". "can not" before "only" ("it can not only save time").
  "what is" when the next ".", "!" or "?" after it turns out to be a "?", and
  "What Is" at the start of a line when you mark the text as a heading.
  "let us" unless it starts a sentence and the next word is one of
  look, start, begin, consider, turn, see, review, compare, break, dig, recap,
  unpack, explore, examine, revisit, focus, go, talk, move, "get started",
  "step back" or "zoom out" ("let us know" and "let us help" mean "allow us").
  "have" and "has" on their own ("we have a team") are never touched, though
  "have not" and "has not" still contract ("haven't", "hasn't").
- **Lines.** Text is read one line at a time: split on "\n". A "\r" right
  before a "\n", or at the very end of the text, is part of the line ending;
  any other "\r" is ordinary text. If you've already split the text into lines
  yourself and a line keeps a final "\r" you want read as text, replace it with
  `VOICE_MASK` first.
  Nothing matches across a line break, and the end of a line ends a clause.
  Every hit's `index` is an offset into the whole string you passed in.
- **The other direction.** `findContracted` finds the contractions a
  question-form heading or FAQ question should spell out ("What's" becomes
  "What is"). It only reports; whether a text is a
  question is the caller's call, and a caller shouldn't apply these blindly.

What the caller does:

- **Mask what isn't prose.** Code, quotations and link destinations shouldn't
  be rewritten. Pass their `[start, end)` positions as `protectedRanges`, or
  replace them with `VOICE_MASK` yourself. Every protected character is masked
  except "\n" and "\r", which stay as they are, so a protected line break still
  ends its line. A masked span separates
  words, can't sit inside a phrase, and counts as text that follows. Ranges
  can arrive in any order, and overlap or touch. An infinite bound clamps to
  the start or end of the text, and an empty or out-of-text range is ignored.
  A malformed range (NaN, not a number, start after end), or a truthy value
  that isn't an array, protects the whole text, so a bad range never causes
  code to be rewritten. A falsy value means no ranges.
- **Pass a string.** Anything else (a number, an object, `null`) is read as no
  text: nothing is found and `contractText` hands it back unchanged.
- **Pass a bare heading.** Set `titleLike` for a heading or title, with the `#`
  and list markers already blanked to spaces.

```ts
import { contractText, findUncontracted } from '@sonordev/contracts/voice'

contractText('We do not ship on Sundays.')
// "We don't ship on Sundays."

findUncontracted('We do not ship on Sundays.')
// [{ index: 3, length: 6, match: 'do not', suggestion: "don't" }]
```

### Known limits

The rule is plain on purpose, and these are what it does today. Each one is
pinned by a test, so changing it later is a visible change.

- **"not" before "only".** Only "can not only" stays full. "You must not only
  wait" becomes "You mustn't only wait", "will not only rise" becomes "won't
  only rise", and "cannot only" becomes "can't only", which reads differently.
- **"need" as a noun.** "The need not being met" becomes "The needn't being
  met", and "must not" and "need not" contract wherever they appear.
- **"let us" by position, not meaning.** "Let us look at your account, please"
  becomes "Let's look at your account, please". "Let us try again", "let us
  walk through the plan" and any sentence that opens with a bracket, a quote
  or a dash ("(Let us look at it.)") stay full.
- **Question detection is the first "." "!" or "?" on the line.** A decimal, a
  domain or an initial hides the "?": "what is 3.5 percent?" becomes "what's
  3.5 percent?". Only "what is" has a question guard: "Who is on the team?"
  becomes "Who's on the team?". A question that wraps onto the next line isn't
  seen.
- **A line break ends a clause.** In a hard-wrapped paragraph, "we think it is"
  at the end of a line stays full.
- **A clause end is an inventory of characters.** ".", ",", ";", ":", "!", "?",
  ")", "]", "}", a straight double quote, a curly closing double or single
  quote, an ellipsis, an em dash, an en dash and "-" end a clause; a straight
  apostrophe doesn't, so "Say 'it is' now." becomes "Say 'it's' now.", and a
  hyphen inside a word counts ("it is-fine" stays full).
- **A masked "]" reads as text that follows.** A link whose anchor text ends a
  clause is contracted when you protect the whole "]\(destination)": "Meet [who
  we are](https://sonor.dev/about)." becomes "Meet [who we're](https://sonor.dev/about).".
- **Only spaces and tabs join the words of a pair.** A non-breaking space
  between "do" and "not", or a line break, keeps the pair from matching.
- **The other direction is plain suffix rules.** `findContracted` always
  expands "'s" to "is" ("It's been" suggests "It is", not "It has"), "'d" to
  "would" ("She'd left" suggests "She would", not "She had"), and "ain't"
  like any other "n't" word ("ai not"). Only the first letter's case carries
  over ("WHY IT'S" suggests "IT is"). Treat its suggestions as prompts for a
  person, not as edits to apply.
- **Case.** Only the first letter's case carries over: "IT IS FINE." becomes
  "It's FINE.".
- **The heading rule is first-on-the-line.** "What Is" keeps its form only
  when nothing but spaces, "\*", "\_" and masked text comes before it on its
  line, and only when `titleLike` is set and the markers are already blanked.
  The check is on the "what is" pair in any case, so "what is local seo" is
  kept too.
- **"let us" opens a sentence after a full stop, "!", "?" or ":".** Spaces,
  "\*", "\_" and masked text before it are skipped, so "Now: let us look at it."
  contracts.

## The proposal-sitemap contract

A website proposal's site plan lists the pages a build delivers: core pages
(Home, About, Contact), top-level pages, and the pages under them. Its numbers
are what a buyer reads first, so they're computed here and never taken from
prose.

- **`countSitemapPlan(plan)`** returns `core`, `architecture` (the plan's own
  pages), `articles` (existing posts re-published with the build), `full`
  (all three), `existing` (pages carried over) and `added` (new pages). Each
  address counts once: core pages first, then each top-level page and the
  pages under it. A row without a slug always counts.
- **`sitemapTransformation(plan)`** returns the before-and-after comparison
  to show (`{ before, after }`), or `null`. It shows only when the plan states
  today's count and the build is bigger. A rebuild that keeps the same pages
  isn't a before and after, so an after-count of zero, or one no bigger than
  today's, hides it.
- **`normalizeSitemapLabels(labels)`** keeps the words a plan may use for its
  own pages when they aren't services sold to industries: `architecture` (the
  heading), `pillar` (a top-level page) and `child` (a page under one). Each is
  one line of 1 to 40 characters.
- **`proposalPageCountIssues(sections)`** lists the page and URL counts a
  proposal states in its headline, summary, pricing and plan copy that match
  nothing the plan or its measured evidence adds up to.
  `describePageCountIssue(issue)` puts one in a sentence for the person
  reviewing it.

```ts
import { countSitemapPlan, proposalPageCountIssues } from '@sonordev/contracts/proposal-sitemap'

countSitemapPlan({
  corePages: [{ slug: '/' }, { slug: '/about/' }, { slug: '/locations/' }],
  pages: [{ slug: '/locations/', children: [{ name: 'Springfield' }, { name: 'Shelbyville' }] }],
}).full
// 5: the top-level /locations/ page is the core one, counted once

proposalPageCountIssues([
  { type: 'GlassHero', props: { stats: [{ value: '12', label: 'pages rebuilt' }] } },
  { type: 'SitemapPlan', props: { pages: [{ slug: '/' }, { slug: '/about/' }] } },
])
// [{ section: 'GlassHero', field: 'stats[0]', claimed: 12, unit: 'page', text: '12 pages', planned: 2 }]
```

It reads "N pages", "an N-page site", "N existing URLs" and up to three words
between the number and "page" or "URL". A count written in words ("five
pages") isn't read, and a count of pages that aren't in the plan (say, a
competitor's) is reported like any other; the issues are for a person to look
at, not edits to apply.

## The error-page contract

A page's stored text is whatever a browser or crawler rendered. Visit a URL
while it's briefly broken and the error page's words become that page's
content, and any title, description or summary written from that content
describes a working page as broken. `error-page` is how writers refuse both
halves of that.

- **`looksLikeErrorPage(text, wordCount?)`** is true when the text is a
  rendered error page: a 404 in its common wordings ("404", "This page could
  not be found", "Page not found"), a framework error ("a client-side
  exception has occurred", "This page couldn't load", "Internal Server
  Error", "Cannot GET /x"), a status page ("502 Bad Gateway", "HTTP Status
  404"), a "something went wrong" or "temporarily unavailable" screen, or a
  maintenance notice. It reads how the text *opens* (the first 300
  characters), decodes HTML entities, and doesn't depend on word boundaries,
  because scraped text runs elements together ("404This page could not be
  found."). Text up to 120 words is checked for every wording, though the
  loosest ones (a 404 glued to its copy, "refresh the page and try again", a
  route's "<name> not found") only read the first 60 or 40 words; up to 400
  words only the check for how it opens after a meta description and skip link
  applies; beyond that it's prose. A phone number, room number or street
  address that starts with 404 isn't an error page, and neither is a help page
  that only *describes* an error ("how to fix a 404 error", "if something went
  wrong"). Pass the word count of the text itself, never a whole page's count
  (a browser's includes the navigation and footer and can hide a short error
  state): a supplied count can only raise the count the check uses.
- **`usablePageText(text, wordCount?)`** returns the text, or `null` when it's
  an error page. It's what a model should be shown as "the page's content".
- **`findErrorPageClaim(copy, pageText?)`** returns the phrase in generated
  copy that says the page itself is missing or unavailable ("Page Not Found",
  "the pricing page is currently unavailable", "returns a 404", "a
  page-not-found message"), or `null`. Copy about something unavailable on a
  working page ("the patio is closed on Mondays", "tours are paused for
  winter") isn't a claim about the page. Pass the page's own text and a phrase
  the page really says is allowed, so a guide to fixing "page not found"
  errors can be titled that way; the text of an error page is never accepted
  as that exemption. `describesErrorPage` is the boolean form.
- **`findPageCopyClaim(fields, pageText?)`** runs `findErrorPageClaim` over
  every string in a set of fields (a title, a description, keywords, a
  JSON-LD object; any object or class instance) and returns the first
  `{ field, phrase }`, or `null`. It's how a writer or a reader judges a whole
  draft in one call. It reads at most 2,000 strings, 8 levels deep and 100,000
  characters in all, and each string's first 5,000 characters, so a hostile
  record can't make it slow.

```ts
import { describesErrorPage, findPageCopyClaim, usablePageText } from '@sonordev/contracts/error-page'

usablePageText('404This page could not be found.') // null: don't generate from it
usablePageText('Weekly mowing for property managers.') // the text

describesErrorPage('Commercial Mowing Page Not Found | Example Co') // true: don't ship it
describesErrorPage('Seasonal tours are paused until April.') // false

findPageCopyClaim({
  title: 'Commercial Mowing | Example Co',
  schema: { '@type': 'WebPage', description: 'This URL currently returns a 404.' },
})
// { field: 'schema', phrase: 'returns a 404' }
```

A text-only check can't prove a page failed. Where you observed the HTTP
status, require 400 or more as well and use this as the fallback; the header of
`src/contracts/error-page.ts` lists the other known limits.

## Why it exists

A rule that lives in several copies is how two of them end up disagreeing.
This package means there's one: the source is `src/contracts/` in the site-kit repo, site-kit bundles it
into its own `*/contract` entries, and this package publishes the same files
for everyone else.

## Loading it

CommonJS and ESM both. `require('@sonordev/contracts/website')` works in a
CommonJS Nest API (including `moduleResolution: "node"`, through
`typesVersions`); `import` works everywhere else. `slots` imports Node's
`crypto`, so keep it out of browser code; browser code reads `slot-content`.

## Changing a rule

Edit `src/contracts/<entry>.ts` in the site-kit repo, run
`pnpm test:contracts`, and bump this package's version. A breaking change to
a shape bumps the major and that contract's own `*_VERSION` constant.
`pnpm build:contracts` builds it and checks every entry loads both ways.
