@sonordev/contracts 2.11.0 → 2.13.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 +15 -0
- package/README.md +57 -1
- package/dist/portfolio.d.ts +170 -0
- package/dist/portfolio.js +249 -0
- package/dist/portfolio.mjs +231 -1
- package/dist/publication-branding.d.ts +4 -1
- package/dist/publication-branding.js +4 -2
- package/dist/publication-branding.mjs +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.13.0
|
|
4
|
+
|
|
5
|
+
- Publication branding adds `editorial.focusClosingBackground`, with `planned_photo` and `article_atmosphere` choices. The setting inherits through publication and platform layers, and supports clearing an override. Article atmosphere closing frames reuse saved artwork and the real author portrait; other publications retain their existing behavior.
|
|
6
|
+
|
|
7
|
+
## 2.12.0
|
|
8
|
+
|
|
9
|
+
A minor release: `@sonordev/contracts/portfolio` now holds the structure of a case study's story. Every other entry is unchanged, and nothing already exported from `portfolio` changed.
|
|
10
|
+
|
|
11
|
+
- **The case study arc.** `CASE_STUDY_STAGES` names the four beats every case study reads in (`problem`, `build`, `proof`, `outcome`), `CASE_STUDY_STAGE_COPY` gives each a generic name and the question it answers, `PORTFOLIO_SECTION_TYPES` is every section type in canonical reading order, and `SECTION_STAGE` / `sectionStage` say which stage each belongs to. The section list used to be kept separately by the Sonor API and agency-site-kit; this is now the one copy.
|
|
12
|
+
- **Reading order.** `orderSections` always keeps the arc (hero, problem, build, proof, outcome, CTA) and keeps an agency's stored order only within a stage, so a reordered case study can no longer put its results above its challenges. `groupSectionsByStage` returns the beats a case study actually has, numbered in order. `PORTFOLIO_SECTION_ORDER` is derived from the list. Performance now reads before Results: the proof comes before the outcome.
|
|
13
|
+
- **Stage status.** `caseStudyStageStatus` reports all four stages, complete only with the stage's core section (`STAGE_CORE_SECTION`), with one sentence on what's missing.
|
|
14
|
+
- **Stage names.** A site can call the four stages what it likes ("Heat", "Shape", "Temper", "Finish"); `CaseStudyStageNames` is that map, `stageName(names, stage)` reads it with the generic name as the fallback, `stageNamesProblems` validates it and `STAGE_NAME_MAX_CHARS` is the length limit. The ids never change; only what people see does.
|
|
15
|
+
- **The narrative.** `CaseStudyNarrative` is a case study's thesis and one lede per stage, written for the project rather than borrowed from the house. `narrativeProblems` validates one, `NARRATIVE_MAX_CHARS` is the length limit and `CASE_STUDY_NARRATIVE_JSON_SCHEMA` the same rule as a schema. An absent narrative passes.
|
|
16
|
+
- **The thread.** A challenge carries an `id` (`challengeId(n)`), a phase `resolves` challenges and a result `proves` them; `caseStudyFlowProblems` names every challenge nothing resolves or proves and every dangling reference, and passes content written before the thread existed.
|
|
17
|
+
|
|
3
18
|
## 2.11.0
|
|
4
19
|
|
|
5
20
|
- Add `@sonordev/contracts/public-url-host`: the pure `isBlockedPublicUrlHost` predicate shared by Sonor and Signal, including private IPv4 and IPv6 ranges, mapped/compatible IPv4, and local hostname suffixes. It uses the standard URL parser without Node dependencies. Scoped IPv6 addresses are refused without throwing. Callers retain URL validation, DNS resolution/address pinning, and redirect protections.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ pnpm add @sonordev/contracts
|
|
|
25
25
|
| `@sonordev/contracts/slot-content` | What a slot of each kind may hold (`normalizeSlotValue`), safe links, the rich-text parser. No `crypto`, so browsers can import it | the Sonor API, site-kit, the dashboard editor |
|
|
26
26
|
| `@sonordev/contracts/site-cache` | The cache tags a site's Sonor fetches carry and the revalidation route's path, payload and ping | site-kit tags and serves the route, the Sonor API calls it |
|
|
27
27
|
| `@sonordev/contracts/site-edit` | Edit on page: the messages between a framed site and the dashboard, trusted origins, how a slot is found on the page | site-kit's overlay, the dashboard's visual editor |
|
|
28
|
-
| `@sonordev/contracts/portfolio` |
|
|
28
|
+
| `@sonordev/contracts/portfolio` | The case study arc: every section type, the stage each belongs to, the reading order, the narrative that opens each stage and the thread from a challenge to the phase that cleared it and the result that proves it. Also where a portfolio number comes from and which may headline, and the live-frame message | the Sonor API, Signal's case-study writer, the dashboard's editor, agency-site-kit |
|
|
29
29
|
| `@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 |
|
|
30
30
|
| `@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 |
|
|
31
31
|
| `@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 |
|
|
@@ -39,6 +39,15 @@ pnpm add @sonordev/contracts
|
|
|
39
39
|
| `@sonordev/contracts/publication-branding` | What a publication's branding may hold and what a change may set (`createPublicationBrandingValidator`), how a change merges, the editorial charter, and safe font stacks | the Sonor API stores and resolves it, Signal checks an agent's change before filing it, the dashboard edits it |
|
|
40
40
|
| `@sonordev/contracts/public-url-host` | Which literal hosts are refused as public fetch destinations (`isBlockedPublicUrlHost`) | the Sonor API and Signal's URL validators and network fetchers |
|
|
41
41
|
|
|
42
|
+
## Focus closing artwork
|
|
43
|
+
|
|
44
|
+
`publication-branding` accepts `editorial.focusClosingBackground` at publication
|
|
45
|
+
and platform levels. `planned_photo` keeps the planned closing image;
|
|
46
|
+
`article_atmosphere` reuses the article's saved atmosphere with its real author
|
|
47
|
+
portrait. Omitting the field keeps existing behavior. A patch value of `null`
|
|
48
|
+
clears the override and restores inheritance. The article-atmosphere mode requires
|
|
49
|
+
both saved sources; it doesn't invent a portrait or substitute unrelated artwork.
|
|
50
|
+
|
|
42
51
|
## Public URL hosts
|
|
43
52
|
|
|
44
53
|
`isBlockedPublicUrlHost(host)` shares the APIs' loopback, private IPv4,
|
|
@@ -801,6 +810,53 @@ The stored shape's version is `PUBLICATION_BRANDING_VERSION`. Resolving a
|
|
|
801
810
|
profile against a project's own colors and its measured site design stays in
|
|
802
811
|
the Sonor API.
|
|
803
812
|
|
|
813
|
+
## The portfolio contract: the case study arc
|
|
814
|
+
|
|
815
|
+
Every case study tells the same four-beat story, whatever an agency calls
|
|
816
|
+
the beats: what was wrong, what we built, how we proved it holds up, and
|
|
817
|
+
what it's doing for the client now. `@sonordev/contracts/portfolio` is
|
|
818
|
+
where that story's structure lives, once, for the generator that writes it,
|
|
819
|
+
the dashboard that edits it and every site that renders it.
|
|
820
|
+
|
|
821
|
+
- **`CASE_STUDY_STAGES`** is the four beats in order: `problem`, `build`,
|
|
822
|
+
`proof`, `outcome`. The ids are deliberately plain; an agency's renderer
|
|
823
|
+
maps them to its own words. `CASE_STUDY_STAGE_COPY` gives each a generic
|
|
824
|
+
name and the question its sections answer.
|
|
825
|
+
- **`PORTFOLIO_SECTION_TYPES`** is every section a case study can hold, in
|
|
826
|
+
canonical reading order: the hero, then each stage's sections, then the
|
|
827
|
+
SEO metadata and the closing CTA. `SECTION_STAGE` says which stage each
|
|
828
|
+
type belongs to (null for the hero, SEO and CTA, which sit outside the
|
|
829
|
+
story) and `sectionStage(type)` reads it for a string. Both are typed over
|
|
830
|
+
every section type, so a new type without a stage is a compile error.
|
|
831
|
+
- **`orderSections(sections, { sectionsOrdered })`** is the reading order.
|
|
832
|
+
The arc always wins: hero, problem, build, proof, outcome, CTA. Within a
|
|
833
|
+
stage the agency's stored order is kept once it has reordered in the
|
|
834
|
+
dashboard; otherwise the canonical order. **`groupSectionsByStage`**
|
|
835
|
+
returns the beats the case study actually has, numbered in order, each
|
|
836
|
+
with its sections; a stage with nothing in it is left out.
|
|
837
|
+
- **`caseStudyStageStatus(sections)`** reports all four stages, complete
|
|
838
|
+
only when the stage's core section (`STAGE_CORE_SECTION`: challenges,
|
|
839
|
+
strategy, performance, results) is present, with one sentence on what's
|
|
840
|
+
missing. The dashboard shows it beside the editor.
|
|
841
|
+
- **Stage names.** The ids are fixed; what people see isn't. A site names
|
|
842
|
+
the four stages in the dashboard ("Heat", "Shape", "Temper", "Finish"),
|
|
843
|
+
per project like the rest of its branding, and `CaseStudyStageNames`
|
|
844
|
+
carries that map to the editor, the public portfolio config and the site. `stageName(names, stage)`
|
|
845
|
+
reads it with the generic name as the fallback; `stageNamesProblems`
|
|
846
|
+
validates it and `STAGE_NAME_MAX_CHARS` caps each name.
|
|
847
|
+
- **`CaseStudyNarrative`** is what the story says above its sections: one
|
|
848
|
+
`thesis` and one lede per stage, written for this project. Signal writes
|
|
849
|
+
it from the evidence it was given and the agency edits it;
|
|
850
|
+
`narrativeProblems` validates one and `CASE_STUDY_NARRATIVE_JSON_SCHEMA`
|
|
851
|
+
is the same rule as a schema. An absent narrative passes, so renderers
|
|
852
|
+
fall back to house copy for older case studies.
|
|
853
|
+
- **The thread.** A challenge carries an `id` (`challengeId(n)` mints
|
|
854
|
+
"c1", "c2"…), a strategy phase says which challenges it `resolves`, a
|
|
855
|
+
result says which it `proves`. `caseStudyFlowProblems({ challenges,
|
|
856
|
+
phases, results })` names every challenge nothing resolves or proves and
|
|
857
|
+
every dangling reference. It judges links only where ids were written, so
|
|
858
|
+
content from before the thread passes.
|
|
859
|
+
|
|
804
860
|
## Why it exists
|
|
805
861
|
|
|
806
862
|
A rule that lives in several copies is how two of them end up disagreeing.
|
package/dist/portfolio.d.ts
CHANGED
|
@@ -26,6 +26,176 @@ export interface FrameReadyMessage {
|
|
|
26
26
|
export declare function isFrameReadyMessage(data: unknown): data is FrameReadyMessage;
|
|
27
27
|
/** Bump on breaking changes to the collapse policy. */
|
|
28
28
|
export declare const PORTFOLIO_CONTRACT_VERSION: 1;
|
|
29
|
+
/** The four beats, in reading order. */
|
|
30
|
+
export declare const CASE_STUDY_STAGES: readonly ['problem', 'build', 'proof', 'outcome'];
|
|
31
|
+
export type CaseStudyStage = (typeof CASE_STUDY_STAGES)[number];
|
|
32
|
+
/** The generic name of each beat and the question its sections answer. */
|
|
33
|
+
export declare const CASE_STUDY_STAGE_COPY: Record<CaseStudyStage, {
|
|
34
|
+
name: string;
|
|
35
|
+
question: string;
|
|
36
|
+
}>;
|
|
37
|
+
/**
|
|
38
|
+
* A site's own names for the four stages ("Heat", "Shape", "Temper",
|
|
39
|
+
* "Finish"), set per project in the dashboard (branding is per project) and
|
|
40
|
+
* read everywhere a stage is named: the editor, the public portfolio config,
|
|
41
|
+
* the site itself. A stage left unnamed falls back to its generic name. The
|
|
42
|
+
* ids never change; only what people see does.
|
|
43
|
+
*/
|
|
44
|
+
export type CaseStudyStageNames = Partial<Record<CaseStudyStage, string>>;
|
|
45
|
+
/** The longest a stage name may run. It's a heading word, a rail label, a tab. */
|
|
46
|
+
export declare const STAGE_NAME_MAX_CHARS = 24;
|
|
47
|
+
/**
|
|
48
|
+
* What's wrong with a set of stage names, or an empty list. Absent passes
|
|
49
|
+
* (generic names); present must be an object of short text keyed by stage.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stageNamesProblems(names: unknown): string[];
|
|
52
|
+
/** The name a stage is shown under: the site's own when set, else the generic one. */
|
|
53
|
+
export declare function stageName(names: CaseStudyStageNames | null | undefined, stage: CaseStudyStage): string;
|
|
54
|
+
/**
|
|
55
|
+
* Every section type a case study can hold, in canonical reading order:
|
|
56
|
+
* hero, then each stage's sections, then the two that aren't rendered as
|
|
57
|
+
* sections of the story (SEO metadata, the closing CTA). This is THE list;
|
|
58
|
+
* the Sonor API and agency-site-kit import it rather than keeping their own.
|
|
59
|
+
*/
|
|
60
|
+
export declare const PORTFOLIO_SECTION_TYPES: readonly ['portfolioHero', 'portfolioChallenges', 'portfolioBeforeAfter', 'portfolioStrategy', 'portfolioSiteArchitecture', 'portfolioDesignSystem', 'portfolioFeatureSpotlight', 'portfolioGallery', 'portfolioVideo', 'portfolioTechStack', 'portfolioPerformance', 'portfolioSpeedComparison', 'portfolioResults', 'portfolioMetricsTimeline', 'portfolioConversionFunnel', 'portfolioServices', 'portfolioTeam', 'portfolioTestimonial', 'portfolioDetails', 'portfolioSeo', 'portfolioCTA'];
|
|
61
|
+
export type PortfolioSectionType = (typeof PORTFOLIO_SECTION_TYPES)[number];
|
|
62
|
+
export declare function isPortfolioSectionType(value: unknown): value is PortfolioSectionType;
|
|
63
|
+
/**
|
|
64
|
+
* Which stage each section belongs to. Null for the hero (it opens the page,
|
|
65
|
+
* above the story), SEO metadata (never rendered as a section) and the CTA
|
|
66
|
+
* (closes the page, after the story). Typed over every section type so a new
|
|
67
|
+
* type without a stage is a compile error, not a section that renders last.
|
|
68
|
+
*/
|
|
69
|
+
export declare const SECTION_STAGE: Record<PortfolioSectionType, CaseStudyStage | null>;
|
|
70
|
+
/** The stage a section type belongs to, or null when it sits outside the story. Unknown types are null. */
|
|
71
|
+
export declare function sectionStage(type: string): CaseStudyStage | null;
|
|
72
|
+
/**
|
|
73
|
+
* The one section each stage can't do without. A stage with other sections
|
|
74
|
+
* but not this one is told so by {@link caseStudyStageStatus}: a proof stage
|
|
75
|
+
* that's only a speed comparison has no measurement of the finished build.
|
|
76
|
+
*/
|
|
77
|
+
export declare const STAGE_CORE_SECTION: Record<CaseStudyStage, PortfolioSectionType>;
|
|
78
|
+
/**
|
|
79
|
+
* Canonical display order, derived from the list above so it can't disagree
|
|
80
|
+
* with the stages. Renderers sort by this when nobody has reordered; the
|
|
81
|
+
* dashboard's stored order only ever moves a section within its stage.
|
|
82
|
+
*/
|
|
83
|
+
export declare const PORTFOLIO_SECTION_ORDER: Record<PortfolioSectionType, number>;
|
|
84
|
+
/** The least a section needs for the ordering helpers: its type. Anything else rides along. */
|
|
85
|
+
export interface SectionLike {
|
|
86
|
+
sectionType: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The sections in the order the case study reads.
|
|
90
|
+
*
|
|
91
|
+
* The arc always wins: the hero, then problem, build, proof, outcome, then
|
|
92
|
+
* the CTA. Within a stage, the agency's own order is kept once it has
|
|
93
|
+
* reordered in the dashboard (`sectionsOrdered`); otherwise the canonical
|
|
94
|
+
* order. Before this, a reordered case study rendered in exactly its stored
|
|
95
|
+
* order, so Results could sit above Challenges and the page read backwards.
|
|
96
|
+
* Never mutates.
|
|
97
|
+
*/
|
|
98
|
+
export declare function orderSections<T extends SectionLike>(sections: readonly T[] | null | undefined, options?: {
|
|
99
|
+
sectionsOrdered?: boolean;
|
|
100
|
+
}): T[];
|
|
101
|
+
/** One beat of the story with the sections that tell it. */
|
|
102
|
+
export interface CaseStudyStageGroup<T extends SectionLike = SectionLike> {
|
|
103
|
+
stage: CaseStudyStage;
|
|
104
|
+
/** 1-based position among the stages this case study actually has. */
|
|
105
|
+
index: number;
|
|
106
|
+
sections: T[];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The story's beats, in order, each with its sections in reading order
|
|
110
|
+
* ({@link orderSections}). A stage with no sections is left out and the
|
|
111
|
+
* numbering follows, so a software case study with nothing measured runs
|
|
112
|
+
* problem, build, outcome as 1, 2, 3. The hero, SEO and CTA aren't in any
|
|
113
|
+
* group; a renderer places them itself.
|
|
114
|
+
*/
|
|
115
|
+
export declare function groupSectionsByStage<T extends SectionLike>(sections: readonly T[] | null | undefined, options?: {
|
|
116
|
+
sectionsOrdered?: boolean;
|
|
117
|
+
}): CaseStudyStageGroup<T>[];
|
|
118
|
+
/** What each stage of a case study has and what it's missing. */
|
|
119
|
+
export interface CaseStudyStageStatus {
|
|
120
|
+
stage: CaseStudyStage;
|
|
121
|
+
/** The section types present, in reading order. */
|
|
122
|
+
present: PortfolioSectionType[];
|
|
123
|
+
/** True when the stage's core section ({@link STAGE_CORE_SECTION}) is present. */
|
|
124
|
+
complete: boolean;
|
|
125
|
+
/** Null when complete; otherwise one sentence on what the stage is missing. */
|
|
126
|
+
missing: string | null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Each stage's status, all four, in order, whether or not the case study has
|
|
130
|
+
* sections in it. The dashboard shows these beside the editor so an empty
|
|
131
|
+
* proof stage is seen before the case study is published, not after.
|
|
132
|
+
*/
|
|
133
|
+
export declare function caseStudyStageStatus(sections: readonly SectionLike[] | null | undefined): CaseStudyStageStatus[];
|
|
134
|
+
export interface CaseStudyNarrative {
|
|
135
|
+
/** One sentence: what this project was, in the end. */
|
|
136
|
+
thesis: string;
|
|
137
|
+
/** One or two sentences opening each stage, specific to this project. A stage may be left unwritten. */
|
|
138
|
+
stages: Partial<Record<CaseStudyStage, string>>;
|
|
139
|
+
}
|
|
140
|
+
/** The longest a thesis or a stage lede may run. Longer is a paragraph, and the sections are the paragraphs. */
|
|
141
|
+
export declare const NARRATIVE_MAX_CHARS = 320;
|
|
142
|
+
/**
|
|
143
|
+
* What's wrong with a narrative, or an empty list when nothing is. An absent
|
|
144
|
+
* narrative is legacy data and passes (renderers fall back to house copy);
|
|
145
|
+
* one that's present must be prose, under the length, with no unknown stage.
|
|
146
|
+
*/
|
|
147
|
+
export declare function narrativeProblems(narrative: unknown): string[];
|
|
148
|
+
/** Agent-native contract: JSON Schema for {@link CaseStudyNarrative}. */
|
|
149
|
+
export declare const CASE_STUDY_NARRATIVE_JSON_SCHEMA: {
|
|
150
|
+
readonly $schema: 'http://json-schema.org/draft-07/schema#';
|
|
151
|
+
readonly $id: 'https://sonor.io/schemas/case-study-narrative.v1.json';
|
|
152
|
+
readonly title: 'CaseStudyNarrative';
|
|
153
|
+
readonly description: 'The thesis of a case study and the lede that opens each of its four stages.';
|
|
154
|
+
readonly type: 'object';
|
|
155
|
+
readonly required: readonly ['thesis'];
|
|
156
|
+
readonly properties: {
|
|
157
|
+
readonly thesis: {
|
|
158
|
+
readonly type: 'string';
|
|
159
|
+
readonly minLength: 1;
|
|
160
|
+
readonly maxLength: 320;
|
|
161
|
+
};
|
|
162
|
+
readonly stages: {
|
|
163
|
+
readonly type: 'object';
|
|
164
|
+
readonly properties: {
|
|
165
|
+
[k: string]: {
|
|
166
|
+
type: string;
|
|
167
|
+
maxLength: number;
|
|
168
|
+
};
|
|
169
|
+
};
|
|
170
|
+
readonly additionalProperties: false;
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
readonly additionalProperties: false;
|
|
174
|
+
};
|
|
175
|
+
export interface ChallengeLike {
|
|
176
|
+
id?: string | null;
|
|
177
|
+
title?: string;
|
|
178
|
+
}
|
|
179
|
+
export interface PhaseLike {
|
|
180
|
+
resolves?: readonly string[] | null;
|
|
181
|
+
}
|
|
182
|
+
export interface ResultLike {
|
|
183
|
+
proves?: readonly string[] | null;
|
|
184
|
+
}
|
|
185
|
+
/** A stable id for the n-th challenge (1-based): "c1", "c2"… Writers that don't mint their own use this. */
|
|
186
|
+
export declare function challengeId(position: number): string;
|
|
187
|
+
/**
|
|
188
|
+
* The problems with how a case study's story hangs together, each one
|
|
189
|
+
* sentence, or an empty list. Read by the generator's audit and the
|
|
190
|
+
* dashboard. It judges links only where they've been written: a case study
|
|
191
|
+
* whose challenges carry no ids has nothing to check and passes, so legacy
|
|
192
|
+
* content isn't failed for predating the thread.
|
|
193
|
+
*/
|
|
194
|
+
export declare function caseStudyFlowProblems(input: {
|
|
195
|
+
challenges?: readonly ChallengeLike[] | null;
|
|
196
|
+
phases?: readonly PhaseLike[] | null;
|
|
197
|
+
results?: readonly ResultLike[] | null;
|
|
198
|
+
}): string[];
|
|
29
199
|
/** Minimal KPI shape the policy reads — callers pass their own richer tiles. */
|
|
30
200
|
export interface KpiLike {
|
|
31
201
|
label: string;
|
package/dist/portfolio.js
CHANGED
|
@@ -6,6 +6,236 @@ function isFrameReadyMessage(data) {
|
|
|
6
6
|
return !!data && typeof data === "object" && data.type === FRAME_READY_MESSAGE && data.v === 1;
|
|
7
7
|
}
|
|
8
8
|
var PORTFOLIO_CONTRACT_VERSION = 1;
|
|
9
|
+
var CASE_STUDY_STAGES = ["problem", "build", "proof", "outcome"];
|
|
10
|
+
var CASE_STUDY_STAGE_COPY = {
|
|
11
|
+
problem: { name: "The problem", question: "What did the client come in with, and what was in the way?" },
|
|
12
|
+
build: { name: "The build", question: "What did we plan and make, and out of what?" },
|
|
13
|
+
proof: { name: "The proof", question: "How does the finished build hold up when measured?" },
|
|
14
|
+
outcome: { name: "The outcome", question: "What is the work doing for the client now?" }
|
|
15
|
+
};
|
|
16
|
+
var STAGE_NAME_MAX_CHARS = 24;
|
|
17
|
+
function stageNamesProblems(names) {
|
|
18
|
+
if (names === void 0 || names === null) return [];
|
|
19
|
+
if (typeof names !== "object" || Array.isArray(names)) return ["stageNames must be an object"];
|
|
20
|
+
const problems = [];
|
|
21
|
+
for (const [stage, name] of Object.entries(names)) {
|
|
22
|
+
if (!CASE_STUDY_STAGES.includes(stage)) problems.push(`stageNames.${stage} is not a stage`);
|
|
23
|
+
else if (name === void 0 || name === null) continue;
|
|
24
|
+
else if (typeof name !== "string") problems.push(`stageNames.${stage} must be text`);
|
|
25
|
+
else if (name.trim().length > STAGE_NAME_MAX_CHARS)
|
|
26
|
+
problems.push(`stageNames.${stage} is over ${STAGE_NAME_MAX_CHARS} characters`);
|
|
27
|
+
}
|
|
28
|
+
return problems;
|
|
29
|
+
}
|
|
30
|
+
function stageName(names, stage) {
|
|
31
|
+
const own = names?.[stage]?.trim();
|
|
32
|
+
return own || CASE_STUDY_STAGE_COPY[stage].name;
|
|
33
|
+
}
|
|
34
|
+
var PORTFOLIO_SECTION_TYPES = [
|
|
35
|
+
"portfolioHero",
|
|
36
|
+
// problem
|
|
37
|
+
"portfolioChallenges",
|
|
38
|
+
"portfolioBeforeAfter",
|
|
39
|
+
// build
|
|
40
|
+
"portfolioStrategy",
|
|
41
|
+
"portfolioSiteArchitecture",
|
|
42
|
+
"portfolioDesignSystem",
|
|
43
|
+
"portfolioFeatureSpotlight",
|
|
44
|
+
"portfolioGallery",
|
|
45
|
+
"portfolioVideo",
|
|
46
|
+
"portfolioTechStack",
|
|
47
|
+
// proof
|
|
48
|
+
"portfolioPerformance",
|
|
49
|
+
"portfolioSpeedComparison",
|
|
50
|
+
// outcome
|
|
51
|
+
"portfolioResults",
|
|
52
|
+
"portfolioMetricsTimeline",
|
|
53
|
+
"portfolioConversionFunnel",
|
|
54
|
+
"portfolioServices",
|
|
55
|
+
"portfolioTeam",
|
|
56
|
+
"portfolioTestimonial",
|
|
57
|
+
"portfolioDetails",
|
|
58
|
+
// outside the story
|
|
59
|
+
"portfolioSeo",
|
|
60
|
+
"portfolioCTA"
|
|
61
|
+
];
|
|
62
|
+
function isPortfolioSectionType(value) {
|
|
63
|
+
return typeof value === "string" && PORTFOLIO_SECTION_TYPES.includes(value);
|
|
64
|
+
}
|
|
65
|
+
var SECTION_STAGE = {
|
|
66
|
+
portfolioHero: null,
|
|
67
|
+
portfolioChallenges: "problem",
|
|
68
|
+
portfolioBeforeAfter: "problem",
|
|
69
|
+
portfolioStrategy: "build",
|
|
70
|
+
portfolioSiteArchitecture: "build",
|
|
71
|
+
portfolioDesignSystem: "build",
|
|
72
|
+
portfolioFeatureSpotlight: "build",
|
|
73
|
+
portfolioGallery: "build",
|
|
74
|
+
portfolioVideo: "build",
|
|
75
|
+
portfolioTechStack: "build",
|
|
76
|
+
portfolioPerformance: "proof",
|
|
77
|
+
portfolioSpeedComparison: "proof",
|
|
78
|
+
portfolioResults: "outcome",
|
|
79
|
+
portfolioMetricsTimeline: "outcome",
|
|
80
|
+
portfolioConversionFunnel: "outcome",
|
|
81
|
+
portfolioServices: "outcome",
|
|
82
|
+
portfolioTeam: "outcome",
|
|
83
|
+
portfolioTestimonial: "outcome",
|
|
84
|
+
portfolioDetails: "outcome",
|
|
85
|
+
portfolioSeo: null,
|
|
86
|
+
portfolioCTA: null
|
|
87
|
+
};
|
|
88
|
+
function sectionStage(type) {
|
|
89
|
+
return isPortfolioSectionType(type) ? SECTION_STAGE[type] : null;
|
|
90
|
+
}
|
|
91
|
+
var STAGE_CORE_SECTION = {
|
|
92
|
+
problem: "portfolioChallenges",
|
|
93
|
+
build: "portfolioStrategy",
|
|
94
|
+
proof: "portfolioPerformance",
|
|
95
|
+
outcome: "portfolioResults"
|
|
96
|
+
};
|
|
97
|
+
var PORTFOLIO_SECTION_ORDER = Object.fromEntries(
|
|
98
|
+
PORTFOLIO_SECTION_TYPES.map((type, i) => [type, i])
|
|
99
|
+
);
|
|
100
|
+
var UNKNOWN_SECTION_ORDER = PORTFOLIO_SECTION_TYPES.length;
|
|
101
|
+
function sectionOrder(type) {
|
|
102
|
+
return isPortfolioSectionType(type) ? PORTFOLIO_SECTION_ORDER[type] : UNKNOWN_SECTION_ORDER;
|
|
103
|
+
}
|
|
104
|
+
function stageOrder(type) {
|
|
105
|
+
if (type === "portfolioHero") return -1;
|
|
106
|
+
const stage = sectionStage(type);
|
|
107
|
+
return stage ? CASE_STUDY_STAGES.indexOf(stage) : CASE_STUDY_STAGES.length;
|
|
108
|
+
}
|
|
109
|
+
function orderSections(sections, options = {}) {
|
|
110
|
+
const list = [...sections ?? []];
|
|
111
|
+
const byStage = list.map((section, index) => ({ section, index }));
|
|
112
|
+
byStage.sort((a, b) => {
|
|
113
|
+
const stage = stageOrder(a.section.sectionType) - stageOrder(b.section.sectionType);
|
|
114
|
+
if (stage !== 0) return stage;
|
|
115
|
+
if (options.sectionsOrdered) return a.index - b.index;
|
|
116
|
+
const order = sectionOrder(a.section.sectionType) - sectionOrder(b.section.sectionType);
|
|
117
|
+
return order !== 0 ? order : a.index - b.index;
|
|
118
|
+
});
|
|
119
|
+
return byStage.map((s) => s.section);
|
|
120
|
+
}
|
|
121
|
+
function groupSectionsByStage(sections, options = {}) {
|
|
122
|
+
const ordered = orderSections(sections, options);
|
|
123
|
+
const groups = [];
|
|
124
|
+
for (const stage of CASE_STUDY_STAGES) {
|
|
125
|
+
const own = ordered.filter((s) => sectionStage(s.sectionType) === stage);
|
|
126
|
+
if (own.length) groups.push({ stage, index: groups.length + 1, sections: own });
|
|
127
|
+
}
|
|
128
|
+
return groups;
|
|
129
|
+
}
|
|
130
|
+
var STAGE_MISSING_COPY = {
|
|
131
|
+
problem: {
|
|
132
|
+
empty: "Nothing says what the client came in with. Add the challenges.",
|
|
133
|
+
noCore: "The problem is pictured but not stated. Add the challenges."
|
|
134
|
+
},
|
|
135
|
+
build: {
|
|
136
|
+
empty: "Nothing says what was planned or built. Add the strategy.",
|
|
137
|
+
noCore: "The build is shown but the plan behind it is missing. Add the strategy."
|
|
138
|
+
},
|
|
139
|
+
proof: {
|
|
140
|
+
empty: "Nothing measures the finished build. Add a performance measurement.",
|
|
141
|
+
noCore: "The old site is compared but the finished build has no measurement of its own. Add a performance measurement."
|
|
142
|
+
},
|
|
143
|
+
outcome: {
|
|
144
|
+
empty: "Nothing says what the work is doing for the client. Add the results.",
|
|
145
|
+
noCore: "The outcome has supporting sections but no results. Add the results."
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
function caseStudyStageStatus(sections) {
|
|
149
|
+
const ordered = orderSections(sections);
|
|
150
|
+
return CASE_STUDY_STAGES.map((stage) => {
|
|
151
|
+
const present = ordered.map((s) => s.sectionType).filter((t) => isPortfolioSectionType(t) && SECTION_STAGE[t] === stage);
|
|
152
|
+
const complete = present.includes(STAGE_CORE_SECTION[stage]);
|
|
153
|
+
const copy = STAGE_MISSING_COPY[stage];
|
|
154
|
+
return {
|
|
155
|
+
stage,
|
|
156
|
+
present,
|
|
157
|
+
complete,
|
|
158
|
+
missing: complete ? null : present.length ? copy.noCore : copy.empty
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
var NARRATIVE_MAX_CHARS = 320;
|
|
163
|
+
function narrativeProblems(narrative) {
|
|
164
|
+
if (narrative === void 0 || narrative === null) return [];
|
|
165
|
+
if (typeof narrative !== "object") return ["narrative must be an object"];
|
|
166
|
+
const problems = [];
|
|
167
|
+
const n = narrative;
|
|
168
|
+
if (typeof n.thesis !== "string" || !n.thesis.trim()) problems.push("narrative.thesis is required");
|
|
169
|
+
else if (n.thesis.length > NARRATIVE_MAX_CHARS) problems.push(`narrative.thesis is over ${NARRATIVE_MAX_CHARS} characters`);
|
|
170
|
+
if (n.stages === void 0 || n.stages === null) return problems;
|
|
171
|
+
if (typeof n.stages !== "object") return [...problems, "narrative.stages must be an object"];
|
|
172
|
+
for (const [stage, lede] of Object.entries(n.stages)) {
|
|
173
|
+
if (!CASE_STUDY_STAGES.includes(stage)) {
|
|
174
|
+
problems.push(`narrative.stages.${stage} is not a stage (${CASE_STUDY_STAGES.join(", ")})`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (lede === void 0 || lede === null) continue;
|
|
178
|
+
if (typeof lede !== "string") problems.push(`narrative.stages.${stage} must be text`);
|
|
179
|
+
else if (lede.length > NARRATIVE_MAX_CHARS) problems.push(`narrative.stages.${stage} is over ${NARRATIVE_MAX_CHARS} characters`);
|
|
180
|
+
}
|
|
181
|
+
return problems;
|
|
182
|
+
}
|
|
183
|
+
var CASE_STUDY_NARRATIVE_JSON_SCHEMA = {
|
|
184
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
185
|
+
$id: "https://sonor.io/schemas/case-study-narrative.v1.json",
|
|
186
|
+
title: "CaseStudyNarrative",
|
|
187
|
+
description: "The thesis of a case study and the lede that opens each of its four stages.",
|
|
188
|
+
type: "object",
|
|
189
|
+
required: ["thesis"],
|
|
190
|
+
properties: {
|
|
191
|
+
thesis: { type: "string", minLength: 1, maxLength: NARRATIVE_MAX_CHARS },
|
|
192
|
+
stages: {
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: Object.fromEntries(
|
|
195
|
+
CASE_STUDY_STAGES.map((stage) => [stage, { type: "string", maxLength: NARRATIVE_MAX_CHARS }])
|
|
196
|
+
),
|
|
197
|
+
additionalProperties: false
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
additionalProperties: false
|
|
201
|
+
};
|
|
202
|
+
function challengeId(position) {
|
|
203
|
+
return `c${position}`;
|
|
204
|
+
}
|
|
205
|
+
function caseStudyFlowProblems(input) {
|
|
206
|
+
const challenges = input.challenges ?? [];
|
|
207
|
+
const ids = challenges.map((c) => typeof c.id === "string" && c.id.trim() ? c.id.trim() : null);
|
|
208
|
+
if (!ids.some(Boolean)) return [];
|
|
209
|
+
const problems = [];
|
|
210
|
+
const known = new Set(ids.filter((id) => !!id));
|
|
211
|
+
if (known.size !== ids.filter(Boolean).length) problems.push("two challenges share an id");
|
|
212
|
+
ids.forEach((id, i) => {
|
|
213
|
+
if (!id) problems.push(`challenge ${i + 1} has no id while the others do`);
|
|
214
|
+
});
|
|
215
|
+
const resolved = /* @__PURE__ */ new Set();
|
|
216
|
+
const proven = /* @__PURE__ */ new Set();
|
|
217
|
+
for (const phase of input.phases ?? []) {
|
|
218
|
+
for (const ref of phase.resolves ?? []) {
|
|
219
|
+
if (!known.has(ref)) problems.push(`a strategy phase resolves "${ref}", which is not a challenge`);
|
|
220
|
+
else resolved.add(ref);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
for (const result of input.results ?? []) {
|
|
224
|
+
for (const ref of result.proves ?? []) {
|
|
225
|
+
if (!known.has(ref)) problems.push(`a result proves "${ref}", which is not a challenge`);
|
|
226
|
+
else proven.add(ref);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const nameOf = (id) => {
|
|
230
|
+
const c = challenges.find((x) => x.id === id);
|
|
231
|
+
return c?.title?.trim() ? `"${c.title.trim()}"` : id;
|
|
232
|
+
};
|
|
233
|
+
for (const id of known) {
|
|
234
|
+
if (!resolved.has(id)) problems.push(`no strategy phase resolves challenge ${nameOf(id)}`);
|
|
235
|
+
if (!proven.has(id)) problems.push(`no result proves challenge ${nameOf(id)}`);
|
|
236
|
+
}
|
|
237
|
+
return problems;
|
|
238
|
+
}
|
|
9
239
|
var LIGHTHOUSE_RE = /lighthouse/i;
|
|
10
240
|
function isLighthouseKpi(kpi) {
|
|
11
241
|
return LIGHTHOUSE_RE.test(kpi.label || "");
|
|
@@ -113,17 +343,36 @@ function carryReportedMetrics(previous, next, keyOf) {
|
|
|
113
343
|
return [...reported, ...generated];
|
|
114
344
|
}
|
|
115
345
|
|
|
346
|
+
exports.CASE_STUDY_NARRATIVE_JSON_SCHEMA = CASE_STUDY_NARRATIVE_JSON_SCHEMA;
|
|
347
|
+
exports.CASE_STUDY_STAGES = CASE_STUDY_STAGES;
|
|
348
|
+
exports.CASE_STUDY_STAGE_COPY = CASE_STUDY_STAGE_COPY;
|
|
116
349
|
exports.FRAME_READY_MESSAGE = FRAME_READY_MESSAGE;
|
|
117
350
|
exports.METRIC_SOURCES = METRIC_SOURCES;
|
|
351
|
+
exports.NARRATIVE_MAX_CHARS = NARRATIVE_MAX_CHARS;
|
|
118
352
|
exports.PORTFOLIO_CONTRACT_VERSION = PORTFOLIO_CONTRACT_VERSION;
|
|
119
353
|
exports.PORTFOLIO_KPI_JSON_SCHEMA = PORTFOLIO_KPI_JSON_SCHEMA;
|
|
354
|
+
exports.PORTFOLIO_SECTION_ORDER = PORTFOLIO_SECTION_ORDER;
|
|
355
|
+
exports.PORTFOLIO_SECTION_TYPES = PORTFOLIO_SECTION_TYPES;
|
|
356
|
+
exports.SECTION_STAGE = SECTION_STAGE;
|
|
357
|
+
exports.STAGE_CORE_SECTION = STAGE_CORE_SECTION;
|
|
358
|
+
exports.STAGE_NAME_MAX_CHARS = STAGE_NAME_MAX_CHARS;
|
|
120
359
|
exports.carryReportedMetrics = carryReportedMetrics;
|
|
360
|
+
exports.caseStudyFlowProblems = caseStudyFlowProblems;
|
|
361
|
+
exports.caseStudyStageStatus = caseStudyStageStatus;
|
|
362
|
+
exports.challengeId = challengeId;
|
|
121
363
|
exports.curateHeroKpis = curateHeroKpis;
|
|
122
364
|
exports.formatAttribution = formatAttribution;
|
|
365
|
+
exports.groupSectionsByStage = groupSectionsByStage;
|
|
123
366
|
exports.isFrameReadyMessage = isFrameReadyMessage;
|
|
124
367
|
exports.isHeadlineMetric = isHeadlineMetric;
|
|
125
368
|
exports.isLighthouseKpi = isLighthouseKpi;
|
|
126
369
|
exports.isMetricSource = isMetricSource;
|
|
370
|
+
exports.isPortfolioSectionType = isPortfolioSectionType;
|
|
127
371
|
exports.metricSourceLabel = metricSourceLabel;
|
|
128
372
|
exports.metricSourceProblem = metricSourceProblem;
|
|
373
|
+
exports.narrativeProblems = narrativeProblems;
|
|
374
|
+
exports.orderSections = orderSections;
|
|
375
|
+
exports.sectionStage = sectionStage;
|
|
129
376
|
exports.shortAttribution = shortAttribution;
|
|
377
|
+
exports.stageName = stageName;
|
|
378
|
+
exports.stageNamesProblems = stageNamesProblems;
|
package/dist/portfolio.mjs
CHANGED
|
@@ -4,6 +4,236 @@ function isFrameReadyMessage(data) {
|
|
|
4
4
|
return !!data && typeof data === "object" && data.type === FRAME_READY_MESSAGE && data.v === 1;
|
|
5
5
|
}
|
|
6
6
|
var PORTFOLIO_CONTRACT_VERSION = 1;
|
|
7
|
+
var CASE_STUDY_STAGES = ["problem", "build", "proof", "outcome"];
|
|
8
|
+
var CASE_STUDY_STAGE_COPY = {
|
|
9
|
+
problem: { name: "The problem", question: "What did the client come in with, and what was in the way?" },
|
|
10
|
+
build: { name: "The build", question: "What did we plan and make, and out of what?" },
|
|
11
|
+
proof: { name: "The proof", question: "How does the finished build hold up when measured?" },
|
|
12
|
+
outcome: { name: "The outcome", question: "What is the work doing for the client now?" }
|
|
13
|
+
};
|
|
14
|
+
var STAGE_NAME_MAX_CHARS = 24;
|
|
15
|
+
function stageNamesProblems(names) {
|
|
16
|
+
if (names === void 0 || names === null) return [];
|
|
17
|
+
if (typeof names !== "object" || Array.isArray(names)) return ["stageNames must be an object"];
|
|
18
|
+
const problems = [];
|
|
19
|
+
for (const [stage, name] of Object.entries(names)) {
|
|
20
|
+
if (!CASE_STUDY_STAGES.includes(stage)) problems.push(`stageNames.${stage} is not a stage`);
|
|
21
|
+
else if (name === void 0 || name === null) continue;
|
|
22
|
+
else if (typeof name !== "string") problems.push(`stageNames.${stage} must be text`);
|
|
23
|
+
else if (name.trim().length > STAGE_NAME_MAX_CHARS)
|
|
24
|
+
problems.push(`stageNames.${stage} is over ${STAGE_NAME_MAX_CHARS} characters`);
|
|
25
|
+
}
|
|
26
|
+
return problems;
|
|
27
|
+
}
|
|
28
|
+
function stageName(names, stage) {
|
|
29
|
+
const own = names?.[stage]?.trim();
|
|
30
|
+
return own || CASE_STUDY_STAGE_COPY[stage].name;
|
|
31
|
+
}
|
|
32
|
+
var PORTFOLIO_SECTION_TYPES = [
|
|
33
|
+
"portfolioHero",
|
|
34
|
+
// problem
|
|
35
|
+
"portfolioChallenges",
|
|
36
|
+
"portfolioBeforeAfter",
|
|
37
|
+
// build
|
|
38
|
+
"portfolioStrategy",
|
|
39
|
+
"portfolioSiteArchitecture",
|
|
40
|
+
"portfolioDesignSystem",
|
|
41
|
+
"portfolioFeatureSpotlight",
|
|
42
|
+
"portfolioGallery",
|
|
43
|
+
"portfolioVideo",
|
|
44
|
+
"portfolioTechStack",
|
|
45
|
+
// proof
|
|
46
|
+
"portfolioPerformance",
|
|
47
|
+
"portfolioSpeedComparison",
|
|
48
|
+
// outcome
|
|
49
|
+
"portfolioResults",
|
|
50
|
+
"portfolioMetricsTimeline",
|
|
51
|
+
"portfolioConversionFunnel",
|
|
52
|
+
"portfolioServices",
|
|
53
|
+
"portfolioTeam",
|
|
54
|
+
"portfolioTestimonial",
|
|
55
|
+
"portfolioDetails",
|
|
56
|
+
// outside the story
|
|
57
|
+
"portfolioSeo",
|
|
58
|
+
"portfolioCTA"
|
|
59
|
+
];
|
|
60
|
+
function isPortfolioSectionType(value) {
|
|
61
|
+
return typeof value === "string" && PORTFOLIO_SECTION_TYPES.includes(value);
|
|
62
|
+
}
|
|
63
|
+
var SECTION_STAGE = {
|
|
64
|
+
portfolioHero: null,
|
|
65
|
+
portfolioChallenges: "problem",
|
|
66
|
+
portfolioBeforeAfter: "problem",
|
|
67
|
+
portfolioStrategy: "build",
|
|
68
|
+
portfolioSiteArchitecture: "build",
|
|
69
|
+
portfolioDesignSystem: "build",
|
|
70
|
+
portfolioFeatureSpotlight: "build",
|
|
71
|
+
portfolioGallery: "build",
|
|
72
|
+
portfolioVideo: "build",
|
|
73
|
+
portfolioTechStack: "build",
|
|
74
|
+
portfolioPerformance: "proof",
|
|
75
|
+
portfolioSpeedComparison: "proof",
|
|
76
|
+
portfolioResults: "outcome",
|
|
77
|
+
portfolioMetricsTimeline: "outcome",
|
|
78
|
+
portfolioConversionFunnel: "outcome",
|
|
79
|
+
portfolioServices: "outcome",
|
|
80
|
+
portfolioTeam: "outcome",
|
|
81
|
+
portfolioTestimonial: "outcome",
|
|
82
|
+
portfolioDetails: "outcome",
|
|
83
|
+
portfolioSeo: null,
|
|
84
|
+
portfolioCTA: null
|
|
85
|
+
};
|
|
86
|
+
function sectionStage(type) {
|
|
87
|
+
return isPortfolioSectionType(type) ? SECTION_STAGE[type] : null;
|
|
88
|
+
}
|
|
89
|
+
var STAGE_CORE_SECTION = {
|
|
90
|
+
problem: "portfolioChallenges",
|
|
91
|
+
build: "portfolioStrategy",
|
|
92
|
+
proof: "portfolioPerformance",
|
|
93
|
+
outcome: "portfolioResults"
|
|
94
|
+
};
|
|
95
|
+
var PORTFOLIO_SECTION_ORDER = Object.fromEntries(
|
|
96
|
+
PORTFOLIO_SECTION_TYPES.map((type, i) => [type, i])
|
|
97
|
+
);
|
|
98
|
+
var UNKNOWN_SECTION_ORDER = PORTFOLIO_SECTION_TYPES.length;
|
|
99
|
+
function sectionOrder(type) {
|
|
100
|
+
return isPortfolioSectionType(type) ? PORTFOLIO_SECTION_ORDER[type] : UNKNOWN_SECTION_ORDER;
|
|
101
|
+
}
|
|
102
|
+
function stageOrder(type) {
|
|
103
|
+
if (type === "portfolioHero") return -1;
|
|
104
|
+
const stage = sectionStage(type);
|
|
105
|
+
return stage ? CASE_STUDY_STAGES.indexOf(stage) : CASE_STUDY_STAGES.length;
|
|
106
|
+
}
|
|
107
|
+
function orderSections(sections, options = {}) {
|
|
108
|
+
const list = [...sections ?? []];
|
|
109
|
+
const byStage = list.map((section, index) => ({ section, index }));
|
|
110
|
+
byStage.sort((a, b) => {
|
|
111
|
+
const stage = stageOrder(a.section.sectionType) - stageOrder(b.section.sectionType);
|
|
112
|
+
if (stage !== 0) return stage;
|
|
113
|
+
if (options.sectionsOrdered) return a.index - b.index;
|
|
114
|
+
const order = sectionOrder(a.section.sectionType) - sectionOrder(b.section.sectionType);
|
|
115
|
+
return order !== 0 ? order : a.index - b.index;
|
|
116
|
+
});
|
|
117
|
+
return byStage.map((s) => s.section);
|
|
118
|
+
}
|
|
119
|
+
function groupSectionsByStage(sections, options = {}) {
|
|
120
|
+
const ordered = orderSections(sections, options);
|
|
121
|
+
const groups = [];
|
|
122
|
+
for (const stage of CASE_STUDY_STAGES) {
|
|
123
|
+
const own = ordered.filter((s) => sectionStage(s.sectionType) === stage);
|
|
124
|
+
if (own.length) groups.push({ stage, index: groups.length + 1, sections: own });
|
|
125
|
+
}
|
|
126
|
+
return groups;
|
|
127
|
+
}
|
|
128
|
+
var STAGE_MISSING_COPY = {
|
|
129
|
+
problem: {
|
|
130
|
+
empty: "Nothing says what the client came in with. Add the challenges.",
|
|
131
|
+
noCore: "The problem is pictured but not stated. Add the challenges."
|
|
132
|
+
},
|
|
133
|
+
build: {
|
|
134
|
+
empty: "Nothing says what was planned or built. Add the strategy.",
|
|
135
|
+
noCore: "The build is shown but the plan behind it is missing. Add the strategy."
|
|
136
|
+
},
|
|
137
|
+
proof: {
|
|
138
|
+
empty: "Nothing measures the finished build. Add a performance measurement.",
|
|
139
|
+
noCore: "The old site is compared but the finished build has no measurement of its own. Add a performance measurement."
|
|
140
|
+
},
|
|
141
|
+
outcome: {
|
|
142
|
+
empty: "Nothing says what the work is doing for the client. Add the results.",
|
|
143
|
+
noCore: "The outcome has supporting sections but no results. Add the results."
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
function caseStudyStageStatus(sections) {
|
|
147
|
+
const ordered = orderSections(sections);
|
|
148
|
+
return CASE_STUDY_STAGES.map((stage) => {
|
|
149
|
+
const present = ordered.map((s) => s.sectionType).filter((t) => isPortfolioSectionType(t) && SECTION_STAGE[t] === stage);
|
|
150
|
+
const complete = present.includes(STAGE_CORE_SECTION[stage]);
|
|
151
|
+
const copy = STAGE_MISSING_COPY[stage];
|
|
152
|
+
return {
|
|
153
|
+
stage,
|
|
154
|
+
present,
|
|
155
|
+
complete,
|
|
156
|
+
missing: complete ? null : present.length ? copy.noCore : copy.empty
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
var NARRATIVE_MAX_CHARS = 320;
|
|
161
|
+
function narrativeProblems(narrative) {
|
|
162
|
+
if (narrative === void 0 || narrative === null) return [];
|
|
163
|
+
if (typeof narrative !== "object") return ["narrative must be an object"];
|
|
164
|
+
const problems = [];
|
|
165
|
+
const n = narrative;
|
|
166
|
+
if (typeof n.thesis !== "string" || !n.thesis.trim()) problems.push("narrative.thesis is required");
|
|
167
|
+
else if (n.thesis.length > NARRATIVE_MAX_CHARS) problems.push(`narrative.thesis is over ${NARRATIVE_MAX_CHARS} characters`);
|
|
168
|
+
if (n.stages === void 0 || n.stages === null) return problems;
|
|
169
|
+
if (typeof n.stages !== "object") return [...problems, "narrative.stages must be an object"];
|
|
170
|
+
for (const [stage, lede] of Object.entries(n.stages)) {
|
|
171
|
+
if (!CASE_STUDY_STAGES.includes(stage)) {
|
|
172
|
+
problems.push(`narrative.stages.${stage} is not a stage (${CASE_STUDY_STAGES.join(", ")})`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (lede === void 0 || lede === null) continue;
|
|
176
|
+
if (typeof lede !== "string") problems.push(`narrative.stages.${stage} must be text`);
|
|
177
|
+
else if (lede.length > NARRATIVE_MAX_CHARS) problems.push(`narrative.stages.${stage} is over ${NARRATIVE_MAX_CHARS} characters`);
|
|
178
|
+
}
|
|
179
|
+
return problems;
|
|
180
|
+
}
|
|
181
|
+
var CASE_STUDY_NARRATIVE_JSON_SCHEMA = {
|
|
182
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
183
|
+
$id: "https://sonor.io/schemas/case-study-narrative.v1.json",
|
|
184
|
+
title: "CaseStudyNarrative",
|
|
185
|
+
description: "The thesis of a case study and the lede that opens each of its four stages.",
|
|
186
|
+
type: "object",
|
|
187
|
+
required: ["thesis"],
|
|
188
|
+
properties: {
|
|
189
|
+
thesis: { type: "string", minLength: 1, maxLength: NARRATIVE_MAX_CHARS },
|
|
190
|
+
stages: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: Object.fromEntries(
|
|
193
|
+
CASE_STUDY_STAGES.map((stage) => [stage, { type: "string", maxLength: NARRATIVE_MAX_CHARS }])
|
|
194
|
+
),
|
|
195
|
+
additionalProperties: false
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
additionalProperties: false
|
|
199
|
+
};
|
|
200
|
+
function challengeId(position) {
|
|
201
|
+
return `c${position}`;
|
|
202
|
+
}
|
|
203
|
+
function caseStudyFlowProblems(input) {
|
|
204
|
+
const challenges = input.challenges ?? [];
|
|
205
|
+
const ids = challenges.map((c) => typeof c.id === "string" && c.id.trim() ? c.id.trim() : null);
|
|
206
|
+
if (!ids.some(Boolean)) return [];
|
|
207
|
+
const problems = [];
|
|
208
|
+
const known = new Set(ids.filter((id) => !!id));
|
|
209
|
+
if (known.size !== ids.filter(Boolean).length) problems.push("two challenges share an id");
|
|
210
|
+
ids.forEach((id, i) => {
|
|
211
|
+
if (!id) problems.push(`challenge ${i + 1} has no id while the others do`);
|
|
212
|
+
});
|
|
213
|
+
const resolved = /* @__PURE__ */ new Set();
|
|
214
|
+
const proven = /* @__PURE__ */ new Set();
|
|
215
|
+
for (const phase of input.phases ?? []) {
|
|
216
|
+
for (const ref of phase.resolves ?? []) {
|
|
217
|
+
if (!known.has(ref)) problems.push(`a strategy phase resolves "${ref}", which is not a challenge`);
|
|
218
|
+
else resolved.add(ref);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
for (const result of input.results ?? []) {
|
|
222
|
+
for (const ref of result.proves ?? []) {
|
|
223
|
+
if (!known.has(ref)) problems.push(`a result proves "${ref}", which is not a challenge`);
|
|
224
|
+
else proven.add(ref);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const nameOf = (id) => {
|
|
228
|
+
const c = challenges.find((x) => x.id === id);
|
|
229
|
+
return c?.title?.trim() ? `"${c.title.trim()}"` : id;
|
|
230
|
+
};
|
|
231
|
+
for (const id of known) {
|
|
232
|
+
if (!resolved.has(id)) problems.push(`no strategy phase resolves challenge ${nameOf(id)}`);
|
|
233
|
+
if (!proven.has(id)) problems.push(`no result proves challenge ${nameOf(id)}`);
|
|
234
|
+
}
|
|
235
|
+
return problems;
|
|
236
|
+
}
|
|
7
237
|
var LIGHTHOUSE_RE = /lighthouse/i;
|
|
8
238
|
function isLighthouseKpi(kpi) {
|
|
9
239
|
return LIGHTHOUSE_RE.test(kpi.label || "");
|
|
@@ -111,4 +341,4 @@ function carryReportedMetrics(previous, next, keyOf) {
|
|
|
111
341
|
return [...reported, ...generated];
|
|
112
342
|
}
|
|
113
343
|
|
|
114
|
-
export { FRAME_READY_MESSAGE, METRIC_SOURCES, PORTFOLIO_CONTRACT_VERSION, PORTFOLIO_KPI_JSON_SCHEMA, carryReportedMetrics, curateHeroKpis, formatAttribution, isFrameReadyMessage, isHeadlineMetric, isLighthouseKpi, isMetricSource, metricSourceLabel, metricSourceProblem, shortAttribution };
|
|
344
|
+
export { CASE_STUDY_NARRATIVE_JSON_SCHEMA, CASE_STUDY_STAGES, CASE_STUDY_STAGE_COPY, FRAME_READY_MESSAGE, METRIC_SOURCES, NARRATIVE_MAX_CHARS, PORTFOLIO_CONTRACT_VERSION, PORTFOLIO_KPI_JSON_SCHEMA, PORTFOLIO_SECTION_ORDER, PORTFOLIO_SECTION_TYPES, SECTION_STAGE, STAGE_CORE_SECTION, STAGE_NAME_MAX_CHARS, carryReportedMetrics, caseStudyFlowProblems, caseStudyStageStatus, challengeId, curateHeroKpis, formatAttribution, groupSectionsByStage, isFrameReadyMessage, isHeadlineMetric, isLighthouseKpi, isMetricSource, isPortfolioSectionType, metricSourceLabel, metricSourceProblem, narrativeProblems, orderSections, sectionStage, shortAttribution, stageName, stageNamesProblems };
|
|
@@ -13,7 +13,8 @@ export declare const PUBLICATION_IDENTITY_FIELDS: readonly ['publicationName', '
|
|
|
13
13
|
export declare const PUBLICATION_LOGO_VARIANTS: readonly ['default', 'light', 'dark'];
|
|
14
14
|
export declare const PUBLICATION_COLOR_FIELDS: readonly ['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'];
|
|
15
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'];
|
|
16
|
+
export declare const PUBLICATION_EDITORIAL_FIELDS: readonly ['articleStyle', 'cardLayout', 'focusClosingBackground', 'charter', 'siteCharters'];
|
|
17
|
+
export declare const FOCUS_CLOSING_BACKGROUNDS: readonly ['planned_photo', 'article_atmosphere'];
|
|
17
18
|
export declare const PUBLICATION_NEWSLETTER_FIELDS: readonly ['name', 'tagline', 'layout', 'maxWidth'];
|
|
18
19
|
/** Fonts every email client has, the only families an email fallback may name. Lowercase. */
|
|
19
20
|
export declare const EMAIL_SAFE_FONTS: ReadonlySet<string>;
|
|
@@ -56,6 +57,8 @@ export interface PublicationEditorial {
|
|
|
56
57
|
/** Shared Broadcast style for articles, reviews and generated posts; portfolio is excluded. Legacy wire name. */
|
|
57
58
|
articleStyle?: BroadcastStyleId;
|
|
58
59
|
cardLayout?: PublicationCardLayout;
|
|
60
|
+
/** Focus closing frames may reuse the article's saved atmosphere and real author portrait. */
|
|
61
|
+
focusClosingBackground?: (typeof FOCUS_CLOSING_BACKGROUNDS)[number];
|
|
59
62
|
}
|
|
60
63
|
export interface PublicationNewsletter {
|
|
61
64
|
name?: string;
|
|
@@ -138,7 +138,8 @@ var PUBLICATION_IDENTITY_FIELDS = ["publicationName", "wordmark", "tagline", "pu
|
|
|
138
138
|
var PUBLICATION_LOGO_VARIANTS = ["default", "light", "dark"];
|
|
139
139
|
var PUBLICATION_COLOR_FIELDS = ["primary", "secondary", "accent", "background", "surface", "text", "muted", "border"];
|
|
140
140
|
var PUBLICATION_TYPOGRAPHY_FIELDS = ["heading", "body", "mono", "emailHeadingFallback", "emailBodyFallback"];
|
|
141
|
-
var PUBLICATION_EDITORIAL_FIELDS = ["articleStyle", "cardLayout", "charter", "siteCharters"];
|
|
141
|
+
var PUBLICATION_EDITORIAL_FIELDS = ["articleStyle", "cardLayout", "focusClosingBackground", "charter", "siteCharters"];
|
|
142
|
+
var FOCUS_CLOSING_BACKGROUNDS = ["planned_photo", "article_atmosphere"];
|
|
142
143
|
var PUBLICATION_NEWSLETTER_FIELDS = ["name", "tagline", "layout", "maxWidth"];
|
|
143
144
|
var EMAIL_SAFE_FONTS = /* @__PURE__ */ new Set([
|
|
144
145
|
"arial",
|
|
@@ -279,7 +280,7 @@ function createPublicationBrandingValidator(options) {
|
|
|
279
280
|
throw new PublicationBrandingValidationError(`${prefix}.${key}`, error.message);
|
|
280
281
|
}
|
|
281
282
|
}
|
|
282
|
-
const allowed = key === "articleStyle" ? BROADCAST_STYLES : patch ? PUBLICATION_CARD_LAYOUTS : SAVED_PUBLICATION_CARD_LAYOUTS;
|
|
283
|
+
const allowed = key === "articleStyle" ? BROADCAST_STYLES : key === "focusClosingBackground" ? FOCUS_CLOSING_BACKGROUNDS : patch ? PUBLICATION_CARD_LAYOUTS : SAVED_PUBLICATION_CARD_LAYOUTS;
|
|
283
284
|
if (typeof v !== "string" || !allowed.includes(v)) throw new PublicationBrandingValidationError(`${prefix}.${key}`, `choose ${allowed.join(", ")}`);
|
|
284
285
|
return v;
|
|
285
286
|
});
|
|
@@ -328,6 +329,7 @@ function createPublicationBrandingValidator(options) {
|
|
|
328
329
|
}
|
|
329
330
|
|
|
330
331
|
exports.EMAIL_SAFE_FONTS = EMAIL_SAFE_FONTS;
|
|
332
|
+
exports.FOCUS_CLOSING_BACKGROUNDS = FOCUS_CLOSING_BACKGROUNDS;
|
|
331
333
|
exports.GENERIC_FONT_FAMILIES = GENERIC_FONT_FAMILIES;
|
|
332
334
|
exports.PUBLICATION_BRANDING_SECTIONS = PUBLICATION_BRANDING_SECTIONS;
|
|
333
335
|
exports.PUBLICATION_BRANDING_VERSION = PUBLICATION_BRANDING_VERSION;
|
|
@@ -136,7 +136,8 @@ var PUBLICATION_IDENTITY_FIELDS = ["publicationName", "wordmark", "tagline", "pu
|
|
|
136
136
|
var PUBLICATION_LOGO_VARIANTS = ["default", "light", "dark"];
|
|
137
137
|
var PUBLICATION_COLOR_FIELDS = ["primary", "secondary", "accent", "background", "surface", "text", "muted", "border"];
|
|
138
138
|
var PUBLICATION_TYPOGRAPHY_FIELDS = ["heading", "body", "mono", "emailHeadingFallback", "emailBodyFallback"];
|
|
139
|
-
var PUBLICATION_EDITORIAL_FIELDS = ["articleStyle", "cardLayout", "charter", "siteCharters"];
|
|
139
|
+
var PUBLICATION_EDITORIAL_FIELDS = ["articleStyle", "cardLayout", "focusClosingBackground", "charter", "siteCharters"];
|
|
140
|
+
var FOCUS_CLOSING_BACKGROUNDS = ["planned_photo", "article_atmosphere"];
|
|
140
141
|
var PUBLICATION_NEWSLETTER_FIELDS = ["name", "tagline", "layout", "maxWidth"];
|
|
141
142
|
var EMAIL_SAFE_FONTS = /* @__PURE__ */ new Set([
|
|
142
143
|
"arial",
|
|
@@ -277,7 +278,7 @@ function createPublicationBrandingValidator(options) {
|
|
|
277
278
|
throw new PublicationBrandingValidationError(`${prefix}.${key}`, error.message);
|
|
278
279
|
}
|
|
279
280
|
}
|
|
280
|
-
const allowed = key === "articleStyle" ? BROADCAST_STYLES : patch ? PUBLICATION_CARD_LAYOUTS : SAVED_PUBLICATION_CARD_LAYOUTS;
|
|
281
|
+
const allowed = key === "articleStyle" ? BROADCAST_STYLES : key === "focusClosingBackground" ? FOCUS_CLOSING_BACKGROUNDS : patch ? PUBLICATION_CARD_LAYOUTS : SAVED_PUBLICATION_CARD_LAYOUTS;
|
|
281
282
|
if (typeof v !== "string" || !allowed.includes(v)) throw new PublicationBrandingValidationError(`${prefix}.${key}`, `choose ${allowed.join(", ")}`);
|
|
282
283
|
return v;
|
|
283
284
|
});
|
|
@@ -325,4 +326,4 @@ function createPublicationBrandingValidator(options) {
|
|
|
325
326
|
};
|
|
326
327
|
}
|
|
327
328
|
|
|
328
|
-
export { EMAIL_SAFE_FONTS, GENERIC_FONT_FAMILIES, PUBLICATION_BRANDING_SECTIONS, PUBLICATION_BRANDING_VERSION, PUBLICATION_CHARTER_FIELDS, PUBLICATION_COLOR_FIELDS, PUBLICATION_EDITORIAL_FIELDS, PUBLICATION_IDENTITY_FIELDS, PUBLICATION_LOGO_VARIANTS, PUBLICATION_NEWSLETTER_FIELDS, PUBLICATION_PLATFORMS, PUBLICATION_TYPOGRAPHY_FIELDS, PublicationBrandingValidationError, containsControlCharacters, createPublicationBrandingValidator, mergePublicationBrandingLayers, parsePublicationCharter, parseSiteCharters, publicationCharterContext, publicationCharterFor, safeFontStack };
|
|
329
|
+
export { EMAIL_SAFE_FONTS, FOCUS_CLOSING_BACKGROUNDS, GENERIC_FONT_FAMILIES, PUBLICATION_BRANDING_SECTIONS, PUBLICATION_BRANDING_VERSION, PUBLICATION_CHARTER_FIELDS, PUBLICATION_COLOR_FIELDS, PUBLICATION_EDITORIAL_FIELDS, PUBLICATION_IDENTITY_FIELDS, PUBLICATION_LOGO_VARIANTS, PUBLICATION_NEWSLETTER_FIELDS, PUBLICATION_PLATFORMS, PUBLICATION_TYPOGRAPHY_FIELDS, PublicationBrandingValidationError, containsControlCharacters, createPublicationBrandingValidator, mergePublicationBrandingLayers, parsePublicationCharter, parseSiteCharters, publicationCharterContext, publicationCharterFor, safeFontStack };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sonordev/contracts",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
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, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, what a publication's branding may hold, and which literal hosts are refused as public fetch destinations. Pure functions, no dependencies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|