pandorga 1.2.1

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 (376) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +77 -0
  3. data/DESIGN.md +1434 -0
  4. data/LICENSE +21 -0
  5. data/README.md +81 -0
  6. data/_data/themes/dark.yml +97 -0
  7. data/_data/themes/light.yml +120 -0
  8. data/_data/themes/shared.yml +37 -0
  9. data/_data/translations.yml +85 -0
  10. data/_includes/chord-tab.html +38 -0
  11. data/_includes/content-runtime/00-config.html +22 -0
  12. data/_includes/content-runtime/10-utils.html +176 -0
  13. data/_includes/content-runtime/20-markdown.html +264 -0
  14. data/_includes/content-runtime/25-code-blocks.html +315 -0
  15. data/_includes/content-runtime/30-includes-bib.html +190 -0
  16. data/_includes/content-runtime/30-includes-xcite.html +122 -0
  17. data/_includes/content-runtime/30-includes.html +365 -0
  18. data/_includes/content-runtime/40-listing.html +225 -0
  19. data/_includes/content-runtime/41-listing-helpers.html +71 -0
  20. data/_includes/content-runtime/42-listing-filter.html +208 -0
  21. data/_includes/content-runtime/43-blocks.html +379 -0
  22. data/_includes/content-runtime/50-detail.html +861 -0
  23. data/_includes/content-runtime/55-toc.html +176 -0
  24. data/_includes/content-runtime/60-fragments.html +567 -0
  25. data/_includes/content-runtime/90-bootstrap.html +76 -0
  26. data/_includes/cut-in.html +10 -0
  27. data/_includes/cv/cv-box.html +18 -0
  28. data/_includes/dict-card.html +24 -0
  29. data/_includes/figure.html +36 -0
  30. data/_includes/home/band-index.html +43 -0
  31. data/_includes/home/band-listing.html +49 -0
  32. data/_includes/mathjax.html +10 -0
  33. data/_includes/ongoing-product.html +1 -0
  34. data/_includes/page/content-runtime.html +20 -0
  35. data/_includes/page/date_evolution.html +19 -0
  36. data/_includes/page/figure-lightbox.html +102 -0
  37. data/_includes/page/formatted_date.html +15 -0
  38. data/_includes/page/hero-default.html +18 -0
  39. data/_includes/page/listing-runtime-attrs.html +8 -0
  40. data/_includes/page/mermaid.html +101 -0
  41. data/_includes/page/network-graph.html +1253 -0
  42. data/_includes/page/page-header.html +41 -0
  43. data/_includes/page/professional-connections.html +2 -0
  44. data/_includes/page/site-footer.html +101 -0
  45. data/_includes/page/timeline-section.html +868 -0
  46. data/_includes/plotly.html +22 -0
  47. data/_includes/theme/dev-reexport.html +100 -0
  48. data/_includes/theme/favicons.html +4 -0
  49. data/_includes/theme/font-loader.html +55 -0
  50. data/_includes/theme/shell-theme-boot.html +115 -0
  51. data/_includes/theme/site-logo.html +6 -0
  52. data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
  53. data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
  54. data/_includes/theme/theme-context.liquid +20 -0
  55. data/_includes/theme/theme-toggle.html +94 -0
  56. data/_includes/theme/theme-vars-block.liquid +161 -0
  57. data/_includes/theme/theme-vars.html +16 -0
  58. data/_includes/transcription.html +80 -0
  59. data/_includes/tweet.html +22 -0
  60. data/_includes/video.html +11 -0
  61. data/_includes/xcite.html +3 -0
  62. data/_includes/youtube-short.html +26 -0
  63. data/_includes/youtube.html +12 -0
  64. data/_layouts/article.html +27 -0
  65. data/_layouts/articles.html +534 -0
  66. data/_layouts/bibliography.html +417 -0
  67. data/_layouts/blog.html +544 -0
  68. data/_layouts/cv.html +393 -0
  69. data/_layouts/home.html +319 -0
  70. data/_layouts/network.html +9 -0
  71. data/_layouts/pandorga/articles.html +4 -0
  72. data/_layouts/pandorga/bibliography.html +4 -0
  73. data/_layouts/pandorga/blog.html +4 -0
  74. data/_layouts/pandorga/cv.html +4 -0
  75. data/_layouts/pandorga/media.html +4 -0
  76. data/_layouts/pandorga/network.html +4 -0
  77. data/_layouts/pandorga/portfolio.html +4 -0
  78. data/_layouts/post.html +7 -0
  79. data/_layouts/product.html +5 -0
  80. data/_layouts/project.html +27 -0
  81. data/_layouts/projects.html +534 -0
  82. data/_layouts/resources.html +387 -0
  83. data/_layouts/shell.html +203 -0
  84. data/_layouts/text.html +246 -0
  85. data/_plugins/bibliography_artifact.rb +57 -0
  86. data/_plugins/content_backend.rb +37 -0
  87. data/_plugins/content_collections.rb +51 -0
  88. data/_plugins/content_detail_paths.rb +58 -0
  89. data/_plugins/content_headers.rb +73 -0
  90. data/_plugins/content_keys.rb +310 -0
  91. data/_plugins/content_local_serve.rb +168 -0
  92. data/_plugins/content_site_adapter.rb +159 -0
  93. data/_plugins/content_validators_hook.rb +20 -0
  94. data/_plugins/content_writing_index.rb +52 -0
  95. data/_plugins/content_writing_paths.rb +55 -0
  96. data/_plugins/force_utf8_encoding.rb +13 -0
  97. data/_plugins/git_chronology.rb +211 -0
  98. data/_plugins/git_metadata.rb +43 -0
  99. data/_plugins/job_sections.rb +25 -0
  100. data/_plugins/lib/content_validators.rb +27 -0
  101. data/_plugins/lib/resource_validator.rb +56 -0
  102. data/_plugins/lib/tag_validator.rb +153 -0
  103. data/_plugins/lib/text_excerpt.rb +56 -0
  104. data/_plugins/lib/writing_entry.rb +107 -0
  105. data/_plugins/local_detail_rewrites.rb +194 -0
  106. data/_plugins/timeline.rb +507 -0
  107. data/_plugins/writing_slug_limits.rb +70 -0
  108. data/assets/css/article.css +1330 -0
  109. data/assets/css/base.css +1432 -0
  110. data/assets/css/cv.css +13 -0
  111. data/assets/css/global-link.css +94 -0
  112. data/assets/css/ledger/bibliography.css +307 -0
  113. data/assets/css/ledger/chrome.css +118 -0
  114. data/assets/css/ledger/cv.css +836 -0
  115. data/assets/css/ledger/detail.css +1270 -0
  116. data/assets/css/ledger/footer.css +298 -0
  117. data/assets/css/ledger/home.css +1490 -0
  118. data/assets/css/ledger/network.css +106 -0
  119. data/assets/css/ledger/projects.css +716 -0
  120. data/assets/css/ledger/rascunhos.css +379 -0
  121. data/assets/css/ledger/sources.css +327 -0
  122. data/assets/css/ledger/writing.css +754 -0
  123. data/assets/tailwind-play.js +15 -0
  124. data/examples/full/Gemfile +7 -0
  125. data/examples/full/_config.yml +92 -0
  126. data/examples/full/_redirects +3 -0
  127. data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
  128. data/examples/full/content/collections/data/contacts.yml +3 -0
  129. data/examples/full/content/collections/data/education.yml +4 -0
  130. data/examples/full/content/collections/jobs/research-fellow.md +12 -0
  131. data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
  132. data/examples/full/content/collections/products/ledger-tool.md +6 -0
  133. data/examples/full/content/collections/projects/demo-kit.md +10 -0
  134. data/examples/full/content/collections/resources/style-guide.md +7 -0
  135. data/examples/full/content/pages/cv/summary.md +1 -0
  136. data/examples/full/content/pages/headers.yml +20 -0
  137. data/examples/full/index.html +4 -0
  138. data/examples/minimal/Gemfile +7 -0
  139. data/examples/minimal/_config.yml +47 -0
  140. data/examples/minimal/_redirects +3 -0
  141. data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
  142. data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
  143. data/examples/minimal/content/pages/headers.yml +6 -0
  144. data/examples/minimal/index.html +4 -0
  145. data/exe/pandorga +7 -0
  146. data/functions/api/studio/[[path]].js +562 -0
  147. data/functions/api/studio/_lib/auth.js +131 -0
  148. data/functions/api/studio/_lib/blocks.js +156 -0
  149. data/functions/api/studio/_lib/gemini.js +62 -0
  150. data/functions/api/studio/_lib/github.js +278 -0
  151. data/functions/api/studio/_lib/guidelines.js +143 -0
  152. data/functions/api/studio/_lib/paths.js +76 -0
  153. data/functions/api/studio/_lib/prompts.js +165 -0
  154. data/functions/api/studio/_lib/rate-limit.js +40 -0
  155. data/functions/api/studio/_lib/rewrite.js +136 -0
  156. data/functions/api/studio/_lib/sanitize.js +143 -0
  157. data/lib/pandorga/cli.rb +66 -0
  158. data/lib/pandorga/commands/doctor.rb +66 -0
  159. data/lib/pandorga/commands/export.rb +30 -0
  160. data/lib/pandorga/commands/install_functions.rb +41 -0
  161. data/lib/pandorga/commands/new.rb +113 -0
  162. data/lib/pandorga/commands/publish.rb +53 -0
  163. data/lib/pandorga/commands/serve.rb +34 -0
  164. data/lib/pandorga/commands/studio_schema.rb +309 -0
  165. data/lib/pandorga/jekyll/generator.rb +74 -0
  166. data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
  167. data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
  168. data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
  169. data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
  170. data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
  171. data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
  172. data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
  173. data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
  174. data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
  175. data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
  176. data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
  177. data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
  178. data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
  179. data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
  180. data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
  181. data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
  182. data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
  183. data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
  184. data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
  185. data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
  186. data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
  187. data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
  188. data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
  189. data/lib/pandorga/jekyll.rb +15 -0
  190. data/lib/pandorga/object_store_env.rb +51 -0
  191. data/lib/pandorga/registry/schema.yml +115 -0
  192. data/lib/pandorga/registry.rb +279 -0
  193. data/lib/pandorga/version.rb +5 -0
  194. data/lib/pandorga.rb +19 -0
  195. data/scripts/cdn-allowlist.json +19 -0
  196. data/scripts/content/backfill-content-keys.rb +99 -0
  197. data/scripts/content/band_art.rb +362 -0
  198. data/scripts/content/export-content-json.rb +741 -0
  199. data/scripts/content/flatten-ansi-capture.mjs +153 -0
  200. data/scripts/content/hero_portraits.rb +28 -0
  201. data/scripts/content/publish-content-to-object-store.sh +78 -0
  202. data/scripts/content/publish-content-to-r2.sh +5 -0
  203. data/scripts/content/sync-content-json-to-r2.rb +167 -0
  204. data/scripts/content/sync-content-redirects.rb +12 -0
  205. data/scripts/generate-configuration-doc.rb +127 -0
  206. data/scripts/lib/content_license.rb +50 -0
  207. data/scripts/lib/content_storage_config.rb +70 -0
  208. data/scripts/lib/normalize-display-math.mjs +52 -0
  209. data/scripts/lib/r2_json_sync.rb +76 -0
  210. data/scripts/lib/withdrawn_public_objects.rb +23 -0
  211. data/scripts/lib/writing_index_contract.rb +61 -0
  212. data/scripts/pdf/md-to-pdf.mjs +1400 -0
  213. data/scripts/pdf/md-to-pdf.sh +27 -0
  214. data/scripts/test/fixtures/dual-template/_config.yml +32 -0
  215. data/scripts/test/fixtures/dual-template/index.html +4 -0
  216. data/scripts/test/render-parity/article-note.json +5 -0
  217. data/scripts/test/render-parity/blockquote.json +5 -0
  218. data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
  219. data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
  220. data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
  221. data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
  222. data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
  223. data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
  224. data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
  225. data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
  226. data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
  227. data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
  228. data/scripts/test/render-parity/blocks/archive-card.json +32 -0
  229. data/scripts/test/render-parity/blocks/cta-link.json +13 -0
  230. data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
  231. data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
  232. data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
  233. data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
  234. data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
  235. data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
  236. data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
  237. data/scripts/test/render-parity/blocks/section-banner.json +13 -0
  238. data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
  239. data/scripts/test/render-parity/blocks/system-card.json +48 -0
  240. data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
  241. data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
  242. data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
  243. data/scripts/test/render-parity/cut-in-right.json +9 -0
  244. data/scripts/test/render-parity/cut-in.json +8 -0
  245. data/scripts/test/render-parity/figure.json +5 -0
  246. data/scripts/test/render-parity/load-runtime.mjs +337 -0
  247. data/scripts/test/render-parity/ongoing-product.json +5 -0
  248. data/scripts/test/render-parity/plotly.json +5 -0
  249. data/scripts/test/render-parity/poem.json +5 -0
  250. data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
  251. data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
  252. data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
  253. data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
  254. data/scripts/test/render-parity/run-fixture.mjs +159 -0
  255. data/scripts/test/render-parity/selectors.mjs +43 -0
  256. data/scripts/test/render-parity/video.json +5 -0
  257. data/scripts/test/render-parity/youtube-short.json +10 -0
  258. data/scripts/test/render-parity/youtube.json +8 -0
  259. data/scripts/test/test-absent-field-absent-element.rb +43 -0
  260. data/scripts/test/test-cdn-pins.rb +44 -0
  261. data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
  262. data/scripts/test/test-configuration-doc.rb +36 -0
  263. data/scripts/test/test-content-detail-urls.rb +346 -0
  264. data/scripts/test/test-content-keys.rb +110 -0
  265. data/scripts/test/test-content-license.rb +45 -0
  266. data/scripts/test/test-content-render-parity.rb +38 -0
  267. data/scripts/test/test-content-validators.rb +12 -0
  268. data/scripts/test/test-css-structure.rb +72 -0
  269. data/scripts/test/test-cv-export-contract.rb +158 -0
  270. data/scripts/test/test-detail-render-parity.rb +40 -0
  271. data/scripts/test/test-dev-reexport.rb +122 -0
  272. data/scripts/test/test-doctor.rb +39 -0
  273. data/scripts/test/test-dual-template-instances.rb +87 -0
  274. data/scripts/test/test-dual-template-runtime.rb +167 -0
  275. data/scripts/test/test-examples-build.rb +60 -0
  276. data/scripts/test/test-export-content-json-incremental.rb +138 -0
  277. data/scripts/test/test-export.rb +53 -0
  278. data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
  279. data/scripts/test/test-form-control-theming.rb +143 -0
  280. data/scripts/test/test-git-chronology.rb +414 -0
  281. data/scripts/test/test-hero-portraits.mjs +97 -0
  282. data/scripts/test/test-hero-portraits.rb +162 -0
  283. data/scripts/test/test-home-band-art.rb +135 -0
  284. data/scripts/test/test-install-functions.rb +39 -0
  285. data/scripts/test/test-language-export.rb +79 -0
  286. data/scripts/test/test-listing-filter-contract.rb +119 -0
  287. data/scripts/test/test-math-row-breaks.mjs +79 -0
  288. data/scripts/test/test-math-row-breaks.rb +18 -0
  289. data/scripts/test/test-new.rb +113 -0
  290. data/scripts/test/test-no-personal-data.rb +85 -0
  291. data/scripts/test/test-public-cv-export.rb +164 -0
  292. data/scripts/test/test-r2-config-consistency.rb +57 -0
  293. data/scripts/test/test-r2-json-sync.rb +193 -0
  294. data/scripts/test/test-registry-home.rb +34 -0
  295. data/scripts/test/test-runtime-js-syntax.rb +64 -0
  296. data/scripts/test/test-slim-writing-index.rb +126 -0
  297. data/scripts/test/test-studio-api-contract.rb +99 -0
  298. data/scripts/test/test-studio-content-files.rb +79 -0
  299. data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
  300. data/scripts/test/test-studio-rate-limit.mjs +70 -0
  301. data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
  302. data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
  303. data/scripts/test/test-studio-schema-composition.rb +126 -0
  304. data/scripts/test/test-studio-slug-config.rb +167 -0
  305. data/scripts/test/test-template-packages.rb +51 -0
  306. data/scripts/test/test-text-excerpt.rb +51 -0
  307. data/scripts/test/test-unwrap-media-src.mjs +39 -0
  308. data/scripts/test/test-unwrap-media-src.rb +19 -0
  309. data/scripts/test/test-withdrawn-content-export.rb +69 -0
  310. data/scripts/test/test-writing-index-export-slugs.rb +84 -0
  311. data/scripts/test/test-writing-slug-limits.rb +61 -0
  312. data/scripts/test/test-xcite-chronology.rb +106 -0
  313. data/scripts/validate.sh +74 -0
  314. data/scripts/visual/build-cv-pages-json.js +33 -0
  315. data/scripts/visual/build-site-pages-json.js +94 -0
  316. data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
  317. data/scripts/visual/take-screenshots.js +88 -0
  318. data/scripts/visual/visual-inspection-pages.json +113 -0
  319. data/studio/assets/index-B_j0pMD5.css +1 -0
  320. data/studio/assets/index-Ch4YEv9g.js +285 -0
  321. data/studio/index.html +27 -0
  322. data/studio/schema.yml +869 -0
  323. data/studio-app/README.md +27 -0
  324. data/studio-app/index.html +26 -0
  325. data/studio-app/package-lock.json +2499 -0
  326. data/studio-app/package.json +26 -0
  327. data/studio-app/skills-lock.json +47 -0
  328. data/studio-app/src/api/client.js +71 -0
  329. data/studio-app/src/editor/body-editor.js +70 -0
  330. data/studio-app/src/editor/liquid-core.js +382 -0
  331. data/studio-app/src/editor/liquid.js +77 -0
  332. data/studio-app/src/lib/document.js +207 -0
  333. data/studio-app/src/lib/list-controls.js +317 -0
  334. data/studio-app/src/lib/schema.js +66 -0
  335. data/studio-app/src/lib/yaml-doc.js +100 -0
  336. data/studio-app/src/main.js +3693 -0
  337. data/studio-app/src/media/browser.js +578 -0
  338. data/studio-app/src/preview/render.js +430 -0
  339. data/studio-app/src/styles/studio.css +2826 -0
  340. data/studio-app/vite.config.js +25 -0
  341. data/templates/articles/README.md +40 -0
  342. data/templates/articles/fixtures/article.md +9 -0
  343. data/templates/articles/screenshots/README.md +6 -0
  344. data/templates/articles/studio.yml +73 -0
  345. data/templates/articles/template.yml +21 -0
  346. data/templates/bibliography/README.md +41 -0
  347. data/templates/bibliography/fixtures/references.json +9 -0
  348. data/templates/bibliography/screenshots/README.md +6 -0
  349. data/templates/bibliography/studio.yml +66 -0
  350. data/templates/bibliography/template.yml +18 -0
  351. data/templates/blog/README.md +38 -0
  352. data/templates/blog/fixtures/post.md +9 -0
  353. data/templates/blog/screenshots/README.md +6 -0
  354. data/templates/blog/studio.yml +69 -0
  355. data/templates/blog/template.yml +20 -0
  356. data/templates/cv/README.md +34 -0
  357. data/templates/cv/fixtures/job.md +12 -0
  358. data/templates/cv/screenshots/README.md +6 -0
  359. data/templates/cv/studio.yml +200 -0
  360. data/templates/cv/template.yml +25 -0
  361. data/templates/media/README.md +38 -0
  362. data/templates/media/fixtures/resource.md +7 -0
  363. data/templates/media/screenshots/README.md +6 -0
  364. data/templates/media/studio.yml +58 -0
  365. data/templates/media/template.yml +17 -0
  366. data/templates/network/README.md +34 -0
  367. data/templates/network/fixtures/sources.json +3 -0
  368. data/templates/network/screenshots/README.md +6 -0
  369. data/templates/network/studio.yml +28 -0
  370. data/templates/network/template.yml +10 -0
  371. data/templates/portfolio/README.md +39 -0
  372. data/templates/portfolio/fixtures/project.md +10 -0
  373. data/templates/portfolio/screenshots/README.md +6 -0
  374. data/templates/portfolio/studio.yml +89 -0
  375. data/templates/portfolio/template.yml +18 -0
  376. metadata +505 -0
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # AC-STU-01, AC-STU-02, AC-STU-09 — API surface contract (static review).
4
+ # Spec: docs/features/studio.md §5
5
+
6
+ require "pathname"
7
+
8
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+
10
+ def check!(condition, message = "condition was false")
11
+ raise "Assertion failed: #{message}" unless condition
12
+ end
13
+
14
+ puts "Testing Studio API contract..."
15
+
16
+ api = REPO_ROOT.join("functions/api/studio/[[path]].js")
17
+ auth = REPO_ROOT.join("functions/api/studio/_lib/auth.js")
18
+ github = REPO_ROOT.join("functions/api/studio/_lib/paths.js")
19
+ gh = REPO_ROOT.join("functions/api/studio/_lib/github.js")
20
+
21
+ [api, auth, github, gh].each do |path|
22
+ check!(path.file?, "missing #{path.relative_path_from(REPO_ROOT)}")
23
+ end
24
+
25
+ api_src = api.read
26
+ auth_src = auth.read
27
+ paths_src = github.read
28
+ gh_src = gh.read
29
+
30
+ %w[session tree file media search rewrite].each do |route|
31
+ check!(api_src.include?(route), "API router missing #{route.inspect} route")
32
+ end
33
+
34
+ check!(api_src.include?("media/folder") || api_src.include?('"folder"'),
35
+ "API router should expose media folder create")
36
+ check!(api_src.include?("content/media"),
37
+ "media routes must be scoped under content/media")
38
+ check!(gh_src.include?("putBinaryFile") || api_src.include?("content_base64"),
39
+ "media upload must accept base64 binary content")
40
+ check!(auth_src.include?("STUDIO_ALLOWLIST"), "auth must read STUDIO_ALLOWLIST")
41
+ check!(auth_src.include?("Bearer"), "auth must expect Bearer JWT")
42
+ check!(gh_src.include?("GITHUB_TOKEN"), "github helper must use GITHUB_TOKEN")
43
+ check!(gh_src.include?("GITHUB_REPO is required") || gh_src.include?("GITHUB_REPO"),
44
+ "github helper must require GITHUB_REPO")
45
+ owner_repo = %w[alexandre lheinen].join + "/website"
46
+ check!(!gh_src.include?(owner_repo),
47
+ "github helper must not default GITHUB_REPO to a personal repo")
48
+ check!(api_src.include?("STUDIO_ALLOWED_ORIGINS"),
49
+ "API CORS must read STUDIO_ALLOWED_ORIGINS")
50
+ owner_slug = %w[alexandre lheinen].join
51
+ check!(!api_src.include?("origin.includes(\"#{owner_slug}\")"),
52
+ "API CORS must not allow any origin containing the owner slug")
53
+ check!(!api_src.include?('origin.includes("pages.dev")'),
54
+ "API CORS must not allow any pages.dev project")
55
+ check!(gh_src.include?("api.github.com"), "github helper must call Contents API")
56
+ check!(gh_src.include?('err.code = conflict ? "conflict" : "github_error"') ||
57
+ gh_src.include?("conflict"),
58
+ "GitHub 409 must be a conflict, not a bare server_error")
59
+ check!(api_src.include?("err.code || \"server_error\""),
60
+ "API catch-all must keep a specific error code when the throw set one")
61
+ check!(REPO_ROOT.join("studio-app/src/api/client.js").read.include?("data?.message || data?.error"),
62
+ "SPA must show the error sentence, not only the error code")
63
+ check!(REPO_ROOT.join("studio-app/src/main.js").read.include?("state.saving"),
64
+ "Save must ignore a second tap while a save is in flight")
65
+ check!(REPO_ROOT.join("studio-app/src/main.js").read.include?("studio-error-banner"),
66
+ "Editor must show the failure sentence outside the truncated toolbar pill")
67
+
68
+ bundles = Dir[REPO_ROOT.join("studio/assets/index-*.js").to_s]
69
+ check!(!bundles.empty?, "committed Studio bundle missing")
70
+ keyed = bundles.any? { |path| File.read(path).match?(/pk_(?:test|live)_[A-Za-z0-9_-]{20,}/) }
71
+ check!(keyed,
72
+ "committed Studio bundle must include a Clerk publishable key; rebuild with VITE_CLERK_PUBLISHABLE_KEY")
73
+ html = REPO_ROOT.join("studio/index.html").read
74
+ check!(html.match?(%r{/studio/assets/index-[A-Za-z0-9_-]+\.js}),
75
+ "studio/index.html must point at the built bundle")
76
+ referenced = html[/\/studio\/assets\/(index-[A-Za-z0-9_-]+\.js)/, 1]
77
+ check!(referenced && File.exist?(REPO_ROOT.join("studio/assets", referenced)),
78
+ "studio/index.html references a missing bundle")
79
+ check!(File.read(REPO_ROOT.join("studio/assets", referenced)).match?(/pk_(?:test|live)_[A-Za-z0-9_-]{20,}/),
80
+ "the bundle studio/index.html loads must contain the Clerk publishable key")
81
+ check!(paths_src.include?("content/"), "path allowlist must require content/")
82
+ check!(paths_src.include?(".."), "path allowlist must reject ..")
83
+ check!(!api_src.match?(/ghp_[A-Za-z0-9]+/), "no hardcoded PAT in API")
84
+ check!(!gh_src.match?(/ghp_[A-Za-z0-9]+/), "no hardcoded PAT in github helper")
85
+
86
+ # Rate limit on mutating surface only. GETs (tree prefetch, open, cite
87
+ # search) used to share the Save bucket and the commit returned rate_limited.
88
+ check!(api_src.include?("isMutatingMethod"),
89
+ "rate limit must apply only to mutating methods")
90
+ check!(api_src.include?("Too many changes in a short time"),
91
+ "rate_limited must include a sentence, not only the code")
92
+ check!(api_src.include?('error: "rate_limited"'),
93
+ "API should rate-limit requests")
94
+ check!(!api_src.include?("if (!rateLimit(request, auth.userId))"),
95
+ "do not rate-limit every authenticated request")
96
+ check!(gh_src.include?("github_rate_limited"),
97
+ "GitHub 403/429 rate limits need their own sentence")
98
+
99
+ puts "All Studio API contract checks passed."
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # AC-STU: schema file coverage for site-copy paths.
4
+ # Spec: docs/features/studio.md
5
+
6
+ require "date"
7
+ require "pathname"
8
+ require "yaml"
9
+
10
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
11
+ CONFIG_PATH = REPO_ROOT.join("studio/schema.yml")
12
+ CONTENT_ROOT = REPO_ROOT.join("content")
13
+
14
+ def check!(condition, message = "condition was false")
15
+ raise "Assertion failed: #{message}" unless condition
16
+ end
17
+
18
+ def each_content_entry(nodes, &block)
19
+ Array(nodes).each do |entry|
20
+ next unless entry.is_a?(Hash)
21
+
22
+ type = entry["type"].to_s
23
+ if type == "group"
24
+ each_content_entry(entry["items"], &block)
25
+ else
26
+ yield entry
27
+ end
28
+ end
29
+ end
30
+
31
+ def schema_file_paths(config)
32
+ paths = []
33
+ each_content_entry(config["content"]) do |entry|
34
+ next unless entry["type"].to_s == "file"
35
+
36
+ path = entry["path"].to_s
37
+ paths << path unless path.empty?
38
+ end
39
+ paths.sort
40
+ end
41
+
42
+ def required_cms_paths
43
+ paths = []
44
+ pages_root = CONTENT_ROOT.join("pages")
45
+ if pages_root.directory?
46
+ pages_root.glob("**/*.{yml,yaml}").sort.each do |data_file|
47
+ paths << data_file.relative_path_from(REPO_ROOT).to_s.tr("\\", "/")
48
+ end
49
+ pages_root.glob("**/*.{md,markdown}").sort.each do |fragment|
50
+ paths << fragment.relative_path_from(REPO_ROOT).to_s.tr("\\", "/")
51
+ end
52
+ end
53
+
54
+ data_root = CONTENT_ROOT.join("collections/data")
55
+ %w[contacts.yml education.yml languages.yml skills.yml].each do |name|
56
+ path = data_root.join(name)
57
+ paths << path.relative_path_from(REPO_ROOT).to_s.tr("\\", "/") if path.file?
58
+ end
59
+
60
+ paths.sort.uniq
61
+ end
62
+
63
+ puts "Testing Studio content file coverage..."
64
+
65
+ check!(CONFIG_PATH.file?, "missing studio/schema.yml")
66
+
67
+ config = YAML.safe_load(CONFIG_PATH.read, permitted_classes: [Date, Time], aliases: true) || {}
68
+ registered = schema_file_paths(config)
69
+ required = required_cms_paths
70
+
71
+ missing = required - registered
72
+ extra = registered - required
73
+
74
+ check!(missing.empty?,
75
+ "studio/schema.yml is missing file entries for:\n #{missing.join("\n ")}")
76
+ check!(extra.empty?,
77
+ "studio/schema.yml points at files that are not editorial CMS paths:\n #{extra.join("\n ")}")
78
+
79
+ puts "All Studio content file checks passed (#{required.length} paths)."
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AC-STU-03, AC-STU-04 — Liquid include round-trip without TipTap URL mangling.
4
+ * Spec: docs/features/studio.md
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { createRequire } from "node:module";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const root = path.resolve(__dirname, "../..");
13
+
14
+ // Import from studio-app source (Node-friendly pure helpers).
15
+ const liquidPath = path.join(root, "studio-app/src/editor/liquid-core.js");
16
+ const { attrsToInclude, parseIncludes, INCLUDE_DEFS, roundTripMarkdown } =
17
+ await import(liquidPath);
18
+ const { splitDocument, rebuildDocument } = await import(
19
+ path.join(root, "studio-app/src/lib/document.js")
20
+ );
21
+
22
+ const remote =
23
+ 'https://vejasp.abril.com.br/wp-content/uploads/2016/11/12209_drone-zangao.jpeg';
24
+
25
+ const figureDef = INCLUDE_DEFS.find((d) => d.name === "figure");
26
+ const serialized = attrsToInclude(figureDef, {
27
+ src: remote,
28
+ caption: "Skydrones Zangão V",
29
+ width: "100",
30
+ });
31
+
32
+ assert.match(serialized, /include figure\.html/);
33
+ assert.equal(serialized.includes(`src="${remote}"`), true);
34
+ assert.equal(serialized.includes(`src="[${remote}](${remote})"`), false);
35
+
36
+ const roundTrip = parseIncludes(serialized);
37
+ assert.equal(roundTrip.length, 1);
38
+ assert.equal(roundTrip[0].type, "include");
39
+ assert.equal(roundTrip[0].attrs.src, remote);
40
+
41
+ const markdown = [
42
+ "Intro paragraph.",
43
+ "",
44
+ serialized,
45
+ "",
46
+ "After.",
47
+ ].join("\n");
48
+
49
+ const segments = parseIncludes(markdown);
50
+ assert.equal(segments.filter((s) => s.type === "include").length, 1);
51
+ assert.equal(segments.find((s) => s.type === "include").attrs.src, remote);
52
+
53
+ const yt = attrsToInclude(
54
+ INCLUDE_DEFS.find((d) => d.name === "youtube"),
55
+ { id: "dQw4w9WgXcQ", caption: "demo", width: "80%" }
56
+ );
57
+ assert.match(yt, /youtube\.html/);
58
+ assert.match(yt, /id="dQw4w9WgXcQ"/);
59
+ assert.match(yt, /width="80%"/);
60
+
61
+ const ytParsed = parseIncludes(yt);
62
+ assert.equal(ytParsed.length, 1);
63
+ assert.equal(ytParsed[0].type, "include");
64
+ assert.equal(ytParsed[0].attrs.id, "dQw4w9WgXcQ");
65
+ assert.equal(ytParsed[0].attrs.width, "80%");
66
+
67
+ // Published form: percent width inside quoted attrs must still parse.
68
+ const ytPublished = `{% include youtube.html id="zeJD6dqJ5lo" width="80%" %}`;
69
+ const ytPubSeg = parseIncludes(ytPublished);
70
+ assert.equal(ytPubSeg.length, 1);
71
+ assert.equal(ytPubSeg[0].attrs.id, "zeJD6dqJ5lo");
72
+ assert.equal(ytPubSeg[0].attrs.width, "80%");
73
+
74
+ // Percent-encoded URLs in attrs must not truncate at `%`.
75
+ const encodedSrc =
76
+ "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fe/foo%2C_bar.jpg/1280px-foo.jpg";
77
+ const figEncoded = attrsToInclude(figureDef, {
78
+ src: encodedSrc,
79
+ caption: "encoded",
80
+ width: "85",
81
+ });
82
+ const figSeg = parseIncludes(figEncoded);
83
+ assert.equal(figSeg[0].attrs.src, encodedSrc);
84
+
85
+ const xc = attrsToInclude(INCLUDE_DEFS.find((d) => d.name === "xcite"), {
86
+ key: "studio-cms",
87
+ });
88
+ assert.match(xc, /xcite\.html/);
89
+ assert.match(xc, /key="studio-cms"/);
90
+ assert.equal(xc.includes("\n"), false);
91
+
92
+ const figureMany = attrsToInclude(figureDef, {
93
+ src: "/media/images/website/studio-editor-light.png",
94
+ alt: "Studio with a sample draft",
95
+ caption: "A draft open beside its preview.",
96
+ source: "Author",
97
+ width: "100",
98
+ });
99
+ assert.equal(
100
+ figureMany,
101
+ '{% include figure.html src="/media/images/website/studio-editor-light.png" alt="Studio with a sample draft" caption="A draft open beside its preview." source="Author" width="100" %}'
102
+ );
103
+
104
+ const inline =
105
+ 'The earlier account, {% include xcite.html key="studio-cms" %}, records that room, and {% include xcite.html key="headless-architecture" %} records how a save reaches the visitor.\n';
106
+ assert.equal(roundTripMarkdown(inline), inline);
107
+
108
+ const figures = [
109
+ '{% include figure.html src="/media/images/website/studio-articles-light.png" alt="List" caption="Articles in Studio." source="Author" width="100" %}',
110
+ "",
111
+ '{% include figure.html src="/media/images/website/studio-editor-light.png" alt="Editor" caption="A draft open beside its preview." source="Author" width="100" %}',
112
+ "",
113
+ "## Beauty in the writing room",
114
+ "",
115
+ "I am happy with it again.",
116
+ "",
117
+ ].join("\n");
118
+ assert.equal(roundTripMarkdown(figures), figures);
119
+
120
+ const keptFigure = `{% include figure.html src="https://decapcms.org/img/screenshot-editor.png" caption="Decap was a proof of concept." source="decapcms.org" width="100" %}`;
121
+ assert.equal(roundTripMarkdown(`${keptFigure}\n`), `${keptFigure}\n`);
122
+
123
+ const multilineVideo = `{% include video.html
124
+ src="https://example.com/demo.mp4"
125
+ caption="A demo."
126
+ width="80"
127
+ %}`;
128
+ assert.equal(roundTripMarkdown(`${multilineVideo}\n`), `${multilineVideo}\n`);
129
+
130
+ for (const name of ["youtube", "youtube-short", "video", "plotly", "cut-in", "cite"]) {
131
+ const def = INCLUDE_DEFS.find((d) => d.include === `${name}.html` || d.include === name);
132
+ assert.ok(def, name);
133
+ const attrs = {};
134
+ for (const field of def.fields) {
135
+ if (field === "body") continue;
136
+ attrs[field] = field === "width" ? "80%" : `${field}-value`;
137
+ }
138
+ const line = attrsToInclude(def, attrs);
139
+ assert.equal(line.includes("\n"), false, name);
140
+ const again = roundTripMarkdown(`${line}\n`);
141
+ assert.equal(again, `${line}\n`, name);
142
+ }
143
+
144
+ const original = `---
145
+ layout: article
146
+ title: If you want it done well, do it yourself
147
+ date: 2026-10-02
148
+ description: How the writing room on this site became an editor I built, measured to the includes the pages already use.
149
+ thumbnail: /media/images/website/studio-editor-light.png
150
+ tags:
151
+ - AI
152
+ - Aesthetics
153
+ - DevOps
154
+ language: enus
155
+ mermaid: true
156
+ published: false
157
+ key: if-you-want-it-done-well
158
+ ---
159
+ The earlier account, {% include xcite.html key="studio-cms" %}, records that room.
160
+
161
+ `;
162
+ const split = splitDocument(original);
163
+ assert.deepEqual(split.fm.tags, ["AI", "Aesthetics", "DevOps"]);
164
+ assert.equal(split.fm.title, "If you want it done well, do it yourself");
165
+ const rebuilt = rebuildDocument(original, {
166
+ fields: { ...split.fm, thumbnail: "" },
167
+ body: split.body,
168
+ format: "yaml-frontmatter",
169
+ });
170
+ assert.equal(rebuilt.includes("thumbnail:"), false);
171
+ assert.match(rebuilt, /^title: If you want it done well, do it yourself$/m);
172
+ assert.match(rebuilt, /tags:\n - AI\n - Aesthetics\n - DevOps\n/);
173
+ assert.match(
174
+ rebuilt,
175
+ /The earlier account, \{% include xcite\.html key="studio-cms" %\}, records that room\./
176
+ );
177
+ assert.equal(rebuilt.includes('title: "'), false);
178
+
179
+ console.log("All Studio liquid round-trip checks passed.");
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Studio save must not share a request budget with reads.
4
+ * Spec: docs/features/studio.md §4 (mutating-route throttle).
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ classifyGithubFailure,
9
+ isGithubRateLimit,
10
+ } from "../../functions/api/studio/_lib/github.js";
11
+ import {
12
+ isMutatingMethod,
13
+ takeToken,
14
+ } from "../../functions/api/studio/_lib/rate-limit.js";
15
+
16
+ const windowMs = 60_000;
17
+ const max = 3;
18
+ const now = 1_700_000_000_000;
19
+
20
+ {
21
+ const store = new Map();
22
+ for (let i = 0; i < max; i += 1) {
23
+ const decision = takeToken(store, "user:1.2.3.4", now, { windowMs, max });
24
+ assert.equal(decision.allowed, true, `write ${i + 1} should be allowed`);
25
+ }
26
+ const blocked = takeToken(store, "user:1.2.3.4", now, { windowMs, max });
27
+ assert.equal(blocked.allowed, false);
28
+ assert.ok(blocked.retryAfterSec >= 1 && blocked.retryAfterSec <= 60);
29
+
30
+ const otherUser = takeToken(store, "other:1.2.3.4", now, { windowMs, max });
31
+ assert.equal(otherUser.allowed, true, "a different key has its own budget");
32
+
33
+ const reset = takeToken(store, "user:1.2.3.4", now + windowMs, { windowMs, max });
34
+ assert.equal(reset.allowed, true, "the window resets on the boundary");
35
+ }
36
+
37
+ {
38
+ assert.equal(isMutatingMethod("PUT"), true);
39
+ assert.equal(isMutatingMethod("POST"), true);
40
+ assert.equal(isMutatingMethod("GET"), false);
41
+ assert.equal(isMutatingMethod("OPTIONS"), false);
42
+ }
43
+
44
+ {
45
+ assert.equal(isGithubRateLimit(429, ""), true);
46
+ assert.equal(
47
+ isGithubRateLimit(403, "You have exceeded a secondary rate limit."),
48
+ true
49
+ );
50
+ assert.equal(isGithubRateLimit(403, "Resource not accessible by integration"), false);
51
+ assert.equal(isGithubRateLimit(404, "Not Found"), false);
52
+
53
+ const limited = classifyGithubFailure(
54
+ "put",
55
+ 403,
56
+ '{"message":"You have exceeded a secondary rate limit."}'
57
+ );
58
+ assert.equal(limited.code, "github_rate_limited");
59
+ assert.equal(limited.status, 429);
60
+ assert.match(limited.message, /Wait a minute/);
61
+
62
+ const conflict = classifyGithubFailure("put", 409, "sha mismatch");
63
+ assert.equal(conflict.code, "conflict");
64
+
65
+ const missing = classifyGithubFailure("get", 404, "Not Found");
66
+ assert.equal(missing.code, "github_error");
67
+ assert.match(missing.message, /GitHub get failed \(404\)/);
68
+ }
69
+
70
+ console.log("All Studio rate-limit checks passed.");
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # AC-PRF-01, AC-PRF-02, AC-PRF-04, AC-PRF-12 — rewrite API surface (static).
4
+ # Spec: docs/features/studio-proof.md §11
5
+
6
+ require "pathname"
7
+
8
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
9
+
10
+ def check!(condition, message = "condition was false")
11
+ raise "Assertion failed: #{message}" unless condition
12
+ end
13
+
14
+ puts "Testing Studio rewrite API contract..."
15
+
16
+ api = REPO_ROOT.join("functions/api/studio/[[path]].js")
17
+ rewrite = REPO_ROOT.join("functions/api/studio/_lib/rewrite.js")
18
+ sanitize = REPO_ROOT.join("functions/api/studio/_lib/sanitize.js")
19
+ prompts = REPO_ROOT.join("functions/api/studio/_lib/prompts.js")
20
+ gemini = REPO_ROOT.join("functions/api/studio/_lib/gemini.js")
21
+ guidelines = REPO_ROOT.join("functions/api/studio/_lib/guidelines.js")
22
+ client = REPO_ROOT.join("studio-app/src/api/client.js")
23
+
24
+ [api, rewrite, sanitize, prompts, gemini, guidelines, client].each do |path|
25
+ check!(path.file?, "missing #{path.relative_path_from(REPO_ROOT)}")
26
+ end
27
+
28
+ api_src = api.read
29
+ rewrite_src = rewrite.read
30
+ prompts_src = prompts.read
31
+ client_src = client.read
32
+
33
+ check!(api_src.include?('"rewrite"') || api_src.include?("head === \"rewrite\""),
34
+ "API router must expose rewrite")
35
+ check!(api_src.include?("runRewrite"), "API must call runRewrite")
36
+ check!(api_src.include?("rewriteRateLimit"), "dedicated rewrite rate limit")
37
+ check!(!rewrite_src.include?("putFile"), "rewrite must not write GitHub")
38
+ check!(prompts_src.include?("CORRECT_SYSTEM") || prompts_src.include?("mechanical corrector"),
39
+ "correct prompt present")
40
+ check!(prompts_src.include?("Writing guidelines") || prompts_src.include?("guidelines pack"),
41
+ "refine prompt embeds guidelines")
42
+ check!(prompts_src.include?("tone consistency") || prompts_src.include?("Preserve tone"),
43
+ "AC-PRF-17 tone instruction")
44
+ check!(prompts_src.include?("REFINE_SCHEMA") || prompts_src.include?("block"),
45
+ "refine schema uses block-indexed patches")
46
+ blocks = REPO_ROOT.join("functions/api/studio/_lib/blocks.js")
47
+ check!(blocks.file?, "missing blocks.js chunker")
48
+ check!(rewrite_src.include?("splitIntoBlocks") || rewrite_src.include?("flattenBlockPatches"),
49
+ "rewrite orchestrates block flatten")
50
+ check!(client_src.include?("rewrite:"), "SPA client exposes rewrite")
51
+ check!(!client_src.match?(/AIza[0-9A-Za-z_-]{10,}/), "no Gemini key in client")
52
+ check!(!api_src.match?(/AIza[0-9A-Za-z_-]{10,}/), "no Gemini key in API router")
53
+ main_spa = REPO_ROOT.join("studio-app/src/main.js")
54
+ check!(main_spa.file?, "missing studio-app main")
55
+ main_src = main_spa.read
56
+ check!(main_src.include?("resolveAiProofTarget") || main_src.include?("wholeBody"),
57
+ "SPA falls back to full body when selection is empty")
58
+
59
+ puts "All Studio rewrite API contract checks passed."