@anthusai/papyrus 0.0.0 → 1.0.0-next.2

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 (254) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Reads the stored Markus body IR (`bodyIr`, the envelope written by
3
+ * `derive_body` in src/papyrus_content/markus_renderer/derive.py) and projects
4
+ * it into the flat structures the Pretext solver consumes. The solver input
5
+ * (`Article.body: string[]`) is unchanged; only its source changed.
6
+ *
7
+ * There is no fallback: an envelope with an unknown schema version, or a
8
+ * placeholder token that the envelope cannot resolve, throws `BodyIrError`.
9
+ */
10
+
11
+ import { MarkusSchemaVersionError, parseMarkusDocument } from "./markus-ir";
12
+ import { projectMarkusDocumentToPretext } from "./markus-projection";
13
+
14
+ export class BodyIrError extends Error {}
15
+
16
+ export type BodyProjectionImage = {
17
+ src: string;
18
+ alt: string;
19
+ caption?: string;
20
+ credit?: string;
21
+ layout?: string;
22
+ };
23
+
24
+ export type BodyProjection = {
25
+ body: string[];
26
+ pullQuotes: string[];
27
+ imageSrcs: BodyProjectionImage[];
28
+ };
29
+
30
+ const SUPPORTED_ENVELOPE_SCHEMA_VERSION = 1;
31
+ const SUPPORTED_MARKUS_IR_SCHEMA_VERSION = 1;
32
+ const TOKEN_PATTERN = /PAPYRUSMARKUP\d{5}END/g;
33
+ const WHOLE_TOKEN_PATTERN = /^PAPYRUSMARKUP\d{5}END$/;
34
+
35
+ type PapyrusSidecar = {
36
+ images: Record<string, Record<string, unknown>>;
37
+ citations: Record<string, string[]>;
38
+ citationLists: Record<string, unknown>;
39
+ bibliography: string[];
40
+ };
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return typeof value === "object" && value !== null && !Array.isArray(value);
44
+ }
45
+
46
+ function parseEnvelope(raw: unknown): Record<string, unknown> {
47
+ let value = raw;
48
+ if (typeof value === "string") {
49
+ try {
50
+ value = JSON.parse(value);
51
+ } catch {
52
+ throw new BodyIrError("bodyIr is not valid JSON");
53
+ }
54
+ }
55
+ if (!isRecord(value)) throw new BodyIrError("bodyIr must be a JSON object");
56
+ return value;
57
+ }
58
+
59
+ function readSidecar(envelope: Record<string, unknown>): PapyrusSidecar {
60
+ const sidecar = envelope.papyrus;
61
+ if (!isRecord(sidecar)) throw new BodyIrError("bodyIr.papyrus is missing");
62
+ const { images, citations, citationLists, bibliography } = sidecar;
63
+ if (!isRecord(images) || !isRecord(citations) || !isRecord(citationLists) || !Array.isArray(bibliography)) {
64
+ throw new BodyIrError("bodyIr.papyrus must contain images, citations, citationLists and bibliography");
65
+ }
66
+ return {
67
+ images: images as PapyrusSidecar["images"],
68
+ citations: citations as PapyrusSidecar["citations"],
69
+ citationLists: citationLists,
70
+ bibliography: bibliography.map(String),
71
+ };
72
+ }
73
+
74
+ function numberCitationKeysByFirstAppearance(document: unknown, sidecar: PapyrusSidecar): Map<string, number> {
75
+ const numbers = new Map<string, number>();
76
+ for (const token of JSON.stringify(document).match(TOKEN_PATTERN) ?? []) {
77
+ for (const key of sidecar.citations[token] ?? []) {
78
+ if (!numbers.has(key)) numbers.set(key, numbers.size + 1);
79
+ }
80
+ }
81
+ return numbers;
82
+ }
83
+
84
+ function resolveCitationTokens(text: string, sidecar: PapyrusSidecar, numbers: Map<string, number>): string {
85
+ return text.replace(TOKEN_PATTERN, (token) => {
86
+ const keys = sidecar.citations[token];
87
+ if (!keys) return token;
88
+ return keys.map((key) => `[${numbers.get(key)}]`).join("");
89
+ });
90
+ }
91
+
92
+ function imageFromSidecar(token: string, sidecar: PapyrusSidecar): BodyProjectionImage {
93
+ const entry = sidecar.images[token];
94
+ if (typeof entry?.src !== "string" || !entry.src) {
95
+ throw new BodyIrError(`bodyIr image ${token} has no src`);
96
+ }
97
+ const image: BodyProjectionImage = { src: entry.src, alt: typeof entry.alt === "string" ? entry.alt : "" };
98
+ if (typeof entry.caption === "string" && entry.caption) image.caption = entry.caption;
99
+ if (typeof entry.credit === "string" && entry.credit) image.credit = entry.credit;
100
+ if (typeof entry.layout === "string" && entry.layout) image.layout = entry.layout;
101
+ return image;
102
+ }
103
+
104
+ export function projectBodyIr(raw: unknown): BodyProjection {
105
+ const envelope = parseEnvelope(raw);
106
+ if (envelope.schemaVersion !== SUPPORTED_ENVELOPE_SCHEMA_VERSION) {
107
+ throw new BodyIrError(
108
+ `bodyIr schemaVersion ${String(envelope.schemaVersion)} is not supported (expected ${SUPPORTED_ENVELOPE_SCHEMA_VERSION})`,
109
+ );
110
+ }
111
+ const markus = envelope.markus;
112
+ if (!isRecord(markus) || markus.irSchemaVersion !== SUPPORTED_MARKUS_IR_SCHEMA_VERSION) {
113
+ throw new BodyIrError(
114
+ `bodyIr markus.irSchemaVersion ${String(isRecord(markus) ? markus.irSchemaVersion : undefined)} is not supported (expected ${SUPPORTED_MARKUS_IR_SCHEMA_VERSION})`,
115
+ );
116
+ }
117
+ const sidecar = readSidecar(envelope);
118
+
119
+ let projected;
120
+ try {
121
+ projected = projectMarkusDocumentToPretext(parseMarkusDocument(envelope.document));
122
+ } catch (error) {
123
+ if (error instanceof MarkusSchemaVersionError) throw new BodyIrError(error.message);
124
+ throw error;
125
+ }
126
+
127
+ const numbers = numberCitationKeysByFirstAppearance(envelope.document, sidecar);
128
+ const body: string[] = [];
129
+ const imageSrcs: BodyProjectionImage[] = [];
130
+ for (const paragraph of projected.body) {
131
+ if (WHOLE_TOKEN_PATTERN.test(paragraph) && paragraph in sidecar.images) {
132
+ imageSrcs.push(imageFromSidecar(paragraph, sidecar));
133
+ continue;
134
+ }
135
+ if (WHOLE_TOKEN_PATTERN.test(paragraph) && paragraph in sidecar.citationLists) {
136
+ sidecar.bibliography.forEach((entry, index) => body.push(`${index + 1}. ${entry}`));
137
+ continue;
138
+ }
139
+ body.push(resolveCitationTokens(paragraph, sidecar, numbers));
140
+ }
141
+ const pullQuotes = projected.pullQuotes.map((quote) => resolveCitationTokens(quote, sidecar, numbers));
142
+
143
+ if ([...body, ...pullQuotes].some((text) => /PAPYRUSMARKUP\d{5}END/.test(text))) {
144
+ throw new BodyIrError("bodyIr contains a placeholder token with no entry in bodyIr.papyrus");
145
+ }
146
+ return { body, pullQuotes, imageSrcs };
147
+ }
148
+
149
+ export type BodyImageMatch =
150
+ | { kind: "media"; mediaIndex: number }
151
+ | { kind: "external"; image: BodyProjectionImage };
152
+
153
+ /**
154
+ * Matches each image the body references to a stored media row by the
155
+ * `srcPath` the row was created from. Absolute http(s) sources need no media
156
+ * row. Any other source without a row is an error, never skipped.
157
+ */
158
+ export function matchBodyImages(
159
+ imageSrcs: BodyProjectionImage[],
160
+ mediaSrcPaths: Array<string | null>,
161
+ ): BodyImageMatch[] {
162
+ return imageSrcs.map((image) => {
163
+ const mediaIndex = mediaSrcPaths.indexOf(image.src);
164
+ if (mediaIndex >= 0) return { kind: "media", mediaIndex };
165
+ if (/^https?:\/\//i.test(image.src)) return { kind: "external", image };
166
+ throw new BodyIrError(`bodyIr references image "${image.src}" but no media asset has that srcPath`);
167
+ });
168
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Typed TypeScript mirror of the Markus document IR (`markus ast`, Markus
3
+ * repo branch `feature/document-ir`, `src/markusmd/ast.py` +
4
+ * `src/markusmd/blocks.py`).
5
+ *
6
+ * This is NOT invented from the Markus language docs -- it is typed
7
+ * directly from a real `markus ast` invocation (see
8
+ * `scripts/fixtures/markus-sample.ast.json`, captured from
9
+ * `scripts/fixtures/markus-sample.md` via `markus ast`) cross-checked
10
+ * against the emitting Python dataclasses' `to_dict()` methods. Papyrus
11
+ * does NOT re-parse Markdown; it only ingests this JSON shape.
12
+ *
13
+ * `IR_SCHEMA_VERSION` mirrors `markusmd.blocks.IR_SCHEMA_VERSION`. Bump it
14
+ * only in lockstep with a corresponding bump on the Markus side, and only
15
+ * after re-deriving the types below from a fresh `markus ast` capture --
16
+ * never by hand-editing the number.
17
+ */
18
+
19
+ /** The Markus document IR schema version Papyrus is typed against. */
20
+ export const MARKUS_IR_SCHEMA_VERSION = 1 as const;
21
+
22
+ // --------------------------------------------------------------------------
23
+ // Inline nodes (markusmd/blocks.py: Text | Emphasis | Strong | Strikethrough
24
+ // | CodeSpan | Link | Image | SoftBreak | HardBreak | HtmlInline)
25
+ // --------------------------------------------------------------------------
26
+
27
+ export type MarkusInlineText = { type: "text"; text: string };
28
+
29
+ export type MarkusInlineEmphasis = { type: "emphasis"; children: MarkusInline[] };
30
+
31
+ export type MarkusInlineStrong = { type: "strong"; children: MarkusInline[] };
32
+
33
+ export type MarkusInlineStrikethrough = { type: "strikethrough"; children: MarkusInline[] };
34
+
35
+ /**
36
+ * An inline code span, e.g. `` `code` ``. Distinct from the block-level
37
+ * `code` node (`MarkusCodeBlock`, type `"code"`) below -- do not conflate
38
+ * the two. `code_span` never has a `lang`; it is always plain text.
39
+ */
40
+ export type MarkusInlineCodeSpan = { type: "code_span"; code: string };
41
+
42
+ export type MarkusInlineLink = {
43
+ type: "link";
44
+ href: string | null;
45
+ title: string | null;
46
+ children: MarkusInline[];
47
+ };
48
+
49
+ export type MarkusInlineImage = {
50
+ type: "image";
51
+ src: string | null;
52
+ alt: string;
53
+ title: string | null;
54
+ };
55
+
56
+ export type MarkusInlineSoftBreak = { type: "soft_break" };
57
+
58
+ export type MarkusInlineHardBreak = { type: "hard_break" };
59
+
60
+ /**
61
+ * Raw inline HTML. Only produced when the document was parsed with
62
+ * `allow_html=True` (`markus ast --allow-html`). Papyrus builds fragments
63
+ * with `--allow-html` OFF (see docs/pluggable-publishers security note), so
64
+ * this node type should not appear in IR Papyrus ingests in practice; it is
65
+ * still typed here because the emitter can produce it, and the projection
66
+ * must not silently drop it if it ever does.
67
+ */
68
+ export type MarkusInlineHtml = { type: "html_inline"; value: string };
69
+
70
+ export type MarkusInline =
71
+ | MarkusInlineText
72
+ | MarkusInlineEmphasis
73
+ | MarkusInlineStrong
74
+ | MarkusInlineStrikethrough
75
+ | MarkusInlineCodeSpan
76
+ | MarkusInlineLink
77
+ | MarkusInlineImage
78
+ | MarkusInlineSoftBreak
79
+ | MarkusInlineHardBreak
80
+ | MarkusInlineHtml;
81
+
82
+ // --------------------------------------------------------------------------
83
+ // Typed Markdown block nodes (markusmd/blocks.py: Heading | Paragraph |
84
+ // List | Blockquote | CodeBlock | ThematicBreak | HtmlBlock | Table).
85
+ // These sit as ordered PEERS of directive nodes in one children[] array --
86
+ // there is no opaque raw-markdown node in the IR.
87
+ // --------------------------------------------------------------------------
88
+
89
+ export type MarkusHeading = {
90
+ type: "heading";
91
+ level: number;
92
+ inline: MarkusInline[];
93
+ line: number | null;
94
+ };
95
+
96
+ export type MarkusParagraph = {
97
+ type: "paragraph";
98
+ inline: MarkusInline[];
99
+ line: number | null;
100
+ };
101
+
102
+ export type MarkusListItem = {
103
+ type: "list_item";
104
+ checked: boolean | null;
105
+ children: MarkusBlock[];
106
+ line: number | null;
107
+ };
108
+
109
+ export type MarkusList = {
110
+ type: "list";
111
+ ordered: boolean;
112
+ start: number | null;
113
+ items: MarkusListItem[];
114
+ line: number | null;
115
+ };
116
+
117
+ export type MarkusBlockquote = {
118
+ type: "blockquote";
119
+ children: MarkusBlock[];
120
+ line: number | null;
121
+ };
122
+
123
+ /**
124
+ * A fenced or indented code BLOCK. Distinct from the inline `code_span`
125
+ * node above -- do not conflate the two.
126
+ */
127
+ export type MarkusCodeBlock = {
128
+ type: "code";
129
+ lang: string | null;
130
+ value: string;
131
+ line: number | null;
132
+ };
133
+
134
+ export type MarkusThematicBreak = { type: "thematic_break"; line: number | null };
135
+
136
+ /**
137
+ * A raw HTML block. Only produced when `allow_html=True`; see
138
+ * `MarkusInlineHtml` above for why Papyrus's build should never emit this
139
+ * in the IR it ingests, and why it is still typed and handled anyway.
140
+ */
141
+ export type MarkusHtmlBlock = { type: "html"; value: string; line: number | null };
142
+
143
+ export type MarkusTable = {
144
+ type: "table";
145
+ align: Array<string | null>;
146
+ header: MarkusInline[][];
147
+ rows: MarkusInline[][][];
148
+ line: number | null;
149
+ };
150
+
151
+ export type MarkusBlock =
152
+ | MarkusHeading
153
+ | MarkusParagraph
154
+ | MarkusList
155
+ | MarkusBlockquote
156
+ | MarkusCodeBlock
157
+ | MarkusThematicBreak
158
+ | MarkusHtmlBlock
159
+ | MarkusTable;
160
+
161
+ // --------------------------------------------------------------------------
162
+ // Directives (markusmd/ast.py: Directive). Container directives have
163
+ // `leaf: false` and nest further nodes; leaf directives (`metric`, `video`)
164
+ // have `leaf: true` and always `children: []`. `attributes` is whatever the
165
+ // directive's validated attribute schema produced -- Papyrus treats it as
166
+ // an open record rather than typing every directive's attribute set, since
167
+ // the vocabulary (and its attributes) are owned and validated by Markus,
168
+ // not re-specified here.
169
+ // --------------------------------------------------------------------------
170
+
171
+ export type MarkusDirectiveAttributes = Record<string, string | number | boolean | null>;
172
+
173
+ export type MarkusDirective = {
174
+ type: "directive";
175
+ name: string;
176
+ attributes: MarkusDirectiveAttributes;
177
+ leaf: boolean;
178
+ line: number;
179
+ children: MarkusNode[];
180
+ };
181
+
182
+ /** A document child: a typed Markdown block or a directive, in document order. */
183
+ export type MarkusNode = MarkusBlock | MarkusDirective;
184
+
185
+ // --------------------------------------------------------------------------
186
+ // Document root (markusmd/ast.py: Document.to_dict()).
187
+ // --------------------------------------------------------------------------
188
+
189
+ export type MarkusFrontMatter = Record<string, unknown>;
190
+
191
+ export type MarkusDocument = {
192
+ type: "document";
193
+ schema_version: number;
194
+ front_matter: MarkusFrontMatter;
195
+ children: MarkusNode[];
196
+ };
197
+
198
+ /**
199
+ * Thrown when ingested IR declares a `schema_version` this module was not
200
+ * typed against. This is the "fail loudly on mismatch" boundary check
201
+ * required before any projection runs -- silently proceeding against an
202
+ * unknown shape would risk mis-typed access on fields that changed shape.
203
+ */
204
+ export class MarkusSchemaVersionError extends Error {
205
+ constructor(public readonly received: unknown) {
206
+ super(
207
+ `Markus document IR schema_version mismatch: this build understands ` +
208
+ `schema_version ${MARKUS_IR_SCHEMA_VERSION}, but received ` +
209
+ `${JSON.stringify(received)}. Re-derive lib/markus-ir.ts from a fresh ` +
210
+ `\`markus ast\` capture before ingesting this document.`,
211
+ );
212
+ this.name = "MarkusSchemaVersionError";
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Parse + validate a raw `markus ast` JSON payload into a typed
218
+ * `MarkusDocument`, checking `schema_version` at the boundary. This is the
219
+ * only place Papyrus should accept untyped Markus IR JSON -- everything
220
+ * downstream (the Pretext projection, a future Markus build step) should
221
+ * consume the typed `MarkusDocument` this returns, never raw `unknown`.
222
+ */
223
+ export function parseMarkusDocument(raw: unknown): MarkusDocument {
224
+ if (typeof raw !== "object" || raw === null) {
225
+ throw new Error("Markus document IR must be a JSON object");
226
+ }
227
+ const candidate = raw as Record<string, unknown>;
228
+ if (candidate.type !== "document") {
229
+ throw new Error(`Expected Markus IR root node of type "document", got ${JSON.stringify(candidate.type)}`);
230
+ }
231
+ if (candidate.schema_version !== MARKUS_IR_SCHEMA_VERSION) {
232
+ throw new MarkusSchemaVersionError(candidate.schema_version);
233
+ }
234
+ return candidate as MarkusDocument;
235
+ }
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The Pretext projection: turns a typed Markus document IR
3
+ * (`lib/markus-ir.ts`) into the flat structures the Pretext client solver
4
+ * consumes today -- `Article.body: string[]`, `Article.pullQuotes: string[]`,
5
+ * and figure-derived `ArticleImageAsset[]`.
6
+ *
7
+ * THIS PROJECTION IS LOSSY BY DESIGN. Markus's directive vocabulary
8
+ * (card-grid, two-up, callout, tabs, timeline, ...) is a strict superset of
9
+ * what Pretext can lay out -- Pretext cannot render a card grid. The
10
+ * non-negotiable degradation rule (decided on PPY-92f846 / PPY-a10206):
11
+ *
12
+ * Any directive Pretext cannot express recurses into its children so its
13
+ * content is preserved INLINE in `body`. Nothing is ever silently
14
+ * dropped.
15
+ *
16
+ * The two directives Pretext *can* express get pulled into their own
17
+ * side-channels instead of `body`:
18
+ * - `pull-quote` -> `pullQuotes[]`
19
+ * - `figure` -> `images[]` (as `ArticleImageAsset`)
20
+ *
21
+ * This mirrors the verified prototype recorded on PPY-a10206: walk
22
+ * `children` in order; paragraph/heading push their flattened text onto
23
+ * `body`; `pull-quote` pushes onto `pullQuotes`; any other directive
24
+ * recurses into its children. This module generalizes that same rule to
25
+ * the rest of the typed block vocabulary (list/blockquote/code/table/
26
+ * thematic_break/html) that the prototype's sample article didn't happen
27
+ * to exercise, and to leaf directives with no children (`metric`, `video`)
28
+ * by degrading to their textual attributes rather than dropping them.
29
+ */
30
+
31
+ import type { ArticleImageAsset } from "./articles";
32
+ import {
33
+ parseMarkusDocument,
34
+ type MarkusBlock,
35
+ type MarkusDirective,
36
+ type MarkusDocument,
37
+ type MarkusInline,
38
+ type MarkusNode,
39
+ } from "./markus-ir";
40
+
41
+ export type PretextProjection = {
42
+ body: string[];
43
+ pullQuotes: string[];
44
+ images: ArticleImageAsset[];
45
+ };
46
+
47
+ /** Flatten an inline node tree to plain text. No markup survives -- Pretext's
48
+ * `body` is plain-text paragraphs, so emphasis/strong/links/code spans all
49
+ * degrade to their text content. Images are dropped here (they are not
50
+ * textual); a top-level `image` inline node inside a paragraph is rare in
51
+ * practice and, if it occurs, its alt text is preserved instead of the
52
+ * image itself, so no content vanishes silently. */
53
+ function inlineText(nodes: MarkusInline[]): string {
54
+ return nodes
55
+ .map((node): string => {
56
+ switch (node.type) {
57
+ case "text":
58
+ return node.text;
59
+ case "code_span":
60
+ return node.code;
61
+ case "emphasis":
62
+ case "strong":
63
+ case "strikethrough":
64
+ return inlineText(node.children);
65
+ case "link":
66
+ return inlineText(node.children);
67
+ case "image":
68
+ return node.alt ?? "";
69
+ case "soft_break":
70
+ return " ";
71
+ case "hard_break":
72
+ return "\n";
73
+ case "html_inline":
74
+ // --allow-html is off for real Papyrus builds (see module doc);
75
+ // preserve the raw text rather than silently dropping it in the
76
+ // defensive case this ever appears.
77
+ return node.value;
78
+ default:
79
+ return "";
80
+ }
81
+ })
82
+ .join("")
83
+ .trim();
84
+ }
85
+
86
+ /** Flatten a block node's own text content (not directives -- callers walk
87
+ * directives separately so their degradation/side-channel rules apply). */
88
+ function blockText(block: MarkusBlock): string[] {
89
+ switch (block.type) {
90
+ case "heading":
91
+ case "paragraph":
92
+ return [inlineText(block.inline)];
93
+ case "list":
94
+ return block.items.map((item) => flattenBlocksToText(item.children).join(" "));
95
+ case "blockquote":
96
+ return flattenBlocksToText(block.children);
97
+ case "code":
98
+ // A code block has no inline richness; preserve its literal text
99
+ // wholesale rather than dropping it because Pretext has no code
100
+ // rendering of its own.
101
+ return block.value.trim().length > 0 ? [block.value] : [];
102
+ case "thematic_break":
103
+ // No textual content exists to preserve; there is nothing to drop.
104
+ return [];
105
+ case "html":
106
+ // See `inlineText`'s html_inline case: only reachable if IR was ever
107
+ // produced with --allow-html, which Papyrus's build does not do.
108
+ return block.value.trim().length > 0 ? [block.value] : [];
109
+ case "table": {
110
+ const rowText = (row: MarkusInline[][]) => row.map((cell) => inlineText(cell)).join(" | ");
111
+ const lines = [rowText(block.header), ...block.rows.map(rowText)];
112
+ return lines.filter((line) => line.trim().length > 0);
113
+ }
114
+ default:
115
+ return [];
116
+ }
117
+ }
118
+
119
+ function isDirective(node: MarkusNode): node is MarkusDirective {
120
+ return node.type === "directive";
121
+ }
122
+
123
+ /** Degrade a leaf directive (no children by definition) to its textual
124
+ * attributes, so `metric`/`video` don't vanish just because they have
125
+ * nothing to recurse into. */
126
+ function leafDirectiveText(directive: MarkusDirective): string[] {
127
+ const attrs = directive.attributes;
128
+ if (directive.name === "metric") {
129
+ const label = attrs.label != null ? String(attrs.label) : null;
130
+ const value = attrs.value != null ? String(attrs.value) : null;
131
+ const unit = attrs.unit != null ? String(attrs.unit) : "";
132
+ const text = [label, value != null ? `${value}${unit}` : null].filter(Boolean).join(": ");
133
+ return text.length > 0 ? [text] : [];
134
+ }
135
+ if (directive.name === "video") {
136
+ const title = attrs.title != null ? String(attrs.title) : null;
137
+ return title ? [title] : [];
138
+ }
139
+ // Unknown future leaf directive: fall back to whatever string-ish
140
+ // attributes it has rather than dropping it silently.
141
+ const text = Object.values(attrs)
142
+ .filter((value) => typeof value === "string" && value.trim().length > 0)
143
+ .join(" ");
144
+ return text ? [text] : [];
145
+ }
146
+
147
+ /** Extract an ArticleImageAsset from a `figure` directive's attributes. */
148
+ function figureToImageAsset(directive: MarkusDirective, index: number): ArticleImageAsset {
149
+ const attrs = directive.attributes;
150
+ const src = attrs.src != null ? String(attrs.src) : "";
151
+ const alt = attrs.alt != null ? String(attrs.alt) : "";
152
+ const caption = attrs.caption != null ? String(attrs.caption) : undefined;
153
+ const credit = attrs.credit != null ? String(attrs.credit) : "";
154
+ return {
155
+ id: `figure-${index}`,
156
+ type: "image",
157
+ src,
158
+ alt,
159
+ caption,
160
+ credit,
161
+ };
162
+ }
163
+
164
+ function flattenBlocksToText(nodes: MarkusNode[]): string[] {
165
+ const out: string[] = [];
166
+ for (const node of nodes) {
167
+ if (isDirective(node)) {
168
+ out.push(...directiveDegradedText(node));
169
+ } else {
170
+ out.push(...blockText(node));
171
+ }
172
+ }
173
+ return out.filter((line) => line.trim().length > 0);
174
+ }
175
+
176
+ /** The degradation rule for a directive Pretext cannot render as itself:
177
+ * recurse into its children (or, for a leaf directive with no children,
178
+ * degrade to its attributes) so nothing is silently dropped. */
179
+ function directiveDegradedText(directive: MarkusDirective): string[] {
180
+ if (directive.leaf || directive.children.length === 0) {
181
+ return leafDirectiveText(directive);
182
+ }
183
+ return flattenBlocksToText(directive.children);
184
+ }
185
+
186
+ /**
187
+ * Project a typed Markus document IR into the flat shape Pretext consumes.
188
+ * Walks `document.children` in order:
189
+ * - `paragraph` / `heading` -> pushed onto `body`
190
+ * - `list` / `blockquote` / `code` / `table` / `thematic_break` / `html`
191
+ * -> degrade-preserved onto `body` (see `blockText`)
192
+ * - directive `pull-quote` -> its flattened text goes to `pullQuotes`,
193
+ * NOT `body`
194
+ * - directive `figure` -> becomes an `ArticleImageAsset` in `images`,
195
+ * NOT `body`
196
+ * - any other directive (`card-grid`, `card`, `two-up`, `column`,
197
+ * `callout`, `aside`, `details`, `tabs`, `tab`, `step-list`, `step`,
198
+ * `timeline`, `timeline-event`, and any future addition to the
199
+ * vocabulary) -> recurses into its children per the degradation rule
200
+ */
201
+ export function projectMarkusDocumentToPretext(document: MarkusDocument): PretextProjection {
202
+ const body: string[] = [];
203
+ const pullQuotes: string[] = [];
204
+ const images: ArticleImageAsset[] = [];
205
+ let figureIndex = 0;
206
+
207
+ for (const node of document.children) {
208
+ if (isDirective(node)) {
209
+ if (node.name === "pull-quote") {
210
+ const text = flattenBlocksToText(node.children).join(" ");
211
+ if (text.trim().length > 0) pullQuotes.push(text);
212
+ continue;
213
+ }
214
+ if (node.name === "figure") {
215
+ images.push(figureToImageAsset(node, figureIndex));
216
+ figureIndex += 1;
217
+ // A figure directive's own children (if any future Markus version
218
+ // ever nests content under it) are still degraded into body so
219
+ // nothing is dropped, matching the non-negotiable rule.
220
+ if (node.children.length > 0) body.push(...flattenBlocksToText(node.children));
221
+ continue;
222
+ }
223
+ body.push(...directiveDegradedText(node));
224
+ continue;
225
+ }
226
+ body.push(...blockText(node));
227
+ }
228
+
229
+ return {
230
+ body: body.filter((line) => line.trim().length > 0),
231
+ pullQuotes,
232
+ images,
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Convenience entry point: parse + validate raw `markus ast` JSON (failing
238
+ * loudly on schema mismatch, per `parseMarkusDocument`) and project it in
239
+ * one call.
240
+ */
241
+ export function projectMarkusJsonToPretext(raw: unknown): PretextProjection {
242
+ return projectMarkusDocumentToPretext(parseMarkusDocument(raw));
243
+ }