@sonordev/contracts 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.4.0
4
+
5
+ A minor release: one new entry, `@sonordev/contracts/schema-placeholders`, and the llms.txt sanitizers stop cutting mid-word. Every other entry is unchanged.
6
+
7
+ - **New entry `@sonordev/contracts/schema-placeholders`**: whether stored JSON-LD carries a template's unfilled slots, so the API that serves it and the site that renders it drop the same nodes.
8
+ - `withoutSchemaPlaceholders(value)` returns `{ value, dropped, notes }`: the JSON-LD without its placeholder nodes (the same reference when nothing was found, `null` when nothing real is left), every node it removed with the reason and the evidence, and the AI note keys it stripped from nodes it kept.
9
+ - `findSchemaPlaceholders(value)` returns just the nodes, and `describeSchemaPlaceholder(node)` turns one into a line a person can act on.
10
+ - It finds reserved example domains (`example.com`, `example.net`, `example.org`, `.example`) in URLs, hosts and emails, whole-value template names ("Example", "Your Business Name"), placeholder phones, bracketed and JSX slots (`[Resident Name]`, `{plan.name}`, `{{business_name}}`), `REPLACE_WITH_` markers, and objects that are only a note. A URI template's `{search_term_string}`, a real name that contains "Example" and a 555 number outside the fiction range are left alone.
11
+ - **`llms`**: `clipAtWordBoundary(text, max)` cuts prose to a length without splitting a word. It ends at a sentence when one closes in the last 40% of the window, otherwise at the last whole word with an ellipsis, and never returns more than `max` characters. `sanitizeLlmsPublicSummary` and `sanitizeLlmsDisclaimerLine` now cut with it, so a long summary ends at a sentence or a whole word instead of mid-word. The limits are unchanged (400 and 280).
12
+
3
13
  ## 2.3.0
4
14
 
5
15
  A minor release: one new entry, `@sonordev/contracts/error-page`. Every other entry is unchanged.
package/README.md CHANGED
@@ -14,7 +14,7 @@ pnpm add @sonordev/contracts
14
14
  | `@sonordev/contracts/sites` | The canonical shape of a site host (the multi-site `site` dimension) | every ingest and read that filters by site |
15
15
  | `@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 |
16
16
  | `@sonordev/contracts/seo-meta` | Whether a title or description is fit to ship | site-kit, Signal's SEO gate |
17
- | `@sonordev/contracts/llms` | llms.txt sanitizers and contract version | site-kit, the Sonor API, Signal's schema validation |
17
+ | `@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 |
18
18
  | `@sonordev/contracts/forms` | The honeypot policy | site-kit forms, the Sonor API's form intake |
19
19
  | `@sonordev/contracts/fleet` | The fleet heartbeat's wire shape | site-kit, the Sonor API |
20
20
  | `@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 |
@@ -25,6 +25,41 @@ pnpm add @sonordev/contracts
25
25
  | `@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 |
26
26
  | `@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 |
27
27
  | `@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 |
28
+ | `@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 |
29
+
30
+ ## Schema placeholders
31
+
32
+ `schema-placeholders` finds the parts of a JSON-LD value that a template left
33
+ unfilled, so a site never publishes them as the business's identity. Schema
34
+ extracted from a site's source, where the base URL was a variable, comes back
35
+ as `https://example.com`; a template filled in by hand or by an AI can leave
36
+ "Example", `+1-000-000-0000`, `[Resident Name]`, `{plan.name}`, or an object
37
+ that's only a note about what goes there.
38
+
39
+ ```ts
40
+ import { withoutSchemaPlaceholders, describeSchemaPlaceholder } from '@sonordev/contracts/schema-placeholders'
41
+
42
+ const { value, dropped } = withoutSchemaPlaceholders(storedJsonLd)
43
+ // value: the JSON-LD without its placeholder nodes (the same reference when
44
+ // nothing was found, null when nothing real is left)
45
+ dropped.map(describeSchemaPlaceholder)
46
+ // ['Organization at $: name "Example" is a placeholder name']
47
+ ```
48
+
49
+ - **The unit is the node.** The innermost object with an `@type` or `@id` (or
50
+ a top-level or `@graph` member) whose own values hold the placeholder is
51
+ dropped whole. Its real siblings, and a real parent it hangs off, stay. A
52
+ node left with nothing but JSON-LD keywords once its placeholder children are
53
+ gone goes too.
54
+ - **Conservative on purpose.** A false positive removes real schema from a live
55
+ site, so names match whole values only ("Example Plumbing Co" is a real
56
+ name), domains are only the reserved example domains, phones are only
57
+ all-zero, sequential, `XXX` and the 555-0100 to 555-0199 fiction range, and a
58
+ URI template's `{search_term_string}` is how schema.org spells a
59
+ `SearchAction`, never a finding.
60
+ - **Notes.** An AI's `note` key on a real node isn't schema.org vocabulary: it's
61
+ stripped and the node stays. An object that's only a note is dropped.
62
+ - **Pure.** The input is never mutated.
28
63
 
29
64
  ## The voice contract
30
65
 
package/dist/color.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Single source of truth for turning a CSS colour string into something the
5
5
  * kit can reason about. Used at build time by the brand-profile extractor
6
6
  * (`src/brand/extract-brand-profile.ts`, theme detection) and in the browser
7
- * by Echo (`src/engage/brand-color.ts`, brand text contrast). Pure, no
7
+ * by Echo (`src/chat/brand-color.ts`, brand text contrast). Pure, no
8
8
  * imports, safe in both.
9
9
  */
