@sonordev/contracts 2.8.0 → 2.10.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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Page-titles contract: what a page is called when a list, a sitemap or a
3
+ * case study needs its own name and not its whole <title>. The Sonor APIs and
4
+ * the agency site kit import it as `@sonordev/contracts/page-titles`.
5
+ *
6
+ * Why it exists: three places each turned a stored title or a URL slug into a
7
+ * label with their own logic, and they disagreed. "ac-replacement" became "Ac
8
+ * Replacement", "faq" became "Faq", "smith-and-smith" became "Smith And
9
+ * Smith", and a title with a stray second pipe shipped as "Loans | | Brand".
10
+ * There's one rule now:
11
+ *
12
+ * - `titleHead` is the page's own name: the title without its trailing
13
+ * " | Brand" segment. Prefer it. A page's title is the label it was
14
+ * written with, so it beats anything guessed from a URL.
15
+ * - `slugLabel` is for the rare case a label has to come from a slug, a file
16
+ * name or a path, because the page has no title at all.
17
+ * - `isRoutePlaceholder` says whether a value is a framework route
18
+ * placeholder (":slug", "[slug]", "[...rest]") and not a page's name.
19
+ * - `isPageAddress` says whether a value is a page's address (a path, a
20
+ * URL, a route pattern, or the page's own path without its slashes) and
21
+ * not its name. It's the one "is this title really a path" rule, so the
22
+ * writer of a page list and the reader that prints it can't disagree.
23
+ * - `isPageName` says whether a stored title is a name: something is left
24
+ * once the pipes are set aside, and no piece between them is an address.
25
+ * It's the one rule for a title that gets printed whole, so the writer
26
+ * that stores a name and the reader that prints it can't disagree about
27
+ * a path, a URL or a placeholder behind a pipe.
28
+ *
29
+ * Pure string logic: no I/O, no Node built-ins. The brand split is
30
+ * seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
31
+ * " | " segment of a title is its brand.
32
+ */
33
+ /**
34
+ * The page's own name: the title without its trailing " | Brand" segment.
35
+ *
36
+ * Whitespace is collapsed first. Stray pipes are repaired before the brand is
37
+ * cut, so "Loans | | Brand" reads as "Loans | Brand" (a run of pipes with
38
+ * only whitespace between them is one separator) and a leading or trailing
39
+ * pipe is dropped ("Pricing |" is "Pricing"). Then the last " | " segment is
40
+ * the brand and goes; anything before it stays, pipes and all:
41
+ * "1 Bedroom | 1BR 1BA | Example Apartments" is "1 Bedroom | 1BR 1BA".
42
+ *
43
+ * Apply it once to a stored title. A title with two or more separators
44
+ * loses one segment per call, so its own output isn't a fixed point.
45
+ *
46
+ * A title with no separator comes back as written (trimmed): this can't tell
47
+ * a brand from a name, so a title that's only a brand stays that brand.
48
+ * Empty, whitespace-only, non-string, null and undefined input give "".
49
+ */
50
+ export declare function titleHead(title: string | null | undefined): string;
51
+ /**
52
+ * A readable label for a slug, a file name or a path: "ac-replacement" is
53
+ * "AC Replacement", "/services/hvac-repair/" is "HVAC Repair".
54
+ *
55
+ * Reach for it only when the page has no title (`titleHead` is the page's own
56
+ * name). It reads the last non-empty path segment of the input, after
57
+ * dropping a "https://host" origin, a query or a hash, and decodes %-escapes
58
+ * without ever throwing. A trailing .html, .htm, .php or .aspx is dropped. The
59
+ * words split on "-", "_", "+" and whitespace.
60
+ *
61
+ * Each word is cased by the first rule that fits:
62
+ * - an acronym is upper case ("ac", "ai", "cms", "faq", "hvac", "seo",
63
+ * "llc", "mls", ...), and "faqs" is "FAQs";
64
+ * - a two-letter US state or DC code is upper case ("ky" is "KY"), apart
65
+ * from the codes that are ordinary words as often as not ("in", "or",
66
+ * "me", "hi", "id", "la", "ma", "pa", "al", "de", "co"). "mt" goes by its
67
+ * position: last, it's Montana ("billings-mt" is "Billings MT"); with a
68
+ * word after it, it's Mount ("mt-adams" is "Mt Adams"). "us" isn't a
69
+ * state: "about-us" is "About Us";
70
+ * - a word with a digit keeps its case and only gains a leading capital
71
+ * when it starts with a letter ("c1" is "C1", "075ct" stays);
72
+ * - a small word ("a", "an", "and", "at", "by", "for", "in", "of", "on",
73
+ * "or", "the", "to", "vs", "with") is lower case, unless it's first;
74
+ * - anything else is capitalized with the rest lower case.
75
+ *
76
+ * Nothing left to label ("", "/", "?x=1") gives "". The home page is the
77
+ * caller's to name. A label isn't a title: "index" isn't special-cased,
78
+ * and a brand's own capitalization ("DeWalt") isn't recoverable from a slug.
79
+ */
80
+ export declare function slugLabel(input: string): string;
81
+ /**
82
+ * True when the value, or any "/"-separated segment of it, is a framework
83
+ * route placeholder: a page's pattern leaked into its name ("/floor-plans/:slug",
84
+ * ":Slug", "[...rest]"). A placeholder is one token with no spaces, so
85
+ * ordinary text that contains a colon or a bracket isn't one ("Update: new
86
+ * hours", "FAQ / [draft] notes"). Nullish, empty and non-string values are
87
+ * false.
88
+ */
89
+ export declare function isRoutePlaceholder(value: string | null | undefined): boolean;
90
+ /**
91
+ * Whether `value` is a page's address and not its name: what a page's title
92
+ * becomes when nothing better was stored (the old sitemap writers saved the
93
+ * path there), or a pattern that leaked out of the router. True for:
94
+ *
95
+ * - a path, or anything starting with a slash ("/services/ac-repair",
96
+ * "//example.com/services");
97
+ * - an absolute URL ("https://example.com/services");
98
+ * - a host and a path with no scheme ("example.com/services");
99
+ * - a route placeholder, as `isRoutePlaceholder` reads it ("/floor-plans/:slug",
100
+ * ":Slug", "[slug]");
101
+ * - the page's own path written without its slashes. Given `path`, the
102
+ * comparison drops leading and trailing slashes from both sides, so for
103
+ * "/services/ac-repair" the values "services/ac-repair" and
104
+ * "services/ac-repair/" are the address, and for "/faq" the value "faq" is.
105
+ * It compares the whole path and it's case-sensitive: "ac-repair" isn't
106
+ * the address of "/services/ac-repair", and "Pricing" for "/pricing" is a
107
+ * name where "pricing" is the path.
108
+ *
109
+ * The value is trimmed first. Empty, whitespace-only, nullish and non-string
110
+ * values give false: they aren't an address, they're nothing, and the caller
111
+ * decides what an empty title means. `path` is optional; without it only the
112
+ * checks that don't need the page's own path run.
113
+ *
114
+ * It judges one string. A title with a " | Brand" tail is `titleHead`'s to
115
+ * cut, and the caller asks about the head: `isPageAddress(titleHead(title), path)`.
116
+ * Pure string logic, linear in the length of the input.
117
+ */
118
+ export declare function isPageAddress(value: string | null | undefined, path?: string | null): boolean;
119
+ /**
120
+ * Whether a stored title is a real name for the page at `path`: something is
121
+ * left once the pipes are set aside, and none of its pieces is a page's
122
+ * address (`isPageAddress`, given the page's path). It's the one rule for a
123
+ * title that gets printed whole, so whoever stores a name and whoever prints
124
+ * it can't disagree.
125
+ *
126
+ * Every piece is judged, the first and the last included, because a title
127
+ * with a pipe in it prints with its pipes: ":slug | Example Apartments" (a
128
+ * placeholder in front of a brand tail) and "Floor Plans | /floor-plans/x" (a
129
+ * path behind the name) are both refused, and so is the unspaced
130
+ * "Floor Plans|/floor-plans/x". A name with a pipe in it stays a name while
131
+ * every piece is text ("1 Bedroom | 1BR 1BA"). A title that's only pipes has
132
+ * no piece and isn't a name. Empty, whitespace-only, nullish and non-string
133
+ * values aren't names either.
134
+ *
135
+ * It judges the title it is given, brand tail and all. A writer cuts the tail
136
+ * first and asks about what it stores: `isPageName(titleHead(title), path)`.
137
+ * `path` is optional, as it is for `isPageAddress`. Pure string logic, linear
138
+ * in the length of the input.
139
+ */
140
+ export declare function isPageName(title: string | null | undefined, path?: string | null): boolean;
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/seo-meta.ts
4
+ var TITLE_SEPARATOR = " | ";
5
+ function stripTitleSuffix(title) {
6
+ const t = title.trim();
7
+ const i = t.lastIndexOf(TITLE_SEPARATOR);
8
+ return i > 0 ? t.slice(0, i).trim() : t;
9
+ }
10
+
11
+ // src/contracts/page-titles.ts
12
+ var PIPE_RUN = /\|(?:\s*\|)+/g;
13
+ var EDGE_PIPES = /^[\s|]+|[\s|]+$/g;
14
+ function titleHead(title) {
15
+ if (typeof title !== "string") return "";
16
+ const tidy = title.replace(/\s+/g, " ").replace(PIPE_RUN, " | ").replace(EDGE_PIPES, "").replace(/\s+/g, " ");
17
+ return tidy ? stripTitleSuffix(tidy) : "";
18
+ }
19
+ var ACRONYMS = /* @__PURE__ */ new Map([
20
+ ["ac", "AC"],
21
+ ["ai", "AI"],
22
+ ["aeo", "AEO"],
23
+ ["api", "API"],
24
+ ["cms", "CMS"],
25
+ ["crm", "CRM"],
26
+ ["faq", "FAQ"],
27
+ ["faqs", "FAQs"],
28
+ ["fsbo", "FSBO"],
29
+ ["hvac", "HVAC"],
30
+ ["jl", "JL"],
31
+ ["llc", "LLC"],
32
+ ["llp", "LLP"],
33
+ ["mls", "MLS"],
34
+ ["pdf", "PDF"],
35
+ ["pllc", "PLLC"],
36
+ ["ppc", "PPC"],
37
+ ["roi", "ROI"],
38
+ ["seo", "SEO"],
39
+ ["ui", "UI"],
40
+ ["usa", "USA"],
41
+ ["ux", "UX"]
42
+ ]);
43
+ var STATE_CODES = new Set(
44
+ "al ak az ar ca co ct de dc fl ga hi id il in ia ks ky la me md ma mi mn ms mo mt ne nv nh nj nm ny nc nd oh ok or pa ri sc sd tn tx ut vt va wa wv wi wy".split(" ")
45
+ );
46
+ var WORD_STATE_CODES = /* @__PURE__ */ new Set([
47
+ "in",
48
+ "or",
49
+ "me",
50
+ "hi",
51
+ "id",
52
+ "la",
53
+ "ma",
54
+ "pa",
55
+ "al",
56
+ "de",
57
+ "co"
58
+ ]);
59
+ var TRAILING_STATE_CODES = /* @__PURE__ */ new Set(["mt"]);
60
+ function isStateCode(lower, isLast) {
61
+ if (!STATE_CODES.has(lower) || WORD_STATE_CODES.has(lower)) return false;
62
+ return TRAILING_STATE_CODES.has(lower) ? isLast : true;
63
+ }
64
+ var SMALL_WORDS = /* @__PURE__ */ new Set([
65
+ "a",
66
+ "an",
67
+ "and",
68
+ "at",
69
+ "by",
70
+ "for",
71
+ "in",
72
+ "of",
73
+ "on",
74
+ "or",
75
+ "the",
76
+ "to",
77
+ "vs",
78
+ "with"
79
+ ]);
80
+ var PAGE_EXTENSION = /\.(?:html?|php|aspx)$/i;
81
+ var URL_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/\\]*/i;
82
+ var PERCENT_RUN = /(?:%[0-9a-f]{2})+/gi;
83
+ function decodeEscapes(value) {
84
+ return value.replace(PERCENT_RUN, (run) => {
85
+ try {
86
+ return decodeURIComponent(run);
87
+ } catch {
88
+ return run;
89
+ }
90
+ });
91
+ }
92
+ function capitalizeFirst(word) {
93
+ const code = word.codePointAt(0);
94
+ if (code === void 0) return word;
95
+ const first = String.fromCodePoint(code);
96
+ return first.toUpperCase() + word.slice(first.length);
97
+ }
98
+ function labelWord(token, isFirst, isLast) {
99
+ const lower = token.toLowerCase();
100
+ const acronym = ACRONYMS.get(lower);
101
+ if (acronym) return acronym;
102
+ if (isStateCode(lower, isLast)) return lower.toUpperCase();
103
+ if (/\p{N}/u.test(token)) return /^\p{L}/u.test(token) ? capitalizeFirst(token) : token;
104
+ if (!isFirst && SMALL_WORDS.has(lower)) return lower;
105
+ return capitalizeFirst(lower);
106
+ }
107
+ function slugLabel(input) {
108
+ if (typeof input !== "string") return "";
109
+ const path = input.split(/[?#]/, 1)[0].replace(URL_ORIGIN, "");
110
+ const segment = path.split(/[/\\]/).filter((s) => s.trim() !== "").pop();
111
+ if (!segment) return "";
112
+ const name = decodeEscapes(segment).replace(PAGE_EXTENSION, "");
113
+ const tokens = name.split(/[-_+\s]+/).filter(Boolean);
114
+ return tokens.map((token, i) => labelWord(token, i === 0, i === tokens.length - 1)).join(" ");
115
+ }
116
+ var PLACEHOLDER_TOKEN = /^(?::[A-Za-z_][\w-]*[?*+]?|\[\[\.\.\.[A-Za-z_][\w-]*\]\]|\[(?:\.\.\.)?[A-Za-z_][\w-]*\])$/;
117
+ function isRoutePlaceholder(value) {
118
+ if (typeof value !== "string") return false;
119
+ return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
120
+ }
121
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
122
+ var HOST_NAME = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d{1,5})?$/i;
123
+ function withoutOuterSlashes(value) {
124
+ let start = 0;
125
+ let end = value.length;
126
+ while (start < end && value[start] === "/") start++;
127
+ while (end > start && value[end - 1] === "/") end--;
128
+ return value.slice(start, end);
129
+ }
130
+ function isHostPath(value) {
131
+ const slash = value.indexOf("/");
132
+ if (slash < 0 || /\s/.test(value)) return false;
133
+ return HOST_NAME.test(value.slice(0, slash));
134
+ }
135
+ function isPageAddress(value, path) {
136
+ if (typeof value !== "string") return false;
137
+ const given = value.trim();
138
+ if (!given) return false;
139
+ if (given.startsWith("/") || ABSOLUTE_URL.test(given) || isHostPath(given) || isRoutePlaceholder(given)) return true;
140
+ if (typeof path !== "string") return false;
141
+ const own = withoutOuterSlashes(path.trim());
142
+ return withoutOuterSlashes(given) === own;
143
+ }
144
+ function pipePieces(title) {
145
+ return title.split("|").map((piece) => piece.trim()).filter(Boolean);
146
+ }
147
+ function isPageName(title, path) {
148
+ if (typeof title !== "string") return false;
149
+ const pieces = pipePieces(title);
150
+ return pieces.length > 0 && pieces.every((piece) => !isPageAddress(piece, path));
151
+ }
152
+
153
+ exports.isPageAddress = isPageAddress;
154
+ exports.isPageName = isPageName;
155
+ exports.isRoutePlaceholder = isRoutePlaceholder;
156
+ exports.slugLabel = slugLabel;
157
+ exports.titleHead = titleHead;
@@ -0,0 +1,151 @@
1
+ // src/contracts/seo-meta.ts
2
+ var TITLE_SEPARATOR = " | ";
3
+ function stripTitleSuffix(title) {
4
+ const t = title.trim();
5
+ const i = t.lastIndexOf(TITLE_SEPARATOR);
6
+ return i > 0 ? t.slice(0, i).trim() : t;
7
+ }
8
+
9
+ // src/contracts/page-titles.ts
10
+ var PIPE_RUN = /\|(?:\s*\|)+/g;
11
+ var EDGE_PIPES = /^[\s|]+|[\s|]+$/g;
12
+ function titleHead(title) {
13
+ if (typeof title !== "string") return "";
14
+ const tidy = title.replace(/\s+/g, " ").replace(PIPE_RUN, " | ").replace(EDGE_PIPES, "").replace(/\s+/g, " ");
15
+ return tidy ? stripTitleSuffix(tidy) : "";
16
+ }
17
+ var ACRONYMS = /* @__PURE__ */ new Map([
18
+ ["ac", "AC"],
19
+ ["ai", "AI"],
20
+ ["aeo", "AEO"],
21
+ ["api", "API"],
22
+ ["cms", "CMS"],
23
+ ["crm", "CRM"],
24
+ ["faq", "FAQ"],
25
+ ["faqs", "FAQs"],
26
+ ["fsbo", "FSBO"],
27
+ ["hvac", "HVAC"],
28
+ ["jl", "JL"],
29
+ ["llc", "LLC"],
30
+ ["llp", "LLP"],
31
+ ["mls", "MLS"],
32
+ ["pdf", "PDF"],
33
+ ["pllc", "PLLC"],
34
+ ["ppc", "PPC"],
35
+ ["roi", "ROI"],
36
+ ["seo", "SEO"],
37
+ ["ui", "UI"],
38
+ ["usa", "USA"],
39
+ ["ux", "UX"]
40
+ ]);
41
+ var STATE_CODES = new Set(
42
+ "al ak az ar ca co ct de dc fl ga hi id il in ia ks ky la me md ma mi mn ms mo mt ne nv nh nj nm ny nc nd oh ok or pa ri sc sd tn tx ut vt va wa wv wi wy".split(" ")
43
+ );
44
+ var WORD_STATE_CODES = /* @__PURE__ */ new Set([
45
+ "in",
46
+ "or",
47
+ "me",
48
+ "hi",
49
+ "id",
50
+ "la",
51
+ "ma",
52
+ "pa",
53
+ "al",
54
+ "de",
55
+ "co"
56
+ ]);
57
+ var TRAILING_STATE_CODES = /* @__PURE__ */ new Set(["mt"]);
58
+ function isStateCode(lower, isLast) {
59
+ if (!STATE_CODES.has(lower) || WORD_STATE_CODES.has(lower)) return false;
60
+ return TRAILING_STATE_CODES.has(lower) ? isLast : true;
61
+ }
62
+ var SMALL_WORDS = /* @__PURE__ */ new Set([
63
+ "a",
64
+ "an",
65
+ "and",
66
+ "at",
67
+ "by",
68
+ "for",
69
+ "in",
70
+ "of",
71
+ "on",
72
+ "or",
73
+ "the",
74
+ "to",
75
+ "vs",
76
+ "with"
77
+ ]);
78
+ var PAGE_EXTENSION = /\.(?:html?|php|aspx)$/i;
79
+ var URL_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/\\]*/i;
80
+ var PERCENT_RUN = /(?:%[0-9a-f]{2})+/gi;
81
+ function decodeEscapes(value) {
82
+ return value.replace(PERCENT_RUN, (run) => {
83
+ try {
84
+ return decodeURIComponent(run);
85
+ } catch {
86
+ return run;
87
+ }
88
+ });
89
+ }
90
+ function capitalizeFirst(word) {
91
+ const code = word.codePointAt(0);
92
+ if (code === void 0) return word;
93
+ const first = String.fromCodePoint(code);
94
+ return first.toUpperCase() + word.slice(first.length);
95
+ }
96
+ function labelWord(token, isFirst, isLast) {
97
+ const lower = token.toLowerCase();
98
+ const acronym = ACRONYMS.get(lower);
99
+ if (acronym) return acronym;
100
+ if (isStateCode(lower, isLast)) return lower.toUpperCase();
101
+ if (/\p{N}/u.test(token)) return /^\p{L}/u.test(token) ? capitalizeFirst(token) : token;
102
+ if (!isFirst && SMALL_WORDS.has(lower)) return lower;
103
+ return capitalizeFirst(lower);
104
+ }
105
+ function slugLabel(input) {
106
+ if (typeof input !== "string") return "";
107
+ const path = input.split(/[?#]/, 1)[0].replace(URL_ORIGIN, "");
108
+ const segment = path.split(/[/\\]/).filter((s) => s.trim() !== "").pop();
109
+ if (!segment) return "";
110
+ const name = decodeEscapes(segment).replace(PAGE_EXTENSION, "");
111
+ const tokens = name.split(/[-_+\s]+/).filter(Boolean);
112
+ return tokens.map((token, i) => labelWord(token, i === 0, i === tokens.length - 1)).join(" ");
113
+ }
114
+ var PLACEHOLDER_TOKEN = /^(?::[A-Za-z_][\w-]*[?*+]?|\[\[\.\.\.[A-Za-z_][\w-]*\]\]|\[(?:\.\.\.)?[A-Za-z_][\w-]*\])$/;
115
+ function isRoutePlaceholder(value) {
116
+ if (typeof value !== "string") return false;
117
+ return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
118
+ }
119
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
120
+ var HOST_NAME = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d{1,5})?$/i;
121
+ function withoutOuterSlashes(value) {
122
+ let start = 0;
123
+ let end = value.length;
124
+ while (start < end && value[start] === "/") start++;
125
+ while (end > start && value[end - 1] === "/") end--;
126
+ return value.slice(start, end);
127
+ }
128
+ function isHostPath(value) {
129
+ const slash = value.indexOf("/");
130
+ if (slash < 0 || /\s/.test(value)) return false;
131
+ return HOST_NAME.test(value.slice(0, slash));
132
+ }
133
+ function isPageAddress(value, path) {
134
+ if (typeof value !== "string") return false;
135
+ const given = value.trim();
136
+ if (!given) return false;
137
+ if (given.startsWith("/") || ABSOLUTE_URL.test(given) || isHostPath(given) || isRoutePlaceholder(given)) return true;
138
+ if (typeof path !== "string") return false;
139
+ const own = withoutOuterSlashes(path.trim());
140
+ return withoutOuterSlashes(given) === own;
141
+ }
142
+ function pipePieces(title) {
143
+ return title.split("|").map((piece) => piece.trim()).filter(Boolean);
144
+ }
145
+ function isPageName(title, path) {
146
+ if (typeof title !== "string") return false;
147
+ const pieces = pipePieces(title);
148
+ return pieces.length > 0 && pieces.every((piece) => !isPageAddress(piece, path));
149
+ }
150
+
151
+ export { isPageAddress, isPageName, isRoutePlaceholder, slugLabel, titleHead };
@@ -0,0 +1,143 @@
1
+ import { type BroadcastStyleId, type PublicationCardLayout } from './broadcast-layouts';
2
+ import { type PublicationCharter } from './publication-charter';
3
+ export { PUBLICATION_CHARTER_FIELDS, parsePublicationCharter, parseSiteCharters, publicationCharterContext, publicationCharterFor, type PublicationCharter, } from './publication-charter';
4
+ /** The stored profile's version. A breaking change to the shape bumps it. */
5
+ export declare const PUBLICATION_BRANDING_VERSION: 1;
6
+ /** Where a publication appears. `social` overrides every social network; each network can override again. */
7
+ export declare const PUBLICATION_PLATFORMS: readonly ['publication', 'email', 'social', 'instagram', 'facebook', 'linkedin', 'gbp'];
8
+ export type PublicationPlatform = (typeof PUBLICATION_PLATFORMS)[number];
9
+ /** The sections of a profile and of each platform override, in the order they're read. */
10
+ export declare const PUBLICATION_BRANDING_SECTIONS: readonly ['identity', 'colors', 'typography', 'editorial', 'newsletter'];
11
+ export type PublicationBrandingSection = (typeof PUBLICATION_BRANDING_SECTIONS)[number];
12
+ export declare const PUBLICATION_IDENTITY_FIELDS: readonly ['publicationName', 'wordmark', 'tagline', 'publisherName', 'publisherUrl', 'publisherLogoUrl', 'logos'];
13
+ export declare const PUBLICATION_LOGO_VARIANTS: readonly ['default', 'light', 'dark'];
14
+ export declare const PUBLICATION_COLOR_FIELDS: readonly ['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'];
15
+ export declare const PUBLICATION_TYPOGRAPHY_FIELDS: readonly ['heading', 'body', 'mono', 'emailHeadingFallback', 'emailBodyFallback'];
16
+ export declare const PUBLICATION_EDITORIAL_FIELDS: readonly ['articleStyle', 'cardLayout', 'charter', 'siteCharters'];
17
+ export declare const PUBLICATION_NEWSLETTER_FIELDS: readonly ['name', 'tagline', 'layout', 'maxWidth'];
18
+ /** Fonts every email client has, the only families an email fallback may name. Lowercase. */
19
+ export declare const EMAIL_SAFE_FONTS: ReadonlySet<string>;
20
+ /** CSS generic families: written bare and lowercase in a font stack, never quoted. */
21
+ export declare const GENERIC_FONT_FAMILIES: ReadonlySet<string>;
22
+ export interface PublicationIdentity {
23
+ publicationName?: string;
24
+ wordmark?: string;
25
+ tagline?: string;
26
+ publisherName?: string;
27
+ publisherUrl?: string;
28
+ /** The publisher's attribution mark, separate from the publication masthead. */
29
+ publisherLogoUrl?: string;
30
+ logos?: {
31
+ default?: string;
32
+ light?: string;
33
+ dark?: string;
34
+ };
35
+ }
36
+ export interface PublicationColors {
37
+ primary?: string;
38
+ secondary?: string;
39
+ accent?: string;
40
+ background?: string;
41
+ surface?: string;
42
+ text?: string;
43
+ muted?: string;
44
+ border?: string;
45
+ }
46
+ export interface PublicationTypography {
47
+ heading?: string;
48
+ body?: string;
49
+ mono?: string;
50
+ emailHeadingFallback?: string;
51
+ emailBodyFallback?: string;
52
+ }
53
+ export interface PublicationEditorial {
54
+ charter?: PublicationCharter;
55
+ siteCharters?: Record<string, PublicationCharter>;
56
+ /** Shared Broadcast style for articles, reviews and generated posts; portfolio is excluded. Legacy wire name. */
57
+ articleStyle?: BroadcastStyleId;
58
+ cardLayout?: PublicationCardLayout;
59
+ }
60
+ export interface PublicationNewsletter {
61
+ name?: string;
62
+ tagline?: string;
63
+ layout?: 'editorial';
64
+ maxWidth?: 600 | 640;
65
+ }
66
+ export interface PublicationBrandingSections {
67
+ identity?: PublicationIdentity;
68
+ colors?: PublicationColors;
69
+ typography?: PublicationTypography;
70
+ editorial?: PublicationEditorial;
71
+ newsletter?: PublicationNewsletter;
72
+ }
73
+ /** Exactly the object stored in projects.settings.publication_branding. */
74
+ export interface PublicationBranding extends PublicationBrandingSections {
75
+ version: typeof PUBLICATION_BRANDING_VERSION;
76
+ platforms?: Partial<Record<PublicationPlatform, PublicationBrandingSections>>;
77
+ }
78
+ export type PublicationBrandingProfile = PublicationBranding;
79
+ type LeafPatch<T> = {
80
+ [K in keyof T]?: NonNullable<T[K]> extends object ? LeafPatch<NonNullable<T[K]>> : T[K] | null;
81
+ };
82
+ export type PublicationBrandingSectionsPatch = LeafPatch<PublicationBrandingSections>;
83
+ /** A change: any leaf set to a value, or to null to clear that override. */
84
+ export interface PublicationBrandingPatch extends PublicationBrandingSectionsPatch {
85
+ version?: typeof PUBLICATION_BRANDING_VERSION;
86
+ platforms?: Partial<Record<PublicationPlatform, PublicationBrandingSectionsPatch>>;
87
+ }
88
+ /**
89
+ * A refusal, naming the field: `publication_branding.colors.primary: use a
90
+ * valid opaque color`. One case throws a plain Error instead: a charter set on
91
+ * a channel override, which belongs to the publication or a site.
92
+ */
93
+ export declare class PublicationBrandingValidationError extends Error {
94
+ readonly path: string;
95
+ constructor(path: string, reason: string);
96
+ }
97
+ /** True for a control character (U+0000-U+001F, U+007F) anywhere in the text. */
98
+ export declare function containsControlCharacters(value: string): boolean;
99
+ /**
100
+ * A CSS font stack as data, never a stylesheet fragment: quoted family names
101
+ * and lowercase generic families joined by commas ("'Brand Display',serif"),
102
+ * or undefined when the value carries rules, variables, url() or anything
103
+ * that isn't a family name. The theme aliases heading, body and mono drop out.
104
+ */
105
+ export declare function safeFontStack(value: unknown): string | undefined;
106
+ export interface PublicationBrandingValidatorOptions {
107
+ /**
108
+ * True when a URL's hostname must never be stored as a logo or publisher
109
+ * URL: loopback, private and link-local ranges, `localhost` and the like.
110
+ * Each API passes its own server-side SSRF host guard. It receives
111
+ * `new URL(value).hostname`, so IPv6 keeps its brackets.
112
+ */
113
+ isBlockedHost: (hostname: string) => boolean;
114
+ }
115
+ export interface PublicationBrandingValidator {
116
+ /** A whole saved profile. Retired card layouts still read. */
117
+ validatePublicationBranding(value: unknown): PublicationBranding;
118
+ /** A change. Leaves may be null (clear that override); a card layout must be one drawn today. */
119
+ validatePublicationBrandingPatch(value: unknown): PublicationBrandingPatch;
120
+ /** The sections of a profile or a platform override, at `path`. */
121
+ validatePublicationBrandingSections(input: unknown, path: string, patch: boolean): PublicationBrandingSections | PublicationBrandingSectionsPatch;
122
+ /**
123
+ * The saved profile with the change applied: a null leaf clears just that
124
+ * override and empty containers are pruned. No profile yet reads as
125
+ * `{ version: 1 }`; a change that sets nothing returns the profile as it was.
126
+ */
127
+ mergePublicationBrandingPatch(current: unknown, patch: unknown): PublicationBranding;
128
+ /** One text leaf: trimmed, 1 to max characters (0 when empty is allowed), no control characters. */
129
+ text(value: unknown, path: string, max: number, empty?: boolean): string;
130
+ /** One URL leaf: a public HTTPS URL without credentials, normalized. */
131
+ url(value: unknown, path: string): string;
132
+ /** One font leaf, normalized by safeFontStack; email fallbacks may name only EMAIL_SAFE_FONTS. */
133
+ font(value: unknown, path: string, emailSafe?: boolean): string;
134
+ }
135
+ /**
136
+ * Leaf-wise merge of a validated change into a profile layer: a null leaf
137
+ * deletes that key, an object merges recursively, and a container left empty
138
+ * is removed. The inputs are never mutated. The Sonor API layers resolved
139
+ * branding with the same merge.
140
+ */
141
+ export declare function mergePublicationBrandingLayers(current: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
142
+ /** Single source of truth for publication-branding validation, bound to the caller's host guard. */
143
+ export declare function createPublicationBrandingValidator(options: PublicationBrandingValidatorOptions): PublicationBrandingValidator;