docs
    contracts: Overview
    v2.4.0.md

    @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.

    pnpm add @sonordev/contracts
    EntryWhat it decidesWho reads it
    @sonordev/contracts/websiteA 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/colorReading a CSS color, WCAG luminance and contrastwebsite, site-kit's brand extraction
    @sonordev/contracts/sitesThe canonical shape of a site host (the multi-site site dimension)every ingest and read that filters by site
    @sonordev/contracts/seo-pagesWhich seo_pages row a path resolves to on a multi-site projectthe Sonor API, Signal, site-kit's sitemap sync
    @sonordev/contracts/seo-metaWhether a title or description is fit to shipsite-kit, Signal's SEO gate
    @sonordev/contracts/llmsllms.txt sanitizers, the word-safe cut they use (clipAtWordBoundary), and the contract versionsite-kit, the Sonor API, Signal's schema validation
    @sonordev/contracts/formsThe honeypot policysite-kit forms, the Sonor API's form intake
    @sonordev/contracts/fleetThe fleet heartbeat's wire shapesite-kit, the Sonor API
    @sonordev/contracts/slotsSlot payload signing, contract v2 (HMAC; Node only). Signs and verifies v1 for older site-kit releasesthe Sonor API, site-kit on the server
    @sonordev/contracts/slot-contentWhat a slot of each kind may hold (normalizeSlotValue), safe links, the rich-text parser. No crypto, so browsers can import itthe Sonor API, site-kit, the dashboard editor
    @sonordev/contracts/site-cacheThe cache tags a site's Sonor fetches carry and the revalidation route's path, payload and pingsite-kit tags and serves the route, the Sonor API calls it
    @sonordev/contracts/site-editEdit on page: the messages between a framed site and the dashboard, trusted origins, how a slot is found on the pagesite-kit's overlay, the dashboard's visual editor
    @sonordev/contracts/portfolioWhere a portfolio number comes from and which may headline; the live-frame messagethe Sonor API, Signal, agency-site-kit
    @sonordev/contracts/proposal-sitemapHow 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 upthe Sonor API, Signal's proposal writer, the dashboard's proposal pages
    @sonordev/contracts/voiceThe 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 expandanything that checks or rewrites copy: Sonor's APIs and dashboard
    @sonordev/contracts/error-pageWhether stored text is a rendered error page rather than a page's content, and whether generated copy says the page itself is missinganything 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-placeholdersWhether 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 themsite-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.

    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.
    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." becomes "Meet who we're.".
    • 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.
    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 " 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.
    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.