@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.
Files changed (182) hide show
  1. package/dist/cli/foundry-emit.d.ts +14 -0
  2. package/dist/cli/foundry-emit.d.ts.map +1 -0
  3. package/dist/cli/foundry-emit.js +69 -0
  4. package/dist/cli/foundry-emit.js.map +1 -0
  5. package/dist/engine/fronts-publish-intent.d.ts +1 -1
  6. package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
  7. package/dist/engine/fronts-publish-intent.js +12 -1
  8. package/dist/engine/fronts-publish-intent.js.map +1 -1
  9. package/dist/engine/publication-receipt/receipt.d.ts +20 -20
  10. package/dist/engine/publisher.d.ts +16 -1
  11. package/dist/engine/publisher.d.ts.map +1 -1
  12. package/dist/engine/publisher.js +55 -4
  13. package/dist/engine/publisher.js.map +1 -1
  14. package/dist/engine/tags.d.ts.map +1 -1
  15. package/dist/engine/tags.js +18 -1
  16. package/dist/engine/tags.js.map +1 -1
  17. package/dist/engine/topics.d.ts.map +1 -1
  18. package/dist/engine/topics.js +4 -1
  19. package/dist/engine/topics.js.map +1 -1
  20. package/dist/engine/video-source.d.ts +21 -0
  21. package/dist/engine/video-source.d.ts.map +1 -1
  22. package/dist/engine/video-source.js +40 -0
  23. package/dist/engine/video-source.js.map +1 -1
  24. package/dist/foundry-emit.d.ts +146 -0
  25. package/dist/foundry-emit.d.ts.map +1 -0
  26. package/dist/foundry-emit.js +189 -0
  27. package/dist/foundry-emit.js.map +1 -0
  28. package/dist/integration/index.d.ts.map +1 -1
  29. package/dist/integration/index.js +9 -0
  30. package/dist/integration/index.js.map +1 -1
  31. package/dist/integration/options.d.ts +24 -0
  32. package/dist/integration/options.d.ts.map +1 -1
  33. package/dist/integration/options.js +22 -0
  34. package/dist/integration/options.js.map +1 -1
  35. package/dist/lib/publish-timezone.d.ts +38 -0
  36. package/dist/lib/publish-timezone.d.ts.map +1 -0
  37. package/dist/lib/publish-timezone.js +135 -0
  38. package/dist/lib/publish-timezone.js.map +1 -0
  39. package/dist/providers/null.d.ts.map +1 -1
  40. package/dist/providers/null.js +6 -0
  41. package/dist/providers/null.js.map +1 -1
  42. package/dist/providers/types.d.ts +56 -0
  43. package/dist/providers/types.d.ts.map +1 -1
  44. package/dist/providers/types.js.map +1 -1
  45. package/dist/routes/ai.d.ts.map +1 -1
  46. package/dist/routes/ai.js +21 -13
  47. package/dist/routes/ai.js.map +1 -1
  48. package/dist/routes/analytics.d.ts +1 -0
  49. package/dist/routes/analytics.d.ts.map +1 -1
  50. package/dist/routes/analytics.js +20 -0
  51. package/dist/routes/analytics.js.map +1 -1
  52. package/dist/routes/content.d.ts +27 -0
  53. package/dist/routes/content.d.ts.map +1 -1
  54. package/dist/routes/content.js +60 -114
  55. package/dist/routes/content.js.map +1 -1
  56. package/dist/routes/foundry-publish.d.ts +50 -0
  57. package/dist/routes/foundry-publish.d.ts.map +1 -1
  58. package/dist/routes/foundry-publish.js +540 -27
  59. package/dist/routes/foundry-publish.js.map +1 -1
  60. package/dist/routes/machine-context.d.ts +6 -0
  61. package/dist/routes/machine-context.d.ts.map +1 -0
  62. package/dist/routes/machine-context.js +20 -0
  63. package/dist/routes/machine-context.js.map +1 -0
  64. package/dist/routes/surveys.d.ts +14 -1
  65. package/dist/routes/surveys.d.ts.map +1 -1
  66. package/dist/routes/surveys.js +515 -10
  67. package/dist/routes/surveys.js.map +1 -1
  68. package/dist/schema/insights-ingest.d.ts +8 -8
  69. package/dist/schema/migrations.d.ts.map +1 -1
  70. package/dist/schema/migrations.js +15 -0
  71. package/dist/schema/migrations.js.map +1 -1
  72. package/dist/schema/tables.d.ts +1 -1
  73. package/dist/schema/tables.d.ts.map +1 -1
  74. package/dist/schema/tables.js +2 -0
  75. package/dist/schema/tables.js.map +1 -1
  76. package/dist/surveys/access.d.ts +1 -0
  77. package/dist/surveys/access.d.ts.map +1 -1
  78. package/dist/surveys/access.js +13 -4
  79. package/dist/surveys/access.js.map +1 -1
  80. package/dist/surveys/auto-label.d.ts +100 -0
  81. package/dist/surveys/auto-label.d.ts.map +1 -0
  82. package/dist/surveys/auto-label.js +325 -0
  83. package/dist/surveys/auto-label.js.map +1 -0
  84. package/dist/surveys/schema.d.ts +38 -12
  85. package/dist/surveys/schema.d.ts.map +1 -1
  86. package/dist/surveys/schema.js +18 -1
  87. package/dist/surveys/schema.js.map +1 -1
  88. package/dist/ui/api/_content-config.d.ts.map +1 -1
  89. package/dist/ui/api/_content-config.js +4 -0
  90. package/dist/ui/api/_content-config.js.map +1 -1
  91. package/dist/ui/api/analytics.d.ts.map +1 -1
  92. package/dist/ui/api/analytics.js +5 -2
  93. package/dist/ui/api/analytics.js.map +1 -1
  94. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
  95. package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
  96. package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
  97. package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
  98. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
  99. package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
  100. package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
  101. package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
  102. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
  103. package/dist/ui/api/surveys/[surveyId].js +28 -1
  104. package/dist/ui/api/surveys/[surveyId].js.map +1 -1
  105. package/dist/ui/commands.d.ts.map +1 -1
  106. package/dist/ui/commands.js +2 -0
  107. package/dist/ui/commands.js.map +1 -1
  108. package/dist/ui/hash-router.d.ts +1 -1
  109. package/dist/ui/hash-router.d.ts.map +1 -1
  110. package/dist/ui/hash-router.js +1 -0
  111. package/dist/ui/hash-router.js.map +1 -1
  112. package/dist/ui/inspector/PublishTab.d.ts +7 -1
  113. package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
  114. package/dist/ui/inspector/PublishTab.js +24 -26
  115. package/dist/ui/inspector/PublishTab.js.map +1 -1
  116. package/dist/ui/nav.d.ts.map +1 -1
  117. package/dist/ui/nav.js +1 -0
  118. package/dist/ui/nav.js.map +1 -1
  119. package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
  120. package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
  121. package/dist/ui/screens/AnalyticsScreen.js +13 -8
  122. package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
  123. package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
  124. package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
  125. package/dist/ui/screens/PodcastListeningScreen.js +244 -0
  126. package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
  127. package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
  128. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
  129. package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
  130. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
  131. package/dist/ui/screens/analytics-data.d.ts +14 -0
  132. package/dist/ui/screens/analytics-data.d.ts.map +1 -1
  133. package/dist/ui/screens/analytics-data.js +16 -0
  134. package/dist/ui/screens/analytics-data.js.map +1 -1
  135. package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
  136. package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
  137. package/dist/ui/screens/podcast-listening-data.js +98 -0
  138. package/dist/ui/screens/podcast-listening-data.js.map +1 -0
  139. package/dist/ui/screens/registry.d.ts.map +1 -1
  140. package/dist/ui/screens/registry.js +2 -0
  141. package/dist/ui/screens/registry.js.map +1 -1
  142. package/docs/foundry-publish.md +208 -0
  143. package/migrations/0033_survey_answer_labels.sql +15 -0
  144. package/package.json +9 -3
  145. package/src/cli/foundry-emit.ts +85 -0
  146. package/src/engine/fronts-publish-intent.ts +23 -1
  147. package/src/engine/publisher.ts +80 -4
  148. package/src/engine/tags.ts +21 -1
  149. package/src/engine/topics.ts +4 -1
  150. package/src/engine/video-source.ts +44 -0
  151. package/src/foundry-emit.ts +255 -0
  152. package/src/integration/index.ts +9 -0
  153. package/src/integration/options.ts +23 -0
  154. package/src/lib/publish-timezone.ts +154 -0
  155. package/src/providers/null.ts +6 -0
  156. package/src/providers/types.ts +52 -0
  157. package/src/routes/ai.ts +29 -17
  158. package/src/routes/analytics.ts +21 -0
  159. package/src/routes/content.ts +86 -131
  160. package/src/routes/foundry-publish.ts +746 -29
  161. package/src/routes/machine-context.ts +24 -0
  162. package/src/routes/surveys.ts +587 -12
  163. package/src/schema/migrations.ts +15 -0
  164. package/src/schema/tables.ts +2 -0
  165. package/src/surveys/access.ts +14 -4
  166. package/src/surveys/auto-label.ts +420 -0
  167. package/src/surveys/schema.ts +25 -1
  168. package/src/ui/api/_content-config.ts +6 -0
  169. package/src/ui/api/analytics.ts +5 -2
  170. package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
  171. package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
  172. package/src/ui/api/surveys/[surveyId].ts +37 -3
  173. package/src/ui/commands.ts +2 -0
  174. package/src/ui/hash-router.ts +1 -0
  175. package/src/ui/inspector/PublishTab.tsx +33 -25
  176. package/src/ui/nav.ts +1 -0
  177. package/src/ui/screens/AnalyticsScreen.tsx +34 -6
  178. package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
  179. package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
  180. package/src/ui/screens/analytics-data.ts +18 -0
  181. package/src/ui/screens/podcast-listening-data.ts +114 -0
  182. 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;AA4B7D,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,CA0BtB;AAED,eAAO,MAAM,UAAU,EAAE,cAAmC,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"}
@@ -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.19",
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 { frontsVideoSourceGeneration, videoSourceIngestableOverHttps } from './video-source.js'
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) ||