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,534 @@
1
+ ---
2
+ layout: shell
3
+ ---
4
+
5
+ {%- comment -%}
6
+ Projects — index and client-side detail view.
7
+
8
+ This page is deliberately less modular than the rest of the site. Projects
9
+ are few, they change rarely, and each one owns a body full of figures,
10
+ videos, plots, diagrams and tables, so the presentation is written for that
11
+ material rather than routed through the generic listing/detail pair.
12
+
13
+ The binding rule still holds: every element here reads a front-matter field
14
+ that exists (`project`, `title`, `tagline`, `label`, `status`, `start_date`,
15
+ `end_date`, `github`, `article_url`, `external_url`, `tags`, `icon`,
16
+ `thumbnail`). Nothing counts, derives or invents. The mockup's metrics
17
+ strip, benchmark ledger, proof transcript, version badges and commit hashes
18
+ have no data source and are not built.
19
+ {%- endcomment -%}
20
+
21
+ <main class="projects-page relative max-w-7xl mx-auto px-4 sm:px-8 md:px-16 pt-10 pb-16" id="projects-main"
22
+ {% include page/listing-runtime-attrs.html
23
+ default_collection="projects"
24
+ default_path="/pages/projects/"
25
+ default_detail="/projects/:slug/" %}>
26
+
27
+ <div id="projects-page-header">
28
+ {% include page/page-header.html key=page.key
29
+ header_class="projects-masthead"
30
+ tags_class="projects-masthead-tags code-technical block hidden" %}
31
+ </div>
32
+
33
+ <div id="projects-listing">
34
+ <div id="projects-filter-note"></div>
35
+ <div id="projects-lead"></div>
36
+ <div id="projects-grid-banner"></div>
37
+ <div id="projects-grid" class="projects-grid"></div>
38
+ <p id="projects-state" class="projects-state font-body text-on-surface-variant italic">Loading projects…</p>
39
+ <div id="projects-repos"></div>
40
+ </div>
41
+
42
+ <div id="projects-detail" class="projects-detail hidden">
43
+ <div id="projects-detail-hero"></div>
44
+ {%- comment -%}
45
+ The tracks are the shared detail pair (`text-detail-grid`, defined in
46
+ ledger/detail.css), but this page holds the split back to 1024px instead
47
+ of 768px: at the tablet breakpoint the dossier is around 170px, which
48
+ broke a long project slug like `example/freshy` across three lines mid-word. 1024px is
49
+ also where the shared table of contents swaps from its disclosure to the
50
+ sidebar, so the two change together. ledger/projects.css keeps the
51
+ tablet band stacked.
52
+ {%- endcomment -%}
53
+ <div class="grid grid-cols-1 gap-8 lg:gap-12 items-start text-detail-grid">
54
+ <aside id="projects-detail-sidebar" class="projects-dossier-column"></aside>
55
+ <div id="projects-detail-main" class="projects-detail-column"></div>
56
+ </div>
57
+ </div>
58
+
59
+ </main>
60
+
61
+ <script>
62
+ (function () {
63
+ 'use strict';
64
+
65
+ var runtime = window.ContentRuntime;
66
+ if (!runtime) return;
67
+
68
+ var esc = runtime.escapeHtml;
69
+ var escAttr = runtime.escapeAttr;
70
+
71
+ var configRoot = document.querySelector('[data-listing-collection]');
72
+ var collection = (configRoot && configRoot.getAttribute('data-listing-collection')) || 'projects';
73
+ var listingPath = (configRoot && configRoot.getAttribute('data-listing-path')) || '/pages/projects/';
74
+ var detailPattern = (configRoot && configRoot.getAttribute('data-listing-detail')) || '/projects/:slug/';
75
+ if (listingPath.slice(-1) !== '/') listingPath += '/';
76
+
77
+ var listing = document.getElementById('projects-listing');
78
+ var noteRoot = document.getElementById('projects-filter-note');
79
+ var leadRoot = document.getElementById('projects-lead');
80
+ var bannerRoot = document.getElementById('projects-grid-banner');
81
+ var grid = document.getElementById('projects-grid');
82
+ var state = document.getElementById('projects-state');
83
+ var reposRoot = document.getElementById('projects-repos');
84
+
85
+ var detail = document.getElementById('projects-detail');
86
+ var detailHero = document.getElementById('projects-detail-hero');
87
+ var detailSidebar = document.getElementById('projects-detail-sidebar');
88
+ var detailMain = document.getElementById('projects-detail-main');
89
+ var pageHeader = document.getElementById('projects-page-header');
90
+
91
+ var params = new URLSearchParams(window.location.search);
92
+ var selectedProject = runtime.contentSlug('project') ||
93
+ runtime.slugFromDetailPattern(window.location.pathname, detailPattern);
94
+ var selectedTag = (params.get('tag') || '').trim();
95
+
96
+ function entryUrl(slug) {
97
+ return runtime.detailUrl(detailPattern, slug);
98
+ }
99
+
100
+ /* ── Field readers ──────────────────────────────────────────────────
101
+ Each returns '' when its field is unset, so an absent field drops the
102
+ element rather than leaving a placeholder beNewsreader. */
103
+
104
+ function projectName(item) {
105
+ var fm = item.front_matter || {};
106
+ return fm.project || fm.title || item.slug || '';
107
+ }
108
+
109
+ /* `status` is almost always the word the date span already ends with, so
110
+ an ongoing project would otherwise read "Ongoing" twice in one line.
111
+ Any other status — archived, paused — has no such carrier and is shown
112
+ on its own. */
113
+ function periodText(fm) {
114
+ if (!fm.start_date) return '';
115
+ var span = String(fm.start_date);
116
+ if (fm.status === 'ongoing') return span + ' – Ongoing';
117
+ if (fm.end_date) return span + ' – ' + fm.end_date;
118
+ return span;
119
+ }
120
+
121
+ function statusPart(fm) {
122
+ var value = String(fm.status || '').trim();
123
+ if (!value) return '';
124
+ if (value.toLowerCase() === 'ongoing' && fm.start_date) return '';
125
+ return '<span class="ledger-status">' + esc(value) + '</span>';
126
+ }
127
+
128
+ function iconMark(fm, className) {
129
+ if (!fm.icon) return '';
130
+ return '<span class="material-symbols-outlined ' + className + '" aria-hidden="true">' +
131
+ esc(fm.icon) + '</span>';
132
+ }
133
+
134
+ /* The ledger line above every project name: glyph, working period, the
135
+ project's own one-line stack description, and any status the period
136
+ does not already carry. */
137
+ function ledgerLine(fm, iconClass) {
138
+ var period = periodText(fm);
139
+ var lead = period ? '<span class="ledger-span">' + esc(period) + '</span>' : '';
140
+ return runtime.renderMetaStrip([
141
+ lead,
142
+ fm.label ? esc(fm.label) : '',
143
+ statusPart(fm)
144
+ ]);
145
+ }
146
+
147
+ /* Media plate. A project with a `thumbnail` shows it; one without shows
148
+ its own `icon` glyph on the panel step instead. The fallback is not a
149
+ placeholder for a missing image — `icon` is set on every project and is
150
+ already how the site marks a project elsewhere — so a project with no
151
+ imagery still reads as finished rather than as broken. */
152
+ function plateHtml(item, modifier) {
153
+ var fm = item.front_matter || {};
154
+ var poster = runtime.renderPosterMedia(fm.thumbnail, projectName(item));
155
+ var cls = 'projects-plate' + (modifier ? ' ' + modifier : '');
156
+ if (poster) return '<div class="' + cls + '">' + poster + '</div>';
157
+ if (!fm.icon) return '';
158
+ return '<div class="' + cls + ' projects-plate--mark">' +
159
+ '<span class="material-symbols-outlined projects-plate-glyph" aria-hidden="true">' +
160
+ esc(fm.icon) + '</span></div>';
161
+ }
162
+
163
+ function tagHref(tag) {
164
+ return listingPath + '?tag=' + encodeURIComponent(tag);
165
+ }
166
+
167
+ /* Outbound links, in the order a reader wants them: the code, the writing
168
+ about it, the thing itself. */
169
+ function outboundLinks(fm) {
170
+ var links = [];
171
+ if (fm.github) links.push(runtime.renderCtaLink(fm.github, 'Source'));
172
+ if (fm.article_url) links.push(runtime.renderCtaLink(fm.article_url, 'Read the article'));
173
+ if (fm.external_url) links.push(runtime.renderCtaLink(fm.external_url, 'Visit'));
174
+ return links;
175
+ }
176
+
177
+ function repoPath(url) {
178
+ return String(url || '')
179
+ .replace(/^https?:\/\/(www\.)?github\.com\//i, '')
180
+ .replace(/\/+$/, '');
181
+ }
182
+
183
+ function articleSlugFromUrl(url) {
184
+ var match = String(url || '').match(/\/articles\/([^\/?#]+)/i);
185
+ return match ? decodeURIComponent(match[1]) : '';
186
+ }
187
+
188
+ /* Resolve the linked article's title so the dossier names the piece rather
189
+ than saying "Read the article". Falls back when the index has no match. */
190
+ async function articleTitleFor(url) {
191
+ var slug = articleSlugFromUrl(url);
192
+ if (!slug) return '';
193
+ try {
194
+ var items = await runtime.loadWritingIndex('articles');
195
+ var hit = (items || []).find(function (entry) {
196
+ return entry && entry.slug === slug;
197
+ });
198
+ if (!hit) return '';
199
+ var fm = hit.front_matter || {};
200
+ return String(fm.title || hit.title || '').trim();
201
+ } catch (error) {
202
+ return '';
203
+ }
204
+ }
205
+
206
+ /* ── Index ──────────────────────────────────────────────────────────── */
207
+
208
+ /* The featured project is drawn the way the writing listings draw their
209
+ featured article — at random from the whole index, then held in session
210
+ storage so navigating around the site does not reshuffle it. It keeps
211
+ its own key, because the article the home page leads with is a separate
212
+ pick, and every project is a candidate: one without a `thumbnail` still
213
+ leads with its glyph plate, so the shared thumbnail rule does not
214
+ apply here. */
215
+ var FEATURED_PICK = {
216
+ storageKey: 'featured-project-slug',
217
+ eligible: function () { return true; }
218
+ };
219
+
220
+ /* Lead plate. One entry opens the page at full width, the way a portfolio
221
+ leads with one piece of work rather than seven equal thumbnails. */
222
+ function renderLead(item) {
223
+ var fm = item.front_matter || {};
224
+ var href = entryUrl(item.slug);
225
+ var name = projectName(item);
226
+ var line = ledgerLine(fm, 'ledger-system-icon');
227
+
228
+ var html = '<article class="projects-lead">';
229
+ html += '<span class="shard-accent projects-lead-shard" aria-hidden="true"></span>';
230
+ /* The plate links to the same place as the title beside it. It is out of
231
+ the tab order so keyboard users are not made to pass the same
232
+ destination twice, but it keeps its accessible name — the poster's alt
233
+ — rather than being hidden from assistive technology, which would be
234
+ wrong on something focusable at all. */
235
+ html += '<a class="projects-lead-media" href="' + escAttr(href) + '" tabindex="-1">' +
236
+ plateHtml(item, 'projects-plate--lead') + '</a>';
237
+ html += '<div class="projects-lead-body">';
238
+ if (line) html += line;
239
+ html += '<h2 class="projects-lead-title"><a href="' + escAttr(href) + '">' + esc(name) + '</a></h2>';
240
+ if (fm.title && fm.title !== name) {
241
+ html += '<p class="projects-lead-subtitle">' + esc(fm.title) + '</p>';
242
+ }
243
+ if (fm.tagline) {
244
+ html += '<p class="projects-lead-copy font-body">' + esc(fm.tagline) + '</p>';
245
+ }
246
+ html += runtime.renderTagChips(fm.tags, { hrefFor: tagHref });
247
+ /* The plate and the title already lead to the project, so the row keeps
248
+ only what leaves the site. It disappears when a project publishes no
249
+ outbound link at all. */
250
+ var leadLinks = outboundLinks(fm);
251
+ if (leadLinks.length) {
252
+ html += '<div class="projects-lead-links ledger-card-links">' + leadLinks.join('') + '</div>';
253
+ }
254
+ html += '</div></article>';
255
+ leadRoot.innerHTML = html;
256
+ }
257
+
258
+ /* Grid card. The shared system card already carries exactly the project
259
+ shape — poster, ledger line, name, tagline, chips, links — so it stays
260
+ the block of record; the only thing grafted on is the glyph plate for a
261
+ project that publishes no imagery, which the shared block has no hook
262
+ for. */
263
+ function renderCard(item) {
264
+ var fm = item.front_matter || {};
265
+ var period = periodText(fm);
266
+ var card = runtime.renderSystemCard(item, {
267
+ href: entryUrl(item.slug),
268
+ repoLabel: 'Source',
269
+ articleLabel: 'Read the article',
270
+ externalLabel: 'Visit',
271
+ metaParts: [
272
+ period ? '<span class="ledger-span">' + esc(period) + '</span>' : '',
273
+ fm.label ? esc(fm.label) : '',
274
+ statusPart(fm)
275
+ ],
276
+ tagHref: tagHref
277
+ });
278
+ card.setAttribute('data-dynamic-project', 'true');
279
+ /* The glyph reads as part of the name, so it sits on the title's line at
280
+ the title's size rather than shrunk into the metadata above it. */
281
+ var cardTitle = card.querySelector('.ledger-card-title');
282
+ if (cardTitle && fm.icon) {
283
+ cardTitle.insertAdjacentHTML('afterbegin', iconMark(fm, 'projects-title-icon'));
284
+ }
285
+ if (!fm.thumbnail) {
286
+ var plate = plateHtml(item, 'projects-plate--card');
287
+ if (plate) card.insertAdjacentHTML('afterbegin', plate);
288
+ }
289
+ return card;
290
+ }
291
+
292
+ /* Closing ledger. One row per repository that front matter actually
293
+ names — five of seven today. The mockup's licence stamp, spec download
294
+ and commit hashes have nothing beNewsreader them and are not drawn. */
295
+ function renderRepositories(items) {
296
+ var rows = items.filter(function (item) {
297
+ return (item.front_matter || {}).github;
298
+ });
299
+ if (!rows.length) {
300
+ reposRoot.innerHTML = '';
301
+ return;
302
+ }
303
+ var html = '<section class="projects-repos">';
304
+ html += runtime.renderSectionBanner({ kicker: 'Open source', title: 'Repositories' });
305
+ html += '<div class="projects-repo-list">';
306
+ rows.forEach(function (item) {
307
+ var fm = item.front_matter || {};
308
+ html += '<a class="projects-repo" href="' + escAttr(fm.github) + '">' +
309
+ '<span class="projects-repo-name">' + esc(projectName(item)) + '</span>' +
310
+ '<span class="projects-repo-path code-technical">' + esc(repoPath(fm.github)) + '</span>' +
311
+ '<span class="material-symbols-outlined ledger-cta-arrow" aria-hidden="true">chevron_right</span>' +
312
+ '</a>';
313
+ });
314
+ html += '</div></section>';
315
+ reposRoot.innerHTML = html;
316
+ }
317
+
318
+ /* The count is a figure, so it takes the notation role; the words around
319
+ it are chrome labels and stay in the label and body faces. */
320
+ function renderFilterNote(tag, count, total) {
321
+ noteRoot.innerHTML = '<div class="projects-filter-note">' +
322
+ '<span class="projects-filter-note-label">Filtered by</span>' +
323
+ '<span class="ledger-chip">' + esc(tag) + '</span>' +
324
+ '<span class="projects-filter-note-count code-technical">' + count + ' of ' + total + '</span>' +
325
+ '<a class="ledger-cta" href="' + escAttr(listingPath) + '"><span>Show all</span></a>' +
326
+ '</div>';
327
+ }
328
+
329
+ function matchesTag(item, tag) {
330
+ var tags = (item.front_matter || {}).tags || [];
331
+ return (Array.isArray(tags) ? tags : []).some(function (value) {
332
+ return String(value).toLowerCase() === tag.toLowerCase();
333
+ });
334
+ }
335
+
336
+ function bySortOrder(a, b) {
337
+ var ao = (a.front_matter && a.front_matter.sort_order) || 0;
338
+ var bo = (b.front_matter && b.front_matter.sort_order) || 0;
339
+ return Number(ao) - Number(bo);
340
+ }
341
+
342
+ function showListing() {
343
+ if (listing) listing.classList.remove('hidden');
344
+ if (detail) detail.classList.add('hidden');
345
+ if (pageHeader) pageHeader.classList.remove('hidden');
346
+ if (detailMain) detailMain.innerHTML = '';
347
+ if (detailSidebar) detailSidebar.innerHTML = '';
348
+ if (detailHero) detailHero.innerHTML = '';
349
+ runtime.removeArticleToc();
350
+ runtime.removeRuntimeBackLink();
351
+ }
352
+
353
+ function renderListing(projects) {
354
+ var ordered = projects.slice().sort(bySortOrder);
355
+ var visible = selectedTag
356
+ ? ordered.filter(function (item) { return matchesTag(item, selectedTag); })
357
+ : ordered;
358
+
359
+ grid.innerHTML = '';
360
+ leadRoot.innerHTML = '';
361
+ bannerRoot.innerHTML = '';
362
+ noteRoot.innerHTML = '';
363
+
364
+ if (selectedTag) renderFilterNote(selectedTag, visible.length, ordered.length);
365
+
366
+ if (!visible.length) {
367
+ state.textContent = 'No project carries that tag.';
368
+ state.classList.remove('hidden');
369
+ reposRoot.innerHTML = '';
370
+ return;
371
+ }
372
+ state.classList.add('hidden');
373
+
374
+ /* A filtered view is a short answer to a question, so it stays a flat
375
+ grid; the lead plate is for the unfiltered page, where the drawn entry
376
+ is genuinely the opening statement. The grid below is then everything
377
+ but that entry, so nothing is listed twice. */
378
+ var rest = visible;
379
+ if (!selectedTag && visible.length > 1) {
380
+ var featured = runtime.pickFeatured(ordered, FEATURED_PICK) || visible[0];
381
+ renderLead(featured);
382
+ rest = visible.filter(function (item) { return item.slug !== featured.slug; });
383
+ bannerRoot.innerHTML = runtime.renderSectionBanner({
384
+ kicker: 'Index',
385
+ title: 'Other projects'
386
+ });
387
+ }
388
+
389
+ rest.forEach(function (item) { grid.appendChild(renderCard(item)); });
390
+ renderRepositories(visible);
391
+ }
392
+
393
+ /* ── Detail ─────────────────────────────────────────────────────────── */
394
+
395
+ /* Full-width masthead. The generic detail view puts the title in a narrow
396
+ third-column dossier, where "Base Operating System for Sensors and
397
+ Actuators" breaks over five lines. A project leads with its short name,
398
+ carries its long title underneath, and states what it is before the
399
+ reader has to scroll. */
400
+ function renderHero(item) {
401
+ var fm = item.front_matter || {};
402
+ var name = projectName(item);
403
+ var line = ledgerLine(fm, 'projects-hero-icon');
404
+
405
+ var html = '<header class="projects-hero">';
406
+ html += '<span class="shard-accent projects-hero-shard" aria-hidden="true"></span>';
407
+ html += '<div class="projects-hero-body">';
408
+ if (line) html += line;
409
+ html += '<h1 class="projects-hero-title">' + esc(name) + '</h1>';
410
+ if (fm.title && fm.title !== name) {
411
+ html += '<p class="projects-hero-subtitle">' + esc(fm.title) + '</p>';
412
+ }
413
+ if (fm.tagline) {
414
+ html += '<p class="projects-hero-standfirst font-body">' + esc(fm.tagline) + '</p>';
415
+ }
416
+ var links = outboundLinks(fm);
417
+ if (links.length) {
418
+ html += '<div class="projects-hero-actions">' + links.join('') + '</div>';
419
+ }
420
+ html += '</div>';
421
+ var plate = plateHtml(item, 'projects-plate--hero');
422
+ if (plate) html += '<div class="projects-hero-media">' + plate + '</div>';
423
+ html += '</header>';
424
+ detailHero.innerHTML = html;
425
+ }
426
+
427
+ function dossierRow(term, value) {
428
+ if (!value) return '';
429
+ return '<div class="projects-dossier-row">' +
430
+ '<dt>' + esc(term) + '</dt><dd>' + value + '</dd></div>';
431
+ }
432
+
433
+ /* Dossier. The mockup's right-hand spec column — ARCH, STACK, SPEC — with
434
+ the fabricated rows replaced by the four facts a project actually
435
+ declares. `mountArticleToc` appends the contents list to this column's
436
+ first child and inserts the mobile disclosure after `.article-meta`, so
437
+ both hooks below are emitted on purpose. */
438
+ async function renderDossier(item) {
439
+ var fm = item.front_matter || {};
440
+ var period = periodText(fm);
441
+ var rows = '';
442
+ rows += dossierRow('Field', fm.label ? esc(fm.label) : '');
443
+ rows += dossierRow('Period', esc(period));
444
+ if (!period && fm.status) rows += dossierRow('Status', esc(fm.status));
445
+ if (fm.github) {
446
+ rows += dossierRow('Repository',
447
+ '<a href="' + escAttr(fm.github) + '">' + esc(repoPath(fm.github)) + '</a>');
448
+ }
449
+ if (fm.article_url) {
450
+ var articleTitle = await articleTitleFor(fm.article_url);
451
+ rows += dossierRow('Article',
452
+ '<a href="' + escAttr(fm.article_url) + '">' +
453
+ esc(articleTitle || 'Read the article') + '</a>');
454
+ }
455
+ if (fm.external_url) {
456
+ rows += dossierRow('Live', '<a href="' + escAttr(fm.external_url) + '">' +
457
+ esc(String(fm.external_url).replace(/^https?:\/\/(www\.)?/i, '').replace(/\/+$/, '')) +
458
+ '</a>');
459
+ }
460
+ var chips = runtime.renderTagChips(fm.tags, { hrefFor: tagHref });
461
+ if (chips) rows += dossierRow('Tags', chips);
462
+
463
+ var html = '<div class="lg:sticky lg:top-32 projects-dossier">';
464
+ if (rows) {
465
+ html += '<p class="projects-dossier-title">Dossier</p>';
466
+ html += '<dl class="projects-dossier-rows article-meta">' + rows + '</dl>';
467
+ }
468
+ html += runtime.buildDetailActions(fm);
469
+ html += '</div>';
470
+ detailSidebar.innerHTML = html;
471
+ }
472
+
473
+ async function renderProjectDetail(item) {
474
+ var fm = item.front_matter || {};
475
+ if (!fm.layout) fm.layout = 'project';
476
+ /* Projects carry no `description`, so the social card and the meta
477
+ description are built from the tagline. A tagline is a short paragraph
478
+ rather than one sentence, and a search result cut off mid-clause reads
479
+ worse than a shorter one that finishes its thought, so only the
480
+ opening sentence travels. The page still shows the whole thing. */
481
+ if (!fm.description && fm.tagline) fm.description = runtime.leadSentence(fm.tagline);
482
+
483
+ if (listing) listing.classList.add('hidden');
484
+ if (pageHeader) pageHeader.classList.add('hidden');
485
+ if (detail) detail.classList.remove('hidden');
486
+
487
+ renderHero(item);
488
+ await renderDossier(item);
489
+
490
+ var allItems = await runtime.loadWritingIndex(collection);
491
+ var sorted = allItems.slice().sort(bySortOrder);
492
+ var index = sorted.findIndex(function (entry) { return entry.slug === item.slug; });
493
+
494
+ await runtime.renderRuntimeDetailSection({
495
+ detailAttribute: 'data-dynamic-project-detail',
496
+ bodyMarkdown: item.body_markdown,
497
+ frontMatter: fm,
498
+ mainColumn: detailMain,
499
+ sidebarEl: detailSidebar,
500
+ /* The list is sorted ascending by `sort_order`, so "previous" is the
501
+ entry before this one in that order. It used to be the entry after,
502
+ which walked the register backwards. */
503
+ footerNav: {
504
+ lang: 'enus',
505
+ previousUrl: index > 0 ? entryUrl(sorted[index - 1].slug) : null,
506
+ nextUrl: index >= 0 && index < sorted.length - 1 ? entryUrl(sorted[index + 1].slug) : null
507
+ }
508
+ });
509
+ }
510
+
511
+ function loadListing() {
512
+ showListing();
513
+ runtime.loadWritingIndex(collection).then(renderListing).catch(function (error) {
514
+ console.error(error);
515
+ state.textContent = 'Projects could not be loaded right now.';
516
+ state.classList.remove('hidden');
517
+ });
518
+ }
519
+
520
+ if (selectedProject) {
521
+ runtime.loadCollectionItem(collection, selectedProject).then(function (item) {
522
+ return renderProjectDetail(item);
523
+ }).catch(function (error) {
524
+ console.error(error);
525
+ showListing();
526
+ state.textContent = 'This project could not be loaded right now.';
527
+ state.classList.remove('hidden');
528
+ });
529
+ return;
530
+ }
531
+
532
+ loadListing();
533
+ })();
534
+ </script>