10
10
  export type Rgb = {
package/dist/llms.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface LLMsPayloadMeta {
28
28
  /** Optional single-line, public-safe note in the llms.txt blockquote (e.g. scope / not legal advice). */
29
29
  llms_disclaimer?: string | null;
30
30
  }
31
+ export declare function clipAtWordBoundary(text: string, max: number): string;
31
32
  /**
32
33
  * Public-safe summary for llms.txt `[title](url): notes` — never CRM-only or internal copy.
33
34
  */
package/dist/llms.js CHANGED
@@ -18,14 +18,64 @@ var MANAGED_LLM_SCHEMA_KNOWN_KEYS = /* @__PURE__ */ new Set([
18
18
  "speakable",
19
19
  "additionalType"
20
20
  ]);
21
+ var SENTENCE_SHARE = 0.4;
22
+ var ELLIPSIS = "\u2026";
23
+ var CLOSERS = /* @__PURE__ */ new Set(['"', "'", ")", "]", "\u201D", "\u2019", "\xBB"]);
24
+ var ABBREVIATIONS = /* @__PURE__ */ new Set([
25
+ "mr",
26
+ "mrs",
27
+ "ms",
28
+ "dr",
29
+ "prof",
30
+ "st",
31
+ "mt",
32
+ "ft",
33
+ "jr",
34
+ "sr",
35
+ "inc",
36
+ "ltd",
37
+ "co",
38
+ "corp",
39
+ "vs",
40
+ "no"
41
+ ]);
42
+ var TRAILING_SEPARATORS = /[\s.,;:\-\u2013\u2014/&([{]+$/;
43
+ function clipAtWordBoundary(text, max) {
44
+ const flat = text.replace(/\s+/g, " ").trim();
45
+ if (flat.length <= max) return flat;
46
+ if (max < 2) return flat.slice(0, Math.max(0, max));
47
+ const floor = Math.floor(max * (1 - SENTENCE_SHARE));
48
+ for (let end = max; end > floor; end--) {
49
+ if (endsSentence(flat, end)) return flat.slice(0, end);
50
+ }
51
+ const room = max - 1;
52
+ const space = flat.lastIndexOf(" ", room);
53
+ if (space <= 0) return `${flat.slice(0, room)}${ELLIPSIS}`;
54
+ if (endsSentence(flat, space)) return flat.slice(0, space);
55
+ const head = flat.slice(0, space).replace(TRAILING_SEPARATORS, "");
56
+ return `${head || flat.slice(0, room)}${ELLIPSIS}`;
57
+ }
58
+ function endsSentence(flat, end) {
59
+ if (flat[end] !== " ") return false;
60
+ let i = end - 1;
61
+ while (i > 0 && CLOSERS.has(flat[i])) i--;
62
+ const terminator = flat[i];
63
+ if (terminator === "!" || terminator === "?") return true;
64
+ return terminator === "." && !isAbbreviation(flat, i);
65
+ }
66
+ function isAbbreviation(flat, dot) {
67
+ let start = dot;
68
+ while (start > 0 && flat[start - 1] !== " ") start--;
69
+ const token = flat.slice(start, dot).replace(/^[("'[\u201c\u2018]+/, "").toLowerCase();
70
+ if (token.includes(".") || /^[a-z]$/.test(token)) return true;
71
+ return ABBREVIATIONS.has(token);
72
+ }
21
73
  function sanitizeLlmsPublicSummary(raw) {
22
74
  if (raw === null || raw === void 0) return null;
23
75
  if (typeof raw !== "string") return null;
24
76
  let s = raw.trim().replace(/\s+/g, " ");
25
77
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
26
- if (s.length > LLMS_PUBLIC_SUMMARY_MAX_LENGTH) {
27
- s = `${s.slice(0, LLMS_PUBLIC_SUMMARY_MAX_LENGTH - 1).trimEnd()}\u2026`;
28
- }
78
+ s = clipAtWordBoundary(s, LLMS_PUBLIC_SUMMARY_MAX_LENGTH);
29
79
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
30
80
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
31
81
  if (s.length < 8) return null;
@@ -36,9 +86,7 @@ function sanitizeLlmsDisclaimerLine(raw) {
36
86
  if (typeof raw !== "string") return null;
37
87
  let s = raw.trim().replace(/\s+/g, " ");
38
88
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
39
- if (s.length > LLMS_DISCLAIMER_MAX_LENGTH) {
40
- s = `${s.slice(0, LLMS_DISCLAIMER_MAX_LENGTH - 1).trimEnd()}\u2026`;
41
- }
89
+ s = clipAtWordBoundary(s, LLMS_DISCLAIMER_MAX_LENGTH);
42
90
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
43
91
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
44
92
  if (s.length < 3) return null;
@@ -100,6 +148,7 @@ exports.LLMS_DISCLAIMER_MAX_LENGTH = LLMS_DISCLAIMER_MAX_LENGTH;
100
148
  exports.LLMS_PUBLIC_SUMMARY_MAX_LENGTH = LLMS_PUBLIC_SUMMARY_MAX_LENGTH;
101
149
  exports.LLM_GEO_CONTRACT_VERSION = LLM_GEO_CONTRACT_VERSION;
102
150
  exports.MANAGED_LLM_SCHEMA_KNOWN_KEYS = MANAGED_LLM_SCHEMA_KNOWN_KEYS;
151
+ exports.clipAtWordBoundary = clipAtWordBoundary;
103
152
  exports.isEmittableReviewNode = isEmittableReviewNode;
104
153
  exports.pickManagedLlmSchemaForJsonLd = pickManagedLlmSchemaForJsonLd;
105
154
  exports.sanitizeLlmsDisclaimerLine = sanitizeLlmsDisclaimerLine;
package/dist/llms.mjs CHANGED
@@ -16,14 +16,64 @@ var MANAGED_LLM_SCHEMA_KNOWN_KEYS = /* @__PURE__ */ new Set([
16
16
  "speakable",
17
17
  "additionalType"
18
18
  ]);
19
+ var SENTENCE_SHARE = 0.4;
20
+ var ELLIPSIS = "\u2026";
21
+ var CLOSERS = /* @__PURE__ */ new Set(['"', "'", ")", "]", "\u201D", "\u2019", "\xBB"]);
22
+ var ABBREVIATIONS = /* @__PURE__ */ new Set([
23
+ "mr",
24
+ "mrs",
25
+ "ms",
26
+ "dr",
27
+ "prof",
28
+ "st",
29
+ "mt",
30
+ "ft",
31
+ "jr",
32
+ "sr",
33
+ "inc",
34
+ "ltd",
35
+ "co",
36
+ "corp",
37
+ "vs",
38
+ "no"
39
+ ]);
40
+ var TRAILING_SEPARATORS = /[\s.,;:\-\u2013\u2014/&([{]+$/;
41
+ function clipAtWordBoundary(text, max) {
42
+ const flat = text.replace(/\s+/g, " ").trim();
43
+ if (flat.length <= max) return flat;
44
+ if (max < 2) return flat.slice(0, Math.max(0, max));
45
+ const floor = Math.floor(max * (1 - SENTENCE_SHARE));
46
+ for (let end = max; end > floor; end--) {
47
+ if (endsSentence(flat, end)) return flat.slice(0, end);
48
+ }
49
+ const room = max - 1;
50
+ const space = flat.lastIndexOf(" ", room);
51
+ if (space <= 0) return `${flat.slice(0, room)}${ELLIPSIS}`;
52
+ if (endsSentence(flat, space)) return flat.slice(0, space);
53
+ const head = flat.slice(0, space).replace(TRAILING_SEPARATORS, "");
54
+ return `${head || flat.slice(0, room)}${ELLIPSIS}`;
55
+ }
56
+ function endsSentence(flat, end) {
57
+ if (flat[end] !== " ") return false;
58
+ let i = end - 1;
59
+ while (i > 0 && CLOSERS.has(flat[i])) i--;
60
+ const terminator = flat[i];
61
+ if (terminator === "!" || terminator === "?") return true;
62
+ return terminator === "." && !isAbbreviation(flat, i);
63
+ }
64
+ function isAbbreviation(flat, dot) {
65
+ let start = dot;
66
+ while (start > 0 && flat[start - 1] !== " ") start--;
67
+ const token = flat.slice(start, dot).replace(/^[("'[\u201c\u2018]+/, "").toLowerCase();
68
+ if (token.includes(".") || /^[a-z]$/.test(token)) return true;
69
+ return ABBREVIATIONS.has(token);
70
+ }
19
71
  function sanitizeLlmsPublicSummary(raw) {
20
72
  if (raw === null || raw === void 0) return null;
21
73
  if (typeof raw !== "string") return null;
22
74
  let s = raw.trim().replace(/\s+/g, " ");
23
75
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
24
- if (s.length > LLMS_PUBLIC_SUMMARY_MAX_LENGTH) {
25
- s = `${s.slice(0, LLMS_PUBLIC_SUMMARY_MAX_LENGTH - 1).trimEnd()}\u2026`;
26
- }
76
+ s = clipAtWordBoundary(s, LLMS_PUBLIC_SUMMARY_MAX_LENGTH);
27
77
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
28
78
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
29
79
  if (s.length < 8) return null;
@@ -34,9 +84,7 @@ function sanitizeLlmsDisclaimerLine(raw) {
34
84
  if (typeof raw !== "string") return null;
35
85
  let s = raw.trim().replace(/\s+/g, " ");
36
86
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
37
- if (s.length > LLMS_DISCLAIMER_MAX_LENGTH) {
38
- s = `${s.slice(0, LLMS_DISCLAIMER_MAX_LENGTH - 1).trimEnd()}\u2026`;
39
- }
87
+ s = clipAtWordBoundary(s, LLMS_DISCLAIMER_MAX_LENGTH);
40
88
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
41
89
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
42
90
  if (s.length < 3) return null;
@@ -94,4 +142,4 @@ function pickManagedLlmSchemaForJsonLd(schema) {
94
142
  return Object.keys(out).length ? out : null;
95
143
  }
96
144
 
97
- export { LLMS_DISCLAIMER_MAX_LENGTH, LLMS_PUBLIC_SUMMARY_MAX_LENGTH, LLM_GEO_CONTRACT_VERSION, MANAGED_LLM_SCHEMA_KNOWN_KEYS, isEmittableReviewNode, pickManagedLlmSchemaForJsonLd, sanitizeLlmsDisclaimerLine, sanitizeLlmsPublicSummary, sanitizePrimaryLanguageTag };
145
+ export { LLMS_DISCLAIMER_MAX_LENGTH, LLMS_PUBLIC_SUMMARY_MAX_LENGTH, LLM_GEO_CONTRACT_VERSION, MANAGED_LLM_SCHEMA_KNOWN_KEYS, clipAtWordBoundary, isEmittableReviewNode, pickManagedLlmSchemaForJsonLd, sanitizeLlmsDisclaimerLine, sanitizeLlmsPublicSummary, sanitizePrimaryLanguageTag };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * JSON-LD template placeholders: found once, dropped wherever sonor-api serves
3
+ * stored schema to a site, and never stored as implemented by register-schema.
4
+ *
5
+ * Stored JSON-LD (seo_schema_markup.schema_json, seo_pages.managed_schema) can
6
+ * carry a template's unfilled slots. Schema extracted from a site's source,
7
+ * where the base URL was a variable, comes back as https://example.com; an AI
8
+ * filling a template leaves "Example", "+1-000-000-0000", "[Resident Name]",
9
+ * "{plan.name}", or an object that is only a "note" saying what goes there.
10
+ * site-kit renders all of it on the live page, where search engines and AI
11
+ * crawlers take it as the business's identity. Observed 2026-10-01: a live
12
+ * site served an Organization named "Example", phone +1-000-000-0000, on its
13
+ * home page, and dozens more of its implemented rows carried example.com URLs
14
+ * and unfilled template slots.
15
+ *
16
+ * Conservative on purpose, because a false positive removes real schema from a
17
+ * live site:
18
+ * - Domains: only the reserved example domains (example.com, example.net,
19
+ * example.org, their subdomains, and the .example TLD), and only when the
20
+ * whole value is a URL, host or email address. A real domain with
21
+ * "example" in its path, or prose that mentions example.com, is fine.
22
+ * - Names: whole-value matches only (trimmed, case-insensitive) on name-like
23
+ * properties. "Example Plumbing Co" is a real name.
24
+ * - Phones (telephone, faxNumber, tel: links): all zeros, 123-456-7890,
25
+ * XXX-XXX-XXXX, and 555-0100 through 555-0199, the North American range
26
+ * reserved for fiction. 555-0200 and 555-1212 are real numbers.
27
+ * - Template slots: a value that is wholly a bracketed slot ("[YYYY-MM-DD]")
28
+ * or a JSX expression ("{plan.name}"), a {{mustache}} slot, a
29
+ * REPLACE_WITH_ marker, a title-case field name in brackets inside text
30
+ * ("Call [Phone Number]"), or a JSX member expression inside a URL. A URI
31
+ * template's {search_term_string} is how schema.org spells a SearchAction
32
+ * and is never a finding.
33
+ * - Annotations: an object that holds nothing but an AI note. A note key on
34
+ * a real node is stripped (it isn't schema.org vocabulary) and the node
35
+ * stays.
36
+ *
37
+ * The unit is the NODE: the innermost object with @type or @id (or a top-level
38
+ * or @graph member) whose own values hold the placeholder. A placeholder node
39
+ * is dropped whole, since its other values came from the same unfilled
40
+ * template. Its real siblings, and a real parent it hangs off, stay: an
41
+ * FAQPage keeps its questions when only its publisher was a placeholder. A
42
+ * node left with nothing but JSON-LD keywords once its placeholder children
43
+ * are gone (a BreadcrumbList whose every item was a placeholder) goes too, so
44
+ * the site falls back to what it generates itself.
45
+ *
46
+ * Single source of truth for "is this stored schema a placeholder", published
47
+ * as `@sonordev/contracts/schema-placeholders`: sonor-api's public schema reads
48
+ * and register-schema, and site-kit's ManagedSchema and LLMSchema (so a site
49
+ * drops a placeholder whichever Sonor source served it) all call it. Add new
50
+ * shapes here, with a test, never in a caller.
51
+ */
52
+ export type SchemaPlaceholderReason = 'example_domain' | 'placeholder_phone' | 'placeholder_name' | 'template_token' | 'annotation' | 'emptied';
53
+ export interface SchemaPlaceholderEvidence {
54
+ reason: SchemaPlaceholderReason;
55
+ /** The property that held the value. */
56
+ key: string;
57
+ /** The value, cut short for logs. */
58
+ value: string;
59
+ }
60
+ export interface SchemaPlaceholderNode {
61
+ /** Where the node sits: '$' for the root, then .key and [index] steps. */
62
+ path: string;
63
+ /** The node's @type ('A,B' when it has several), or null. */
64
+ type: string | null;
65
+ reasons: SchemaPlaceholderReason[];
66
+ evidence: SchemaPlaceholderEvidence[];
67
+ }
68
+ export interface SchemaPlaceholderScan<T> {
69
+ /** The value without its placeholder nodes: the same reference when nothing was found, null when nothing real is left. */
70
+ value: T | null;
71
+ /** Every node removed, outermost only (a dropped node's children go with it). */
72
+ dropped: SchemaPlaceholderNode[];
73
+ /** AI note keys stripped from nodes that were kept. */
74
+ notes: Array<{
75
+ path: string;
76
+ key: string;
77
+ }>;
78
+ }
79
+ /**
80
+ * The value with its placeholder nodes removed, and what was removed. Pure; the
81
+ * input is never mutated.
82
+ */
83
+ export declare function withoutSchemaPlaceholders<T>(value: T): SchemaPlaceholderScan<T>;
84
+ /** Which nodes of a JSON-LD value are placeholders, and why. */
85
+ export declare function findSchemaPlaceholders(value: unknown): SchemaPlaceholderNode[];
86
+ /** One line a person can act on: 'Organization at $: name "Example" is a placeholder name'. */
87
+ export declare function describeSchemaPlaceholder(node: SchemaPlaceholderNode): string;
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/schema-placeholders.ts
4
+ var RESERVED_EXAMPLE_DOMAINS = ["example.com", "example.net", "example.org"];
5
+ var RESERVED_EXAMPLE_TLD = "example";
6
+ var PLACEHOLDER_NAMES = /* @__PURE__ */ new Set([
7
+ "example",
8
+ "example business",
9
+ "example company",
10
+ "example organization",
11
+ "your business",
12
+ "your business name",
13
+ "your company",
14
+ "your company name",
15
+ "your organization",
16
+ "your organization name",
17
+ "business name",
18
+ "company name",
19
+ "organization name",
20
+ "your name",
21
+ "lorem ipsum"
22
+ ]);
23
+ var NAME_KEYS = /* @__PURE__ */ new Set([
24
+ "name",
25
+ "legalName",
26
+ "alternateName",
27
+ "author",
28
+ "creator",
29
+ "publisher",
30
+ "brand",
31
+ "provider",
32
+ "seller",
33
+ "manufacturer",
34
+ "organizer",
35
+ "founder"
36
+ ]);
37
+ var PHONE_KEYS = /* @__PURE__ */ new Set(["telephone", "faxNumber"]);
38
+ var URI_TEMPLATE_KEYS = /* @__PURE__ */ new Set(["target", "urlTemplate"]);
39
+ var BODY_TEXT_KEYS = /* @__PURE__ */ new Set(["articleBody"]);
40
+ var ANNOTATION_KEYS = /* @__PURE__ */ new Set(["note", "notes", "_note", "_notes", "_comment", "_comments", "__comment", "$comment"]);
41
+ var STRUCTURAL_KEYS = /* @__PURE__ */ new Set(["@context", "@type", "@id", "position"]);
42
+ var MUSTACHE_SLOT = /\{\{\s*[#^/>&]?\s*[A-Za-z_$][\w$.[\]-]*\s*\}\}/;
43
+ var JSX_EXPRESSION_VALUE = /^\{\s*[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*|\[[^\]]*\])+[^{}]*\}$/;
44
+ var JSX_MEMBER_IN_TEXT = /\{\s*[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+\s*\}/;
45
+ var BRACKET_SLOT_VALUE = /^\[\s*[A-Za-z][^[\]"{}<>]{0,80}\]$/;
46
+ var BRACKET_SLOT_IN_TEXT = /\[\s*((?:your|insert your|enter your|add your)\s+[^[\]]{1,60}?|(?:(?:business|company|organization|brand|full|first|last|resident|client|customer|author)\s+)?(?:name|phone(?:\s+number)?|telephone|email(?:\s+address)?|(?:street\s+)?address|city|state|zip(?:\s+code)?|postal\s+code|website|url|logo|date))\s*\](?!\()/gi;
47
+ var REPLACE_MARKER = /\bREPLACE(?:_WITH_\w*|_ME\b)/;
48
+ var UPPER_SLOT_VALUE = /^YOUR_[A-Z0-9_]+$/;
49
+ var MAX_DEPTH = 40;
50
+ var MAX_EVIDENCE = 5;
51
+ var EVIDENCE_VALUE_LENGTH = 120;
52
+ function withoutSchemaPlaceholders(value) {
53
+ const state = { dropped: [], notes: [] };
54
+ if (value === null || value === void 0) return { value: null, ...state };
55
+ const result = visitRoot(value, state);
56
+ return { value: result, ...state };
57
+ }
58
+ function findSchemaPlaceholders(value) {
59
+ return withoutSchemaPlaceholders(value).dropped;
60
+ }
61
+ var REASON_TEXT = {
62
+ example_domain: "is on a reserved example domain",
63
+ placeholder_phone: "is a placeholder phone number",
64
+ placeholder_name: "is a placeholder name",
65
+ template_token: "is an unfilled template slot",
66
+ annotation: "is an AI note, not schema",
67
+ emptied: "held only placeholders"
68
+ };
69
+ function describeSchemaPlaceholder(node) {
70
+ const where = `${node.type || "Node"} at ${node.path}`;
71
+ const first = node.evidence[0];
72
+ const detail = first ? `${first.key} ${JSON.stringify(first.value)} ${REASON_TEXT[first.reason]}` : REASON_TEXT[node.reasons[0]];
73
+ return node.reasons.includes("emptied") ? `${where} has nothing real left: ${detail}` : `${where}: ${detail}`;
74
+ }
75
+ function visitRoot(value, state) {
76
+ if (Array.isArray(value)) {
77
+ const out = visitArray(value, "$", state, 0, true);
78
+ return out.removed && out.value.length === 0 ? null : out.value;
79
+ }
80
+ if (!isPlainObject(value)) return value;
81
+ if (isGraphWrapper(value)) {
82
+ const out = visitContainer(value, "$", state, 0);
83
+ return out.removedChild && !hasContentBeyond(out.value, /* @__PURE__ */ new Set(["@context"])) ? null : out.value;
84
+ }
85
+ const visit = visitNode(value, "$", state, 0);
86
+ return visit.keep ? visit.value : null;
87
+ }
88
+ function visitNode(node, path, state, depth) {
89
+ if (isAnnotationOnly(node)) {
90
+ state.dropped.push({ path, type: null, reasons: ["annotation"], evidence: annotationEvidence(node) });
91
+ return { keep: false };
92
+ }
93
+ const evidence = [];
94
+ collectEvidence(node, evidence, depth);
95
+ if (evidence.length) {
96
+ state.dropped.push({ path, type: typeOf(node), reasons: unique(evidence.map((e) => e.reason)), evidence });
97
+ return { keep: false };
98
+ }
99
+ const droppedMark = state.dropped.length;
100
+ const notesMark = state.notes.length;
101
+ const pruned = visitContainer(node, path, state, depth);
102
+ if (pruned.removedChild && !hasContentBeyond(pruned.value, STRUCTURAL_KEYS)) {
103
+ const children = state.dropped.splice(droppedMark);
104
+ state.notes.length = notesMark;
105
+ state.dropped.push({
106
+ path,
107
+ type: typeOf(node),
108
+ reasons: unique(["emptied", ...children.flatMap((c) => c.reasons)]),
109
+ evidence: children.flatMap((c) => c.evidence).slice(0, MAX_EVIDENCE)
110
+ });
111
+ return { keep: false };
112
+ }
113
+ return { keep: true, value: pruned.value };
114
+ }
115
+ function visitContainer(obj, path, state, depth) {
116
+ if (depth >= MAX_DEPTH) return { value: obj, removedChild: false };
117
+ let changed = false;
118
+ let removedChild = false;
119
+ const out = {};
120
+ for (const [key, child] of Object.entries(obj)) {
121
+ if (key === "@context") {
122
+ out[key] = child;
123
+ continue;
124
+ }
125
+ if (ANNOTATION_KEYS.has(key)) {
126
+ state.notes.push({ path, key });
127
+ changed = true;
128
+ continue;
129
+ }
130
+ const childPath = stepKey(path, key);
131
+ if (Array.isArray(child)) {
132
+ const pruned = visitArray(child, childPath, state, depth + 1, key === "@graph");
133
+ if (pruned.removed) removedChild = true;
134
+ if (pruned.value !== child) changed = true;
135
+ if (pruned.removed && pruned.value.length === 0) continue;
136
+ out[key] = pruned.value;
137
+ continue;
138
+ }
139
+ if (isPlainObject(child)) {
140
+ if (isNestedNode(child)) {
141
+ const visit = visitNode(child, childPath, state, depth + 1);
142
+ if (!visit.keep) {
143
+ changed = true;
144
+ removedChild = true;
145
+ continue;
146
+ }
147
+ if (visit.value !== child) changed = true;
148
+ out[key] = visit.value;
149
+ continue;
150
+ }
151
+ const pruned = visitContainer(child, childPath, state, depth + 1);
152
+ if (pruned.removedChild) removedChild = true;
153
+ if (pruned.value !== child) changed = true;
154
+ out[key] = pruned.value;
155
+ continue;
156
+ }
157
+ out[key] = child;
158
+ }
159
+ return { value: changed ? out : obj, removedChild };
160
+ }
161
+ function visitArray(items, path, state, depth, units) {
162
+ if (depth >= MAX_DEPTH) return { value: items, removed: false };
163
+ let changed = false;
164
+ let removed = false;
165
+ const out = [];
166
+ items.forEach((item, index) => {
167
+ const itemPath = `${path}[${index}]`;
168
+ if (Array.isArray(item)) {
169
+ const pruned = visitArray(item, itemPath, state, depth + 1, units);
170
+ if (pruned.removed) removed = true;
171
+ if (pruned.value !== item) changed = true;
172
+ out.push(pruned.value);
173
+ return;
174
+ }
175
+ if (isPlainObject(item) && (units || isNestedNode(item))) {
176
+ const visit = visitNode(item, itemPath, state, depth + 1);
177
+ if (!visit.keep) {
178
+ changed = true;
179
+ removed = true;
180
+ return;
181
+ }
182
+ if (visit.value !== item) changed = true;
183
+ out.push(visit.value);
184
+ return;
185
+ }
186
+ if (isPlainObject(item)) {
187
+ const pruned = visitContainer(item, itemPath, state, depth + 1);
188
+ if (pruned.removedChild) removed = true;
189
+ if (pruned.value !== item) changed = true;
190
+ out.push(pruned.value);
191
+ return;
192
+ }
193
+ out.push(item);
194
+ });
195
+ return { value: changed ? out : items, removed };
196
+ }
197
+ function collectEvidence(holder, out, depth) {
198
+ for (const [key, value] of Object.entries(holder)) {
199
+ if (key === "@context" || ANNOTATION_KEYS.has(key)) continue;
200
+ collectFromValue(holder, key, value, out, depth);
201
+ }
202
+ }
203
+ function collectFromValue(holder, key, value, out, depth) {
204
+ if (depth >= MAX_DEPTH || out.length >= MAX_EVIDENCE) return;
205
+ if (typeof value === "string") {
206
+ for (const reason of placeholderReasons(key, value, holder)) {
207
+ if (out.length >= MAX_EVIDENCE) return;
208
+ out.push({ reason, key, value: cutForLog(value) });
209
+ }
210
+ return;
211
+ }
212
+ if (Array.isArray(value)) {
213
+ for (const item of value) collectFromValue(holder, key, item, out, depth + 1);
214
+ return;
215
+ }
216
+ if (isPlainObject(value) && !isNestedNode(value)) collectEvidence(value, out, depth + 1);
217
+ }
218
+ function placeholderReasons(key, raw, holder) {
219
+ const value = raw.trim();
220
+ if (!value) return [];
221
+ const reasons = [];
222
+ const host = urlHost(value);
223
+ if (host && isReservedExampleHost(host)) reasons.push("example_domain");
224
+ if ((PHONE_KEYS.has(key) || /^tel:/i.test(value)) && isPlaceholderPhone(value)) reasons.push("placeholder_phone");
225
+ if (NAME_KEYS.has(key) && !("value" in holder) && PLACEHOLDER_NAMES.has(normalizeName(value))) {
226
+ reasons.push("placeholder_name");
227
+ }
228
+ if (isTemplateSlot(key, value)) reasons.push("template_token");
229
+ return reasons;
230
+ }
231
+ function isTemplateSlot(key, value) {
232
+ if (JSX_EXPRESSION_VALUE.test(value) || BRACKET_SLOT_VALUE.test(value) || UPPER_SLOT_VALUE.test(value)) return true;
233
+ if (REPLACE_MARKER.test(value)) return true;
234
+ if (!BODY_TEXT_KEYS.has(key)) {
235
+ if (MUSTACHE_SLOT.test(value)) return true;
236
+ for (const match of value.matchAll(BRACKET_SLOT_IN_TEXT)) {
237
+ if (/^[A-Z]/.test(match[1])) return true;
238
+ }
239
+ }
240
+ return !URI_TEMPLATE_KEYS.has(key) && isUrlOrPath(value) && JSX_MEMBER_IN_TEXT.test(value);
241
+ }
242
+ function urlHost(value) {
243
+ const mailto = /^mailto:[^@\s?]+@([^?\s#/]+)/i.exec(value);
244
+ if (mailto) return mailto[1];
245
+ const url = /^(?:[a-z][a-z0-9+.-]*:)?\/\/(?:[^/?#@\s]*@)?([^/?#:\s]+)/i.exec(value);
246
+ if (url) return url[1];
247
+ const email = /^[^\s@/:]+@([^\s@/:?#]+)$/.exec(value);
248
+ if (email) return email[1];
249
+ const bare = /^((?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*)\.?(?::\d+)?(?:[/?#]\S*)?$/i.exec(value);
250
+ return bare ? bare[1] : null;
251
+ }
252
+ function isReservedExampleHost(host) {
253
+ const h = host.toLowerCase().replace(/\.$/, "");
254
+ if (h === RESERVED_EXAMPLE_TLD || h.endsWith(`.${RESERVED_EXAMPLE_TLD}`)) return true;
255
+ return RESERVED_EXAMPLE_DOMAINS.some((domain) => h === domain || h.endsWith(`.${domain}`));
256
+ }
257
+ function isUrlOrPath(value) {
258
+ return /^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(value) || value.startsWith("/");
259
+ }
260
+ function isPlaceholderPhone(raw) {
261
+ const value = raw.replace(/^tel:/i, "").trim();
262
+ if (/^[+\d\s().xX-]+$/.test(value) && (value.match(/x/gi)?.length ?? 0) >= 4) return true;
263
+ let digits = value.replace(/\D/g, "");
264
+ if (digits.length === 11 && digits.startsWith("1")) digits = digits.slice(1);
265
+ if (digits.length >= 7 && /^0+$/.test(digits)) return true;
266
+ if (digits === "1234567890" || digits === "1234567") return true;
267
+ const local = digits.length === 10 ? digits.slice(3) : digits.length === 7 ? digits : null;
268
+ return local !== null && /^55501\d\d$/.test(local);
269
+ }
270
+ function normalizeName(value) {
271
+ return value.replace(/\s+/g, " ").trim().replace(/[.,]+$/, "").toLowerCase();
272
+ }
273
+ function isPlainObject(value) {
274
+ return typeof value === "object" && value !== null && !Array.isArray(value);
275
+ }
276
+ function isNestedNode(value) {
277
+ return "@type" in value || "@id" in value || isAnnotationOnly(value);
278
+ }
279
+ function isGraphWrapper(value) {
280
+ return "@graph" in value && !("@type" in value) && !("@id" in value);
281
+ }
282
+ function isAnnotationOnly(value) {
283
+ if ("@type" in value || "@id" in value || "@graph" in value) return false;
284
+ const keys = Object.keys(value).filter((key) => key !== "@context");
285
+ return keys.length > 0 && keys.every((key) => ANNOTATION_KEYS.has(key));
286
+ }
287
+ function annotationEvidence(node) {
288
+ return Object.entries(node).filter(([key]) => ANNOTATION_KEYS.has(key)).slice(0, MAX_EVIDENCE).map(([key, value]) => ({
289
+ reason: "annotation",
290
+ key,
291
+ value: cutForLog(typeof value === "string" ? value : JSON.stringify(value) ?? "")
292
+ }));
293
+ }
294
+ function hasContentBeyond(obj, ignored) {
295
+ return Object.entries(obj).some(([key, value]) => !ignored.has(key) && hasContent(value));
296
+ }
297
+ function hasContent(value) {
298
+ if (value === null || value === void 0 || value === "") return false;
299
+ if (Array.isArray(value)) return value.length > 0;
300
+ if (isPlainObject(value)) return Object.keys(value).length > 0;
301
+ return true;
302
+ }
303
+ function typeOf(node) {
304
+ const type = node["@type"];
305
+ if (typeof type === "string") return type;
306
+ if (Array.isArray(type)) return type.filter((t) => typeof t === "string").join(",") || null;
307
+ return null;
308
+ }
309
+ function stepKey(path, key) {
310
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
311
+ }
312
+ function unique(values) {
313
+ return [...new Set(values)];
314
+ }
315
+ function cutForLog(value) {
316
+ return value.length > EVIDENCE_VALUE_LENGTH ? `${value.slice(0, EVIDENCE_VALUE_LENGTH)}...` : value;
317
+ }
318
+
319
+ exports.describeSchemaPlaceholder = describeSchemaPlaceholder;
320
+ exports.findSchemaPlaceholders = findSchemaPlaceholders;
321
+ exports.withoutSchemaPlaceholders = withoutSchemaPlaceholders;
@@ -0,0 +1,317 @@
1
+ // src/contracts/schema-placeholders.ts
2
+ var RESERVED_EXAMPLE_DOMAINS = ["example.com", "example.net", "example.org"];
3
+ var RESERVED_EXAMPLE_TLD = "example";
4
+ var PLACEHOLDER_NAMES = /* @__PURE__ */ new Set([
5
+ "example",
6
+ "example business",
7
+ "example company",
8
+ "example organization",
9
+ "your business",
10
+ "your business name",
11
+ "your company",
12
+ "your company name",
13
+ "your organization",
14
+ "your organization name",
15
+ "business name",
16
+ "company name",
17
+ "organization name",
18
+ "your name",
19
+ "lorem ipsum"
20
+ ]);
21
+ var NAME_KEYS = /* @__PURE__ */ new Set([
22
+ "name",
23
+ "legalName",
24
+ "alternateName",
25
+ "author",
26
+ "creator",
27
+ "publisher",
28
+ "brand",
29
+ "provider",
30
+ "seller",
31
+ "manufacturer",
32
+ "organizer",
33
+ "founder"
34
+ ]);
35
+ var PHONE_KEYS = /* @__PURE__ */ new Set(["telephone", "faxNumber"]);
36
+ var URI_TEMPLATE_KEYS = /* @__PURE__ */ new Set(["target", "urlTemplate"]);
37
+ var BODY_TEXT_KEYS = /* @__PURE__ */ new Set(["articleBody"]);
38
+ var ANNOTATION_KEYS = /* @__PURE__ */ new Set(["note", "notes", "_note", "_notes", "_comment", "_comments", "__comment", "$comment"]);
39
+ var STRUCTURAL_KEYS = /* @__PURE__ */ new Set(["@context", "@type", "@id", "position"]);
40
+ var MUSTACHE_SLOT = /\{\{\s*[#^/>&]?\s*[A-Za-z_$][\w$.[\]-]*\s*\}\}/;
41
+ var JSX_EXPRESSION_VALUE = /^\{\s*[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*|\[[^\]]*\])+[^{}]*\}$/;
42
+ var JSX_MEMBER_IN_TEXT = /\{\s*[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)+\s*\}/;
43
+ var BRACKET_SLOT_VALUE = /^\[\s*[A-Za-z][^[\]"{}<>]{0,80}\]$/;
44
+ var BRACKET_SLOT_IN_TEXT = /\[\s*((?:your|insert your|enter your|add your)\s+[^[\]]{1,60}?|(?:(?:business|company|organization|brand|full|first|last|resident|client|customer|author)\s+)?(?:name|phone(?:\s+number)?|telephone|email(?:\s+address)?|(?:street\s+)?address|city|state|zip(?:\s+code)?|postal\s+code|website|url|logo|date))\s*\](?!\()/gi;
45
+ var REPLACE_MARKER = /\bREPLACE(?:_WITH_\w*|_ME\b)/;
46
+ var UPPER_SLOT_VALUE = /^YOUR_[A-Z0-9_]+$/;
47
+ var MAX_DEPTH = 40;
48
+ var MAX_EVIDENCE = 5;
49
+ var EVIDENCE_VALUE_LENGTH = 120;
50
+ function withoutSchemaPlaceholders(value) {
51
+ const state = { dropped: [], notes: [] };
52
+ if (value === null || value === void 0) return { value: null, ...state };
53
+ const result = visitRoot(value, state);
54
+ return { value: result, ...state };
55
+ }
56
+ function findSchemaPlaceholders(value) {
57
+ return withoutSchemaPlaceholders(value).dropped;
58
+ }
59
+ var REASON_TEXT = {
60
+ example_domain: "is on a reserved example domain",
61
+ placeholder_phone: "is a placeholder phone number",
62
+ placeholder_name: "is a placeholder name",
63
+ template_token: "is an unfilled template slot",
64
+ annotation: "is an AI note, not schema",
65
+ emptied: "held only placeholders"
66
+ };
67
+ function describeSchemaPlaceholder(node) {
68
+ const where = `${node.type || "Node"} at ${node.path}`;
69
+ const first = node.evidence[0];
70
+ const detail = first ? `${first.key} ${JSON.stringify(first.value)} ${REASON_TEXT[first.reason]}` : REASON_TEXT[node.reasons[0]];
71
+ return node.reasons.includes("emptied") ? `${where} has nothing real left: ${detail}` : `${where}: ${detail}`;
72
+ }
73
+ function visitRoot(value, state) {
74
+ if (Array.isArray(value)) {
75
+ const out = visitArray(value, "$", state, 0, true);
76
+ return out.removed && out.value.length === 0 ? null : out.value;
77
+ }
78
+ if (!isPlainObject(value)) return value;
79
+ if (isGraphWrapper(value)) {
80
+ const out = visitContainer(value, "$", state, 0);
81
+ return out.removedChild && !hasContentBeyond(out.value, /* @__PURE__ */ new Set(["@context"])) ? null : out.value;
82
+ }
83
+ const visit = visitNode(value, "$", state, 0);
84
+ return visit.keep ? visit.value : null;
85
+ }
86
+ function visitNode(node, path, state, depth) {
87
+ if (isAnnotationOnly(node)) {
88
+ state.dropped.push({ path, type: null, reasons: ["annotation"], evidence: annotationEvidence(node) });
89
+ return { keep: false };
90
+ }
91
+ const evidence = [];
92
+ collectEvidence(node, evidence, depth);
93
+ if (evidence.length) {
94
+ state.dropped.push({ path, type: typeOf(node), reasons: unique(evidence.map((e) => e.reason)), evidence });
95
+ return { keep: false };
96
+ }
97
+ const droppedMark = state.dropped.length;
98
+ const notesMark = state.notes.length;
99
+ const pruned = visitContainer(node, path, state, depth);
100
+ if (pruned.removedChild && !hasContentBeyond(pruned.value, STRUCTURAL_KEYS)) {
101
+ const children = state.dropped.splice(droppedMark);
102
+ state.notes.length = notesMark;
103
+ state.dropped.push({
104
+ path,
105
+ type: typeOf(node),
106
+ reasons: unique(["emptied", ...children.flatMap((c) => c.reasons)]),
107
+ evidence: children.flatMap((c) => c.evidence).slice(0, MAX_EVIDENCE)
108
+ });
109
+ return { keep: false };
110
+ }
111
+ return { keep: true, value: pruned.value };
112
+ }
113
+ function visitContainer(obj, path, state, depth) {
114
+ if (depth >= MAX_DEPTH) return { value: obj, removedChild: false };
115
+ let changed = false;
116
+ let removedChild = false;
117
+ const out = {};
118
+ for (const [key, child] of Object.entries(obj)) {
119
+ if (key === "@context") {
120
+ out[key] = child;
121
+ continue;
122
+ }
123
+ if (ANNOTATION_KEYS.has(key)) {
124
+ state.notes.push({ path, key });
125
+ changed = true;
126
+ continue;
127
+ }
128
+ const childPath = stepKey(path, key);
129
+ if (Array.isArray(child)) {
130
+ const pruned = visitArray(child, childPath, state, depth + 1, key === "@graph");
131
+ if (pruned.removed) removedChild = true;
132
+ if (pruned.value !== child) changed = true;
133
+ if (pruned.removed && pruned.value.length === 0) continue;
134
+ out[key] = pruned.value;
135
+ continue;
136
+ }
137
+ if (isPlainObject(child)) {
138
+ if (isNestedNode(child)) {
139
+ const visit = visitNode(child, childPath, state, depth + 1);
140
+ if (!visit.keep) {
141
+ changed = true;
142
+ removedChild = true;
143
+ continue;
144
+ }
145
+ if (visit.value !== child) changed = true;
146
+ out[key] = visit.value;
147
+ continue;
148
+ }
149
+ const pruned = visitContainer(child, childPath, state, depth + 1);
150
+ if (pruned.removedChild) removedChild = true;
151
+ if (pruned.value !== child) changed = true;
152
+ out[key] = pruned.value;
153
+ continue;
154
+ }
155
+ out[key] = child;
156
+ }
157
+ return { value: changed ? out : obj, removedChild };
158
+ }
159
+ function visitArray(items, path, state, depth, units) {
160
+ if (depth >= MAX_DEPTH) return { value: items, removed: false };
161
+ let changed = false;
162
+ let removed = false;
163
+ const out = [];
164
+ items.forEach((item, index) => {
165
+ const itemPath = `${path}[${index}]`;
166
+ if (Array.isArray(item)) {
167
+ const pruned = visitArray(item, itemPath, state, depth + 1, units);
168
+ if (pruned.removed) removed = true;
169
+ if (pruned.value !== item) changed = true;
170
+ out.push(pruned.value);
171
+ return;
172
+ }
173
+ if (isPlainObject(item) && (units || isNestedNode(item))) {
174
+ const visit = visitNode(item, itemPath, state, depth + 1);
175
+ if (!visit.keep) {
176
+ changed = true;
177
+ removed = true;
178
+ return;
179
+ }
180
+ if (visit.value !== item) changed = true;
181
+ out.push(visit.value);
182
+ return;
183
+ }
184
+ if (isPlainObject(item)) {
185
+ const pruned = visitContainer(item, itemPath, state, depth + 1);
186
+ if (pruned.removedChild) removed = true;
187
+ if (pruned.value !== item) changed = true;
188
+ out.push(pruned.value);
189
+ return;
190
+ }
191
+ out.push(item);
192
+ });
193
+ return { value: changed ? out : items, removed };
194
+ }
195
+ function collectEvidence(holder, out, depth) {
196
+ for (const [key, value] of Object.entries(holder)) {
197
+ if (key === "@context" || ANNOTATION_KEYS.has(key)) continue;
198
+ collectFromValue(holder, key, value, out, depth);
199
+ }
200
+ }
201
+ function collectFromValue(holder, key, value, out, depth) {
202
+ if (depth >= MAX_DEPTH || out.length >= MAX_EVIDENCE) return;
203
+ if (typeof value === "string") {
204
+ for (const reason of placeholderReasons(key, value, holder)) {
205
+ if (out.length >= MAX_EVIDENCE) return;
206
+ out.push({ reason, key, value: cutForLog(value) });
207
+ }
208
+ return;
209
+ }
210
+ if (Array.isArray(value)) {
211
+ for (const item of value) collectFromValue(holder, key, item, out, depth + 1);
212
+ return;
213
+ }
214
+ if (isPlainObject(value) && !isNestedNode(value)) collectEvidence(value, out, depth + 1);
215
+ }
216
+ function placeholderReasons(key, raw, holder) {
217
+ const value = raw.trim();
218
+ if (!value) return [];
219
+ const reasons = [];
220
+ const host = urlHost(value);
221
+ if (host && isReservedExampleHost(host)) reasons.push("example_domain");
222
+ if ((PHONE_KEYS.has(key) || /^tel:/i.test(value)) && isPlaceholderPhone(value)) reasons.push("placeholder_phone");
223
+ if (NAME_KEYS.has(key) && !("value" in holder) && PLACEHOLDER_NAMES.has(normalizeName(value))) {
224
+ reasons.push("placeholder_name");
225
+ }
226
+ if (isTemplateSlot(key, value)) reasons.push("template_token");
227
+ return reasons;
228
+ }
229
+ function isTemplateSlot(key, value) {
230
+ if (JSX_EXPRESSION_VALUE.test(value) || BRACKET_SLOT_VALUE.test(value) || UPPER_SLOT_VALUE.test(value)) return true;
231
+ if (REPLACE_MARKER.test(value)) return true;
232
+ if (!BODY_TEXT_KEYS.has(key)) {
233
+ if (MUSTACHE_SLOT.test(value)) return true;
234
+ for (const match of value.matchAll(BRACKET_SLOT_IN_TEXT)) {
235
+ if (/^[A-Z]/.test(match[1])) return true;
236
+ }
237
+ }
238
+ return !URI_TEMPLATE_KEYS.has(key) && isUrlOrPath(value) && JSX_MEMBER_IN_TEXT.test(value);
239
+ }
240
+ function urlHost(value) {
241
+ const mailto = /^mailto:[^@\s?]+@([^?\s#/]+)/i.exec(value);
242
+ if (mailto) return mailto[1];
243
+ const url = /^(?:[a-z][a-z0-9+.-]*:)?\/\/(?:[^/?#@\s]*@)?([^/?#:\s]+)/i.exec(value);
244
+ if (url) return url[1];
245
+ const email = /^[^\s@/:]+@([^\s@/:?#]+)$/.exec(value);
246
+ if (email) return email[1];
247
+ const bare = /^((?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*)\.?(?::\d+)?(?:[/?#]\S*)?$/i.exec(value);
248
+ return bare ? bare[1] : null;
249
+ }
250
+ function isReservedExampleHost(host) {
251
+ const h = host.toLowerCase().replace(/\.$/, "");
252
+ if (h === RESERVED_EXAMPLE_TLD || h.endsWith(`.${RESERVED_EXAMPLE_TLD}`)) return true;
253
+ return RESERVED_EXAMPLE_DOMAINS.some((domain) => h === domain || h.endsWith(`.${domain}`));
254
+ }
255
+ function isUrlOrPath(value) {
256
+ return /^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(value) || value.startsWith("/");
257
+ }
258
+ function isPlaceholderPhone(raw) {
259
+ const value = raw.replace(/^tel:/i, "").trim();
260
+ if (/^[+\d\s().xX-]+$/.test(value) && (value.match(/x/gi)?.length ?? 0) >= 4) return true;
261
+ let digits = value.replace(/\D/g, "");
262
+ if (digits.length === 11 && digits.startsWith("1")) digits = digits.slice(1);
263
+ if (digits.length >= 7 && /^0+$/.test(digits)) return true;
264
+ if (digits === "1234567890" || digits === "1234567") return true;
265
+ const local = digits.length === 10 ? digits.slice(3) : digits.length === 7 ? digits : null;
266
+ return local !== null && /^55501\d\d$/.test(local);
267
+ }
268
+ function normalizeName(value) {
269
+ return value.replace(/\s+/g, " ").trim().replace(/[.,]+$/, "").toLowerCase();
270
+ }
271
+ function isPlainObject(value) {
272
+ return typeof value === "object" && value !== null && !Array.isArray(value);
273
+ }
274
+ function isNestedNode(value) {
275
+ return "@type" in value || "@id" in value || isAnnotationOnly(value);
276
+ }
277
+ function isGraphWrapper(value) {
278
+ return "@graph" in value && !("@type" in value) && !("@id" in value);
279
+ }
280
+ function isAnnotationOnly(value) {
281
+ if ("@type" in value || "@id" in value || "@graph" in value) return false;
282
+ const keys = Object.keys(value).filter((key) => key !== "@context");
283
+ return keys.length > 0 && keys.every((key) => ANNOTATION_KEYS.has(key));
284
+ }
285
+ function annotationEvidence(node) {
286
+ return Object.entries(node).filter(([key]) => ANNOTATION_KEYS.has(key)).slice(0, MAX_EVIDENCE).map(([key, value]) => ({
287
+ reason: "annotation",
288
+ key,
289
+ value: cutForLog(typeof value === "string" ? value : JSON.stringify(value) ?? "")
290
+ }));
291
+ }
292
+ function hasContentBeyond(obj, ignored) {
293
+ return Object.entries(obj).some(([key, value]) => !ignored.has(key) && hasContent(value));
294
+ }
295
+ function hasContent(value) {
296
+ if (value === null || value === void 0 || value === "") return false;
297
+ if (Array.isArray(value)) return value.length > 0;
298
+ if (isPlainObject(value)) return Object.keys(value).length > 0;
299
+ return true;
300
+ }
301
+ function typeOf(node) {
302
+ const type = node["@type"];
303
+ if (typeof type === "string") return type;
304
+ if (Array.isArray(type)) return type.filter((t) => typeof t === "string").join(",") || null;
305
+ return null;
306
+ }
307
+ function stepKey(path, key) {
308
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
309
+ }
310
+ function unique(values) {
311
+ return [...new Set(values)];
312
+ }
313
+ function cutForLog(value) {
314
+ return value.length > EVIDENCE_VALUE_LENGTH ? `${value.slice(0, EVIDENCE_VALUE_LENGTH)}...` : value;
315
+ }
316
+
317
+ export { describeSchemaPlaceholder, findSchemaPlaceholders, withoutSchemaPlaceholders };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.3.0",
4
- "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, and what separates an error page from a page's content. Pure functions, no dependencies.",
3
+ "version": "2.4.0",
4
+ "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, and which stored JSON-LD is a template's unfilled placeholder. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -95,6 +95,11 @@
95
95
  "import": "./dist/error-page.mjs",
96
96
  "require": "./dist/error-page.js"
97
97
  },
98
+ "./schema-placeholders": {
99
+ "types": "./dist/schema-placeholders.d.ts",
100
+ "import": "./dist/schema-placeholders.mjs",
101
+ "require": "./dist/schema-placeholders.js"
102
+ },
98
103
  "./package.json": "./package.json"
99
104
  },
100
105
  "typesVersions": {