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,210 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AC-PRF-05, AC-PRF-06, AC-PRF-07, AC-PRF-15 — patch sanitizer + block chunking.
4
+ * Spec: docs/features/studio-proof.md §8
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import {
8
+ flattenBlockPatches,
9
+ locateUnique,
10
+ splitIntoBlocks,
11
+ } from "../../functions/api/studio/_lib/blocks.js";
12
+ import { sanitizePatches } from "../../functions/api/studio/_lib/sanitize.js";
13
+ import {
14
+ buildGeminiRequest,
15
+ truncateGuidelines,
16
+ } from "../../functions/api/studio/_lib/prompts.js";
17
+
18
+ const text =
19
+ 'Hello wrld.\n\n{% include figure.html src="/media/x.png" %}\n\n`code` and ```\nfence\n```\n';
20
+
21
+ {
22
+ const { patches, stats } = sanitizePatches({
23
+ mode: "correct",
24
+ text,
25
+ patches: [
26
+ {
27
+ start: 6,
28
+ end: 10,
29
+ original: "wrld",
30
+ replacement: "world",
31
+ category: "spelling",
32
+ reason: "typo",
33
+ },
34
+ {
35
+ start: 6,
36
+ end: 10,
37
+ original: "NOPE",
38
+ replacement: "world",
39
+ category: "spelling",
40
+ reason: "bad",
41
+ },
42
+ {
43
+ start: text.indexOf("{%"),
44
+ end: text.indexOf("%}") + 2,
45
+ original: text.slice(text.indexOf("{%"), text.indexOf("%}") + 2),
46
+ replacement: "x",
47
+ category: "grammar",
48
+ reason: "include",
49
+ },
50
+ {
51
+ start: 0,
52
+ end: 5,
53
+ original: "Hello",
54
+ replacement: "Hiya!",
55
+ category: "clarity",
56
+ reason: "style",
57
+ },
58
+ ],
59
+ });
60
+ assert.equal(patches.length, 1);
61
+ assert.equal(patches[0].replacement, "world");
62
+ assert.equal(stats.kept, 1);
63
+ assert.ok(stats.dropped >= 1);
64
+ }
65
+
66
+ {
67
+ // Offset recovery: wrong start/end, exact original still locates.
68
+ const { patches } = sanitizePatches({
69
+ mode: "correct",
70
+ text: "alpha beta gamma",
71
+ patches: [
72
+ {
73
+ start: 99,
74
+ end: 103,
75
+ original: "beta",
76
+ replacement: "BETA",
77
+ category: "spelling",
78
+ reason: "case",
79
+ },
80
+ ],
81
+ });
82
+ assert.equal(patches.length, 1);
83
+ assert.equal(patches[0].start, 6);
84
+ assert.equal(patches[0].end, 10);
85
+ }
86
+
87
+ {
88
+ const { patches } = sanitizePatches({
89
+ mode: "refine",
90
+ text: "A clunky sentence here.",
91
+ patches: [
92
+ {
93
+ start: 0,
94
+ end: 23,
95
+ original: "A clunky sentence here.",
96
+ replacement: "A clearer sentence here.",
97
+ category: "clarity",
98
+ reason: "flow",
99
+ },
100
+ ],
101
+ });
102
+ assert.equal(patches.length, 1);
103
+ assert.equal(patches[0].category, "clarity");
104
+ }
105
+
106
+ {
107
+ // {% cite %} is protected like include.
108
+ const src = "Antes {% cite turino-2008 %} depois.";
109
+ const cite = "{% cite turino-2008 %}";
110
+ const at = src.indexOf(cite);
111
+ const { patches } = sanitizePatches({
112
+ mode: "refine",
113
+ text: src,
114
+ patches: [
115
+ {
116
+ start: at,
117
+ end: at + cite.length,
118
+ original: cite,
119
+ replacement: "REMOVED",
120
+ category: "clarity",
121
+ reason: "bad",
122
+ },
123
+ ],
124
+ });
125
+ assert.equal(patches.length, 0);
126
+ }
127
+
128
+ {
129
+ const body =
130
+ "First paragraph here.\n\nSecond paragraph here.\n\n## Heading\n\nThird paragraph with more words to grow.";
131
+ const blocks = splitIntoBlocks(body, { targetChars: 40, maxChars: 80 });
132
+ assert.ok(blocks.length >= 2);
133
+ assert.equal(blocks[0].start, 0);
134
+ assert.equal(body.slice(blocks[0].start, blocks[0].end), blocks[0].text);
135
+ const joined = blocks.map((b) => b.text).join("");
136
+ // Separators are kept on preceding blocks; concatenation should rebuild body.
137
+ assert.equal(joined, body);
138
+ }
139
+
140
+ {
141
+ const body = "Alpha one.\n\nBeta two.\n\nGamma three.";
142
+ const blocks = splitIntoBlocks(body, { targetChars: 10, maxChars: 20 });
143
+ const flat = flattenBlockPatches(blocks, [
144
+ {
145
+ block: 1,
146
+ original: "Beta two.",
147
+ replacement: "Beta too.",
148
+ category: "clarity",
149
+ reason: "tight",
150
+ },
151
+ ]);
152
+ assert.equal(flat.length, 1);
153
+ assert.equal(body.slice(flat[0].start, flat[0].end), "Beta two.");
154
+ }
155
+
156
+ {
157
+ const hit = locateUnique("aa bb aa", "aa", 5);
158
+ assert.equal(hit.start, 6);
159
+ }
160
+
161
+ {
162
+ const req = buildGeminiRequest({
163
+ mode: "correct",
164
+ language: "en",
165
+ path: "content/collections/articles/x.md",
166
+ text: "teh",
167
+ });
168
+ const sys = req.systemInstruction.parts[0].text;
169
+ assert.ok(sys.includes("mechanical corrector"));
170
+ assert.ok(!sys.includes("Writing guidelines (published pack)"));
171
+ assert.equal(
172
+ req.generationConfig.responseSchema.properties.patches.items.required.includes(
173
+ "start"
174
+ ),
175
+ true
176
+ );
177
+ }
178
+
179
+ {
180
+ const blocks = splitIntoBlocks("Short prose for refine.\n\nAnother beat.");
181
+ const req = buildGeminiRequest({
182
+ mode: "refine",
183
+ language: "pt-BR",
184
+ path: "content/collections/posts/x.md",
185
+ text: "Short prose for refine.\n\nAnother beat.",
186
+ guidelinesBody: "You are not a co-author.\n",
187
+ blocks,
188
+ });
189
+ const sys = req.systemInstruction.parts[0].text;
190
+ const user = req.contents[0].parts[0].text;
191
+ assert.ok(sys.includes("tone consistency") || sys.includes("Preserve tone"));
192
+ assert.ok(sys.includes("not a co-author"));
193
+ assert.ok(sys.includes("invoked Refine"));
194
+ assert.ok(user.includes("blocks:"));
195
+ assert.ok(user.includes("[0]"));
196
+ assert.ok(
197
+ req.generationConfig.responseSchema.properties.patches.items.required.includes(
198
+ "block"
199
+ )
200
+ );
201
+ }
202
+
203
+ {
204
+ const long = "a".repeat(100);
205
+ const truncated = truncateGuidelines(long, 50);
206
+ assert.ok(truncated.length < long.length);
207
+ assert.ok(truncated.includes("truncated"));
208
+ }
209
+
210
+ console.log("All Studio rewrite sanitize checks passed.");
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Refs: PLT-AC-3 — Studio schema includes registry collections and drops them
5
+ # when the page is removed.
6
+ require "pathname"
7
+ require "yaml"
8
+ require "fileutils"
9
+ require "tmpdir"
10
+ require "open3"
11
+
12
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
13
+ $LOAD_PATH.unshift(ROOT.join("lib").to_s)
14
+ require "pandorga"
15
+ require "pandorga/commands/studio_schema"
16
+
17
+ EXAMPLE = ROOT.join("examples/full")
18
+ TEMPLATES = ROOT.join("templates")
19
+ EXE = ROOT.join("exe/pandorga")
20
+
21
+ def fail!(message)
22
+ warn "FAIL test-studio-schema-composition: #{message}"
23
+ exit 1
24
+ end
25
+
26
+ def registered_collections(config)
27
+ names = []
28
+ Array(config.dig("pandorga", "pages")).each do |page|
29
+ next unless page.is_a?(Hash)
30
+
31
+ single = page["collection"].to_s
32
+ names << single unless single.empty?
33
+
34
+ cols = page["collections"]
35
+ next unless cols.is_a?(Hash)
36
+
37
+ cols.each_value do |name|
38
+ n = name.to_s
39
+ next if n.empty? || n == "data" || n == "profile"
40
+
41
+ names << n
42
+ end
43
+ end
44
+ names.uniq
45
+ end
46
+
47
+ def collection_names_from_schema(schema)
48
+ names = []
49
+ Array(schema["content"]).each do |group|
50
+ next unless group.is_a?(Hash)
51
+
52
+ Array(group["items"]).each do |item|
53
+ next unless item.is_a?(Hash)
54
+ next unless item["type"].to_s == "collection"
55
+
56
+ names << item["name"].to_s
57
+ end
58
+ end
59
+ if schema["collections"].is_a?(Hash)
60
+ names |= schema["collections"].keys.map(&:to_s)
61
+ end
62
+ names.uniq
63
+ end
64
+
65
+ config = YAML.safe_load(EXAMPLE.join("_config.yml").read, aliases: true)
66
+ expected = registered_collections(config)
67
+ fail!("examples/full has no registered collections") if expected.empty?
68
+
69
+ composer = Pandorga::Commands::StudioSchema.new
70
+ schema = composer.compose(config, TEMPLATES)
71
+ present = collection_names_from_schema(schema)
72
+
73
+ missing = expected - present
74
+ fail!("schema missing collections: #{missing.inspect} (have #{present.inspect})") unless missing.empty?
75
+
76
+ unless schema["media"].is_a?(Hash)
77
+ fail!("composed schema should include a media block when templates need it")
78
+ end
79
+
80
+ unless schema["content"].is_a?(Array) && !schema["content"].empty?
81
+ fail!("composed schema should include content groups")
82
+ end
83
+
84
+ # Taxonomy from examples/full should land on the articles tags field.
85
+ articles_entry = schema.dig("collections", "articles")
86
+ fail!("articles collection missing from index") unless articles_entry.is_a?(Hash)
87
+ tags_field = Array(articles_entry["fields"]).find { |f| f.is_a?(Hash) && f["name"] == "tags" }
88
+ tag_values = tags_field&.dig("options", "values")
89
+ unless tag_values == ["Writing"]
90
+ fail!("articles tags should come from pandorga.taxonomies (got #{tag_values.inspect})")
91
+ end
92
+
93
+ # Remove the articles page; articles must leave the schema.
94
+ reduced = Marshal.load(Marshal.dump(config))
95
+ reduced["pandorga"]["pages"].reject! { |p| p.is_a?(Hash) && p["key"].to_s == "articles" }
96
+ reduced_schema = composer.compose(reduced, TEMPLATES)
97
+ reduced_names = collection_names_from_schema(reduced_schema)
98
+ if reduced_names.include?("articles")
99
+ fail!("articles still present after removing the page from the registry")
100
+ end
101
+ still_expected = registered_collections(reduced)
102
+ still_missing = still_expected - reduced_names
103
+ unless still_missing.empty?
104
+ fail!("after removal, still missing: #{still_missing.inspect}")
105
+ end
106
+
107
+ # CLI writes schema under a temp site root (optional end-to-end check).
108
+ Dir.mktmpdir("pandorga-studio-schema-") do |dir|
109
+ site = Pathname.new(dir)
110
+ FileUtils.cp(EXAMPLE.join("_config.yml"), site.join("_config.yml"))
111
+ env = { "RUBYOPT" => "-I#{ROOT.join('lib')}" }
112
+ stdout, stderr, status = Open3.capture3(
113
+ env, "ruby", EXE.to_s, "studio-schema", site.to_s
114
+ )
115
+ fail!("CLI failed: #{stdout}\n#{stderr}") unless status.success?
116
+
117
+ written = site.join("studio/schema.yml")
118
+ fail!("CLI did not write #{written}") unless written.file?
119
+
120
+ from_disk = YAML.safe_load(written.read, aliases: true)
121
+ disk_names = collection_names_from_schema(from_disk)
122
+ disk_missing = expected - disk_names
123
+ fail!("CLI schema missing: #{disk_missing.inspect}") unless disk_missing.empty?
124
+ end
125
+
126
+ puts "PASS test-studio-schema-composition (#{expected.length} collections; removal + CLI ok)"
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+ # AC-STU: Studio schema replaces .pages.yml as the live CMS model.
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
+
13
+ def check!(condition, message = "condition was false")
14
+ raise "Assertion failed: #{message}" unless condition
15
+ end
16
+
17
+ def each_content_entry(nodes, &block)
18
+ Array(nodes).each do |entry|
19
+ next unless entry.is_a?(Hash)
20
+
21
+ type = entry["type"].to_s
22
+ if type == "group"
23
+ each_content_entry(entry["items"], &block)
24
+ else
25
+ yield entry
26
+ end
27
+ end
28
+ end
29
+
30
+ def find_collection(config, name)
31
+ found = nil
32
+ each_content_entry(config["content"]) do |entry|
33
+ found = entry if entry["type"].to_s == "collection" && entry["name"].to_s == name
34
+ end
35
+ found
36
+ end
37
+
38
+ def field_names(collection)
39
+ (collection["fields"] || []).map { |field| field["name"].to_s }
40
+ end
41
+
42
+ puts "Testing Studio schema (studio/schema.yml)..."
43
+
44
+ check!(CONFIG_PATH.file?, "missing studio/schema.yml")
45
+
46
+ config = YAML.safe_load(CONFIG_PATH.read, permitted_classes: [Date, Time], aliases: true) || {}
47
+ check!(config["media"].is_a?(Hash) || config["media"].is_a?(String) || config["media"].is_a?(Array),
48
+ "studio/schema.yml must define media")
49
+
50
+ media = config["media"]
51
+ media_input = nil
52
+ if media.is_a?(Hash)
53
+ media_input = media["input"].to_s
54
+ check!(media_input.include?("content/media"),
55
+ "schema media.input must point under content/media")
56
+ check!(media["output"].to_s.start_with?("/media"),
57
+ "schema media.output must be a /media public path")
58
+ elsif media.is_a?(Array)
59
+ images = media.find { |entry| entry.is_a?(Hash) && entry["name"].to_s == "images" } || media.first
60
+ media_input = images.is_a?(Hash) ? images["input"].to_s : nil
61
+ end
62
+
63
+ if media_input && !media_input.empty?
64
+ each_content_entry(config["content"]) do |entry|
65
+ (entry["fields"] || []).each do |field|
66
+ next unless field.is_a?(Hash) && field["type"].to_s == "image"
67
+
68
+ path = field.dig("options", "path").to_s
69
+ next if path.empty?
70
+
71
+ check!(path == media_input || path.start_with?("#{media_input}/"),
72
+ "#{entry['name']}.#{field['name']} options.path must start with media.input " \
73
+ "(#{media_input.inspect}); got #{path.inspect}")
74
+ end
75
+ end
76
+ end
77
+
78
+ posts = find_collection(config, "posts")
79
+ articles = find_collection(config, "articles")
80
+ check!(!posts.nil?, "missing posts collection in studio/schema.yml")
81
+ check!(!articles.nil?, "missing articles collection in studio/schema.yml")
82
+
83
+ check!(posts["format"].to_s == "yaml-frontmatter",
84
+ "posts must use yaml-frontmatter")
85
+ check!(articles["format"].to_s == "yaml-frontmatter",
86
+ "articles must use yaml-frontmatter")
87
+ check!(posts.dig("operations", "delete") == false,
88
+ "posts operations.delete must be false")
89
+ check!(articles.dig("operations", "delete") == false,
90
+ "articles operations.delete must be false")
91
+
92
+ posts_fields = field_names(posts)
93
+ articles_fields = field_names(articles)
94
+
95
+ check!(!posts_fields.include?("description"),
96
+ "posts must not expose description (excerpts come from the body)")
97
+ check!(!posts_fields.include?("last_updated"),
98
+ "posts must not expose last_updated (build derives it from Git)")
99
+ check!(!articles_fields.include?("last_updated"),
100
+ "articles must not expose last_updated (build derives it from Git)")
101
+ check!(articles_fields.include?("description"),
102
+ "articles must keep description for editorial deck copy")
103
+
104
+ filename = posts["filename"].to_s
105
+ check!(filename.include?("{year}") && filename.include?("{key}"),
106
+ "posts filename must be dated + key (got #{filename.inspect})")
107
+ articles_filename = articles["filename"].to_s
108
+ check!(articles_filename.include?("{year}") && articles_filename.include?("{key}"),
109
+ "articles filename must be dated + key (got #{articles_filename.inspect})")
110
+ check!(posts.dig("view", "primary").to_s == "title",
111
+ "posts list primary should stay title (filename uses key separately)")
112
+ check!(articles.dig("view", "primary").to_s == "title",
113
+ "articles list primary should stay title (filename uses key separately)")
114
+
115
+ %w[posts articles].each do |name|
116
+ collection = name == "posts" ? posts : articles
117
+ key_field = (collection["fields"] || []).find { |field| field["name"].to_s == "key" }
118
+ check!(!key_field.nil?, "#{name} must define a key field")
119
+ check!(key_field["required"] == true, "#{name} key must be required for filename creation")
120
+ end
121
+
122
+ products = find_collection(config, "products")
123
+ projects = find_collection(config, "projects")
124
+ check!(!products.nil?, "missing products collection")
125
+ check!(!projects.nil?, "missing projects collection")
126
+ check!(products["filename"].to_s.include?("{key}"),
127
+ "products filename must use key (got #{products['filename'].inspect})")
128
+ check!(projects["filename"].to_s.include?("{key}"),
129
+ "projects filename must use key (got #{projects['filename'].inspect})")
130
+
131
+ pipeline = REPO_ROOT.join(".github/workflows/content-pipeline.yml").read
132
+ on_push = pipeline[/\non:\n push:\n(?: .*\n)*/]
133
+ check!(!on_push.nil? && !on_push.include?("paths:"),
134
+ "content-pipeline.yml on.push must not use paths: (CMS commits must always trigger)")
135
+ check!(!pipeline.include?("MODE=skip"),
136
+ "content-pipeline.yml must not skip empty CMS commits: those still need an R2 publish")
137
+ check!(pipeline.include?("if: github.ref == 'refs/heads/main'"),
138
+ "content-pipeline.yml must publish on main for push and manual run")
139
+ check!(!pipeline.match?(/sveltia/i),
140
+ "content-pipeline.yml must not reference Sveltia")
141
+
142
+ index = REPO_ROOT.join("studio/index.html").read
143
+ check!(!index.include?("app.pagescms.org"),
144
+ "studio/index.html must not redirect to Pages CMS (AC-STU-08)")
145
+ check!(!index.match?(/sveltia/i),
146
+ "studio/index.html must not load or mention Sveltia")
147
+ check!(index.include?("studio-logo"),
148
+ "studio/index.html must use the Studio logo favicon")
149
+ check!(index.include?('id="app"') || index.include?("src/main"),
150
+ "studio/index.html must mount the Studio SPA")
151
+
152
+ api = REPO_ROOT.join("functions/api/studio/[[path]].js")
153
+ check!(api.file?, "missing functions/api/studio/[[path]].js")
154
+ api_src = api.read
155
+ check!(api_src.include?("STUDIO_ALLOWLIST") || REPO_ROOT.join("functions/api/studio/_lib/auth.js").read.include?("STUDIO_ALLOWLIST"),
156
+ "Studio API must enforce STUDIO_ALLOWLIST")
157
+ check!(REPO_ROOT.join("functions/api/studio/_lib/github.js").read.include?("GITHUB_TOKEN"),
158
+ "Studio API must use server-side GITHUB_TOKEN")
159
+ check!(!api_src.include?("ghp_"), "API must not hardcode a GitHub PAT (AC-STU-09)")
160
+
161
+ editor = REPO_ROOT.join("studio-app/src/editor/body-editor.js").read
162
+ check!(editor.include?("autolink: false"),
163
+ "TipTap Link must set autolink: false (AC-STU-03)")
164
+ check!(editor.include?("linkOnPaste: false"),
165
+ "TipTap Link must set linkOnPaste: false")
166
+
167
+ puts "All Studio schema / shell checks passed."
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Refs: PLT-AC-4 — each template ships README, template.yml, fixtures, screenshots.
4
+ require "pathname"
5
+
6
+ ROOT = Pathname.new(__dir__).join("../..").expand_path
7
+ TEMPLATES = %w[cv articles blog media network bibliography portfolio].freeze
8
+ TEMPLATES_ROOT = ROOT.join("templates")
9
+
10
+ failed = false
11
+
12
+ TEMPLATES.each do |name|
13
+ dir = TEMPLATES_ROOT.join(name)
14
+ unless dir.directory?
15
+ warn "FAIL test-template-packages: missing templates/#{name}/"
16
+ failed = true
17
+ next
18
+ end
19
+
20
+ readme = dir.join("README.md")
21
+ unless readme.file? && readme.read.strip.length.positive?
22
+ warn "FAIL test-template-packages: #{name} missing README.md"
23
+ failed = true
24
+ end
25
+
26
+ yaml = dir.join("template.yml")
27
+ unless yaml.file? && yaml.read.strip.length.positive?
28
+ warn "FAIL test-template-packages: #{name} missing template.yml"
29
+ failed = true
30
+ end
31
+
32
+ fixtures = dir.join("fixtures")
33
+ unless fixtures.directory? && fixtures.children.any? { |c| c.file? }
34
+ warn "FAIL test-template-packages: #{name} missing fixtures/ with sample files"
35
+ failed = true
36
+ end
37
+
38
+ screenshots = dir.join("screenshots")
39
+ has_shot_dir = screenshots.directory?
40
+ has_shot_asset = has_shot_dir && screenshots.children.any? do |c|
41
+ c.file? && (c.basename.to_s.match?(/\.(png|jpe?g|webp|gif)\z/i) || c.basename.to_s == "README.md" || c.basename.to_s == ".gitkeep")
42
+ end
43
+ unless has_shot_asset
44
+ warn "FAIL test-template-packages: #{name} missing screenshots/ (asset, README, or .gitkeep)"
45
+ failed = true
46
+ end
47
+ end
48
+
49
+ exit 1 if failed
50
+
51
+ puts "PASS test-template-packages"
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+
6
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
7
+ require REPO_ROOT.join("_plugins/lib/text_excerpt").to_s
8
+
9
+ def fail!(message)
10
+ warn message
11
+ exit 1
12
+ end
13
+
14
+ def check!(condition, message)
15
+ fail!(message) unless condition
16
+ end
17
+
18
+ puts "Testing TextExcerpt listing previews..."
19
+
20
+ heading_body = <<~MD
21
+ ## Contexto
22
+
23
+ Os dados públicos do INSEE permitem medir os casamentos celebrados na França.
24
+ MD
25
+
26
+ excerpt = TextExcerpt.from_markdown(heading_body)
27
+ check!(excerpt.start_with?("Contexto: Os dados públicos"),
28
+ "headings should become 'Title: ' prefixes (got #{excerpt.inspect})")
29
+
30
+ long = "palavra " * 80
31
+ truncated = TextExcerpt.from_markdown(long)
32
+ check!(truncated.end_with?("..."), "long bodies must truncate with an ellipsis")
33
+ check!(truncated.length <= 220, "truncated excerpt must stay within 220 characters")
34
+
35
+ liquid = "{% include plotly.html src=\"/media/plots/x.html\" %}\n\nDepois do gráfico vem o texto."
36
+ check!(TextExcerpt.from_markdown(liquid) == "Depois do gráfico vem o texto.",
37
+ "liquid includes must not leak into the excerpt")
38
+
39
+ fields = TextExcerpt.listing_fields({ "description" => " Deck editorial. " }, heading_body)
40
+ check!(fields["description"] == "Deck editorial.",
41
+ "listing_fields must keep the trimmed editorial description")
42
+ check!(fields["excerpt"].start_with?("Contexto:"),
43
+ "listing_fields must still generate a body excerpt")
44
+
45
+ empty_desc = TextExcerpt.listing_fields({ "description" => "" }, heading_body)
46
+ check!(empty_desc["description"].empty?,
47
+ "empty editorial description stays empty in the index")
48
+ check!(!empty_desc["excerpt"].empty?,
49
+ "empty editorial description still gets a generated excerpt")
50
+
51
+ puts "All TextExcerpt checks passed."
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // Mirrors unwrapMediaSrc in content-runtime/10-utils.html and md-to-pdf.mjs.
3
+ function unwrapMediaSrc(path) {
4
+ const value = String(path == null ? '' : path).trim();
5
+ if (!value) return '';
6
+ const md = value.match(/^\[([^\]]*)\]\(([^)\s]+)\)\s*$/);
7
+ if (md) return String(md[2] || '').trim();
8
+ const angle = value.match(/^<([^>\s]+)>\s*$/);
9
+ if (angle) return String(angle[1] || '').trim();
10
+ return value;
11
+ }
12
+
13
+ const cases = [
14
+ ['https://example.com/a.png', 'https://example.com/a.png'],
15
+ [
16
+ '[https://example.com/a.png](https://example.com/a.png)',
17
+ 'https://example.com/a.png',
18
+ ],
19
+ ['[label](https://example.com/a.png)', 'https://example.com/a.png'],
20
+ ['<https://example.com/a.png>', 'https://example.com/a.png'],
21
+ ['media/images/a.png', 'media/images/a.png'],
22
+ ['[not a full](link) leftover', '[not a full](link) leftover'],
23
+ ['', ''],
24
+ ];
25
+
26
+ let failed = 0;
27
+ for (const [input, expected] of cases) {
28
+ const got = unwrapMediaSrc(input);
29
+ if (got !== expected) {
30
+ console.error(`FAIL: ${JSON.stringify(input)} → ${JSON.stringify(got)} (want ${JSON.stringify(expected)})`);
31
+ failed += 1;
32
+ }
33
+ }
34
+
35
+ if (failed) {
36
+ console.error(`unwrapMediaSrc: ${failed} failure(s)`);
37
+ process.exit(1);
38
+ }
39
+ console.log(`PASS: unwrapMediaSrc (${cases.length} cases)`);
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Keep unwrapMediaSrc (Pages CMS Markdown-autolink mangling of include src=)
5
+ # honest against a small Node mirror. The browser runtime and PDF exporter
6
+ # share the same contract.
7
+
8
+ require "pathname"
9
+ require "open3"
10
+
11
+ REPO_ROOT = Pathname.new(__dir__).join("..", "..").expand_path
12
+ SCRIPT = REPO_ROOT.join("scripts", "test", "test-unwrap-media-src.mjs")
13
+
14
+ puts "Testing unwrapMediaSrc (CMS autolink unwrap)..."
15
+
16
+ stdout, stderr, status = Open3.capture3("node", SCRIPT.to_s)
17
+ print stdout
18
+ warn stderr unless stderr.empty?
19
+ exit(status.exitstatus)