@actuate-media/cms-core 0.76.2 → 0.77.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/__tests__/ai/brand-voice-alignment.test.d.ts +2 -0
  3. package/dist/__tests__/ai/brand-voice-alignment.test.d.ts.map +1 -0
  4. package/dist/__tests__/ai/brand-voice-alignment.test.js +38 -0
  5. package/dist/__tests__/ai/brand-voice-alignment.test.js.map +1 -0
  6. package/dist/__tests__/ai/brand-voice-analyze.test.d.ts +2 -0
  7. package/dist/__tests__/ai/brand-voice-analyze.test.d.ts.map +1 -0
  8. package/dist/__tests__/ai/brand-voice-analyze.test.js +117 -0
  9. package/dist/__tests__/ai/brand-voice-analyze.test.js.map +1 -0
  10. package/dist/__tests__/ai/brand-voice-document.test.d.ts +2 -0
  11. package/dist/__tests__/ai/brand-voice-document.test.d.ts.map +1 -0
  12. package/dist/__tests__/ai/brand-voice-document.test.js +74 -0
  13. package/dist/__tests__/ai/brand-voice-document.test.js.map +1 -0
  14. package/dist/__tests__/ai/brand-voice-fix.test.d.ts +2 -0
  15. package/dist/__tests__/ai/brand-voice-fix.test.d.ts.map +1 -0
  16. package/dist/__tests__/ai/brand-voice-fix.test.js +34 -0
  17. package/dist/__tests__/ai/brand-voice-fix.test.js.map +1 -0
  18. package/dist/__tests__/ai/brand-voice-sources.test.d.ts +2 -0
  19. package/dist/__tests__/ai/brand-voice-sources.test.d.ts.map +1 -0
  20. package/dist/__tests__/ai/brand-voice-sources.test.js +14 -0
  21. package/dist/__tests__/ai/brand-voice-sources.test.js.map +1 -0
  22. package/dist/__tests__/ai/brand-voice-structured.test.d.ts +2 -0
  23. package/dist/__tests__/ai/brand-voice-structured.test.d.ts.map +1 -0
  24. package/dist/__tests__/ai/brand-voice-structured.test.js +49 -0
  25. package/dist/__tests__/ai/brand-voice-structured.test.js.map +1 -0
  26. package/dist/__tests__/api/route-contracts.test.js +3 -0
  27. package/dist/__tests__/api/route-contracts.test.js.map +1 -1
  28. package/dist/__tests__/diagnostics/env.test.js +12 -1
  29. package/dist/__tests__/diagnostics/env.test.js.map +1 -1
  30. package/dist/ai/brand-voice-alignment.d.ts +17 -0
  31. package/dist/ai/brand-voice-alignment.d.ts.map +1 -0
  32. package/dist/ai/brand-voice-alignment.js +40 -0
  33. package/dist/ai/brand-voice-alignment.js.map +1 -0
  34. package/dist/ai/brand-voice-analyze.d.ts +71 -0
  35. package/dist/ai/brand-voice-analyze.d.ts.map +1 -0
  36. package/dist/ai/brand-voice-analyze.js +151 -0
  37. package/dist/ai/brand-voice-analyze.js.map +1 -0
  38. package/dist/ai/brand-voice-document.d.ts +56 -0
  39. package/dist/ai/brand-voice-document.d.ts.map +1 -0
  40. package/dist/ai/brand-voice-document.js +335 -0
  41. package/dist/ai/brand-voice-document.js.map +1 -0
  42. package/dist/ai/brand-voice-fix.d.ts +26 -0
  43. package/dist/ai/brand-voice-fix.d.ts.map +1 -0
  44. package/dist/ai/brand-voice-fix.js +97 -0
  45. package/dist/ai/brand-voice-fix.js.map +1 -0
  46. package/dist/ai/brand-voice-save.d.ts +11 -0
  47. package/dist/ai/brand-voice-save.d.ts.map +1 -0
  48. package/dist/ai/brand-voice-save.js +51 -0
  49. package/dist/ai/brand-voice-save.js.map +1 -0
  50. package/dist/ai/brand-voice-sources.d.ts +16 -0
  51. package/dist/ai/brand-voice-sources.d.ts.map +1 -0
  52. package/dist/ai/brand-voice-sources.js +106 -0
  53. package/dist/ai/brand-voice-sources.js.map +1 -0
  54. package/dist/ai/brand-voice-structured.d.ts +87 -0
  55. package/dist/ai/brand-voice-structured.d.ts.map +1 -0
  56. package/dist/ai/brand-voice-structured.js +438 -0
  57. package/dist/ai/brand-voice-structured.js.map +1 -0
  58. package/dist/ai/brand-voice.d.ts +3 -0
  59. package/dist/ai/brand-voice.d.ts.map +1 -1
  60. package/dist/ai/brand-voice.js +18 -0
  61. package/dist/ai/brand-voice.js.map +1 -1
  62. package/dist/ai/index.d.ts +7 -0
  63. package/dist/ai/index.d.ts.map +1 -1
  64. package/dist/ai/index.js +7 -0
  65. package/dist/ai/index.js.map +1 -1
  66. package/dist/ai/types.d.ts +8 -0
  67. package/dist/ai/types.d.ts.map +1 -1
  68. package/dist/api/routes/ai-seo.d.ts.map +1 -1
  69. package/dist/api/routes/ai-seo.js +22 -3
  70. package/dist/api/routes/ai-seo.js.map +1 -1
  71. package/dist/api/routes/ai-settings.d.ts.map +1 -1
  72. package/dist/api/routes/ai-settings.js +272 -7
  73. package/dist/api/routes/ai-settings.js.map +1 -1
  74. package/dist/api/routes/media.d.ts.map +1 -1
  75. package/dist/api/routes/media.js +10 -6
  76. package/dist/api/routes/media.js.map +1 -1
  77. package/dist/api/routes/saved-sections.d.ts.map +1 -1
  78. package/dist/api/routes/saved-sections.js +4 -1
  79. package/dist/api/routes/saved-sections.js.map +1 -1
  80. package/dist/brand-voice/index.d.ts +7 -0
  81. package/dist/brand-voice/index.d.ts.map +1 -0
  82. package/dist/brand-voice/index.js +7 -0
  83. package/dist/brand-voice/index.js.map +1 -0
  84. package/dist/diagnostics/env.d.ts.map +1 -1
  85. package/dist/diagnostics/env.js +18 -11
  86. package/dist/diagnostics/env.js.map +1 -1
  87. package/package.json +6 -1
