@sonordev/contracts 2.10.0 → 2.12.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 +71 -5
- package/dist/portfolio.d.ts +170 -0
- package/dist/portfolio.js +249 -0
- package/dist/portfolio.mjs +231 -1
- package/dist/public-url-host.d.ts +10 -0
- package/dist/public-url-host.js +34 -0
- package/dist/public-url-host.mjs +32 -0
- package/dist/publication-branding.d.ts +1 -1
- package/package.json +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.12.0
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **Stage names.** An agency 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.
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
|
|
14
|
+
## 2.11.0
|
|
15
|
+
|
|
16
|
+
- 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.
|
|
17
|
+
|
|
3
18
|
## 2.10.0
|
|
4
19
|
|
|
5
20
|
A minor release: two new functions in `@sonordev/contracts/page-titles`. Every entry is otherwise unchanged.
|
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 |
|
|
@@ -37,6 +37,22 @@ pnpm add @sonordev/contracts
|
|
|
37
37
|
| `@sonordev/contracts/page-titles` | A page's own name: its title without the brand (`titleHead`), a readable label from a slug (`slugLabel`), whether a value is a route placeholder like `:slug` (`isRoutePlaceholder`), whether a value is a page's address and not its name (`isPageAddress`), and whether a stored title is a name when every piece between its pipes is judged (`isPageName`) | Signal's site-architecture writer, the agency site kit's portfolio components |
|
|
38
38
|
| `@sonordev/contracts/broadcast-layouts` | Which Broadcast card layouts are drawn (centered `headline`, low `anchor`), which retired IDs still read and what each draws as (`normalizeArticleCoverLayout`), the Broadcast styles, and the review compositions with the frozen draw that gives each review its own (`reviewCardVariationFromDigest`) | the Sonor API's card renderers and settings, Signal's agent tools, the dashboard's pickers |
|
|
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
|
+
| `@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
|
+
|
|
42
|
+
## Public URL hosts
|
|
43
|
+
|
|
44
|
+
`isBlockedPublicUrlHost(host)` shares the APIs' loopback, private IPv4,
|
|
45
|
+
link-local, carrier-grade NAT, multicast/reserved IPv4, private IPv6 and local
|
|
46
|
+
hostname checks. It checks IPv4-mapped and IPv4-compatible IPv6 destinations
|
|
47
|
+
too, with or without brackets. It uses the standard `URL` parser, with no
|
|
48
|
+
Node, DNS or network dependencies. Scoped IPv6 addresses are refused.
|
|
49
|
+
|
|
50
|
+
Pass the **parsed `URL.hostname`**, so alternate IPv4 spellings such as
|
|
51
|
+
`127.1` and `0x7f000001` are normalized first, or pass a DNS result's address.
|
|
52
|
+
This predicate doesn't validate a URL or trim a raw string. A `false` result
|
|
53
|
+
isn't proof a destination is public: the fetcher still validates the protocol,
|
|
54
|
+
credentials and port, checks the DNS addresses it connects to, and applies its
|
|
55
|
+
redirect policy on each hop. Those network protections stay in the APIs.
|
|
40
56
|
|
|
41
57
|
## Schema placeholders
|
|
42
58
|
|
|
@@ -740,9 +756,10 @@ valid, so the validator lives here once.
|
|
|
740
756
|
|
|
741
757
|
```ts
|
|
742
758
|
import { createPublicationBrandingValidator } from '@sonordev/contracts/publication-branding'
|
|
759
|
+
import { isBlockedPublicUrlHost } from '@sonordev/contracts/public-url-host'
|
|
743
760
|
|
|
744
|
-
// Bind it once
|
|
745
|
-
const branding = createPublicationBrandingValidator({ isBlockedHost })
|
|
761
|
+
// Bind it once to the same literal-host policy both APIs use.
|
|
762
|
+
const branding = createPublicationBrandingValidator({ isBlockedHost: isBlockedPublicUrlHost })
|
|
746
763
|
|
|
747
764
|
branding.validatePublicationBrandingPatch({ colors: { primary: '#ABC' } })
|
|
748
765
|
// { colors: { primary: '#aabbcc' } }
|
|
@@ -755,8 +772,10 @@ branding.mergePublicationBrandingPatch(saved, { identity: { logos: { dark: null
|
|
|
755
772
|
- **`createPublicationBrandingValidator({ isBlockedHost })`** returns the
|
|
756
773
|
validator. A URL leaf must be a public HTTPS URL without credentials;
|
|
757
774
|
whether its host is private (loopback, a LAN range, link-local,
|
|
758
|
-
`localhost`)
|
|
759
|
-
`
|
|
775
|
+
`localhost`) uses the injected guard. Both APIs pass the shared
|
|
776
|
+
`public-url-host` predicate, which doesn't need Node. It gets
|
|
777
|
+
`new URL(value).hostname`, so IPv6 keeps its brackets. DNS and connection
|
|
778
|
+
checks remain the responsibility of the fetcher.
|
|
760
779
|
- **`validatePublicationBranding`** reads a whole saved profile.
|
|
761
780
|
**`validatePublicationBrandingPatch`** reads a change: a leaf set to `null`
|
|
762
781
|
clears that override, and a card layout must be one that's drawn today.
|
|
@@ -782,6 +801,53 @@ The stored shape's version is `PUBLICATION_BRANDING_VERSION`. Resolving a
|
|
|
782
801
|
profile against a project's own colors and its measured site design stays in
|
|
783
802
|
the Sonor API.
|
|
784
803
|
|
|
804
|
+
## The portfolio contract: the case study arc
|
|
805
|
+
|
|
806
|
+
Every case study tells the same four-beat story, whatever an agency calls
|
|
807
|
+
the beats: what was wrong, what we built, how we proved it holds up, and
|
|
808
|
+
what it's doing for the client now. `@sonordev/contracts/portfolio` is
|
|
809
|
+
where that story's structure lives, once, for the generator that writes it,
|
|
810
|
+
the dashboard that edits it and every site that renders it.
|
|
811
|
+
|
|
812
|
+
- **`CASE_STUDY_STAGES`** is the four beats in order: `problem`, `build`,
|
|
813
|
+
`proof`, `outcome`. The ids are deliberately plain; an agency's renderer
|
|
814
|
+
maps them to its own words. `CASE_STUDY_STAGE_COPY` gives each a generic
|
|
815
|
+
name and the question its sections answer.
|
|
816
|
+
- **`PORTFOLIO_SECTION_TYPES`** is every section a case study can hold, in
|
|
817
|
+
canonical reading order: the hero, then each stage's sections, then the
|
|
818
|
+
SEO metadata and the closing CTA. `SECTION_STAGE` says which stage each
|
|
819
|
+
type belongs to (null for the hero, SEO and CTA, which sit outside the
|
|
820
|
+
story) and `sectionStage(type)` reads it for a string. Both are typed over
|
|
821
|
+
every section type, so a new type without a stage is a compile error.
|
|
822
|
+
- **`orderSections(sections, { sectionsOrdered })`** is the reading order.
|
|
823
|
+
The arc always wins: hero, problem, build, proof, outcome, CTA. Within a
|
|
824
|
+
stage the agency's stored order is kept once it has reordered in the
|
|
825
|
+
dashboard; otherwise the canonical order. **`groupSectionsByStage`**
|
|
826
|
+
returns the beats the case study actually has, numbered in order, each
|
|
827
|
+
with its sections; a stage with nothing in it is left out.
|
|
828
|
+
- **`caseStudyStageStatus(sections)`** reports all four stages, complete
|
|
829
|
+
only when the stage's core section (`STAGE_CORE_SECTION`: challenges,
|
|
830
|
+
strategy, performance, results) is present, with one sentence on what's
|
|
831
|
+
missing. The dashboard shows it beside the editor.
|
|
832
|
+
- **Stage names.** The ids are fixed; what people see isn't. An agency
|
|
833
|
+
names the four stages once in the dashboard ("Heat", "Shape", "Temper",
|
|
834
|
+
"Finish") and `CaseStudyStageNames` carries that map to the editor, the
|
|
835
|
+
public portfolio config and the agency's site. `stageName(names, stage)`
|
|
836
|
+
reads it with the generic name as the fallback; `stageNamesProblems`
|
|
837
|
+
validates it and `STAGE_NAME_MAX_CHARS` caps each name.
|
|
838
|
+
- **`CaseStudyNarrative`** is what the story says above its sections: one
|
|
839
|
+
`thesis` and one lede per stage, written for this project. Signal writes
|
|
840
|
+
it from the evidence it was given and the agency edits it;
|
|
841
|
+
`narrativeProblems` validates one and `CASE_STUDY_NARRATIVE_JSON_SCHEMA`
|
|
842
|
+
is the same rule as a schema. An absent narrative passes, so renderers
|
|
843
|
+
fall back to house copy for older case studies.
|
|
844
|
+
- **The thread.** A challenge carries an `id` (`challengeId(n)` mints
|
|
845
|
+
"c1", "c2"…), a strategy phase says which challenges it `resolves`, a
|
|
846
|
+
result says which it `proves`. `caseStudyFlowProblems({ challenges,
|
|
847
|
+
phases, results })` names every challenge nothing resolves or proves and
|
|
848
|
+
every dangling reference. It judges links only where ids were written, so
|
|
849
|
+
content from before the thread passes.
|
|
850
|
+
|
|
785
851
|
## Why it exists
|
|
786
852
|
|
|
787
853
|
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
|
+
* An agency's own names for the four stages ("Heat", "Shape", "Temper",
|
|
39
|
+
* "Finish"), set once in the dashboard and read everywhere a stage is named:
|
|
40
|
+
* the editor, the public portfolio config, the agency's site. A stage left
|
|
41
|
+
* unnamed falls back to its generic name. The ids never change; only what
|
|
42
|
+
* 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 agency'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 };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared literal-host SSRF policy. Pass URL.hostname (which canonicalizes
|
|
3
|
+
* numeric IPv4 spellings), or an address returned by a DNS lookup. IPv6 may
|
|
4
|
+
* have the brackets URL.hostname retains. This isn't a URL validator and
|
|
5
|
+
* doesn't trim input, resolve DNS, follow redirects or open a connection.
|
|
6
|
+
* Callers still enforce their protocol/port rules and check or pin the DNS
|
|
7
|
+
* addresses used by each connection; a false result alone can't prove a
|
|
8
|
+
* destination is public.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isBlockedPublicUrlHost(rawHost: string): boolean;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/public-url-host.ts
|
|
4
|
+
function isPrivateIpv4(a, b) {
|
|
5
|
+
return a === 127 || a === 10 || a === 0 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a >= 224;
|
|
6
|
+
}
|
|
7
|
+
function ipv6Words(host) {
|
|
8
|
+
if (!host.includes(":") || !/^[a-f\d:.]+$/.test(host)) return null;
|
|
9
|
+
let canonical;
|
|
10
|
+
try {
|
|
11
|
+
canonical = new URL(`http://[${host}]/`).hostname.slice(1, -1);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const sides = canonical.split("::");
|
|
16
|
+
const left = sides[0] ? sides[0].split(":") : [];
|
|
17
|
+
const right = sides.length > 1 && sides[1] ? sides[1].split(":") : [];
|
|
18
|
+
return [...left, ...Array(sides.length > 1 ? 8 - left.length - right.length : 0).fill("0"), ...right].map((word) => parseInt(word, 16));
|
|
19
|
+
}
|
|
20
|
+
function isBlockedPublicUrlHost(rawHost) {
|
|
21
|
+
const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
|
|
22
|
+
const blockedName = host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal");
|
|
23
|
+
if (host.includes(":") && host.includes("%")) return true;
|
|
24
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
25
|
+
const blockedIp = !!ipv4 && isPrivateIpv4(Number(ipv4[1]), Number(ipv4[2]));
|
|
26
|
+
const words = ipv6Words(host);
|
|
27
|
+
const blockedIpv6 = !!words && // Unspecified/loopback, unique-local, link-local, deprecated site-local
|
|
28
|
+
// and multicast destinations aren't public logo/webhook endpoints.
|
|
29
|
+
(words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1 || (words[0] & 65024) === 64512 || (words[0] & 65472) === 65152 || (words[0] & 65472) === 65216 || (words[0] & 65280) === 65280 || // IPv4-mapped and older IPv4-compatible forms carry an IPv4 destination.
|
|
30
|
+
words.slice(0, 5).every((word) => word === 0) && [0, 65535].includes(words[5]) && isPrivateIpv4(words[6] >> 8, words[6] & 255));
|
|
31
|
+
return blockedName || blockedIp || blockedIpv6;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
exports.isBlockedPublicUrlHost = isBlockedPublicUrlHost;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// src/contracts/public-url-host.ts
|
|
2
|
+
function isPrivateIpv4(a, b) {
|
|
3
|
+
return a === 127 || a === 10 || a === 0 || a === 192 && b === 168 || a === 172 && b >= 16 && b <= 31 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127 || a >= 224;
|
|
4
|
+
}
|
|
5
|
+
function ipv6Words(host) {
|
|
6
|
+
if (!host.includes(":") || !/^[a-f\d:.]+$/.test(host)) return null;
|
|
7
|
+
let canonical;
|
|
8
|
+
try {
|
|
9
|
+
canonical = new URL(`http://[${host}]/`).hostname.slice(1, -1);
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const sides = canonical.split("::");
|
|
14
|
+
const left = sides[0] ? sides[0].split(":") : [];
|
|
15
|
+
const right = sides.length > 1 && sides[1] ? sides[1].split(":") : [];
|
|
16
|
+
return [...left, ...Array(sides.length > 1 ? 8 - left.length - right.length : 0).fill("0"), ...right].map((word) => parseInt(word, 16));
|
|
17
|
+
}
|
|
18
|
+
function isBlockedPublicUrlHost(rawHost) {
|
|
19
|
+
const host = rawHost.toLowerCase().replace(/^\[|\]$/g, "");
|
|
20
|
+
const blockedName = host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal");
|
|
21
|
+
if (host.includes(":") && host.includes("%")) return true;
|
|
22
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
23
|
+
const blockedIp = !!ipv4 && isPrivateIpv4(Number(ipv4[1]), Number(ipv4[2]));
|
|
24
|
+
const words = ipv6Words(host);
|
|
25
|
+
const blockedIpv6 = !!words && // Unspecified/loopback, unique-local, link-local, deprecated site-local
|
|
26
|
+
// and multicast destinations aren't public logo/webhook endpoints.
|
|
27
|
+
(words.every((word) => word === 0) || words.slice(0, 7).every((word) => word === 0) && words[7] === 1 || (words[0] & 65024) === 64512 || (words[0] & 65472) === 65152 || (words[0] & 65472) === 65216 || (words[0] & 65280) === 65280 || // IPv4-mapped and older IPv4-compatible forms carry an IPv4 destination.
|
|
28
|
+
words.slice(0, 5).every((word) => word === 0) && [0, 65535].includes(words[5]) && isPrivateIpv4(words[6] >> 8, words[6] & 255));
|
|
29
|
+
return blockedName || blockedIp || blockedIpv6;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { isBlockedPublicUrlHost };
|
|
@@ -107,7 +107,7 @@ export interface PublicationBrandingValidatorOptions {
|
|
|
107
107
|
/**
|
|
108
108
|
* True when a URL's hostname must never be stored as a logo or publisher
|
|
109
109
|
* URL: loopback, private and link-local ranges, `localhost` and the like.
|
|
110
|
-
* Each API passes
|
|
110
|
+
* Each API passes the shared `public-url-host` policy. It receives
|
|
111
111
|
* `new URL(value).hostname`, so IPv6 keeps its brackets.
|
|
112
112
|
*/
|
|
113
113
|
isBlockedHost: (hostname: string) => boolean;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sonordev/contracts",
|
|
3
|
-
"version": "2.
|
|
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,
|
|
3
|
+
"version": "2.12.0",
|
|
4
|
+
"description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, 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": {
|
|
7
7
|
"type": "git",
|
|
@@ -141,6 +141,11 @@
|
|
|
141
141
|
"types": "./dist/publication-branding.d.ts",
|
|
142
142
|
"import": "./dist/publication-branding.mjs",
|
|
143
143
|
"require": "./dist/publication-branding.js"
|
|
144
|
+
},
|
|
145
|
+
"./public-url-host": {
|
|
146
|
+
"types": "./dist/public-url-host.d.ts",
|
|
147
|
+
"import": "./dist/public-url-host.mjs",
|
|
148
|
+
"require": "./dist/public-url-host.js"
|
|
144
149
|
}
|
|
145
150
|
},
|
|
146
151
|
"typesVersions": {
|