@growth-labs/cms 0.8.19 → 0.8.23
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/dist/cli/foundry-emit.d.ts +14 -0
- package/dist/cli/foundry-emit.d.ts.map +1 -0
- package/dist/cli/foundry-emit.js +69 -0
- package/dist/cli/foundry-emit.js.map +1 -0
- package/dist/engine/fronts-publish-intent.d.ts +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -1
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +20 -20
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +55 -4
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +18 -1
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/topics.d.ts.map +1 -1
- package/dist/engine/topics.js +4 -1
- package/dist/engine/topics.js.map +1 -1
- package/dist/engine/video-source.d.ts +21 -0
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +40 -0
- package/dist/engine/video-source.js.map +1 -1
- package/dist/foundry-emit.d.ts +146 -0
- package/dist/foundry-emit.d.ts.map +1 -0
- package/dist/foundry-emit.js +189 -0
- package/dist/foundry-emit.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +24 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +22 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/lib/publish-timezone.d.ts +38 -0
- package/dist/lib/publish-timezone.d.ts.map +1 -0
- package/dist/lib/publish-timezone.js +135 -0
- package/dist/lib/publish-timezone.js.map +1 -0
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +6 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +21 -13
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/analytics.d.ts +1 -0
- package/dist/routes/analytics.d.ts.map +1 -1
- package/dist/routes/analytics.js +20 -0
- package/dist/routes/analytics.js.map +1 -1
- package/dist/routes/content.d.ts +27 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +60 -114
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts +50 -0
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +540 -27
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/machine-context.d.ts +6 -0
- package/dist/routes/machine-context.d.ts.map +1 -0
- package/dist/routes/machine-context.js +20 -0
- package/dist/routes/machine-context.js.map +1 -0
- package/dist/routes/surveys.d.ts +14 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +515 -10
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +8 -8
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/surveys/access.d.ts +1 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +13 -4
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +100 -0
- package/dist/surveys/auto-label.d.ts.map +1 -0
- package/dist/surveys/auto-label.js +325 -0
- package/dist/surveys/auto-label.js.map +1 -0
- package/dist/surveys/schema.d.ts +38 -12
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +18 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +4 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/analytics.d.ts.map +1 -1
- package/dist/ui/api/analytics.js +5 -2
- package/dist/ui/api/analytics.js.map +1 -1
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
- package/dist/ui/api/surveys/[surveyId].js +28 -1
- package/dist/ui/api/surveys/[surveyId].js.map +1 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +2 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/inspector/PublishTab.d.ts +7 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +24 -26
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +1 -0
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +13 -8
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
- package/dist/ui/screens/PodcastListeningScreen.js +244 -0
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts +14 -0
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -0
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
- package/dist/ui/screens/podcast-listening-data.js +98 -0
- package/dist/ui/screens/podcast-listening-data.js.map +1 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -0
- package/dist/ui/screens/registry.js.map +1 -1
- package/docs/foundry-publish.md +208 -0
- package/migrations/0033_survey_answer_labels.sql +15 -0
- package/package.json +9 -3
- package/src/cli/foundry-emit.ts +85 -0
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/publisher.ts +80 -4
- package/src/engine/tags.ts +21 -1
- package/src/engine/topics.ts +4 -1
- package/src/engine/video-source.ts +44 -0
- package/src/foundry-emit.ts +255 -0
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +23 -0
- package/src/lib/publish-timezone.ts +154 -0
- package/src/providers/null.ts +6 -0
- package/src/providers/types.ts +52 -0
- package/src/routes/ai.ts +29 -17
- package/src/routes/analytics.ts +21 -0
- package/src/routes/content.ts +86 -131
- package/src/routes/foundry-publish.ts +746 -29
- package/src/routes/machine-context.ts +24 -0
- package/src/routes/surveys.ts +587 -12
- package/src/schema/migrations.ts +15 -0
- package/src/schema/tables.ts +2 -0
- package/src/surveys/access.ts +14 -4
- package/src/surveys/auto-label.ts +420 -0
- package/src/surveys/schema.ts +25 -1
- package/src/ui/api/_content-config.ts +6 -0
- package/src/ui/api/analytics.ts +5 -2
- package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
- package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +37 -3
- package/src/ui/commands.ts +2 -0
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/inspector/PublishTab.tsx +33 -25
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PodcastListening, PodcastListeningEpisode } from '../../providers/types.js';
|
|
2
|
+
export type PodcastSortKey = 'episode' | 'plays' | 'listeners' | 'reached10' | 'reached25' | 'reached50' | 'reached75' | 'reached90' | 'completes' | 'completionRate' | 'totalListeningSeconds';
|
|
3
|
+
/**
|
|
4
|
+
* Normalize the GET /admin/api/analytics?type=podcastListening body. The route
|
|
5
|
+
* returns the provider's PodcastListening verbatim; this keeps a malformed
|
|
6
|
+
* host payload from crashing the screen — broken rows drop, broken numbers
|
|
7
|
+
* read as 0, and a body that is not a PodcastListening at all yields null
|
|
8
|
+
* (rendered as the explicit unavailable state, never silent zeros).
|
|
9
|
+
*/
|
|
10
|
+
export declare function normalizePodcastListening(input: unknown): PodcastListening | null;
|
|
11
|
+
/**
|
|
12
|
+
* Sort episodes for the table. `episode` sorts by display title then slug;
|
|
13
|
+
* every metric sorts descending-first (the interesting end), then ascending on
|
|
14
|
+
* the second click.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sortEpisodes(episodes: readonly PodcastListeningEpisode[], key: PodcastSortKey, dir: 'asc' | 'desc'): PodcastListeningEpisode[];
|
|
17
|
+
/** The table's display name: the D1 title, the slug, or the unattributed label. */
|
|
18
|
+
export declare function episodeLabel(episode: Pick<PodcastListeningEpisode, 'slug' | 'title'>): string;
|
|
19
|
+
//# sourceMappingURL=podcast-listening-data.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"podcast-listening-data.d.ts","sourceRoot":"","sources":["../../../src/ui/screens/podcast-listening-data.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAA;AAEzF,MAAM,MAAM,cAAc,GACvB,SAAS,GACT,OAAO,GACP,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,WAAW,GACX,gBAAgB,GAChB,uBAAuB,CAAA;AAiC1B;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,GAAG,IAAI,CA0BjF;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAC3B,QAAQ,EAAE,SAAS,uBAAuB,EAAE,EAC5C,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,KAAK,GAAG,MAAM,GACjB,uBAAuB,EAAE,CAiB3B;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,OAAO,EAAE,IAAI,CAAC,uBAAuB,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,MAAM,CAG7F"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** Defensive read of one metric: a non-finite value renders as 0, not NaN. */
|
|
2
|
+
function num(value) {
|
|
3
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
4
|
+
return Number.isFinite(n) ? n : 0;
|
|
5
|
+
}
|
|
6
|
+
function normalizeEpisode(input) {
|
|
7
|
+
if (!input || typeof input !== 'object')
|
|
8
|
+
return null;
|
|
9
|
+
const row = input;
|
|
10
|
+
const slug = typeof row.slug === 'string' ? row.slug : '';
|
|
11
|
+
const hasListening = typeof row.totalListeningSeconds === 'number';
|
|
12
|
+
const listening = hasListening ? num(row.totalListeningSeconds) : null;
|
|
13
|
+
const listeners = num(row.listeners);
|
|
14
|
+
return {
|
|
15
|
+
slug,
|
|
16
|
+
...(typeof row.title === 'string' && row.title.length > 0 ? { title: row.title } : {}),
|
|
17
|
+
plays: num(row.plays),
|
|
18
|
+
listeners,
|
|
19
|
+
reached10: num(row.reached10),
|
|
20
|
+
reached25: num(row.reached25),
|
|
21
|
+
reached50: num(row.reached50),
|
|
22
|
+
reached75: num(row.reached75),
|
|
23
|
+
reached90: num(row.reached90),
|
|
24
|
+
completes: num(row.completes),
|
|
25
|
+
completionRate: num(row.completionRate),
|
|
26
|
+
totalListeningSeconds: listening,
|
|
27
|
+
avgListeningSecondsPerListener: listening !== null && listeners > 0 ? listening / listeners : null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Normalize the GET /admin/api/analytics?type=podcastListening body. The route
|
|
32
|
+
* returns the provider's PodcastListening verbatim; this keeps a malformed
|
|
33
|
+
* host payload from crashing the screen — broken rows drop, broken numbers
|
|
34
|
+
* read as 0, and a body that is not a PodcastListening at all yields null
|
|
35
|
+
* (rendered as the explicit unavailable state, never silent zeros).
|
|
36
|
+
*/
|
|
37
|
+
export function normalizePodcastListening(input) {
|
|
38
|
+
if (!input || typeof input !== 'object')
|
|
39
|
+
return null;
|
|
40
|
+
const body = input;
|
|
41
|
+
if (!Array.isArray(body.episodes))
|
|
42
|
+
return null;
|
|
43
|
+
const episodes = body.episodes
|
|
44
|
+
.map(normalizeEpisode)
|
|
45
|
+
.filter((e) => e !== null);
|
|
46
|
+
const totalsRow = body.totals;
|
|
47
|
+
const totals = totalsRow ?? {};
|
|
48
|
+
const rangeRow = body.range;
|
|
49
|
+
return {
|
|
50
|
+
episodes,
|
|
51
|
+
totals: {
|
|
52
|
+
episodes: num(totals.episodes),
|
|
53
|
+
plays: num(totals.plays),
|
|
54
|
+
listeners: num(totals.listeners),
|
|
55
|
+
completes: num(totals.completes),
|
|
56
|
+
completionRate: num(totals.completionRate),
|
|
57
|
+
totalListeningSeconds: typeof totals.totalListeningSeconds === 'number' ? num(totals.totalListeningSeconds) : null,
|
|
58
|
+
},
|
|
59
|
+
range: {
|
|
60
|
+
from: typeof rangeRow?.from === 'string' ? rangeRow.from : '',
|
|
61
|
+
to: typeof rangeRow?.to === 'string' ? rangeRow.to : '',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Sort episodes for the table. `episode` sorts by display title then slug;
|
|
67
|
+
* every metric sorts descending-first (the interesting end), then ascending on
|
|
68
|
+
* the second click.
|
|
69
|
+
*/
|
|
70
|
+
export function sortEpisodes(episodes, key, dir) {
|
|
71
|
+
const factor = dir === 'asc' ? 1 : -1;
|
|
72
|
+
return [...episodes].sort((a, b) => {
|
|
73
|
+
if (key === 'episode') {
|
|
74
|
+
const cmp = episodeLabel(a).localeCompare(episodeLabel(b));
|
|
75
|
+
return factor * cmp;
|
|
76
|
+
}
|
|
77
|
+
const av = a[key];
|
|
78
|
+
const bv = b[key];
|
|
79
|
+
// Unmeasured listening time always sinks to the bottom regardless of
|
|
80
|
+
// direction — it is an unknown, not a small number.
|
|
81
|
+
if (av === null && bv === null)
|
|
82
|
+
return episodeLabel(a).localeCompare(episodeLabel(b));
|
|
83
|
+
if (av === null)
|
|
84
|
+
return 1;
|
|
85
|
+
if (bv === null)
|
|
86
|
+
return -1;
|
|
87
|
+
if (av === bv)
|
|
88
|
+
return episodeLabel(a).localeCompare(episodeLabel(b));
|
|
89
|
+
return factor * (av - bv);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** The table's display name: the D1 title, the slug, or the unattributed label. */
|
|
93
|
+
export function episodeLabel(episode) {
|
|
94
|
+
if (episode.slug === 'unattributed')
|
|
95
|
+
return '(unattributed episodes)';
|
|
96
|
+
return episode.title && episode.title.length > 0 ? episode.title : episode.slug;
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=podcast-listening-data.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"podcast-listening-data.js","sourceRoot":"","sources":["../../../src/ui/screens/podcast-listening-data.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/ui/screens/registry.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,0BAA0B,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAA;AACnG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../../src/ui/screens/registry.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,0BAA0B,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAA;AACnG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAA;AA6B7D,wBAAgB,gBAAgB,CAAC,EAChC,yBAAgC,EAChC,iBAAsB,EACtB,YAAY,EACZ,sBAA2B,EAC3B,QAAQ,GACR,GAAE;IACF,yBAAyB,CAAC,EAAE,OAAO,CAAA;IACnC,iBAAiB,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC1D,YAAY,CAAC,EAAE,mBAAmB,CAAA;IAClC,sBAAsB,CAAC,EAAE,0BAA0B,EAAE,CAAA;IACrD,QAAQ,CAAC,EAAE,MAAM,CAAA;CACZ,GAAG,cAAc,CA2BtB;AAED,eAAO,MAAM,UAAU,EAAE,cAAmC,CAAA"}
|
|
@@ -7,6 +7,7 @@ import { ContentInsightsScreen } from './ContentInsightsScreen.js';
|
|
|
7
7
|
import { ContentRoute } from './ContentRoute.js';
|
|
8
8
|
import { DashboardScreen } from './DashboardScreen.js';
|
|
9
9
|
import { MediaScreen } from './MediaScreen.js';
|
|
10
|
+
import { PodcastListeningScreen } from './PodcastListeningScreen.js';
|
|
10
11
|
import { SettingsScreen } from './SettingsScreen.js';
|
|
11
12
|
import { SocialShareScreen } from './SocialShareScreen.js';
|
|
12
13
|
import { SubscriptionsScreen } from './SubscriptionsScreen.js';
|
|
@@ -31,6 +32,7 @@ export function createCmsScreens({ allowInlineAuthorCreation = true, primaryCate
|
|
|
31
32
|
calendar: () => _jsx(CalendarRoute, {}),
|
|
32
33
|
media: () => _jsx(MediaScreen, {}),
|
|
33
34
|
analytics: () => _jsx(AnalyticsScreen, {}),
|
|
35
|
+
podcasts: () => _jsx(PodcastListeningScreen, {}),
|
|
34
36
|
authors: () => _jsx(AuthorsScreen, {}),
|
|
35
37
|
topics: () => _jsx(TopicsScreen, {}),
|
|
36
38
|
subscriptions: () => _jsx(SubscriptionsScreen, {}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/ui/screens/registry.tsx"],"names":[],"mappings":";AAeA,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,uEAAuE;AACvE,2EAA2E;AAC3E,+EAA+E;AAC/E,SAAS,cAAc;IACtB,MAAM,EAAE,QAAQ,EAAE,GAAG,aAAa,EAAE,CAAA;IACpC,OAAO,KAAC,eAAe,IAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,GAAI,CAAA;AAC/D,CAAC;AAED,SAAS,aAAa;IACrB,MAAM,EAAE,QAAQ,EAAE,GAAG,aAAa,EAAE,CAAA;IACpC,OAAO,KAAC,cAAc,IAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,GAAI,CAAA;AAC9D,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,EAChC,yBAAyB,GAAG,IAAI,EAChC,iBAAiB,GAAG,EAAE,EACtB,YAAY,EACZ,sBAAsB,GAAG,EAAE,EAC3B,QAAQ,MAOL,EAAE;IACL,OAAO;QACN,OAAO,EAAE,GAAG,EAAE,CAAC,CACd,KAAC,YAAY,IACZ,yBAAyB,EAAE,yBAAyB,EACpD,iBAAiB,EAAE,iBAAiB,EACpC,YAAY,EAAE,YAAY,EAC1B,sBAAsB,EAAE,sBAAsB,GAC7C,CACF;QACD,kBAAkB,EAAE,GAAG,EAAE,CAAC,KAAC,qBAAqB,KAAG;QACnD,SAAS,EAAE,GAAG,EAAE,CAAC,KAAC,cAAc,KAAG;QACnC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAC,aAAa,KAAG;QACjC,KAAK,EAAE,GAAG,EAAE,CAAC,KAAC,WAAW,KAAG;QAC5B,SAAS,EAAE,GAAG,EAAE,CAAC,KAAC,eAAe,KAAG;QACpC,OAAO,EAAE,GAAG,EAAE,CAAC,KAAC,aAAa,KAAG;QAChC,MAAM,EAAE,GAAG,EAAE,CAAC,KAAC,YAAY,KAAG;QAC9B,aAAa,EAAE,GAAG,EAAE,CAAC,KAAC,mBAAmB,KAAG;QAC5C,GAAG,CAAC,QAAQ;YACX,CAAC,CAAC,EAAE,kBAAkB,EAAE,GAAG,EAAE,CAAC,KAAC,qBAAqB,IAAC,QAAQ,EAAE,QAAQ,GAAI,EAAE;YAC7E,CAAC,CAAC,EAAE,CAAC;QACN,cAAc,EAAE,GAAG,EAAE,CAAC,CACrB,KAAC,iBAAiB,IAAC,yBAAyB,EAAE,yBAAyB,GAAI,CAC3E;QACD,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAC,cAAc,KAAG;KAClC,CAAA;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAmB,gBAAgB,EAAE,CAAA"}
|
|
1
|
+
{"version":3,"file":"registry.js","sourceRoot":"","sources":["../../../src/ui/screens/registry.tsx"],"names":[],"mappings":";AAeA,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAClD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAChD,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAC9C,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAA;AACpE,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AACpD,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAC1D,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AAClE,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAEhD,uEAAuE;AACvE,2EAA2E;AAC3E,+EAA+E;AAC/E,SAAS,cAAc;IACtB,MAAM,EAAE,QAAQ,EAAE,GAAG,aAAa,EAAE,CAAA;IACpC,OAAO,KAAC,eAAe,IAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,GAAI,CAAA;AAC/D,CAAC;AAED,SAAS,aAAa;IACrB,MAAM,EAAE,QAAQ,EAAE,GAAG,aAAa,EAAE,CAAA;IACpC,OAAO,KAAC,cAAc,IAAC,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,SAAS,CAAC,GAAI,CAAA;AAC9D,CAAC;AAED,MAAM,UAAU,gBAAgB,CAAC,EAChC,yBAAyB,GAAG,IAAI,EAChC,iBAAiB,GAAG,EAAE,EACtB,YAAY,EACZ,sBAAsB,GAAG,EAAE,EAC3B,QAAQ,MAOL,EAAE;IACL,OAAO;QACN,OAAO,EAAE,GAAG,EAAE,CAAC,CACd,KAAC,YAAY,IACZ,yBAAyB,EAAE,yBAAyB,EACpD,iBAAiB,EAAE,iBAAiB,EACpC,YAAY,EAAE,YAAY,EAC1B,sBAAsB,EAAE,sBAAsB,GAC7C,CACF;QACD,kBAAkB,EAAE,GAAG,EAAE,CAAC,KAAC,qBAAqB,KAAG;QACnD,SAAS,EAAE,GAAG,EAAE,CAAC,KAAC,cAAc,KAAG;QACnC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAC,aAAa,KAAG;QACjC,KAAK,EAAE,GAAG,EAAE,CAAC,KAAC,WAAW,KAAG;QAC5B,SAAS,EAAE,GAAG,EAAE,CAAC,KAAC,eAAe,KAAG;QACpC,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAC,sBAAsB,KAAG;QAC1C,OAAO,EAAE,GAAG,EAAE,CAAC,KAAC,aAAa,KAAG;QAChC,MAAM,EAAE,GAAG,EAAE,CAAC,KAAC,YAAY,KAAG;QAC9B,aAAa,EAAE,GAAG,EAAE,CAAC,KAAC,mBAAmB,KAAG;QAC5C,GAAG,CAAC,QAAQ;YACX,CAAC,CAAC,EAAE,kBAAkB,EAAE,GAAG,EAAE,CAAC,KAAC,qBAAqB,IAAC,QAAQ,EAAE,QAAQ,GAAI,EAAE;YAC7E,CAAC,CAAC,EAAE,CAAC;QACN,cAAc,EAAE,GAAG,EAAE,CAAC,CACrB,KAAC,iBAAiB,IAAC,yBAAyB,EAAE,yBAAyB,GAAI,CAC3E;QACD,QAAQ,EAAE,GAAG,EAAE,CAAC,KAAC,cAAc,KAAG;KAClC,CAAA;AACF,CAAC;AAED,MAAM,CAAC,MAAM,UAAU,GAAmB,gBAAgB,EAAE,CAAA"}
|
package/docs/foundry-publish.md
CHANGED
|
@@ -64,6 +64,214 @@ GET returns HTTP 404 with `{ "error": "not_found" }` when the external id is abs
|
|
|
64
64
|
|
|
65
65
|
POST has no request body. It moves an item back to `draft` through the native CMS lifecycle handler and returns the same four-field receipt shape. Repeating the request while already draft is idempotent.
|
|
66
66
|
|
|
67
|
+
## v2 body, `POST /publish` and adoption (opt-in, per publication)
|
|
68
|
+
|
|
69
|
+
Draft-only is the default and stays byte-identical to the v1 contract above. A
|
|
70
|
+
publication opts in through the route's config, which the Astro adapter reads
|
|
71
|
+
from `locals.cmsHooks.foundryPublish`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
locals.cmsHooks = {
|
|
75
|
+
...locals.cmsHooks,
|
|
76
|
+
foundryPublish: {
|
|
77
|
+
autoPublish: true, // only the literal `true` opts in
|
|
78
|
+
canonicalHost: 'warfronts.pub', // REQUIRED when opted in: lowercase, no scheme, no port
|
|
79
|
+
surfaces: (ctx) => [sitemapSurface, feedSurface, markdownTwinSurface], // the LIVE surfaces
|
|
80
|
+
requiredSurfaceNames: ['seo-discovery'], // optional, forwarded to publishOne
|
|
81
|
+
bridgeSourceNamespace: 'warfronts', // the archive bridge's source_id namespace (adoption)
|
|
82
|
+
scheduling: 'publishOne', // only if the site's scheduler projects through publishOne
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
With the flag off (omitted, `false`, or anything but `true`):
|
|
88
|
+
|
|
89
|
+
- every existing request takes the v1 path, byte-for-byte;
|
|
90
|
+
- a body carrying `schemaVersion: 2` is refused as today (`400 invalid_request`);
|
|
91
|
+
- `POST .../publish` returns `409 published_requires_approval` (404 for an unknown id).
|
|
92
|
+
|
|
93
|
+
With the flag on, v1 bodies still take the v1 path unchanged. Only a body
|
|
94
|
+
declaring `"schemaVersion": 2` takes the v2 path.
|
|
95
|
+
|
|
96
|
+
### Host-bound signatures on opted-in publications
|
|
97
|
+
|
|
98
|
+
The Foundry HMAC secret is one value shared by every site. An opted-in
|
|
99
|
+
publication therefore verifies every `/api/cms/foundry/*` verb (PUT, GET,
|
|
100
|
+
publish, unpublish) against a host-bound string, newline-joined, with no
|
|
101
|
+
trailing newline:
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
foundry-cms-v2
|
|
105
|
+
METHOD
|
|
106
|
+
HOST
|
|
107
|
+
PATH
|
|
108
|
+
TIMESTAMP
|
|
109
|
+
sha256hex(raw_body)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `HOST` is the configured `foundryPublish.canonicalHost`, never the request's
|
|
113
|
+
Host header. A request signed for another site, the legacy dot form, or a
|
|
114
|
+
dot-joined host-bound string is `401`.
|
|
115
|
+
- An opted-in publication without a valid `canonicalHost` answers
|
|
116
|
+
`503 GL_CMS_FOUNDRY_HOST_UNCONFIGURED` on every verb; it never falls back to
|
|
117
|
+
the unbound form.
|
|
118
|
+
- Publications that do not opt in keep the legacy `method.path.timestamp.sha256`
|
|
119
|
+
string described above, byte-for-byte.
|
|
120
|
+
- Foundry's signer is `SignPublishRequestV2` (fulcrum-labs/foundry
|
|
121
|
+
`articlechain`). This route verifies with `@growth-labs/foundry-signing`, the
|
|
122
|
+
one shared implementation, whose golden vectors (`/fixtures`) the route tests
|
|
123
|
+
import.
|
|
124
|
+
|
|
125
|
+
### v2 PUT
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"schemaVersion": 2,
|
|
130
|
+
"slug": "exact-canonical-slug",
|
|
131
|
+
"title": "Article title",
|
|
132
|
+
"body_html": "<p>Complete article HTML.</p>",
|
|
133
|
+
"hero_image_key": "articles/<id>/hero.jpg",
|
|
134
|
+
"publishAt": "2026-10-01T09:00:00Z",
|
|
135
|
+
"author": "Byline",
|
|
136
|
+
"desk": "analysis",
|
|
137
|
+
"tags": ["drones"],
|
|
138
|
+
"description": "The dek.",
|
|
139
|
+
"visibility": "free",
|
|
140
|
+
"adoptSourceId": "warfronts:articles/military/<file>.md"
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The schema is strict and exported as `FoundryArticleV2Schema` from
|
|
145
|
+
`@growth-labs/cms/foundry-emit`.
|
|
146
|
+
|
|
147
|
+
- `slug`, `title` and `body_html` are required.
|
|
148
|
+
- `slug` is used verbatim and must already be canonical (`slugify(slug) === slug`,
|
|
149
|
+
3–512 characters). It is never derived and never suffixed: a slug held by
|
|
150
|
+
another item is `409 slug_conflict`.
|
|
151
|
+
- `hero_image_key` (a relative object key, stored as `hero_image_id` exactly as the
|
|
152
|
+
bridge import stores it) and `hero_image_url` (https) are mutually exclusive.
|
|
153
|
+
- `description` fills `description` and `excerpt`, `author` fills `byline`, and
|
|
154
|
+
`desk` fills the primary category.
|
|
155
|
+
- `publishAt` is carried on the draft (`publish_at`) for `POST /publish` to act on.
|
|
156
|
+
|
|
157
|
+
A v2 PUT never publishes. It writes a draft, or, on an opted-in publication, edits
|
|
158
|
+
a **published** item in place. That write is a CAS on `status = 'published'`, and
|
|
159
|
+
the live revision does not move until `POST /publish` resyncs it. Scheduled,
|
|
160
|
+
review and archived items stay human-owned: a changed PUT is
|
|
161
|
+
`409 published_requires_approval`.
|
|
162
|
+
|
|
163
|
+
Every v2 PUT, publish and GET response is the four-field receipt plus
|
|
164
|
+
`request_sha256`: the lowercase hex SHA-256 of the exact bytes of the last
|
|
165
|
+
accepted v2 PUT body, the value the route stores. An identical repeat PUT returns
|
|
166
|
+
the existing receipt.
|
|
167
|
+
|
|
168
|
+
### `POST /api/cms/foundry/articles/:external_id/publish`
|
|
169
|
+
|
|
170
|
+
HMAC-signed like the others, with an empty body. It returns the receipt (plus
|
|
171
|
+
`request_sha256`).
|
|
172
|
+
|
|
173
|
+
- **Draft** with `publish_at` in the future: scheduled through the native schedule
|
|
174
|
+
action (`state: "scheduled"`), but only when the publication declares
|
|
175
|
+
`foundryPublish.scheduling: 'publishOne'`, meaning its scheduler publishes
|
|
176
|
+
scheduled items through `publishOne` with the live surfaces. Otherwise the
|
|
177
|
+
request is refused with `409 scheduling_requires_surface_scheduler`: the legacy
|
|
178
|
+
scheduled sweep (`engine/cron.ts` `runScheduledPublish`) publishes with no
|
|
179
|
+
surfaces, and that gap must surface at the request, not after the fact.
|
|
180
|
+
- **Draft**, otherwise: published through `publishOne` with the publication's live
|
|
181
|
+
surfaces, so the `.md` twin, sitemap and feed are projected. The publication
|
|
182
|
+
time is the draft's `publishAt`, else now.
|
|
183
|
+
- **Every** publish, schedule or resync acts on the working copy only while it is
|
|
184
|
+
still exactly what Foundry's last v2 PUT wrote; this applies to drafts and to
|
|
185
|
+
published items alike.
|
|
186
|
+
- **Published** item whose working copy changed since its live revision: re-runs
|
|
187
|
+
`publishOne` (the resync), keeping the original publication time. The guard:
|
|
188
|
+
every v2 PUT records a History revision (`created_by: foundry`, tag
|
|
189
|
+
`foundry.put:<request_sha256>`), and the item's `canonical_version` must still
|
|
190
|
+
equal that revision's. A human edit since then is `409 human_edit_pending`,
|
|
191
|
+
with nothing written, until Foundry sends a new PUT or a human publishes.
|
|
192
|
+
Human-initiated writes outside the editor form (editor-triggered AI meta
|
|
193
|
+
description, dek, takeaways and OG image, the bulk topic op, and a topic rename)
|
|
194
|
+
advance `canonical_version` as an editor save does. The automatic enrichment a
|
|
195
|
+
publish triggers (the pre-commit dek, takeaways, FAQ and host enrichment) does
|
|
196
|
+
not. A person pulling an item (native unpublish, unschedule, or set-status to
|
|
197
|
+
draft or review) is a canonical edit too, so Foundry never republishes an
|
|
198
|
+
article a person pulled until it sends a new PUT. Foundry's own `/unpublish`
|
|
199
|
+
is not, so Foundry can unpublish and republish its own item. "Foundry's own" is
|
|
200
|
+
decided by an internal marker the HMAC-verified route sets on the contexts it
|
|
201
|
+
builds, never by a subject or user id. Tag rename and delete are canonical
|
|
202
|
+
edits to every tagged item too.
|
|
203
|
+
- `POST /publish` takes no body: any body bytes, or a non-zero `Content-Length`,
|
|
204
|
+
is `400 invalid_request`.
|
|
205
|
+
- Idempotent: a published item whose live revision is current, or a scheduled
|
|
206
|
+
item, returns its existing receipt.
|
|
207
|
+
- The route never passes `allowNoSurfaces`. Absent, empty or failing surfaces
|
|
208
|
+
refuse with `503 GL_CMS_FOUNDRY_PUBLISH_UNCONFIGURED` before anything is written.
|
|
209
|
+
- The publish runs the **native publish action itself**. Only its commit step is
|
|
210
|
+
swapped, from `publishContent` + `createRevision` to `publishOne` with the live
|
|
211
|
+
surfaces, through the internal `publishCommit` seam. So the readiness hook, the
|
|
212
|
+
body guard, dek generation (or the dek-failure notification), the tag check, the
|
|
213
|
+
activity log, takeaways and FAQ dispatch (first publish only), and the
|
|
214
|
+
`content.published` webhook run exactly as for an editor's publish, and its
|
|
215
|
+
refusals keep the native shapes.
|
|
216
|
+
- An exception thrown inside the publication is `502 publish_failed` (or `422
|
|
217
|
+
publish_validation_failed` from the kernel's own guard). It leaves the item and
|
|
218
|
+
its receipt unchanged, so a retry re-runs it.
|
|
219
|
+
- `unpublish` remains the undo.
|
|
220
|
+
|
|
221
|
+
### One-time adoption of bridge-imported items
|
|
222
|
+
|
|
223
|
+
When no item is bound to `foundry:<external_id>` yet, a v2 PUT first looks for an
|
|
224
|
+
existing item:
|
|
225
|
+
|
|
226
|
+
- by exact `source_id` when the body carries `adoptSourceId`;
|
|
227
|
+
- otherwise by the verbatim slug.
|
|
228
|
+
|
|
229
|
+
Only a bridge-imported article that no external id owns can be adopted, and
|
|
230
|
+
only under the publication's declared bridge namespace
|
|
231
|
+
(`foundryPublish.bridgeSourceNamespace`, so `source_id` = `<namespace>:<relpath>`,
|
|
232
|
+
as a channel repo's `scripts/archive-import/compiler.mjs` `buildSourceId` writes
|
|
233
|
+
it). Any other source is `409 adoption_source_not_bridge`; with no namespace
|
|
234
|
+
declared, nothing is adoptable. Exactly one match is bound to `foundry:<external_id>` in one
|
|
235
|
+
D1 transaction together with a `foundry.adopted` row in `cms_activity_log`, and
|
|
236
|
+
the receipt carries an `adoption` object (`content_id`, `from_source_id`,
|
|
237
|
+
`matched_by`). A draft or published match is then written as above. A scheduled,
|
|
238
|
+
review or archived match is bound and answered with
|
|
239
|
+
`409 published_requires_approval` plus the `adoption` object.
|
|
240
|
+
|
|
241
|
+
| Response | When |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| `409 adoption_ambiguous` | `adoptSourceId` and the slug name two different items |
|
|
244
|
+
| `409 adoption_conflict` | the match is deleted, not an article, already bound to another external id, not bridge-imported, or the bind lost a race |
|
|
245
|
+
| `409 slug_conflict` | the slug is held by an item the request did not identify |
|
|
246
|
+
|
|
247
|
+
### content-emit.json (`foundry-emit` bin)
|
|
248
|
+
|
|
249
|
+
`foundry-emit --app <id> --source-sha <sha> --out <file> [--in <archive.json>]`
|
|
250
|
+
reads an app compiler's ContentArchiveInput (`{ items }`, or compileArchive's
|
|
251
|
+
`{ archive, receipt }`) from `--in` or stdin. It writes:
|
|
252
|
+
|
|
253
|
+
```json
|
|
254
|
+
{ "schemaVersion": 1, "app": "warfronts", "sourceSha": "<sha>",
|
|
255
|
+
"items": [
|
|
256
|
+
{ "path": "articles/x.md", "videoId": "abc", "retire": false,
|
|
257
|
+
"requestSha256": "<hex>", "request": "{\"schemaVersion\":2,...}" },
|
|
258
|
+
{ "path": "articles/old.md", "videoId": "def", "retire": true }
|
|
259
|
+
] }
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
- It covers the whole archive; any item that cannot be emitted fails the run.
|
|
263
|
+
- `retire` is the compiler's carry of frontmatter `status: archived`. A retire item
|
|
264
|
+
omits `request` and `requestSha256`.
|
|
265
|
+
- `request` is a JSON **string**, the exact PUT body, so re-indenting the envelope
|
|
266
|
+
never changes its bytes.
|
|
267
|
+
- `requestSha256` is the SHA-256 of its UTF-8 bytes. When foundryd PUTs exactly
|
|
268
|
+
those bytes, the route stores and echoes the same value as `request_sha256`.
|
|
269
|
+
|
|
270
|
+
The mapper is exported as `toFoundryArticleV2(item)`, and the envelope builder as
|
|
271
|
+
`buildContentEmit(archive, { app, sourceSha })`.
|
|
272
|
+
|
|
273
|
+
No migration is needed for v2.
|
|
274
|
+
|
|
67
275
|
## Cloudflare binding
|
|
68
276
|
|
|
69
277
|
```toml
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Owner-side classification of written survey answers (2026-10-02). Only the
|
|
2
|
+
-- CMS owner ever sees an answer that is not labeled 'general': technical and
|
|
3
|
+
-- unlabeled comments are withheld from every other role by the survey routes.
|
|
4
|
+
-- No FK to a survey table: surveys live in the reporting upstream, so the key
|
|
5
|
+
-- references provider-issued ids only.
|
|
6
|
+
CREATE TABLE survey_answer_labels (
|
|
7
|
+
survey_id TEXT NOT NULL,
|
|
8
|
+
response_id TEXT NOT NULL,
|
|
9
|
+
question_id TEXT NOT NULL,
|
|
10
|
+
label TEXT NOT NULL CHECK (label IN ('technical','general')),
|
|
11
|
+
source TEXT NOT NULL CHECK (source IN ('owner','auto','import')),
|
|
12
|
+
labeled_by TEXT,
|
|
13
|
+
labeled_at INTEGER NOT NULL,
|
|
14
|
+
PRIMARY KEY (survey_id, response_id, question_id)
|
|
15
|
+
);
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@growth-labs/cms",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
|
-
"growth-labs-cms-migrations": "./dist/cli/migrations.js"
|
|
6
|
+
"growth-labs-cms-migrations": "./dist/cli/migrations.js",
|
|
7
|
+
"foundry-emit": "./dist/cli/foundry-emit.js"
|
|
7
8
|
},
|
|
8
9
|
"types": "./dist/index.d.ts",
|
|
9
10
|
"exports": {
|
|
@@ -31,6 +32,10 @@
|
|
|
31
32
|
"types": "./dist/integration/index.d.ts",
|
|
32
33
|
"import": "./dist/integration/index.js"
|
|
33
34
|
},
|
|
35
|
+
"./foundry-emit": {
|
|
36
|
+
"types": "./dist/foundry-emit.d.ts",
|
|
37
|
+
"import": "./dist/foundry-emit.js"
|
|
38
|
+
},
|
|
34
39
|
"./migration-vendor": {
|
|
35
40
|
"types": "./dist/migration-vendor.d.ts",
|
|
36
41
|
"import": "./dist/migration-vendor.js"
|
|
@@ -128,7 +133,8 @@
|
|
|
128
133
|
"@tiptap/suggestion": "3.30.5",
|
|
129
134
|
"@tiptap/y-tiptap": "^3.0.9",
|
|
130
135
|
"yjs": "^13.6.32",
|
|
131
|
-
"zod": "^3.24.0"
|
|
136
|
+
"zod": "^3.24.0",
|
|
137
|
+
"@growth-labs/foundry-signing": "^0.1.0"
|
|
132
138
|
},
|
|
133
139
|
"devDependencies": {
|
|
134
140
|
"@puckeditor/core": "0.23.0",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foundry-emit --app <id> --source-sha <sha> --out <file> [--in <archive.json>]
|
|
3
|
+
//
|
|
4
|
+
// Reads an app compiler's ContentArchiveInput (`{ items }`, or compileArchive's
|
|
5
|
+
// `{ archive, receipt }`) from --in or stdin and writes content-emit.json: the
|
|
6
|
+
// whole archive as the exact v2 PUT bodies foundryd sends (see
|
|
7
|
+
// buildContentEmit in ../foundry-emit.ts).
|
|
8
|
+
import { readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { pathToFileURL } from 'node:url'
|
|
10
|
+
import type { ContentArchiveInput } from '../engine/publication.js'
|
|
11
|
+
import { buildContentEmit, type ContentEmit } from '../foundry-emit.js'
|
|
12
|
+
|
|
13
|
+
const USAGE = 'Usage: foundry-emit --app <id> --source-sha <sha> --out <file> [--in <archive.json>]'
|
|
14
|
+
|
|
15
|
+
interface EmitArgs {
|
|
16
|
+
app: string
|
|
17
|
+
sourceSha: string
|
|
18
|
+
out: string
|
|
19
|
+
in?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseEmitArgs(argv: string[]): EmitArgs {
|
|
23
|
+
const values: Record<string, string> = {}
|
|
24
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
25
|
+
const flag = argv[index]
|
|
26
|
+
const value = argv[index + 1]
|
|
27
|
+
if (!['--app', '--source-sha', '--out', '--in'].includes(flag ?? '')) {
|
|
28
|
+
throw new Error(`unknown argument ${flag}\n${USAGE}`)
|
|
29
|
+
}
|
|
30
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value\n${USAGE}`)
|
|
31
|
+
values[(flag as string).slice(2)] = value
|
|
32
|
+
}
|
|
33
|
+
for (const required of ['app', 'source-sha', 'out']) {
|
|
34
|
+
if (!values[required]) throw new Error(`--${required} is required\n${USAGE}`)
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
app: values.app as string,
|
|
38
|
+
sourceSha: values['source-sha'] as string,
|
|
39
|
+
out: values.out as string,
|
|
40
|
+
...(values.in ? { in: values.in } : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function archiveFrom(raw: string): ContentArchiveInput {
|
|
45
|
+
const parsed = JSON.parse(raw) as { items?: unknown; archive?: { items?: unknown } }
|
|
46
|
+
if (Array.isArray(parsed.items)) return parsed as ContentArchiveInput
|
|
47
|
+
if (parsed.archive && Array.isArray(parsed.archive.items)) {
|
|
48
|
+
return parsed.archive as ContentArchiveInput
|
|
49
|
+
}
|
|
50
|
+
throw new Error('input is not a ContentArchiveInput: no items array')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runFoundryEmit(
|
|
54
|
+
argv: string[],
|
|
55
|
+
io: { readStdin: () => string } = { readStdin: () => readFileSync(0, 'utf8') },
|
|
56
|
+
): Promise<ContentEmit> {
|
|
57
|
+
const args = parseEmitArgs(argv)
|
|
58
|
+
const raw = args.in ? readFileSync(args.in, 'utf8') : io.readStdin()
|
|
59
|
+
const emit = await buildContentEmit(archiveFrom(raw), {
|
|
60
|
+
app: args.app,
|
|
61
|
+
sourceSha: args.sourceSha,
|
|
62
|
+
})
|
|
63
|
+
// Written whole or not at all: a reader never sees a truncated emit.
|
|
64
|
+
const temporary = `${args.out}.tmp-${process.pid}`
|
|
65
|
+
writeFileSync(temporary, `${JSON.stringify(emit, null, 2)}\n`)
|
|
66
|
+
renameSync(temporary, args.out)
|
|
67
|
+
return emit
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// An installed bin runs through a node_modules/.bin symlink, so compare real paths.
|
|
71
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
72
|
+
runFoundryEmit(process.argv.slice(2))
|
|
73
|
+
.then((emit) => {
|
|
74
|
+
const retired = emit.items.filter((item) => item.retire).length
|
|
75
|
+
console.log(
|
|
76
|
+
`foundry-emit: wrote ${emit.items.length} items (${retired} retire) for ${emit.app}@${emit.sourceSha}`,
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
.catch((error: unknown) => {
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`foundry-emit: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
82
|
+
)
|
|
83
|
+
process.exitCode = 1
|
|
84
|
+
})
|
|
85
|
+
}
|
|
@@ -47,7 +47,11 @@ import {
|
|
|
47
47
|
podcastPublishBlocker,
|
|
48
48
|
validPodcastSourceUrl,
|
|
49
49
|
} from './podcast-source.js'
|
|
50
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
frontsVideoSourceGeneration,
|
|
52
|
+
videoSourceIngestableOverHttps,
|
|
53
|
+
videoSourceUrlIngestableOverHttps,
|
|
54
|
+
} from './video-source.js'
|
|
51
55
|
|
|
52
56
|
/** The CMS's own name for the record this intent describes. */
|
|
53
57
|
export const FRONTS_PUBLISH_INTENT_SOURCE = 'masthead'
|
|
@@ -71,6 +75,7 @@ export type FrontsPublishIntentErrorCode =
|
|
|
71
75
|
| 'missing_article_body'
|
|
72
76
|
| 'video_media_not_ready'
|
|
73
77
|
| 'video_source_duration_unavailable'
|
|
78
|
+
| 'video_source_not_ingestable'
|
|
74
79
|
| 'missing_podcast_audio'
|
|
75
80
|
| PodcastPublishBlockerCode
|
|
76
81
|
|
|
@@ -779,6 +784,23 @@ async function videoSourceRef(
|
|
|
779
784
|
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(correlation)
|
|
780
785
|
)
|
|
781
786
|
return null
|
|
787
|
+
// A URL with a query, fragment, userinfo or non-https scheme passes
|
|
788
|
+
// validPodcastSourceUrl above but is refused by Foundry's media probe
|
|
789
|
+
// ("artifact ingest probe source URL is invalid"), which fails without
|
|
790
|
+
// telling the CMS — the HomeFronts 2026-10-01T18:05Z incident, where the
|
|
791
|
+
// video sat scheduled-but-never-prepared with no incomplete entry. Raise
|
|
792
|
+
// it loudly here instead of handing the URL to Foundry; consumers catch
|
|
793
|
+
// this per item, so the feed's other items are unaffected.
|
|
794
|
+
if (
|
|
795
|
+
found.processing_source_kind === 'http_url' &&
|
|
796
|
+
!videoSourceUrlIngestableOverHttps(found.processing_source_url)
|
|
797
|
+
) {
|
|
798
|
+
throw new FrontsPublishIntentError(
|
|
799
|
+
'video_source_not_ingestable',
|
|
800
|
+
row.id,
|
|
801
|
+
'Video source URL is not a plain HTTPS URL Foundry can ingest (no query string or fragment); re-capture the source with its direct /download link.',
|
|
802
|
+
)
|
|
803
|
+
}
|
|
782
804
|
if (
|
|
783
805
|
typeof found.duration_seconds !== 'number' ||
|
|
784
806
|
!Number.isFinite(found.duration_seconds) ||
|