@@ -0,0 +1,106 @@
1
+ /**
2
+ * External sources for brand voice analysis — SSRF-safe URL fetch and pasted uploads.
3
+ */
4
+ import { safeFetch, SsrfBlockedError } from '../security/safe-fetch.js';
5
+ import { stripHtmlTags } from '../seo/analysis.js';
6
+ export const MAX_ANALYZE_URLS = 5;
7
+ export const MAX_ANALYZE_UPLOADS = 5;
8
+ export const MAX_UPLOAD_TEXT_CHARS = 15_000;
9
+ export const MAX_URL_FETCH_CHARS = 8_000;
10
+ export const MAX_URL_FETCH_BYTES = 512 * 1024;
11
+ function clampText(text, max) {
12
+ return text.replace(/\s+/g, ' ').trim().slice(0, max);
13
+ }
14
+ function extractHtmlTitle(html) {
15
+ const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
16
+ if (!match?.[1])
17
+ return 'Web page';
18
+ return stripHtmlTags(match[1]).slice(0, 200) || 'Web page';
19
+ }
20
+ function extractReadableText(html) {
21
+ const withoutScripts = html
22
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
23
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
24
+ .replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
25
+ .replace(/<footer[\s\S]*?<\/footer>/gi, ' ');
26
+ return clampText(stripHtmlTags(withoutScripts), MAX_URL_FETCH_CHARS);
27
+ }
28
+ export class BrandVoiceSourceError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = 'BrandVoiceSourceError';
32
+ }
33
+ }
34
+ /** Fetch and extract readable text from a public URL (SSRF-safe, no redirects). */
35
+ export async function fetchBrandVoiceUrlSample(url) {
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(url);
39
+ }
40
+ catch {
41
+ throw new BrandVoiceSourceError(`Invalid URL: ${url}`);
42
+ }
43
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
44
+ throw new BrandVoiceSourceError('Only http and https URLs are supported.');
45
+ }
46
+ let response;
47
+ try {
48
+ response = await safeFetch(url, { method: 'GET', timeoutMs: 8000, followRedirects: false });
49
+ }
50
+ catch (err) {
51
+ if (err instanceof SsrfBlockedError) {
52
+ throw new BrandVoiceSourceError(`URL blocked for security: ${url}`);
53
+ }
54
+ throw new BrandVoiceSourceError(`Could not fetch ${url}`);
55
+ }
56
+ if (!response.ok) {
57
+ throw new BrandVoiceSourceError(`URL returned HTTP ${response.status}: ${url}`);
58
+ }
59
+ const contentType = (response.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
60
+ const buffer = Buffer.from(await response.arrayBuffer());
61
+ if (buffer.byteLength === 0) {
62
+ throw new BrandVoiceSourceError(`Empty response from ${url}`);
63
+ }
64
+ if (buffer.byteLength > MAX_URL_FETCH_BYTES) {
65
+ throw new BrandVoiceSourceError(`Response too large from ${url}`);
66
+ }
67
+ const raw = buffer.toString('utf-8');
68
+ let text;
69
+ let title;
70
+ if (contentType.includes('text/html') || raw.includes('<html')) {
71
+ title = extractHtmlTitle(raw);
72
+ text = extractReadableText(raw);
73
+ }
74
+ else if (contentType.startsWith('text/') || contentType.includes('markdown')) {
75
+ title = parsed.hostname;
76
+ text = clampText(raw, MAX_URL_FETCH_CHARS);
77
+ }
78
+ else {
79
+ throw new BrandVoiceSourceError(`Unsupported content type "${contentType || 'unknown'}" from ${url}`);
80
+ }
81
+ if (!text) {
82
+ throw new BrandVoiceSourceError(`No readable text found at ${url}`);
83
+ }
84
+ return {
85
+ collection: 'url',
86
+ documentId: url,
87
+ title,
88
+ text,
89
+ };
90
+ }
91
+ export function uploadToBrandVoiceSample(name, text) {
92
+ const trimmed = text.trim();
93
+ if (!trimmed) {
94
+ throw new BrandVoiceSourceError(`Upload "${name}" is empty.`);
95
+ }
96
+ if (trimmed.length > MAX_UPLOAD_TEXT_CHARS) {
97
+ throw new BrandVoiceSourceError(`Upload "${name}" exceeds ${MAX_UPLOAD_TEXT_CHARS.toLocaleString()} characters.`);
98
+ }
99
+ return {
100
+ collection: 'upload',
101
+ documentId: name.slice(0, 120),
102
+ title: name.slice(0, 200) || 'Uploaded document',
103
+ text: trimmed,
104
+ };
105
+ }
106
+ //# sourceMappingURL=brand-voice-sources.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brand-voice-sources.js","sourceRoot":"","sources":["../../src/ai/brand-voice-sources.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Structured brand voice rules — parsed from profile frontmatter and used for
3
+ * deterministic alignment scoring and the Phase 3 admin editors.
4
+ */
5
+ export type ContractionsRule = 'allow' | 'avoid' | 'require';
6
+ export type ExclamationRule = 'allow' | 'avoid' | 'forbid';
7
+ export type EmojiRule = 'allow' | 'sparingly' | 'forbid';
8
+ export type CaseRule = 'sentence' | 'title';
9
+ export type NumbersRule = 'spell_all' | 'numerals_all' | 'numerals_for_10_plus';
10
+ export type SpellingRule = 'us' | 'uk';
11
+ export interface BrandVoiceToneScales {
12
+ formalCasual: number;
13
+ seriousPlayful: number;
14
+ conciseDetailed: number;
15
+ reservedBold: number;
16
+ readingLevel: number;
17
+ }
18
+ export interface BrandVoiceMechanics {
19
+ oxfordComma: boolean;
20
+ contractions: ContractionsRule;
21
+ exclamationPoints: ExclamationRule;
22
+ emoji: EmojiRule;
23
+ caseHeadlines: CaseRule;
24
+ caseButtons: CaseRule;
25
+ numbers: NumbersRule;
26
+ serialSpelling: SpellingRule;
27
+ }
28
+ export interface BrandVoiceBannedTerm {
29
+ term: string;
30
+ useInstead: string;
31
+ }
32
+ export interface BrandVoiceChannelRule {
33
+ id: string;
34
+ label: string;
35
+ maxChars?: number;
36
+ maxHeadlineChars?: number;
37
+ maxDescriptionChars?: number;
38
+ note?: string;
39
+ }
40
+ export interface BrandVoiceWorkedExample {
41
+ offBrand: string;
42
+ onBrand: string;
43
+ why: string;
44
+ }
45
+ export interface BrandVoiceStructuredProfile {
46
+ tone: BrandVoiceToneScales;
47
+ mechanics: BrandVoiceMechanics;
48
+ bannedTerms: BrandVoiceBannedTerm[];
49
+ preferredTerms: string[];
50
+ avoidPatterns: string[];
51
+ channels: BrandVoiceChannelRule[];
52
+ workedExamples: BrandVoiceWorkedExample[];
53
+ }
54
+ export interface BrandAlignmentIssue {
55
+ id: string;
56
+ severity: 'error' | 'warning' | 'info';
57
+ message: string;
58
+ suggestion?: string;
59
+ }
60
+ export interface BrandAlignmentDimension {
61
+ name: string;
62
+ score: number;
63
+ feedback: string;
64
+ }
65
+ export interface BrandAlignmentScoreResult {
66
+ score: number;
67
+ channel?: string;
68
+ issues: BrandAlignmentIssue[];
69
+ dimensions: BrandAlignmentDimension[];
70
+ }
71
+ export declare const BRAND_VOICE_CHANNEL_PRESETS: Array<{
72
+ id: string;
73
+ label: string;
74
+ }>;
75
+ declare const MAX_WORKED_EXAMPLES = 10;
76
+ declare const MAX_BANNED_TERMS = 40;
77
+ declare const MAX_PREFERRED_TERMS = 40;
78
+ declare const MAX_AVOID_PATTERNS = 20;
79
+ export declare function defaultStructuredProfile(): BrandVoiceStructuredProfile;
80
+ /** Parse machine-readable rules from a profile markdown document. */
81
+ export declare function parseStructuredProfile(document: string): BrandVoiceStructuredProfile;
82
+ /** Apply structured rule edits back into the profile markdown document. */
83
+ export declare function applyStructuredProfileToDocument(document: string, structured: BrandVoiceStructuredProfile): string;
84
+ /** Deterministic alignment score using frontmatter rules. */
85
+ export declare function scoreBrandAlignment(content: string, structured: BrandVoiceStructuredProfile, channelId?: string): BrandAlignmentScoreResult;
86
+ export { MAX_BANNED_TERMS, MAX_PREFERRED_TERMS, MAX_AVOID_PATTERNS, MAX_WORKED_EXAMPLES };
87
+ //# sourceMappingURL=brand-voice-structured.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"brand-voice-structured.d.ts","sourceRoot":"","sources":["../../src/ai/brand-voice-structured.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,438 @@
1
+ /**
2
+ * Structured brand voice rules — parsed from profile frontmatter and used for
3
+ * deterministic alignment scoring and the Phase 3 admin editors.
4
+ */
5
+ import { splitBrandVoiceDocument } from './brand-voice-document.js';
6
+ export const BRAND_VOICE_CHANNEL_PRESETS = [
7
+ { id: 'meta_description', label: 'Meta description' },
8
+ { id: 'blog', label: 'Blog post' },
9
+ { id: 'google_ads', label: 'Google Ads' },
10
+ ];
11
+ const FRONTMATTER_SECTION_END = '(?=^[^\\s#].|^#|\\Z)';
12
+ const MAX_WORKED_EXAMPLES = 10;
13
+ const MAX_BANNED_TERMS = 40;
14
+ const MAX_PREFERRED_TERMS = 40;
15
+ const MAX_AVOID_PATTERNS = 20;
16
+ function extractSection(frontmatter, section) {
17
+ const re = new RegExp(`^${section}:\\s*\\n([\\s\\S]*?)${FRONTMATTER_SECTION_END}`, 'm');
18
+ return re.exec(frontmatter)?.[1]?.trim() ?? '';
19
+ }
20
+ function readScale(section, key, fallback) {
21
+ const match = new RegExp(`^\\s*${key}:\\s*(\\d+)`, 'm').exec(section);
22
+ if (!match)
23
+ return fallback;
24
+ const n = Number.parseInt(match[1], 10);
25
+ return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : fallback;
26
+ }
27
+ function readBool(section, key, fallback) {
28
+ const match = new RegExp(`^\\s*${key}:\\s*(true|false)`, 'm').exec(section);
29
+ if (!match)
30
+ return fallback;
31
+ return match[1] === 'true';
32
+ }
33
+ function readEnum(section, key, allowed, fallback) {
34
+ const match = new RegExp(`^\\s*${key}:\\s*(\\S+)`, 'm').exec(section);
35
+ if (!match)
36
+ return fallback;
37
+ const value = match[1];
38
+ return allowed.includes(value) ? value : fallback;
39
+ }
40
+ function parseBannedTerms(vocabularySection) {
41
+ const terms = [];
42
+ const blocks = vocabularySection.split(/\n\s*-\s*term:/).slice(1);
43
+ for (const block of blocks) {
44
+ const termMatch = /^\s*"?([^"\n]+)"?/.exec(block);
45
+ const useMatch = /use_instead:\s*"?([^"\n#]*)"?/.exec(block);
46
+ if (!termMatch)
47
+ continue;
48
+ terms.push({
49
+ term: termMatch[1].trim(),
50
+ useInstead: (useMatch?.[1] ?? '').trim(),
51
+ });
52
+ }
53
+ return terms.slice(0, MAX_BANNED_TERMS);
54
+ }
55
+ function parseSimpleList(section, key) {
56
+ const block = new RegExp(`^\\s*${key}:\\s*\\n((?:\\s+-\\s+.+\n?)*)`, 'm').exec(section)?.[1] ?? '';
57
+ return [...block.matchAll(/^\s+-\s+(.+)\s*$/gm)]
58
+ .map((m) => m[1].replace(/^["']|["']$/g, '').trim())
59
+ .filter(Boolean)
60
+ .slice(0, key === 'preferred' ? MAX_PREFERRED_TERMS : MAX_AVOID_PATTERNS);
61
+ }
62
+ function parseChannels(channelsSection) {
63
+ const rules = [];
64
+ const chunks = channelsSection.split(/\n(?=[a-z][a-z0-9_]*:)/);
65
+ for (const chunk of chunks) {
66
+ const idMatch = /^([a-z][a-z0-9_]*):/m.exec(chunk);
67
+ if (!idMatch)
68
+ continue;
69
+ const id = idMatch[1];
70
+ const preset = BRAND_VOICE_CHANNEL_PRESETS.find((p) => p.id === id);
71
+ const readNum = (key) => {
72
+ const m = new RegExp(`^\\s*${key}:\\s*(\\d+)`, 'm').exec(chunk);
73
+ return m ? Number.parseInt(m[1], 10) : undefined;
74
+ };
75
+ const noteMatch = /^\s*note:\s*"?([^"\n]+)"?/m.exec(chunk);
76
+ rules.push({
77
+ id,
78
+ label: preset?.label ?? id.replace(/_/g, ' '),
79
+ maxChars: readNum('max_chars'),
80
+ maxHeadlineChars: readNum('max_headline_chars'),
81
+ maxDescriptionChars: readNum('max_description_chars'),
82
+ note: noteMatch?.[1]?.trim(),
83
+ });
84
+ }
85
+ return rules;
86
+ }
87
+ function parseWorkedExamples(body) {
88
+ const sectionRe = /^##\s+Worked examples\s*$[\s\S]*?(?=^##\s+|\Z)/im;
89
+ const section = sectionRe.exec(body)?.[0] ?? '';
90
+ const rows = section
91
+ .split('\n')
92
+ .map((line) => line.trim())
93
+ .filter((line) => line.startsWith('|') && !line.includes('Off-brand') && !/^\|[-| ]+\|$/.test(line));
94
+ const examples = [];
95
+ for (const row of rows) {
96
+ const cells = row
97
+ .split('|')
98
+ .map((c) => c.trim())
99
+ .filter(Boolean);
100
+ if (cells.length < 3)
101
+ continue;
102
+ examples.push({
103
+ offBrand: cells[0].replace(/^["']|["']$/g, ''),
104
+ onBrand: cells[1].replace(/^["']|["']$/g, ''),
105
+ why: cells[2].replace(/^["']|["']$/g, ''),
106
+ });
107
+ }
108
+ return examples.slice(0, MAX_WORKED_EXAMPLES);
109
+ }
110
+ export function defaultStructuredProfile() {
111
+ return {
112
+ tone: {
113
+ formalCasual: 50,
114
+ seriousPlayful: 50,
115
+ conciseDetailed: 50,
116
+ reservedBold: 50,
117
+ readingLevel: 8,
118
+ },
119
+ mechanics: {
120
+ oxfordComma: true,
121
+ contractions: 'allow',
122
+ exclamationPoints: 'avoid',
123
+ emoji: 'forbid',
124
+ caseHeadlines: 'sentence',
125
+ caseButtons: 'sentence',
126
+ numbers: 'numerals_for_10_plus',
127
+ serialSpelling: 'us',
128
+ },
129
+ bannedTerms: [],
130
+ preferredTerms: [],
131
+ avoidPatterns: [],
132
+ channels: BRAND_VOICE_CHANNEL_PRESETS.map((preset) => ({
133
+ id: preset.id,
134
+ label: preset.label,
135
+ ...(preset.id === 'meta_description' ? { maxChars: 160 } : {}),
136
+ ...(preset.id === 'google_ads' ? { maxHeadlineChars: 30, maxDescriptionChars: 90 } : {}),
137
+ ...(preset.id === 'blog' ? { note: 'authoritative; still no hype words' } : {}),
138
+ ...(preset.id === 'meta_description'
139
+ ? { note: 'lead with the benefit; one clear action' }
140
+ : {}),
141
+ })),
142
+ workedExamples: [],
143
+ };
144
+ }
145
+ /** Parse machine-readable rules from a profile markdown document. */
146
+ export function parseStructuredProfile(document) {
147
+ const defaults = defaultStructuredProfile();
148
+ const split = splitBrandVoiceDocument(document.trim());
149
+ if (!split)
150
+ return defaults;
151
+ const toneSection = extractSection(split.frontmatter, 'tone');
152
+ const mechanicsSection = extractSection(split.frontmatter, 'mechanics');
153
+ const vocabularySection = extractSection(split.frontmatter, 'vocabulary');
154
+ const channelsSection = extractSection(split.frontmatter, 'channels');
155
+ const parsedChannels = parseChannels(channelsSection);
156
+ const channels = parsedChannels.length > 0 ? parsedChannels : defaults.channels;
157
+ return {
158
+ tone: {
159
+ formalCasual: readScale(toneSection, 'formal_casual', defaults.tone.formalCasual),
160
+ seriousPlayful: readScale(toneSection, 'serious_playful', defaults.tone.seriousPlayful),
161
+ conciseDetailed: readScale(toneSection, 'concise_detailed', defaults.tone.conciseDetailed),
162
+ reservedBold: readScale(toneSection, 'reserved_bold', defaults.tone.reservedBold),
163
+ readingLevel: readScale(toneSection, 'reading_level', defaults.tone.readingLevel),
164
+ },
165
+ mechanics: {
166
+ oxfordComma: readBool(mechanicsSection, 'oxford_comma', defaults.mechanics.oxfordComma),
167
+ contractions: readEnum(mechanicsSection, 'contractions', ['allow', 'avoid', 'require'], defaults.mechanics.contractions),
168
+ exclamationPoints: readEnum(mechanicsSection, 'exclamation_points', ['allow', 'avoid', 'forbid'], defaults.mechanics.exclamationPoints),
169
+ emoji: readEnum(mechanicsSection, 'emoji', ['allow', 'sparingly', 'forbid'], defaults.mechanics.emoji),
170
+ caseHeadlines: readEnum(mechanicsSection, 'case_headlines', ['sentence', 'title'], defaults.mechanics.caseHeadlines),
171
+ caseButtons: readEnum(mechanicsSection, 'case_buttons', ['sentence', 'title'], defaults.mechanics.caseButtons),
172
+ numbers: readEnum(mechanicsSection, 'numbers', ['spell_all', 'numerals_all', 'numerals_for_10_plus'], defaults.mechanics.numbers),
173
+ serialSpelling: readEnum(mechanicsSection, 'serial_spelling', ['us', 'uk'], defaults.mechanics.serialSpelling),
174
+ },
175
+ bannedTerms: parseBannedTerms(vocabularySection),
176
+ preferredTerms: parseSimpleList(vocabularySection, 'preferred'),
177
+ avoidPatterns: parseSimpleList(vocabularySection, 'avoid_patterns'),
178
+ channels,
179
+ workedExamples: parseWorkedExamples(split.body),
180
+ };
181
+ }
182
+ function escapeYaml(value) {
183
+ if (/[:#{}[\],&*?|>-]/.test(value) || value.includes('"')) {
184
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
185
+ }
186
+ return value.includes('\n') ? `"${value.replace(/"/g, '\\"')}"` : value;
187
+ }
188
+ function renderToneSection(tone) {
189
+ return `tone:
190
+ formal_casual: ${tone.formalCasual}
191
+ serious_playful: ${tone.seriousPlayful}
192
+ concise_detailed: ${tone.conciseDetailed}
193
+ reserved_bold: ${tone.reservedBold}
194
+ reading_level: ${tone.readingLevel}`;
195
+ }
196
+ function renderMechanicsSection(mechanics) {
197
+ return `mechanics:
198
+ oxford_comma: ${mechanics.oxfordComma}
199
+ contractions: ${mechanics.contractions}
200
+ exclamation_points: ${mechanics.exclamationPoints}
201
+ emoji: ${mechanics.emoji}
202
+ case_headlines: ${mechanics.caseHeadlines}
203
+ case_buttons: ${mechanics.caseButtons}
204
+ numbers: ${mechanics.numbers}
205
+ serial_spelling: ${mechanics.serialSpelling}`;
206
+ }
207
+ function renderVocabularySection(structured) {
208
+ const banned = structured.bannedTerms.length === 0
209
+ ? ' banned: []'
210
+ : ` banned:\n${structured.bannedTerms
211
+ .map((row) => ` - term: ${escapeYaml(row.term)}\n use_instead: ${escapeYaml(row.useInstead)}`)
212
+ .join('\n')}`;
213
+ const preferred = structured.preferredTerms.length === 0
214
+ ? ' preferred: []'
215
+ : ` preferred:\n${structured.preferredTerms.map((t) => ` - ${escapeYaml(t)}`).join('\n')}`;
216
+ const avoid = structured.avoidPatterns.length === 0
217
+ ? ' avoid_patterns: []'
218
+ : ` avoid_patterns:\n${structured.avoidPatterns.map((t) => ` - ${escapeYaml(t)}`).join('\n')}`;
219
+ return `vocabulary:\n${banned}\n${preferred}\n${avoid}`;
220
+ }
221
+ function renderChannelsSection(channels) {
222
+ if (channels.length === 0)
223
+ return 'channels: {}';
224
+ const lines = channels.map((channel) => {
225
+ const props = [` ${channel.id}:`];
226
+ if (typeof channel.maxChars === 'number')
227
+ props.push(` max_chars: ${channel.maxChars}`);
228
+ if (typeof channel.maxHeadlineChars === 'number') {
229
+ props.push(` max_headline_chars: ${channel.maxHeadlineChars}`);
230
+ }
231
+ if (typeof channel.maxDescriptionChars === 'number') {
232
+ props.push(` max_description_chars: ${channel.maxDescriptionChars}`);
233
+ }
234
+ if (channel.note?.trim())
235
+ props.push(` note: ${escapeYaml(channel.note.trim())}`);
236
+ return props.join('\n');
237
+ });
238
+ return `channels:\n${lines.join('\n')}`;
239
+ }
240
+ function renderWorkedExamplesSection(examples) {
241
+ const rows = examples.length === 0
242
+ ? '| _Off-brand example_ | _On-brand rewrite_ | _Why the change helps._ |'
243
+ : examples.map((ex) => `| ${ex.offBrand} | ${ex.onBrand} | ${ex.why} |`).join('\n');
244
+ return `## Worked examples
245
+ | Off-brand | On-brand | Why |
246
+ |---|---|---|
247
+ ${rows}
248
+ `;
249
+ }
250
+ function replaceFrontmatterSection(frontmatter, section, content) {
251
+ const re = new RegExp(`^${section}:[\\s\\S]*?(?=${FRONTMATTER_SECTION_END})`, 'm');
252
+ if (re.test(frontmatter))
253
+ return frontmatter.replace(re, content);
254
+ return `${frontmatter.trim()}\n\n${content}`;
255
+ }
256
+ function replaceBodySection(body, heading, content) {
257
+ const re = new RegExp(`^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?(?=^##\\s+|\\Z)`, 'im');
258
+ if (re.test(body))
259
+ return body.replace(re, `${content.trim()}\n`);
260
+ return `${body.trim()}\n\n${content.trim()}`;
261
+ }
262
+ /** Apply structured rule edits back into the profile markdown document. */
263
+ export function applyStructuredProfileToDocument(document, structured) {
264
+ const split = splitBrandVoiceDocument(document.trim());
265
+ if (!split)
266
+ return document;
267
+ let frontmatter = split.frontmatter;
268
+ frontmatter = replaceFrontmatterSection(frontmatter, 'tone', renderToneSection(structured.tone));
269
+ frontmatter = replaceFrontmatterSection(frontmatter, 'mechanics', renderMechanicsSection(structured.mechanics));
270
+ frontmatter = replaceFrontmatterSection(frontmatter, 'vocabulary', renderVocabularySection(structured));
271
+ frontmatter = replaceFrontmatterSection(frontmatter, 'channels', renderChannelsSection(structured.channels));
272
+ let body = replaceBodySection(split.body, 'Worked examples', renderWorkedExamplesSection(structured.workedExamples));
273
+ return `---\n${frontmatter.trim()}\n---\n\n${body.trim()}`;
274
+ }
275
+ const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u;
276
+ const CONTRACTION_RE = /\b(?:can't|won't|don't|it's|we're|you're|they're|i'm|you'll|we'll|isn't|aren't|wasn't|weren't|haven't|hasn't|didn't|doesn't|wouldn't|couldn't|shouldn't)\b/i;
277
+ const SUPERLATIVE_RE = /\b(best|worst|greatest|revolutionary|amazing|incredible|unbelievable|world[- ]class|leading)\b/gi;
278
+ function clampScore(score) {
279
+ return Math.max(0, Math.min(100, Math.round(score)));
280
+ }
281
+ /** Deterministic alignment score using frontmatter rules. */
282
+ export function scoreBrandAlignment(content, structured, channelId) {
283
+ const text = content.trim();
284
+ const lower = text.toLowerCase();
285
+ const issues = [];
286
+ const dimensions = [];
287
+ let score = 100;
288
+ for (const banned of structured.bannedTerms) {
289
+ if (!banned.term.trim())
290
+ continue;
291
+ const term = banned.term.trim();
292
+ if (!lower.includes(term.toLowerCase()))
293
+ continue;
294
+ score -= 15;
295
+ issues.push({
296
+ id: `banned-${term}`,
297
+ severity: 'error',
298
+ message: `Banned term "${term}" found.`,
299
+ suggestion: banned.useInstead
300
+ ? `Use "${banned.useInstead}" instead.`
301
+ : 'Remove this term entirely.',
302
+ });
303
+ }
304
+ const exclamations = (text.match(/!/g) ?? []).length;
305
+ if (exclamations > 0) {
306
+ if (structured.mechanics.exclamationPoints === 'forbid') {
307
+ score -= exclamations * 12;
308
+ issues.push({
309
+ id: 'exclamation-forbid',
310
+ severity: 'error',
311
+ message: `Exclamation point${exclamations === 1 ? '' : 's'} not allowed (${exclamations}).`,
312
+ suggestion: 'Remove exclamation points for this brand voice.',
313
+ });
314
+ }
315
+ else if (structured.mechanics.exclamationPoints === 'avoid') {
316
+ score -= exclamations * 6;
317
+ issues.push({
318
+ id: 'exclamation-avoid',
319
+ severity: 'warning',
320
+ message: `Exclamation point${exclamations === 1 ? '' : 's'} discouraged (${exclamations}).`,
321
+ });
322
+ }
323
+ }
324
+ if (structured.mechanics.emoji === 'forbid' && EMOJI_RE.test(text)) {
325
+ score -= 15;
326
+ issues.push({
327
+ id: 'emoji-forbid',
328
+ severity: 'error',
329
+ message: 'Emoji are not allowed.',
330
+ suggestion: 'Remove emoji from the copy.',
331
+ });
332
+ }
333
+ if (structured.mechanics.contractions === 'avoid' && CONTRACTION_RE.test(text)) {
334
+ score -= 8;
335
+ issues.push({
336
+ id: 'contractions-avoid',
337
+ severity: 'warning',
338
+ message: 'Contractions are discouraged for this brand voice.',
339
+ suggestion: 'Expand contractions (e.g. "do not" instead of "don\'t").',
340
+ });
341
+ }
342
+ for (const pattern of structured.avoidPatterns) {
343
+ if (!pattern.trim())
344
+ continue;
345
+ const normalized = pattern.toLowerCase();
346
+ if (normalized.includes('hype stacking') && (text.match(SUPERLATIVE_RE) ?? []).length >= 2) {
347
+ score -= 10;
348
+ issues.push({
349
+ id: 'avoid-hype-stacking',
350
+ severity: 'warning',
351
+ message: 'Multiple hype words detected in one piece.',
352
+ });
353
+ continue;
354
+ }
355
+ if (normalized.includes("in today's") && lower.includes("in today's")) {
356
+ score -= 10;
357
+ issues.push({
358
+ id: 'avoid-filler-opener',
359
+ severity: 'warning',
360
+ message: 'Filler opener detected ("In today\'s…").',
361
+ suggestion: 'Start with the point or benefit instead.',
362
+ });
363
+ continue;
364
+ }
365
+ if (normalized.includes('vague claims') && SUPERLATIVE_RE.test(text) && !/\d/.test(text)) {
366
+ score -= 8;
367
+ issues.push({
368
+ id: 'avoid-vague-claim',
369
+ severity: 'warning',
370
+ message: 'Strong claim without a supporting number or proof point.',
371
+ });
372
+ }
373
+ }
374
+ const preferredHits = structured.preferredTerms.filter((term) => lower.includes(term.toLowerCase()));
375
+ const preferredScore = structured.preferredTerms.length > 0
376
+ ? clampScore((preferredHits.length / structured.preferredTerms.length) * 100)
377
+ : 100;
378
+ dimensions.push({
379
+ name: 'Preferred vocabulary',
380
+ score: preferredScore,
381
+ feedback: preferredHits.length > 0
382
+ ? `Uses ${preferredHits.length} preferred term(s).`
383
+ : structured.preferredTerms.length > 0
384
+ ? 'Consider using more preferred brand terms.'
385
+ : 'No preferred terms configured.',
386
+ });
387
+ if (structured.preferredTerms.length > 0 && preferredHits.length === 0) {
388
+ issues.push({
389
+ id: 'preferred-missing',
390
+ severity: 'info',
391
+ message: 'No preferred brand terms detected.',
392
+ });
393
+ }
394
+ const channel = channelId ? structured.channels.find((c) => c.id === channelId) : undefined;
395
+ if (channel) {
396
+ const len = text.length;
397
+ const limit = channel.maxChars ?? channel.maxDescriptionChars;
398
+ if (typeof limit === 'number' && len > limit) {
399
+ score -= 20;
400
+ issues.push({
401
+ id: `channel-length-${channel.id}`,
402
+ severity: 'error',
403
+ message: `${channel.label} exceeds ${limit} characters (${len}).`,
404
+ suggestion: `Trim to ${limit} characters or fewer.`,
405
+ });
406
+ }
407
+ dimensions.push({
408
+ name: `${channel.label} length`,
409
+ score: typeof limit === 'number' ? clampScore(100 - Math.max(0, len - limit) * 2) : 100,
410
+ feedback: typeof limit === 'number'
411
+ ? `${len}/${limit} characters`
412
+ : 'No length limit configured for this channel.',
413
+ });
414
+ }
415
+ const bannedScore = structured.bannedTerms.length > 0
416
+ ? clampScore(100 - issues.filter((i) => i.id.startsWith('banned-')).length * 25)
417
+ : 100;
418
+ dimensions.push({
419
+ name: 'Banned vocabulary',
420
+ score: bannedScore,
421
+ feedback: bannedScore >= 100 ? 'No banned terms detected.' : 'Off-brand terms found in the copy.',
422
+ });
423
+ dimensions.push({
424
+ name: 'Mechanics',
425
+ score: clampScore(100 -
426
+ issues.filter((i) => ['exclamation-forbid', 'emoji-forbid'].includes(i.id)).length * 20 -
427
+ issues.filter((i) => i.severity === 'warning' && i.id.startsWith('exclamation')).length * 8),
428
+ feedback: 'Style mechanics (punctuation, emoji, contractions).',
429
+ });
430
+ return {
431
+ score: clampScore(score),
432
+ channel: channel?.id,
433
+ issues,
434
+ dimensions,
435
+ };
436
+ }
437
+ export { MAX_BANNED_TERMS, MAX_PREFERRED_TERMS, MAX_AVOID_PATTERNS, MAX_WORKED_EXAMPLES };
438
+ //# sourceMappingURL=brand-voice-structured.js.map