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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +77 -0
- data/DESIGN.md +1434 -0
- data/LICENSE +21 -0
- data/README.md +81 -0
- data/_data/themes/dark.yml +97 -0
- data/_data/themes/light.yml +120 -0
- data/_data/themes/shared.yml +37 -0
- data/_data/translations.yml +85 -0
- data/_includes/chord-tab.html +38 -0
- data/_includes/content-runtime/00-config.html +22 -0
- data/_includes/content-runtime/10-utils.html +176 -0
- data/_includes/content-runtime/20-markdown.html +264 -0
- data/_includes/content-runtime/25-code-blocks.html +315 -0
- data/_includes/content-runtime/30-includes-bib.html +190 -0
- data/_includes/content-runtime/30-includes-xcite.html +122 -0
- data/_includes/content-runtime/30-includes.html +365 -0
- data/_includes/content-runtime/40-listing.html +225 -0
- data/_includes/content-runtime/41-listing-helpers.html +71 -0
- data/_includes/content-runtime/42-listing-filter.html +208 -0
- data/_includes/content-runtime/43-blocks.html +379 -0
- data/_includes/content-runtime/50-detail.html +861 -0
- data/_includes/content-runtime/55-toc.html +176 -0
- data/_includes/content-runtime/60-fragments.html +567 -0
- data/_includes/content-runtime/90-bootstrap.html +76 -0
- data/_includes/cut-in.html +10 -0
- data/_includes/cv/cv-box.html +18 -0
- data/_includes/dict-card.html +24 -0
- data/_includes/figure.html +36 -0
- data/_includes/home/band-index.html +43 -0
- data/_includes/home/band-listing.html +49 -0
- data/_includes/mathjax.html +10 -0
- data/_includes/ongoing-product.html +1 -0
- data/_includes/page/content-runtime.html +20 -0
- data/_includes/page/date_evolution.html +19 -0
- data/_includes/page/figure-lightbox.html +102 -0
- data/_includes/page/formatted_date.html +15 -0
- data/_includes/page/hero-default.html +18 -0
- data/_includes/page/listing-runtime-attrs.html +8 -0
- data/_includes/page/mermaid.html +101 -0
- data/_includes/page/network-graph.html +1253 -0
- data/_includes/page/page-header.html +41 -0
- data/_includes/page/professional-connections.html +2 -0
- data/_includes/page/site-footer.html +101 -0
- data/_includes/page/timeline-section.html +868 -0
- data/_includes/plotly.html +22 -0
- data/_includes/theme/dev-reexport.html +100 -0
- data/_includes/theme/favicons.html +4 -0
- data/_includes/theme/font-loader.html +55 -0
- data/_includes/theme/shell-theme-boot.html +115 -0
- data/_includes/theme/site-logo.html +6 -0
- data/_includes/theme/tailwind-theme-colors.json.liquid +65 -0
- data/_includes/theme/tailwind-theme-fonts.json.liquid +6 -0
- data/_includes/theme/theme-context.liquid +20 -0
- data/_includes/theme/theme-toggle.html +94 -0
- data/_includes/theme/theme-vars-block.liquid +161 -0
- data/_includes/theme/theme-vars.html +16 -0
- data/_includes/transcription.html +80 -0
- data/_includes/tweet.html +22 -0
- data/_includes/video.html +11 -0
- data/_includes/xcite.html +3 -0
- data/_includes/youtube-short.html +26 -0
- data/_includes/youtube.html +12 -0
- data/_layouts/article.html +27 -0
- data/_layouts/articles.html +534 -0
- data/_layouts/bibliography.html +417 -0
- data/_layouts/blog.html +544 -0
- data/_layouts/cv.html +393 -0
- data/_layouts/home.html +319 -0
- data/_layouts/network.html +9 -0
- data/_layouts/pandorga/articles.html +4 -0
- data/_layouts/pandorga/bibliography.html +4 -0
- data/_layouts/pandorga/blog.html +4 -0
- data/_layouts/pandorga/cv.html +4 -0
- data/_layouts/pandorga/media.html +4 -0
- data/_layouts/pandorga/network.html +4 -0
- data/_layouts/pandorga/portfolio.html +4 -0
- data/_layouts/post.html +7 -0
- data/_layouts/product.html +5 -0
- data/_layouts/project.html +27 -0
- data/_layouts/projects.html +534 -0
- data/_layouts/resources.html +387 -0
- data/_layouts/shell.html +203 -0
- data/_layouts/text.html +246 -0
- data/_plugins/bibliography_artifact.rb +57 -0
- data/_plugins/content_backend.rb +37 -0
- data/_plugins/content_collections.rb +51 -0
- data/_plugins/content_detail_paths.rb +58 -0
- data/_plugins/content_headers.rb +73 -0
- data/_plugins/content_keys.rb +310 -0
- data/_plugins/content_local_serve.rb +168 -0
- data/_plugins/content_site_adapter.rb +159 -0
- data/_plugins/content_validators_hook.rb +20 -0
- data/_plugins/content_writing_index.rb +52 -0
- data/_plugins/content_writing_paths.rb +55 -0
- data/_plugins/force_utf8_encoding.rb +13 -0
- data/_plugins/git_chronology.rb +211 -0
- data/_plugins/git_metadata.rb +43 -0
- data/_plugins/job_sections.rb +25 -0
- data/_plugins/lib/content_validators.rb +27 -0
- data/_plugins/lib/resource_validator.rb +56 -0
- data/_plugins/lib/tag_validator.rb +153 -0
- data/_plugins/lib/text_excerpt.rb +56 -0
- data/_plugins/lib/writing_entry.rb +107 -0
- data/_plugins/local_detail_rewrites.rb +194 -0
- data/_plugins/timeline.rb +507 -0
- data/_plugins/writing_slug_limits.rb +70 -0
- data/assets/css/article.css +1330 -0
- data/assets/css/base.css +1432 -0
- data/assets/css/cv.css +13 -0
- data/assets/css/global-link.css +94 -0
- data/assets/css/ledger/bibliography.css +307 -0
- data/assets/css/ledger/chrome.css +118 -0
- data/assets/css/ledger/cv.css +836 -0
- data/assets/css/ledger/detail.css +1270 -0
- data/assets/css/ledger/footer.css +298 -0
- data/assets/css/ledger/home.css +1490 -0
- data/assets/css/ledger/network.css +106 -0
- data/assets/css/ledger/projects.css +716 -0
- data/assets/css/ledger/rascunhos.css +379 -0
- data/assets/css/ledger/sources.css +327 -0
- data/assets/css/ledger/writing.css +754 -0
- data/assets/tailwind-play.js +15 -0
- data/examples/full/Gemfile +7 -0
- data/examples/full/_config.yml +92 -0
- data/examples/full/_redirects +3 -0
- data/examples/full/content/collections/articles/2026-01-01-welcome.md +9 -0
- data/examples/full/content/collections/data/contacts.yml +3 -0
- data/examples/full/content/collections/data/education.yml +4 -0
- data/examples/full/content/collections/jobs/research-fellow.md +12 -0
- data/examples/full/content/collections/posts/2026-01-02-note.md +9 -0
- data/examples/full/content/collections/products/ledger-tool.md +6 -0
- data/examples/full/content/collections/projects/demo-kit.md +10 -0
- data/examples/full/content/collections/resources/style-guide.md +7 -0
- data/examples/full/content/pages/cv/summary.md +1 -0
- data/examples/full/content/pages/headers.yml +20 -0
- data/examples/full/index.html +4 -0
- data/examples/minimal/Gemfile +7 -0
- data/examples/minimal/_config.yml +47 -0
- data/examples/minimal/_redirects +3 -0
- data/examples/minimal/content/collections/articles/2026-01-01-hello.md +10 -0
- data/examples/minimal/content/collections/articles/2026-01-02-second.md +9 -0
- data/examples/minimal/content/pages/headers.yml +6 -0
- data/examples/minimal/index.html +4 -0
- data/exe/pandorga +7 -0
- data/functions/api/studio/[[path]].js +562 -0
- data/functions/api/studio/_lib/auth.js +131 -0
- data/functions/api/studio/_lib/blocks.js +156 -0
- data/functions/api/studio/_lib/gemini.js +62 -0
- data/functions/api/studio/_lib/github.js +278 -0
- data/functions/api/studio/_lib/guidelines.js +143 -0
- data/functions/api/studio/_lib/paths.js +76 -0
- data/functions/api/studio/_lib/prompts.js +165 -0
- data/functions/api/studio/_lib/rate-limit.js +40 -0
- data/functions/api/studio/_lib/rewrite.js +136 -0
- data/functions/api/studio/_lib/sanitize.js +143 -0
- data/lib/pandorga/cli.rb +66 -0
- data/lib/pandorga/commands/doctor.rb +66 -0
- data/lib/pandorga/commands/export.rb +30 -0
- data/lib/pandorga/commands/install_functions.rb +41 -0
- data/lib/pandorga/commands/new.rb +113 -0
- data/lib/pandorga/commands/publish.rb +53 -0
- data/lib/pandorga/commands/serve.rb +34 -0
- data/lib/pandorga/commands/studio_schema.rb +309 -0
- data/lib/pandorga/jekyll/generator.rb +74 -0
- data/lib/pandorga/jekyll/plugins/bibliography_artifact.rb +57 -0
- data/lib/pandorga/jekyll/plugins/content_backend.rb +37 -0
- data/lib/pandorga/jekyll/plugins/content_collections.rb +51 -0
- data/lib/pandorga/jekyll/plugins/content_detail_paths.rb +58 -0
- data/lib/pandorga/jekyll/plugins/content_headers.rb +73 -0
- data/lib/pandorga/jekyll/plugins/content_keys.rb +310 -0
- data/lib/pandorga/jekyll/plugins/content_local_serve.rb +168 -0
- data/lib/pandorga/jekyll/plugins/content_site_adapter.rb +159 -0
- data/lib/pandorga/jekyll/plugins/content_validators_hook.rb +20 -0
- data/lib/pandorga/jekyll/plugins/content_writing_index.rb +52 -0
- data/lib/pandorga/jekyll/plugins/content_writing_paths.rb +55 -0
- data/lib/pandorga/jekyll/plugins/force_utf8_encoding.rb +13 -0
- data/lib/pandorga/jekyll/plugins/git_chronology.rb +211 -0
- data/lib/pandorga/jekyll/plugins/git_metadata.rb +43 -0
- data/lib/pandorga/jekyll/plugins/job_sections.rb +25 -0
- data/lib/pandorga/jekyll/plugins/lib/content_validators.rb +27 -0
- data/lib/pandorga/jekyll/plugins/lib/resource_validator.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/tag_validator.rb +153 -0
- data/lib/pandorga/jekyll/plugins/lib/text_excerpt.rb +56 -0
- data/lib/pandorga/jekyll/plugins/lib/writing_entry.rb +107 -0
- data/lib/pandorga/jekyll/plugins/local_detail_rewrites.rb +194 -0
- data/lib/pandorga/jekyll/plugins/timeline.rb +507 -0
- data/lib/pandorga/jekyll/plugins/writing_slug_limits.rb +70 -0
- data/lib/pandorga/jekyll.rb +15 -0
- data/lib/pandorga/object_store_env.rb +51 -0
- data/lib/pandorga/registry/schema.yml +115 -0
- data/lib/pandorga/registry.rb +279 -0
- data/lib/pandorga/version.rb +5 -0
- data/lib/pandorga.rb +19 -0
- data/scripts/cdn-allowlist.json +19 -0
- data/scripts/content/backfill-content-keys.rb +99 -0
- data/scripts/content/band_art.rb +362 -0
- data/scripts/content/export-content-json.rb +741 -0
- data/scripts/content/flatten-ansi-capture.mjs +153 -0
- data/scripts/content/hero_portraits.rb +28 -0
- data/scripts/content/publish-content-to-object-store.sh +78 -0
- data/scripts/content/publish-content-to-r2.sh +5 -0
- data/scripts/content/sync-content-json-to-r2.rb +167 -0
- data/scripts/content/sync-content-redirects.rb +12 -0
- data/scripts/generate-configuration-doc.rb +127 -0
- data/scripts/lib/content_license.rb +50 -0
- data/scripts/lib/content_storage_config.rb +70 -0
- data/scripts/lib/normalize-display-math.mjs +52 -0
- data/scripts/lib/r2_json_sync.rb +76 -0
- data/scripts/lib/withdrawn_public_objects.rb +23 -0
- data/scripts/lib/writing_index_contract.rb +61 -0
- data/scripts/pdf/md-to-pdf.mjs +1400 -0
- data/scripts/pdf/md-to-pdf.sh +27 -0
- data/scripts/test/fixtures/dual-template/_config.yml +32 -0
- data/scripts/test/fixtures/dual-template/index.html +4 -0
- data/scripts/test/render-parity/article-note.json +5 -0
- data/scripts/test/render-parity/blockquote.json +5 -0
- data/scripts/test/render-parity/blocks/ansi-basic.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-bold-is-bright.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-drops-cursor-moves.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-escapes-markup.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-extended-colour.json +7 -0
- data/scripts/test/render-parity/blocks/ansi-real-control-byte.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-date-once.json +19 -0
- data/scripts/test/render-parity/blocks/archive-card-empty-foot.json +7 -0
- data/scripts/test/render-parity/blocks/archive-card-footer-dates.json +22 -0
- data/scripts/test/render-parity/blocks/archive-card-meta-parts.json +26 -0
- data/scripts/test/render-parity/blocks/archive-card.json +32 -0
- data/scripts/test/render-parity/blocks/cta-link.json +13 -0
- data/scripts/test/render-parity/blocks/lang-pill-pt.json +8 -0
- data/scripts/test/render-parity/blocks/lang-pill.json +8 -0
- data/scripts/test/render-parity/blocks/ledger-row-meta-parts.json +29 -0
- data/scripts/test/render-parity/blocks/ledger-row.json +35 -0
- data/scripts/test/render-parity/blocks/meta-strip.json +27 -0
- data/scripts/test/render-parity/blocks/poster-media-image.json +8 -0
- data/scripts/test/render-parity/blocks/poster-media-video.json +9 -0
- data/scripts/test/render-parity/blocks/section-banner.json +13 -0
- data/scripts/test/render-parity/blocks/system-card-custom-meta.json +22 -0
- data/scripts/test/render-parity/blocks/system-card.json +48 -0
- data/scripts/test/render-parity/blocks/tag-chips-links.json +12 -0
- data/scripts/test/render-parity/blocks/tag-chips.json +9 -0
- data/scripts/test/render-parity/cut-in-blank-line.json +9 -0
- data/scripts/test/render-parity/cut-in-right.json +9 -0
- data/scripts/test/render-parity/cut-in.json +8 -0
- data/scripts/test/render-parity/figure.json +5 -0
- data/scripts/test/render-parity/load-runtime.mjs +337 -0
- data/scripts/test/render-parity/ongoing-product.json +5 -0
- data/scripts/test/render-parity/plotly.json +5 -0
- data/scripts/test/render-parity/poem.json +5 -0
- data/scripts/test/render-parity/run-absent-field-checks.mjs +232 -0
- data/scripts/test/render-parity/run-block-fixture.mjs +86 -0
- data/scripts/test/render-parity/run-detail-parity-checks.mjs +123 -0
- data/scripts/test/render-parity/run-field-flow-checks.mjs +227 -0
- data/scripts/test/render-parity/run-fixture.mjs +159 -0
- data/scripts/test/render-parity/selectors.mjs +43 -0
- data/scripts/test/render-parity/video.json +5 -0
- data/scripts/test/render-parity/youtube-short.json +10 -0
- data/scripts/test/render-parity/youtube.json +8 -0
- data/scripts/test/test-absent-field-absent-element.rb +43 -0
- data/scripts/test/test-cdn-pins.rb +44 -0
- data/scripts/test/test-cloudflare-watch-paths-doc.rb +31 -0
- data/scripts/test/test-configuration-doc.rb +36 -0
- data/scripts/test/test-content-detail-urls.rb +346 -0
- data/scripts/test/test-content-keys.rb +110 -0
- data/scripts/test/test-content-license.rb +45 -0
- data/scripts/test/test-content-render-parity.rb +38 -0
- data/scripts/test/test-content-validators.rb +12 -0
- data/scripts/test/test-css-structure.rb +72 -0
- data/scripts/test/test-cv-export-contract.rb +158 -0
- data/scripts/test/test-detail-render-parity.rb +40 -0
- data/scripts/test/test-dev-reexport.rb +122 -0
- data/scripts/test/test-doctor.rb +39 -0
- data/scripts/test/test-dual-template-instances.rb +87 -0
- data/scripts/test/test-dual-template-runtime.rb +167 -0
- data/scripts/test/test-examples-build.rb +60 -0
- data/scripts/test/test-export-content-json-incremental.rb +138 -0
- data/scripts/test/test-export.rb +53 -0
- data/scripts/test/test-exported-fields-reach-the-view.rb +60 -0
- data/scripts/test/test-form-control-theming.rb +143 -0
- data/scripts/test/test-git-chronology.rb +414 -0
- data/scripts/test/test-hero-portraits.mjs +97 -0
- data/scripts/test/test-hero-portraits.rb +162 -0
- data/scripts/test/test-home-band-art.rb +135 -0
- data/scripts/test/test-install-functions.rb +39 -0
- data/scripts/test/test-language-export.rb +79 -0
- data/scripts/test/test-listing-filter-contract.rb +119 -0
- data/scripts/test/test-math-row-breaks.mjs +79 -0
- data/scripts/test/test-math-row-breaks.rb +18 -0
- data/scripts/test/test-new.rb +113 -0
- data/scripts/test/test-no-personal-data.rb +85 -0
- data/scripts/test/test-public-cv-export.rb +164 -0
- data/scripts/test/test-r2-config-consistency.rb +57 -0
- data/scripts/test/test-r2-json-sync.rb +193 -0
- data/scripts/test/test-registry-home.rb +34 -0
- data/scripts/test/test-runtime-js-syntax.rb +64 -0
- data/scripts/test/test-slim-writing-index.rb +126 -0
- data/scripts/test/test-studio-api-contract.rb +99 -0
- data/scripts/test/test-studio-content-files.rb +79 -0
- data/scripts/test/test-studio-liquid-roundtrip.mjs +179 -0
- data/scripts/test/test-studio-rate-limit.mjs +70 -0
- data/scripts/test/test-studio-rewrite-api-contract.rb +59 -0
- data/scripts/test/test-studio-rewrite-sanitize.mjs +210 -0
- data/scripts/test/test-studio-schema-composition.rb +126 -0
- data/scripts/test/test-studio-slug-config.rb +167 -0
- data/scripts/test/test-template-packages.rb +51 -0
- data/scripts/test/test-text-excerpt.rb +51 -0
- data/scripts/test/test-unwrap-media-src.mjs +39 -0
- data/scripts/test/test-unwrap-media-src.rb +19 -0
- data/scripts/test/test-withdrawn-content-export.rb +69 -0
- data/scripts/test/test-writing-index-export-slugs.rb +84 -0
- data/scripts/test/test-writing-slug-limits.rb +61 -0
- data/scripts/test/test-xcite-chronology.rb +106 -0
- data/scripts/validate.sh +74 -0
- data/scripts/visual/build-cv-pages-json.js +33 -0
- data/scripts/visual/build-site-pages-json.js +94 -0
- data/scripts/visual/publish-visual-inspection-to-r2.sh +42 -0
- data/scripts/visual/take-screenshots.js +88 -0
- data/scripts/visual/visual-inspection-pages.json +113 -0
- data/studio/assets/index-B_j0pMD5.css +1 -0
- data/studio/assets/index-Ch4YEv9g.js +285 -0
- data/studio/index.html +27 -0
- data/studio/schema.yml +869 -0
- data/studio-app/README.md +27 -0
- data/studio-app/index.html +26 -0
- data/studio-app/package-lock.json +2499 -0
- data/studio-app/package.json +26 -0
- data/studio-app/skills-lock.json +47 -0
- data/studio-app/src/api/client.js +71 -0
- data/studio-app/src/editor/body-editor.js +70 -0
- data/studio-app/src/editor/liquid-core.js +382 -0
- data/studio-app/src/editor/liquid.js +77 -0
- data/studio-app/src/lib/document.js +207 -0
- data/studio-app/src/lib/list-controls.js +317 -0
- data/studio-app/src/lib/schema.js +66 -0
- data/studio-app/src/lib/yaml-doc.js +100 -0
- data/studio-app/src/main.js +3693 -0
- data/studio-app/src/media/browser.js +578 -0
- data/studio-app/src/preview/render.js +430 -0
- data/studio-app/src/styles/studio.css +2826 -0
- data/studio-app/vite.config.js +25 -0
- data/templates/articles/README.md +40 -0
- data/templates/articles/fixtures/article.md +9 -0
- data/templates/articles/screenshots/README.md +6 -0
- data/templates/articles/studio.yml +73 -0
- data/templates/articles/template.yml +21 -0
- data/templates/bibliography/README.md +41 -0
- data/templates/bibliography/fixtures/references.json +9 -0
- data/templates/bibliography/screenshots/README.md +6 -0
- data/templates/bibliography/studio.yml +66 -0
- data/templates/bibliography/template.yml +18 -0
- data/templates/blog/README.md +38 -0
- data/templates/blog/fixtures/post.md +9 -0
- data/templates/blog/screenshots/README.md +6 -0
- data/templates/blog/studio.yml +69 -0
- data/templates/blog/template.yml +20 -0
- data/templates/cv/README.md +34 -0
- data/templates/cv/fixtures/job.md +12 -0
- data/templates/cv/screenshots/README.md +6 -0
- data/templates/cv/studio.yml +200 -0
- data/templates/cv/template.yml +25 -0
- data/templates/media/README.md +38 -0
- data/templates/media/fixtures/resource.md +7 -0
- data/templates/media/screenshots/README.md +6 -0
- data/templates/media/studio.yml +58 -0
- data/templates/media/template.yml +17 -0
- data/templates/network/README.md +34 -0
- data/templates/network/fixtures/sources.json +3 -0
- data/templates/network/screenshots/README.md +6 -0
- data/templates/network/studio.yml +28 -0
- data/templates/network/template.yml +10 -0
- data/templates/portfolio/README.md +39 -0
- data/templates/portfolio/fixtures/project.md +10 -0
- data/templates/portfolio/screenshots/README.md +6 -0
- data/templates/portfolio/studio.yml +89 -0
- data/templates/portfolio/template.yml +18 -0
- metadata +505 -0
|
@@ -0,0 +1,3693 @@
|
|
|
1
|
+
import "./styles/studio.css";
|
|
2
|
+
import { loadSchema, fieldIsBody } from "./lib/schema.js";
|
|
3
|
+
import { studioApi, setTokenGetter } from "./api/client.js";
|
|
4
|
+
import { splitDocument, rebuildDocument } from "./lib/document.js";
|
|
5
|
+
import { createBodyEditor, INCLUDE_DEFS } from "./editor/body-editor.js";
|
|
6
|
+
import { roundTripMarkdown } from "./editor/liquid-core.js";
|
|
7
|
+
import { renderPreview } from "./preview/render.js";
|
|
8
|
+
import {
|
|
9
|
+
LIST_SORT_OPTIONS,
|
|
10
|
+
LIST_PAGE_SIZE_OPTIONS,
|
|
11
|
+
DEFAULT_SORT_ID,
|
|
12
|
+
DEFAULT_PAGE_SIZE_ID,
|
|
13
|
+
applyListControls,
|
|
14
|
+
paginateItems,
|
|
15
|
+
loadSortPreference,
|
|
16
|
+
saveSortPreference,
|
|
17
|
+
loadPageSizePreference,
|
|
18
|
+
savePageSizePreference,
|
|
19
|
+
formatEntryListMeta,
|
|
20
|
+
itemTags,
|
|
21
|
+
collectDistinctTags,
|
|
22
|
+
} from "./lib/list-controls.js";
|
|
23
|
+
import {
|
|
24
|
+
yamlEditKind,
|
|
25
|
+
dumpYaml,
|
|
26
|
+
loadYaml,
|
|
27
|
+
emptyItemFromFields,
|
|
28
|
+
pruneEmptyFields,
|
|
29
|
+
asObjectList,
|
|
30
|
+
asObjectMap,
|
|
31
|
+
} from "./lib/yaml-doc.js";
|
|
32
|
+
import { renderMediaPane, openMediaDir, mediaRootFromSchema } from "./media/browser.js";
|
|
33
|
+
|
|
34
|
+
const PUBLISHABLE_KEY =
|
|
35
|
+
import.meta.env.VITE_CLERK_PUBLISHABLE_KEY ||
|
|
36
|
+
window.__STUDIO_CLERK_KEY__ ||
|
|
37
|
+
"";
|
|
38
|
+
|
|
39
|
+
/** Studio lives at /studio/; Clerk's component fallbackRedirectUrl defaults to `/`. */
|
|
40
|
+
const STUDIO_PATH = "/studio/";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keep in sync with `_config.yml` → `content_api_base_url` and `_redirects` `/media/*`.
|
|
44
|
+
* Studio list thumbs must hit R2 directly: Vite/local has no Pages `_redirects`, and
|
|
45
|
+
* same-origin `/media/…` is otherwise a dead path in the SPA.
|
|
46
|
+
*/
|
|
47
|
+
const MEDIA_BASE = import.meta.env.VITE_CONTENT_API_BASE_URL || "";
|
|
48
|
+
|
|
49
|
+
const CLERK_REDIRECTS = {
|
|
50
|
+
forceRedirectUrl: STUDIO_PATH,
|
|
51
|
+
fallbackRedirectUrl: STUDIO_PATH,
|
|
52
|
+
signInForceRedirectUrl: STUDIO_PATH,
|
|
53
|
+
signInFallbackRedirectUrl: STUDIO_PATH,
|
|
54
|
+
signUpForceRedirectUrl: STUDIO_PATH,
|
|
55
|
+
signUpFallbackRedirectUrl: STUDIO_PATH,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Decode Frontend API host from a Clerk publishable key (`pk_test_` / `pk_live_`). */
|
|
59
|
+
function frontendApiHostFromKey(key) {
|
|
60
|
+
const raw = String(key || "").replace(/^pk_(test|live)_/, "");
|
|
61
|
+
if (!raw) return "";
|
|
62
|
+
try {
|
|
63
|
+
const pad = "=".repeat((4 - (raw.length % 4)) % 4);
|
|
64
|
+
const b64 = (raw + pad).replace(/-/g, "+").replace(/_/g, "/");
|
|
65
|
+
return atob(b64).replace(/\$$/, "").trim();
|
|
66
|
+
} catch {
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load Clerk from the instance CDN (clerk-js@6) with UI components.
|
|
73
|
+
* Clerk dashboard “Quick copy” loads `@clerk/ui` first, then `clerk-js` with
|
|
74
|
+
* data-clerk-publishable-key. Without the UI script, mountSignIn throws
|
|
75
|
+
* “Clerk was not loaded with Ui components”. Do not `new` window.Clerk —
|
|
76
|
+
* the browser build exposes a ready instance.
|
|
77
|
+
*/
|
|
78
|
+
function loadScript(src, attrs = {}) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const existing = document.querySelector(`script[src="${src}"]`);
|
|
81
|
+
if (existing) {
|
|
82
|
+
if (existing.dataset.loaded === "1") return resolve();
|
|
83
|
+
existing.addEventListener("load", () => resolve(), { once: true });
|
|
84
|
+
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
|
|
85
|
+
once: true,
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const s = document.createElement("script");
|
|
90
|
+
s.src = src;
|
|
91
|
+
s.async = true;
|
|
92
|
+
s.defer = true;
|
|
93
|
+
s.crossOrigin = "anonymous";
|
|
94
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
95
|
+
if (k === "dataset" && v && typeof v === "object") {
|
|
96
|
+
for (const [dk, dv] of Object.entries(v)) s.dataset[dk] = dv;
|
|
97
|
+
} else if (v != null) s.setAttribute(k, v);
|
|
98
|
+
}
|
|
99
|
+
s.onload = () => {
|
|
100
|
+
s.dataset.loaded = "1";
|
|
101
|
+
resolve();
|
|
102
|
+
};
|
|
103
|
+
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
|
104
|
+
document.head.appendChild(s);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function loadClerkInstance(publishableKey) {
|
|
109
|
+
if (window.Clerk && typeof window.Clerk.load === "function" && window.__internal_ClerkUICtor) {
|
|
110
|
+
return window.Clerk;
|
|
111
|
+
}
|
|
112
|
+
const host =
|
|
113
|
+
String(import.meta.env.VITE_CLERK_JWT_ISSUER || "")
|
|
114
|
+
.replace(/^https?:\/\//, "")
|
|
115
|
+
.replace(/\/$/, "") || frontendApiHostFromKey(publishableKey);
|
|
116
|
+
if (!host) {
|
|
117
|
+
throw new Error("Cannot resolve Clerk Frontend API host from publishable key");
|
|
118
|
+
}
|
|
119
|
+
const base = `https://${host}/npm`;
|
|
120
|
+
// UI first (publishes window.__internal_ClerkUICtor), then clerk-js instance.
|
|
121
|
+
await loadScript(`${base}/@clerk/ui@1/dist/ui.browser.js`);
|
|
122
|
+
await loadScript(`${base}/@clerk/clerk-js@6/dist/clerk.browser.js`, {
|
|
123
|
+
dataset: { clerkPublishableKey: publishableKey },
|
|
124
|
+
});
|
|
125
|
+
for (let i = 0; i < 80; i++) {
|
|
126
|
+
const ready =
|
|
127
|
+
window.Clerk &&
|
|
128
|
+
typeof window.Clerk.load === "function" &&
|
|
129
|
+
window.__internal_ClerkUICtor;
|
|
130
|
+
if (ready) return window.Clerk;
|
|
131
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
132
|
+
}
|
|
133
|
+
throw new Error("Clerk / ClerkUI global missing after script load");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function initClerk() {
|
|
137
|
+
if (!PUBLISHABLE_KEY) {
|
|
138
|
+
// Dev / preview without Clerk: rely on STUDIO_DEV_BYPASS on the Function.
|
|
139
|
+
setTokenGetter(async () => "dev-bypass-token");
|
|
140
|
+
return { dev: true };
|
|
141
|
+
}
|
|
142
|
+
const clerk = await loadClerkInstance(PUBLISHABLE_KEY);
|
|
143
|
+
// v6: UICtor must be passed into load() or mountSignIn asserts components missing.
|
|
144
|
+
// Pin redirects in code: dashboard Paths alone leave component fallback at `/`.
|
|
145
|
+
await clerk.load({
|
|
146
|
+
clerkUICtor: window.__internal_ClerkUICtor,
|
|
147
|
+
...CLERK_REDIRECTS,
|
|
148
|
+
});
|
|
149
|
+
setTokenGetter(async () => {
|
|
150
|
+
if (!clerk.session) return null;
|
|
151
|
+
return clerk.session.getToken();
|
|
152
|
+
});
|
|
153
|
+
return clerk;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const app = document.getElementById("app");
|
|
157
|
+
|
|
158
|
+
const state = {
|
|
159
|
+
schema: null,
|
|
160
|
+
session: null,
|
|
161
|
+
collection: null,
|
|
162
|
+
items: [],
|
|
163
|
+
file: null,
|
|
164
|
+
sha: null,
|
|
165
|
+
fields: {},
|
|
166
|
+
body: "",
|
|
167
|
+
yamlDoc: null,
|
|
168
|
+
/** Root YAML sequence when collection.list (Profile contacts/education/…). */
|
|
169
|
+
yamlList: null,
|
|
170
|
+
/** YAML editor chrome: structured fields vs monospace source. */
|
|
171
|
+
yamlUiMode: "structured",
|
|
172
|
+
/** Markdown body chrome: visual | source | preview (preview is mobile-only tab). */
|
|
173
|
+
bodyMode: "visual",
|
|
174
|
+
/** Last Visual/Source mode before switching to the Preview tab. */
|
|
175
|
+
lastEditorMode: "visual",
|
|
176
|
+
/** Snapshot of last fetched / last saved entry for Discard. */
|
|
177
|
+
baseline: null,
|
|
178
|
+
dirty: false,
|
|
179
|
+
/** True while a save request is in flight. Blocks a second tap. */
|
|
180
|
+
saving: false,
|
|
181
|
+
status: "",
|
|
182
|
+
error: "",
|
|
183
|
+
/** API `error` code, when the failure came from the Studio function. */
|
|
184
|
+
errorCode: "",
|
|
185
|
+
/** Compact fetch/error detail for the list/editor (status + payload). */
|
|
186
|
+
errorDetail: "",
|
|
187
|
+
listLoading: false,
|
|
188
|
+
fileLoading: false,
|
|
189
|
+
previewHtml: "",
|
|
190
|
+
bodyEditor: null,
|
|
191
|
+
insertMenuOpen: false,
|
|
192
|
+
/** Soft hint under AI Proof when selection is missing. */
|
|
193
|
+
aiProofHint: "",
|
|
194
|
+
/** In-flight Correct/Refine on the current body selection. */
|
|
195
|
+
aiProofBusy: false,
|
|
196
|
+
/**
|
|
197
|
+
* Body selection captured on Correct/Refine mousedown, before the click
|
|
198
|
+
* takes focus off the editor and clears the live selection.
|
|
199
|
+
*/
|
|
200
|
+
aiProofSelection: null,
|
|
201
|
+
/** Open left-nav accordion groups (start empty = all folded). */
|
|
202
|
+
navOpenGroups: new Set(),
|
|
203
|
+
/** Mobile nav disclosure (≤960px); ignored on wide layouts. */
|
|
204
|
+
navMenuOpen: false,
|
|
205
|
+
/** Per-collection list query (session memory). */
|
|
206
|
+
listQueryByCollection: Object.create(null),
|
|
207
|
+
/** Per-collection sort id (session + localStorage). */
|
|
208
|
+
listSortByCollection: Object.create(null),
|
|
209
|
+
/** Per-collection page size id (session + localStorage). */
|
|
210
|
+
listPageSizeByCollection: Object.create(null),
|
|
211
|
+
/** Per-collection current page (1-based; session only). */
|
|
212
|
+
listPageByCollection: Object.create(null),
|
|
213
|
+
/** Per-collection tag filter (session only; writing lists). */
|
|
214
|
+
listTagByCollection: Object.create(null),
|
|
215
|
+
/** In-session nav entry counts keyed by collection name. */
|
|
216
|
+
navCountByCollection: Object.create(null),
|
|
217
|
+
/** Session tree cache: collection name → { items }. Avoids re-fetch on revisit. */
|
|
218
|
+
treeCacheByCollection: Object.create(null),
|
|
219
|
+
/** Bumped to drop stale idle prefetch work. */
|
|
220
|
+
treePrefetchGen: 0,
|
|
221
|
+
/** Editor front-matter accordion open state (null = choose from fields/dirty). */
|
|
222
|
+
frontMatterOpen: null,
|
|
223
|
+
/** Media library view (content/media tree). */
|
|
224
|
+
mediaOpen: false,
|
|
225
|
+
mediaPath: "content/media",
|
|
226
|
+
mediaItems: [],
|
|
227
|
+
mediaLoading: false,
|
|
228
|
+
mediaBusy: false,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/** Material Symbol names for Liquid include Insert menu. */
|
|
232
|
+
const INCLUDE_ICONS = {
|
|
233
|
+
figure: "image",
|
|
234
|
+
cutIn: "transition_push",
|
|
235
|
+
youtube: "smart_display",
|
|
236
|
+
youtubeShort: "mobile_screen_share",
|
|
237
|
+
video: "videocam",
|
|
238
|
+
plotly: "analytics",
|
|
239
|
+
xcite: "format_quote",
|
|
240
|
+
cite: "bookmark",
|
|
241
|
+
articleNote: "sticky_note_2",
|
|
242
|
+
poem: "history_edu",
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const GROUP_ICONS = {
|
|
246
|
+
Writing: "edit_note",
|
|
247
|
+
References: "menu_book",
|
|
248
|
+
Portfolio: "work",
|
|
249
|
+
Media: "photo_library",
|
|
250
|
+
Pages: "description",
|
|
251
|
+
"CV Summary": "badge",
|
|
252
|
+
Presentation: "campaign",
|
|
253
|
+
Profile: "person",
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const TEXT_COLORS = [
|
|
257
|
+
{ label: "Default", value: "" },
|
|
258
|
+
{ label: "Primary", value: "var(--primary)" },
|
|
259
|
+
{ label: "Secondary", value: "var(--secondary)" },
|
|
260
|
+
{ label: "Tertiary", value: "var(--tertiary)" },
|
|
261
|
+
{ label: "Muted", value: "var(--on-surface-variant)" },
|
|
262
|
+
{ label: "Error", value: "var(--error)" },
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
/** Short hover hints for left-nav section groups. */
|
|
266
|
+
const GROUP_HINTS = {
|
|
267
|
+
Writing: "Articles and Portuguese rascunhos",
|
|
268
|
+
References: "Source cards and bibliography entries",
|
|
269
|
+
Portfolio: "Products, projects, and CV jobs",
|
|
270
|
+
Media: "Upload and browse content/media — cite /media/… in includes",
|
|
271
|
+
Pages: "Listing page headers and furniture copy",
|
|
272
|
+
"CV Summary": "Public and PDF CV summary variants",
|
|
273
|
+
Presentation: "Home catch phrase and CV side notes",
|
|
274
|
+
Profile: "Contacts, education, languages, and skills",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
function formatPath(path) {
|
|
278
|
+
return String(path || "")
|
|
279
|
+
.split("/")
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.join(" | ");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function groupHint(label) {
|
|
285
|
+
return GROUP_HINTS[label] || `Browse ${label} collections`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function navGroupDomId(group) {
|
|
289
|
+
return (
|
|
290
|
+
"nav-group-" +
|
|
291
|
+
String(group)
|
|
292
|
+
.toLowerCase()
|
|
293
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
294
|
+
.replace(/^-|-$/g, "")
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function collectionHint(col) {
|
|
299
|
+
const cleaned = sanitizeUiCopy(col.description);
|
|
300
|
+
if (cleaned) return cleaned;
|
|
301
|
+
const where = formatPath(col.path) || col.name;
|
|
302
|
+
if (col.type === "file") return `Open ${col.label} | ${where}`;
|
|
303
|
+
return `List entries in ${col.label} | ${where}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Strip developer-contract prose from schema descriptions before UI display.
|
|
308
|
+
* Keeps short editorial hints; drops filename rules, TipTap/Liquid notes, etc.
|
|
309
|
+
*/
|
|
310
|
+
function sanitizeUiCopy(raw) {
|
|
311
|
+
let text = String(raw || "").trim();
|
|
312
|
+
if (!text) return "";
|
|
313
|
+
const dropPatterns = [
|
|
314
|
+
/\s*The filename uses Key as YYYY-MM-DD-key[^.]*\./gi,
|
|
315
|
+
/\s*Also the filename stem:[^.]*\./gi,
|
|
316
|
+
/\s*Also the filename:[^.]*\./gi,
|
|
317
|
+
/\s*Must match the YYYY-MM-DD in the filename[^.]*\./gi,
|
|
318
|
+
/\s*;?\s*must match the YYYY-MM-DD in the filename\./gi,
|
|
319
|
+
/\s*The Updated date is read from Git, never set here\./gi,
|
|
320
|
+
/\s*ASCII kebab-case\./gi,
|
|
321
|
+
/\s*\(44 characters total for the key segment is a good ceiling\)\.?/gi,
|
|
322
|
+
/Metadata-first forms[^.]*\./gi,
|
|
323
|
+
/TipTap with Liquid blocks[^.]*\./gi,
|
|
324
|
+
/preview via content-runtime contract\.?/gi,
|
|
325
|
+
/Paste Liquid \{%\s*include\s*%\} markers[^.]*\./gi,
|
|
326
|
+
/Do not use rich-text:[^.]*\./gi,
|
|
327
|
+
/its Editor mode rewrites URLs inside includes\.?/gi,
|
|
328
|
+
/Markdown source \(code editor\)\.?/gi,
|
|
329
|
+
];
|
|
330
|
+
for (const re of dropPatterns) text = text.replace(re, "");
|
|
331
|
+
text = text.replace(/\s{2,}/g, " ").replace(/\s+\./g, ".").trim();
|
|
332
|
+
if (/filename|YYYY-MM-DD-key|content-runtime|autolink|TipTap/i.test(text)) {
|
|
333
|
+
return "";
|
|
334
|
+
}
|
|
335
|
+
return text;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function fieldHint(field) {
|
|
339
|
+
return sanitizeUiCopy(field?.description);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function captureError(err) {
|
|
343
|
+
const message = String(err?.message || err || "Request failed");
|
|
344
|
+
const parts = [];
|
|
345
|
+
if (err?.status) parts.push(`HTTP ${err.status}`);
|
|
346
|
+
const detail = err?.data?.detail;
|
|
347
|
+
if (detail) parts.push(String(detail));
|
|
348
|
+
else if (err?.data && typeof err.data === "object") {
|
|
349
|
+
try {
|
|
350
|
+
parts.push(JSON.stringify(err.data, null, 2));
|
|
351
|
+
} catch {
|
|
352
|
+
/* ignore */
|
|
353
|
+
}
|
|
354
|
+
} else if (err?.stack && !String(err.stack).includes(message)) {
|
|
355
|
+
parts.push(String(err.stack).split("\n").slice(0, 6).join("\n"));
|
|
356
|
+
}
|
|
357
|
+
state.error = message;
|
|
358
|
+
state.errorCode = String(err?.code || err?.data?.error || "");
|
|
359
|
+
state.errorDetail = parts.join("\n").trim();
|
|
360
|
+
state.status = "";
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function clearError() {
|
|
364
|
+
state.error = "";
|
|
365
|
+
state.errorCode = "";
|
|
366
|
+
state.errorDetail = "";
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function currentNavLabel() {
|
|
370
|
+
if (state.mediaOpen) return "Media";
|
|
371
|
+
if (state.collection?.label) return state.collection.label;
|
|
372
|
+
return "Home";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function closeNavMenu() {
|
|
376
|
+
state.navMenuOpen = false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function goHome() {
|
|
380
|
+
destroyBodyEditor();
|
|
381
|
+
state.collection = null;
|
|
382
|
+
state.file = null;
|
|
383
|
+
state.baseline = null;
|
|
384
|
+
state.items = [];
|
|
385
|
+
state.yamlDoc = null;
|
|
386
|
+
state.yamlList = null;
|
|
387
|
+
state.listLoading = false;
|
|
388
|
+
state.fileLoading = false;
|
|
389
|
+
state.dirty = false;
|
|
390
|
+
state.insertMenuOpen = false;
|
|
391
|
+
state.mediaOpen = false;
|
|
392
|
+
state.mediaItems = [];
|
|
393
|
+
state.mediaLoading = false;
|
|
394
|
+
state.mediaBusy = false;
|
|
395
|
+
closeNavMenu();
|
|
396
|
+
clearError();
|
|
397
|
+
state.status = state.session?.email || state.session?.userId || "";
|
|
398
|
+
renderShell();
|
|
399
|
+
scheduleTreePrefetch();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function mediaOpts() {
|
|
403
|
+
return {
|
|
404
|
+
api: studioApi,
|
|
405
|
+
state,
|
|
406
|
+
el,
|
|
407
|
+
materialIcon,
|
|
408
|
+
resolveMediaUrl: resolveThumbnailUrl,
|
|
409
|
+
render: () => renderShell(),
|
|
410
|
+
captureError,
|
|
411
|
+
clearError,
|
|
412
|
+
goHome,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function openMediaLibrary(dirPath) {
|
|
417
|
+
destroyBodyEditor();
|
|
418
|
+
state.collection = null;
|
|
419
|
+
state.file = null;
|
|
420
|
+
state.baseline = null;
|
|
421
|
+
state.items = [];
|
|
422
|
+
state.insertMenuOpen = false;
|
|
423
|
+
state.dirty = false;
|
|
424
|
+
state.mediaOpen = true;
|
|
425
|
+
state.navOpenGroups.add("Media");
|
|
426
|
+
closeNavMenu();
|
|
427
|
+
const root = mediaRootFromSchema(state.schema);
|
|
428
|
+
await openMediaDir(dirPath || root, mediaOpts());
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function materialIcon(name, extraClass = "") {
|
|
432
|
+
return el("span", {
|
|
433
|
+
className: "material-symbols-outlined" + (extraClass ? ` ${extraClass}` : ""),
|
|
434
|
+
text: name,
|
|
435
|
+
"aria-hidden": "true",
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Studio mark — same theme swap as the public site stamp (base.css):
|
|
441
|
+
* light page → pastel triad; dark page → production triad.
|
|
442
|
+
* Inline SVG so data-theme CSS can paint fills (img + prefers-color-scheme cannot).
|
|
443
|
+
*/
|
|
444
|
+
function studioMark({ size = 40, label = "" } = {}) {
|
|
445
|
+
const aria =
|
|
446
|
+
label
|
|
447
|
+
? { role: "img", "aria-label": label }
|
|
448
|
+
: { "aria-hidden": "true" };
|
|
449
|
+
return el("span", {
|
|
450
|
+
className: "studio-mark",
|
|
451
|
+
...aria,
|
|
452
|
+
html:
|
|
453
|
+
`<svg class="studio-mark-svg" viewBox="0 0 32 32" width="${size}" height="${size}" focusable="false">` +
|
|
454
|
+
`<polygon class="studio-mark-primary" points="16,1 24,5 13,14 2,17 2,9"/>` +
|
|
455
|
+
`<polygon class="studio-mark-secondary" points="24,5 30,9 30,23 22,28 19,18 13,14"/>` +
|
|
456
|
+
`<polygon class="studio-mark-tertiary" points="2,17 13,14 19,18 22,28 16,31 2,23"/>` +
|
|
457
|
+
`<polygon class="studio-mark-frame" points="16,1 30,9 30,23 16,31 2,23 2,9"/>` +
|
|
458
|
+
`</svg>`,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function entryHint(item) {
|
|
463
|
+
const title = item.title || item.name || "Entry";
|
|
464
|
+
const path = formatPath(item.path);
|
|
465
|
+
return path ? `Open ${title} | ${path}` : `Open ${title}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function currentListQuery() {
|
|
469
|
+
const name = state.collection?.name;
|
|
470
|
+
if (!name) return "";
|
|
471
|
+
return state.listQueryByCollection[name] || "";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function currentListSortId() {
|
|
475
|
+
const name = state.collection?.name;
|
|
476
|
+
if (!name) return DEFAULT_SORT_ID;
|
|
477
|
+
if (state.listSortByCollection[name]) return state.listSortByCollection[name];
|
|
478
|
+
const stored = loadSortPreference(name, DEFAULT_SORT_ID);
|
|
479
|
+
state.listSortByCollection[name] = stored;
|
|
480
|
+
return stored;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function setListSortId(sortId) {
|
|
484
|
+
const name = state.collection?.name;
|
|
485
|
+
if (!name) return;
|
|
486
|
+
state.listSortByCollection[name] = sortId;
|
|
487
|
+
saveSortPreference(name, sortId);
|
|
488
|
+
resetListPage();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function currentListPageSizeId() {
|
|
492
|
+
const name = state.collection?.name;
|
|
493
|
+
if (!name) return DEFAULT_PAGE_SIZE_ID;
|
|
494
|
+
if (state.listPageSizeByCollection[name]) {
|
|
495
|
+
return state.listPageSizeByCollection[name];
|
|
496
|
+
}
|
|
497
|
+
const stored = loadPageSizePreference(name, DEFAULT_PAGE_SIZE_ID);
|
|
498
|
+
state.listPageSizeByCollection[name] = stored;
|
|
499
|
+
return stored;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function setListPageSizeId(pageSizeId) {
|
|
503
|
+
const name = state.collection?.name;
|
|
504
|
+
if (!name) return;
|
|
505
|
+
state.listPageSizeByCollection[name] = pageSizeId;
|
|
506
|
+
savePageSizePreference(name, pageSizeId);
|
|
507
|
+
resetListPage();
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function currentListPage() {
|
|
511
|
+
const name = state.collection?.name;
|
|
512
|
+
if (!name) return 1;
|
|
513
|
+
return Math.max(1, Number(state.listPageByCollection[name]) || 1);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function setListPage(page) {
|
|
517
|
+
const name = state.collection?.name;
|
|
518
|
+
if (!name) return;
|
|
519
|
+
state.listPageByCollection[name] = Math.max(1, Number(page) || 1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function resetListPage() {
|
|
523
|
+
const name = state.collection?.name;
|
|
524
|
+
if (!name) return;
|
|
525
|
+
state.listPageByCollection[name] = 1;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function currentListTag() {
|
|
529
|
+
const name = state.collection?.name;
|
|
530
|
+
if (!name) return "";
|
|
531
|
+
return state.listTagByCollection[name] || "";
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function setListTag(tag) {
|
|
535
|
+
const name = state.collection?.name;
|
|
536
|
+
if (!name) return;
|
|
537
|
+
state.listTagByCollection[name] = String(tag || "");
|
|
538
|
+
resetListPage();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function rememberNavCount(col, count) {
|
|
542
|
+
if (!col?.name) return;
|
|
543
|
+
state.navCountByCollection[col.name] = Math.max(0, Number(count) || 0);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function rememberTree(col, items) {
|
|
547
|
+
if (!col?.name) return;
|
|
548
|
+
const list = Array.isArray(items) ? items : [];
|
|
549
|
+
state.treeCacheByCollection[col.name] = { items: list };
|
|
550
|
+
rememberNavCount(col, list.length);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function cachedTreeItems(col) {
|
|
554
|
+
if (!col?.name) return null;
|
|
555
|
+
const hit = state.treeCacheByCollection[col.name];
|
|
556
|
+
return hit ? hit.items : null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function invalidateTreeCache(colName) {
|
|
560
|
+
if (!colName) {
|
|
561
|
+
state.treeCacheByCollection = Object.create(null);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
delete state.treeCacheByCollection[colName];
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function folderCollections(schema = state.schema) {
|
|
568
|
+
return (schema?.collections || []).filter(
|
|
569
|
+
(c) => c && c.type !== "file" && c.path
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Writing first, then the rest — fills nav counts users care about earliest. */
|
|
574
|
+
function prefetchCollectionOrder(cols) {
|
|
575
|
+
return [...cols].sort((a, b) => {
|
|
576
|
+
const pa = a.group === "Writing" ? 0 : 1;
|
|
577
|
+
const pb = b.group === "Writing" ? 0 : 1;
|
|
578
|
+
if (pa !== pb) return pa - pb;
|
|
579
|
+
return String(a.label || a.name).localeCompare(String(b.label || b.name));
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function scheduleTreePrefetch() {
|
|
584
|
+
const gen = ++state.treePrefetchGen;
|
|
585
|
+
const run = () => {
|
|
586
|
+
void prefetchTreesInIdle(gen);
|
|
587
|
+
};
|
|
588
|
+
if (typeof requestIdleCallback === "function") {
|
|
589
|
+
requestIdleCallback(run, { timeout: 2500 });
|
|
590
|
+
} else {
|
|
591
|
+
setTimeout(run, 400);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async function prefetchTreesInIdle(gen) {
|
|
596
|
+
const pending = prefetchCollectionOrder(folderCollections()).filter(
|
|
597
|
+
(c) => !state.treeCacheByCollection[c.name]
|
|
598
|
+
);
|
|
599
|
+
for (const col of pending) {
|
|
600
|
+
if (gen !== state.treePrefetchGen) return;
|
|
601
|
+
try {
|
|
602
|
+
const data = await studioApi.tree(col.path);
|
|
603
|
+
if (gen !== state.treePrefetchGen) return;
|
|
604
|
+
// Keep a fresher selectCollection result if it won the race.
|
|
605
|
+
if (!state.treeCacheByCollection[col.name]) {
|
|
606
|
+
rememberTree(col, data.items || []);
|
|
607
|
+
// Rebuild only when not mid-entry — avoids yanking the editor.
|
|
608
|
+
if (!state.file) renderShell();
|
|
609
|
+
}
|
|
610
|
+
} catch {
|
|
611
|
+
// Best-effort: leave the count absent until the user opens the list.
|
|
612
|
+
}
|
|
613
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Resolve Studio list thumbnails to a loadable URL. */
|
|
618
|
+
function resolveThumbnailUrl(raw) {
|
|
619
|
+
const value = String(raw || "").trim();
|
|
620
|
+
if (!value) return "";
|
|
621
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
622
|
+
if (value.startsWith("/media/")) {
|
|
623
|
+
return `${MEDIA_BASE.replace(/\/$/, "")}${value}`;
|
|
624
|
+
}
|
|
625
|
+
if (value.startsWith("media/")) {
|
|
626
|
+
return `${MEDIA_BASE.replace(/\/$/, "")}/${value}`;
|
|
627
|
+
}
|
|
628
|
+
if (value.startsWith("/")) return value;
|
|
629
|
+
return value;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function missingThumbPlaceholder() {
|
|
633
|
+
return el("span", {
|
|
634
|
+
className: "ledger-thumb placeholder",
|
|
635
|
+
title: "No thumbnail",
|
|
636
|
+
"aria-label": "No thumbnail",
|
|
637
|
+
}, [
|
|
638
|
+
el("span", {
|
|
639
|
+
className: "material-symbols-outlined",
|
|
640
|
+
text: "web_asset_off",
|
|
641
|
+
"aria-hidden": "true",
|
|
642
|
+
}),
|
|
643
|
+
]);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function thumbnailNode(item) {
|
|
647
|
+
const src = resolveThumbnailUrl(item.thumbnail);
|
|
648
|
+
if (!src) return missingThumbPlaceholder();
|
|
649
|
+
const img = el("img", {
|
|
650
|
+
className: "ledger-thumb",
|
|
651
|
+
src,
|
|
652
|
+
alt: "",
|
|
653
|
+
loading: "lazy",
|
|
654
|
+
referrerpolicy: "no-referrer",
|
|
655
|
+
});
|
|
656
|
+
img.addEventListener("error", () => {
|
|
657
|
+
img.replaceWith(missingThumbPlaceholder());
|
|
658
|
+
});
|
|
659
|
+
return img;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function $(sel, root = document) {
|
|
663
|
+
return root.querySelector(sel);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function el(tag, attrs = {}, children = []) {
|
|
667
|
+
const node = document.createElement(tag);
|
|
668
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
669
|
+
if (k === "className") node.className = v;
|
|
670
|
+
else if (k === "text") node.textContent = v;
|
|
671
|
+
else if (k === "html") node.innerHTML = v;
|
|
672
|
+
else if (k === "htmlFor") node.htmlFor = v;
|
|
673
|
+
else if (k.startsWith("on") && typeof v === "function") {
|
|
674
|
+
node.addEventListener(k.slice(2).toLowerCase(), v);
|
|
675
|
+
} else if (
|
|
676
|
+
k === "checked" ||
|
|
677
|
+
k === "selected" ||
|
|
678
|
+
k === "disabled" ||
|
|
679
|
+
k === "multiple" ||
|
|
680
|
+
k === "open"
|
|
681
|
+
) {
|
|
682
|
+
// IDL boolean props — setAttribute("checked","") is unreliable for toggles.
|
|
683
|
+
node[k] = !!v;
|
|
684
|
+
} else if (v === false || v == null) {
|
|
685
|
+
/* skip */
|
|
686
|
+
} else if (v === true) node.setAttribute(k, "");
|
|
687
|
+
else node.setAttribute(k, v);
|
|
688
|
+
}
|
|
689
|
+
for (const child of [].concat(children)) {
|
|
690
|
+
if (child == null || child === false) continue;
|
|
691
|
+
node.append(child.nodeType ? child : document.createTextNode(String(child)));
|
|
692
|
+
}
|
|
693
|
+
return node;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function setTheme(theme) {
|
|
697
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
698
|
+
localStorage.setItem("studio-theme", theme);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function initTheme() {
|
|
702
|
+
const saved = localStorage.getItem("studio-theme");
|
|
703
|
+
if (saved) setTheme(saved);
|
|
704
|
+
else if (window.matchMedia("(prefers-color-scheme: dark)").matches) setTheme("dark");
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function clerkUserEmail(clerk) {
|
|
708
|
+
if (!clerk || clerk.dev || !clerk.user) return "";
|
|
709
|
+
const primary = clerk.user.primaryEmailAddress?.emailAddress;
|
|
710
|
+
if (primary) return primary;
|
|
711
|
+
const list = clerk.user.emailAddresses || [];
|
|
712
|
+
return list[0]?.emailAddress || "";
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function renderAuthGate(clerk) {
|
|
716
|
+
// Clerk refuses to show SignIn for an existing session and redirects to its
|
|
717
|
+
// fallback (default `/` = site home). Never remount SignIn while signed in.
|
|
718
|
+
if (clerk && !clerk.dev && clerk.user) {
|
|
719
|
+
renderSignedInApiErrorGate(
|
|
720
|
+
clerk,
|
|
721
|
+
state.error || "Studio API session failed while you are signed in."
|
|
722
|
+
);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
app.innerHTML = "";
|
|
727
|
+
const box = el("div", { className: "auth-gate" }, [
|
|
728
|
+
el("div", { className: "studio-brand" }, [
|
|
729
|
+
studioMark({ size: 32, label: "Studio" }),
|
|
730
|
+
el("strong", { text: "Studio" }),
|
|
731
|
+
]),
|
|
732
|
+
el("h1", { text: "Sign in to edit" }),
|
|
733
|
+
el("p", {
|
|
734
|
+
text: "Studio is for site admins only. Anyone may register; access is granted after review.",
|
|
735
|
+
}),
|
|
736
|
+
el("div", { id: "clerk-sign-in" }),
|
|
737
|
+
]);
|
|
738
|
+
app.append(box);
|
|
739
|
+
if (clerk && !clerk.dev) {
|
|
740
|
+
clerk.mountSignIn($("#clerk-sign-in"), { ...CLERK_REDIRECTS });
|
|
741
|
+
} else {
|
|
742
|
+
$("#clerk-sign-in").append(
|
|
743
|
+
el("p", {
|
|
744
|
+
className: "status-pill",
|
|
745
|
+
text: "No VITE_CLERK_PUBLISHABLE_KEY — using API bypass if enabled.",
|
|
746
|
+
}),
|
|
747
|
+
el("button", {
|
|
748
|
+
className: "btn btn-primary",
|
|
749
|
+
text: "Continue",
|
|
750
|
+
onClick: () => bootApp(clerk),
|
|
751
|
+
})
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Signed in with Clerk, but /api/studio/session failed (local Jekyll, misconfig, …). */
|
|
757
|
+
function renderSignedInApiErrorGate(clerk, message) {
|
|
758
|
+
app.innerHTML = "";
|
|
759
|
+
const box = el("div", { className: "auth-gate" }, [
|
|
760
|
+
el("div", { className: "studio-brand" }, [
|
|
761
|
+
studioMark({ size: 32, label: "Studio" }),
|
|
762
|
+
el("strong", { text: "Studio" }),
|
|
763
|
+
]),
|
|
764
|
+
el("h1", { text: "Studio API unavailable" }),
|
|
765
|
+
el("p", {
|
|
766
|
+
className: "status-pill error",
|
|
767
|
+
text: message || "Session request failed.",
|
|
768
|
+
}),
|
|
769
|
+
el("p", {
|
|
770
|
+
text: "You are signed in with Clerk, but /api/studio/session did not succeed. Local jekyll serve has no Pages Functions — use a Pages preview, wrangler pages dev, or STUDIO_DEV_BYPASS against a running Function.",
|
|
771
|
+
}),
|
|
772
|
+
el("button", {
|
|
773
|
+
className: "btn",
|
|
774
|
+
type: "button",
|
|
775
|
+
text: "Sign out",
|
|
776
|
+
onClick: async () => {
|
|
777
|
+
if (clerk && !clerk.dev && clerk.signOut) {
|
|
778
|
+
await clerk.signOut({ redirectUrl: STUDIO_PATH });
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
renderAuthGate(clerk);
|
|
782
|
+
},
|
|
783
|
+
}),
|
|
784
|
+
]);
|
|
785
|
+
app.append(box);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Signed-in but not on STUDIO_ALLOWLIST — polite brick wall. */
|
|
789
|
+
function renderPendingReviewGate(clerk, emailHint) {
|
|
790
|
+
const email = emailHint || clerkUserEmail(clerk) || "the address you used to register";
|
|
791
|
+
app.innerHTML = "";
|
|
792
|
+
const p1 = el("p", { className: "pending-copy" });
|
|
793
|
+
p1.append(
|
|
794
|
+
document.createTextNode(
|
|
795
|
+
"Thank you for your registration. Your profile is being transferred to the site administration to be reviewed as a potential contributor. You will be contacted via "
|
|
796
|
+
)
|
|
797
|
+
);
|
|
798
|
+
const bold = document.createElement("strong");
|
|
799
|
+
bold.textContent = email;
|
|
800
|
+
p1.append(bold);
|
|
801
|
+
p1.append(document.createTextNode("."));
|
|
802
|
+
|
|
803
|
+
const box = el("div", { className: "auth-gate auth-gate--pending" }, [
|
|
804
|
+
el("div", { className: "studio-brand" }, [
|
|
805
|
+
studioMark({ size: 32, label: "Studio" }),
|
|
806
|
+
el("strong", { text: "Studio" }),
|
|
807
|
+
]),
|
|
808
|
+
el("h1", { text: "Registration received" }),
|
|
809
|
+
p1,
|
|
810
|
+
el("p", {
|
|
811
|
+
className: "pending-signoff",
|
|
812
|
+
text: "Have a nice one! ✍️",
|
|
813
|
+
}),
|
|
814
|
+
el("button", {
|
|
815
|
+
className: "btn",
|
|
816
|
+
type: "button",
|
|
817
|
+
text: "Sign out",
|
|
818
|
+
onClick: async () => {
|
|
819
|
+
if (clerk && !clerk.dev && clerk.signOut) {
|
|
820
|
+
await clerk.signOut({ redirectUrl: STUDIO_PATH });
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
renderAuthGate(clerk);
|
|
824
|
+
},
|
|
825
|
+
}),
|
|
826
|
+
]);
|
|
827
|
+
app.append(box);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function collectionGroups(schema) {
|
|
831
|
+
const map = new Map();
|
|
832
|
+
for (const c of schema.collections) {
|
|
833
|
+
if (!map.has(c.group)) map.set(c.group, []);
|
|
834
|
+
map.get(c.group).push(c);
|
|
835
|
+
}
|
|
836
|
+
return map;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
/** Left-nav Media section (Library → content/media browser). */
|
|
840
|
+
function renderMediaNavGroup() {
|
|
841
|
+
const group = "Media";
|
|
842
|
+
const open = state.navOpenGroups.has(group);
|
|
843
|
+
const items = el("div", {
|
|
844
|
+
className: "studio-nav-group-items",
|
|
845
|
+
id: navGroupDomId(group),
|
|
846
|
+
role: "region",
|
|
847
|
+
"aria-label": group,
|
|
848
|
+
});
|
|
849
|
+
if (!open) items.hidden = true;
|
|
850
|
+
items.append(
|
|
851
|
+
el("button", {
|
|
852
|
+
className: "nav-item",
|
|
853
|
+
type: "button",
|
|
854
|
+
text: "Library",
|
|
855
|
+
title: "Browse and upload under content/media",
|
|
856
|
+
"aria-current": state.mediaOpen ? "page" : null,
|
|
857
|
+
onClick: () => openMediaLibrary(),
|
|
858
|
+
})
|
|
859
|
+
);
|
|
860
|
+
const toggle = el("button", {
|
|
861
|
+
className: "studio-nav-group-toggle",
|
|
862
|
+
type: "button",
|
|
863
|
+
title: groupHint(group),
|
|
864
|
+
"aria-expanded": open ? "true" : "false",
|
|
865
|
+
"aria-controls": items.id,
|
|
866
|
+
onClick: () => {
|
|
867
|
+
if (state.navOpenGroups.has(group)) state.navOpenGroups.delete(group);
|
|
868
|
+
else state.navOpenGroups.add(group);
|
|
869
|
+
renderShell();
|
|
870
|
+
},
|
|
871
|
+
}, [
|
|
872
|
+
el("span", {
|
|
873
|
+
className: "material-symbols-outlined",
|
|
874
|
+
text: open ? "expand_more" : "chevron_right",
|
|
875
|
+
"aria-hidden": "true",
|
|
876
|
+
}),
|
|
877
|
+
el("span", {
|
|
878
|
+
className: "nav-group-label",
|
|
879
|
+
text: group,
|
|
880
|
+
}),
|
|
881
|
+
]);
|
|
882
|
+
return el("div", { className: "studio-nav-group" }, [toggle, items]);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Full failure sentence under the toolbar. The status pill is one line beside
|
|
887
|
+
* Dark / Discard / Save, so on a phone it ellipsizes to the error code.
|
|
888
|
+
*/
|
|
889
|
+
function renderErrorBanner() {
|
|
890
|
+
if (!state.error) return null;
|
|
891
|
+
// The collection list already paints .list-error with the same sentence.
|
|
892
|
+
if (!state.file && !state.mediaOpen) return null;
|
|
893
|
+
const actions = [];
|
|
894
|
+
if (state.errorCode === "conflict" && state.file) {
|
|
895
|
+
const path = state.file;
|
|
896
|
+
actions.push(
|
|
897
|
+
el("button", {
|
|
898
|
+
className: "btn",
|
|
899
|
+
type: "button",
|
|
900
|
+
text: "Reload from GitHub",
|
|
901
|
+
title: "Replace this screen with the file currently on GitHub",
|
|
902
|
+
onClick: () => openFile(path),
|
|
903
|
+
})
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
actions.push(
|
|
907
|
+
el("button", {
|
|
908
|
+
className: "btn",
|
|
909
|
+
type: "button",
|
|
910
|
+
text: "Dismiss",
|
|
911
|
+
onClick: () => {
|
|
912
|
+
clearError();
|
|
913
|
+
renderShell();
|
|
914
|
+
},
|
|
915
|
+
})
|
|
916
|
+
);
|
|
917
|
+
return el("div", { className: "studio-error-banner", role: "alert" }, [
|
|
918
|
+
el("p", { className: "studio-error-message", text: state.error }),
|
|
919
|
+
el("div", { className: "studio-error-actions" }, actions),
|
|
920
|
+
]);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function renderShell() {
|
|
924
|
+
app.innerHTML = "";
|
|
925
|
+
const groups = collectionGroups(state.schema);
|
|
926
|
+
const menuOpen = !!state.navMenuOpen;
|
|
927
|
+
|
|
928
|
+
const brand = el("button", {
|
|
929
|
+
className: "studio-brand studio-brand-home",
|
|
930
|
+
type: "button",
|
|
931
|
+
title: "Studio home",
|
|
932
|
+
"aria-current": !state.collection && !state.mediaOpen ? "page" : null,
|
|
933
|
+
onClick: () => goHome(),
|
|
934
|
+
}, [
|
|
935
|
+
studioMark({ size: 40 }),
|
|
936
|
+
el("span", { className: "studio-brand-copy" }, [
|
|
937
|
+
el("span", { className: "studio-brand-name", text: "Studio" }),
|
|
938
|
+
]),
|
|
939
|
+
]);
|
|
940
|
+
|
|
941
|
+
const menuToggle = el("button", {
|
|
942
|
+
className: "studio-nav-menu-toggle",
|
|
943
|
+
type: "button",
|
|
944
|
+
title: menuOpen ? "Close collections menu" : "Open collections menu",
|
|
945
|
+
"aria-expanded": menuOpen ? "true" : "false",
|
|
946
|
+
"aria-controls": "studio-nav-panel",
|
|
947
|
+
onClick: () => {
|
|
948
|
+
state.navMenuOpen = !state.navMenuOpen;
|
|
949
|
+
renderShell();
|
|
950
|
+
},
|
|
951
|
+
}, [
|
|
952
|
+
el("span", {
|
|
953
|
+
className: "studio-nav-menu-label",
|
|
954
|
+
text: currentNavLabel(),
|
|
955
|
+
}),
|
|
956
|
+
el("span", {
|
|
957
|
+
className: "material-symbols-outlined",
|
|
958
|
+
text: menuOpen ? "expand_less" : "expand_more",
|
|
959
|
+
"aria-hidden": "true",
|
|
960
|
+
}),
|
|
961
|
+
]);
|
|
962
|
+
|
|
963
|
+
const panel = el("div", {
|
|
964
|
+
className: "studio-nav-panel",
|
|
965
|
+
id: "studio-nav-panel",
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
for (const [group, cols] of groups) {
|
|
969
|
+
const open = state.navOpenGroups.has(group);
|
|
970
|
+
const items = el("div", {
|
|
971
|
+
className: "studio-nav-group-items",
|
|
972
|
+
id: navGroupDomId(group),
|
|
973
|
+
role: "region",
|
|
974
|
+
"aria-label": group,
|
|
975
|
+
});
|
|
976
|
+
if (!open) items.hidden = true;
|
|
977
|
+
|
|
978
|
+
for (const col of cols) {
|
|
979
|
+
const count = state.navCountByCollection[col.name];
|
|
980
|
+
const labelKids = [
|
|
981
|
+
el("span", { className: "nav-item-label", text: col.label }),
|
|
982
|
+
];
|
|
983
|
+
if (count != null) {
|
|
984
|
+
labelKids.push(
|
|
985
|
+
el("span", {
|
|
986
|
+
className: "nav-item-count",
|
|
987
|
+
text: String(count),
|
|
988
|
+
})
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
items.append(
|
|
992
|
+
el("button", {
|
|
993
|
+
className: "nav-item",
|
|
994
|
+
type: "button",
|
|
995
|
+
title: collectionHint(col),
|
|
996
|
+
"aria-current":
|
|
997
|
+
state.collection?.name === col.name ? "page" : null,
|
|
998
|
+
onClick: () => selectCollection(col),
|
|
999
|
+
}, labelKids)
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const toggle = el("button", {
|
|
1004
|
+
className: "studio-nav-group-toggle",
|
|
1005
|
+
type: "button",
|
|
1006
|
+
title: groupHint(group),
|
|
1007
|
+
"aria-expanded": open ? "true" : "false",
|
|
1008
|
+
"aria-controls": items.id,
|
|
1009
|
+
onClick: () => {
|
|
1010
|
+
if (state.navOpenGroups.has(group)) state.navOpenGroups.delete(group);
|
|
1011
|
+
else state.navOpenGroups.add(group);
|
|
1012
|
+
renderShell();
|
|
1013
|
+
},
|
|
1014
|
+
}, [
|
|
1015
|
+
el("span", {
|
|
1016
|
+
className: "material-symbols-outlined",
|
|
1017
|
+
text: open ? "expand_more" : "chevron_right",
|
|
1018
|
+
"aria-hidden": "true",
|
|
1019
|
+
}),
|
|
1020
|
+
el("span", {
|
|
1021
|
+
className: "nav-group-label",
|
|
1022
|
+
text: group,
|
|
1023
|
+
}),
|
|
1024
|
+
]);
|
|
1025
|
+
|
|
1026
|
+
panel.append(el("div", { className: "studio-nav-group" }, [toggle, items]));
|
|
1027
|
+
|
|
1028
|
+
if (group === "Portfolio" || (group === "References" && !groups.has("Portfolio"))) {
|
|
1029
|
+
panel.append(renderMediaNavGroup());
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (!groups.has("Portfolio") && !groups.has("References")) {
|
|
1034
|
+
panel.append(renderMediaNavGroup());
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const nav = el(
|
|
1038
|
+
"nav",
|
|
1039
|
+
{
|
|
1040
|
+
className: "studio-nav" + (menuOpen ? " is-open" : ""),
|
|
1041
|
+
"aria-label": "Collections",
|
|
1042
|
+
},
|
|
1043
|
+
[
|
|
1044
|
+
el("div", { className: "studio-nav-bar" }, [brand, menuToggle]),
|
|
1045
|
+
panel,
|
|
1046
|
+
]
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
const toolbar = el("div", { className: "studio-toolbar" }, [
|
|
1050
|
+
el("span", {
|
|
1051
|
+
className: "status-pill" + (state.error ? " error" : ""),
|
|
1052
|
+
text: state.error
|
|
1053
|
+
? "Error"
|
|
1054
|
+
: state.status || (state.session?.email || state.session?.userId || ""),
|
|
1055
|
+
title: state.error || state.errorDetail || state.status || "",
|
|
1056
|
+
}),
|
|
1057
|
+
el("span", { className: "spacer" }),
|
|
1058
|
+
el("button", {
|
|
1059
|
+
className: "btn",
|
|
1060
|
+
type: "button",
|
|
1061
|
+
text:
|
|
1062
|
+
document.documentElement.getAttribute("data-theme") === "dark"
|
|
1063
|
+
? "Dark"
|
|
1064
|
+
: "Light",
|
|
1065
|
+
title: "Toggle light / dark theme",
|
|
1066
|
+
"data-studio-action": "theme",
|
|
1067
|
+
onClick: (e) => {
|
|
1068
|
+
const cur = document.documentElement.getAttribute("data-theme");
|
|
1069
|
+
const next = cur === "dark" ? "light" : "dark";
|
|
1070
|
+
setTheme(next);
|
|
1071
|
+
e.currentTarget.textContent = next === "dark" ? "Dark" : "Light";
|
|
1072
|
+
// Mermaid bakes palette at render time — redraw on theme flip.
|
|
1073
|
+
const frame = $("#preview-frame");
|
|
1074
|
+
if (frame) void mountMermaidPreview(frame);
|
|
1075
|
+
},
|
|
1076
|
+
}),
|
|
1077
|
+
el("button", {
|
|
1078
|
+
className: "btn",
|
|
1079
|
+
type: "button",
|
|
1080
|
+
text: "Discard",
|
|
1081
|
+
title: "Revert all edits to the last loaded or saved version",
|
|
1082
|
+
"data-studio-action": "discard",
|
|
1083
|
+
disabled: !state.file || !state.dirty || !state.baseline,
|
|
1084
|
+
onClick: () => discardChanges(),
|
|
1085
|
+
}),
|
|
1086
|
+
el("button", {
|
|
1087
|
+
className: "btn btn-primary",
|
|
1088
|
+
type: "button",
|
|
1089
|
+
text: state.saving ? "Saving…" : state.dirty ? "Save" : "Saved",
|
|
1090
|
+
title: state.saving
|
|
1091
|
+
? "Saving this draft"
|
|
1092
|
+
: state.dirty
|
|
1093
|
+
? "Commit this draft"
|
|
1094
|
+
: "No edits to commit yet",
|
|
1095
|
+
"data-studio-action": "save",
|
|
1096
|
+
disabled: state.saving || !state.file || !state.dirty,
|
|
1097
|
+
onClick: () => saveCurrent(),
|
|
1098
|
+
}),
|
|
1099
|
+
]);
|
|
1100
|
+
|
|
1101
|
+
const main = el("div", { className: "studio-main" }, [
|
|
1102
|
+
toolbar,
|
|
1103
|
+
renderErrorBanner(),
|
|
1104
|
+
renderStudioBreadcrumb(),
|
|
1105
|
+
el("div", {
|
|
1106
|
+
className: "studio-body" + (state.file ? "" : " list-only"),
|
|
1107
|
+
id: "studio-body",
|
|
1108
|
+
}),
|
|
1109
|
+
]);
|
|
1110
|
+
|
|
1111
|
+
app.append(el("div", { className: "studio-shell" }, [nav, main]));
|
|
1112
|
+
renderMain();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function renderStudioBreadcrumb() {
|
|
1116
|
+
const parts = [el("span", { className: "studio-crumb", text: "Studio" })];
|
|
1117
|
+
if (state.mediaOpen && !state.collection) {
|
|
1118
|
+
parts.push(
|
|
1119
|
+
el("span", { className: "studio-crumb-sep", "aria-hidden": "true", text: "/" }),
|
|
1120
|
+
el("span", { className: "studio-crumb is-current", text: "Media" })
|
|
1121
|
+
);
|
|
1122
|
+
} else if (state.collection) {
|
|
1123
|
+
const group = state.collection.group || "Content";
|
|
1124
|
+
const label = state.collection.label || state.collection.name;
|
|
1125
|
+
parts.push(
|
|
1126
|
+
el("span", { className: "studio-crumb-sep", "aria-hidden": "true", text: "/" }),
|
|
1127
|
+
el("span", { className: "studio-crumb", text: group }),
|
|
1128
|
+
el("span", { className: "studio-crumb-sep", "aria-hidden": "true", text: "/" }),
|
|
1129
|
+
el("span", {
|
|
1130
|
+
className: "studio-crumb" + (state.file ? "" : " is-current"),
|
|
1131
|
+
text: label,
|
|
1132
|
+
})
|
|
1133
|
+
);
|
|
1134
|
+
if (state.file) {
|
|
1135
|
+
const leaf =
|
|
1136
|
+
state.fields?.title ||
|
|
1137
|
+
state.fields?.key ||
|
|
1138
|
+
String(state.file).split("/").pop() ||
|
|
1139
|
+
"Entry";
|
|
1140
|
+
parts.push(
|
|
1141
|
+
el("span", { className: "studio-crumb-sep", "aria-hidden": "true", text: "/" }),
|
|
1142
|
+
el("span", { className: "studio-crumb is-current", text: leaf })
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
parts.push(
|
|
1147
|
+
el("span", { className: "studio-crumb-sep", "aria-hidden": "true", text: "/" }),
|
|
1148
|
+
el("span", { className: "studio-crumb is-current", text: "Map" })
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
return el("nav", {
|
|
1152
|
+
className: "studio-breadcrumb",
|
|
1153
|
+
"aria-label": "Location",
|
|
1154
|
+
}, parts);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** Flip Save / Discard as soon as an edit lands, without rebuilding the form. */
|
|
1158
|
+
function markDirty() {
|
|
1159
|
+
state.dirty = true;
|
|
1160
|
+
syncCommitButtons();
|
|
1161
|
+
refreshBodyStats();
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function canAiProof() {
|
|
1165
|
+
if (!state.file || isYamlCollection()) return false;
|
|
1166
|
+
if (state.aiProofBusy) return false;
|
|
1167
|
+
return Boolean(String(state.fields?.language || "").trim());
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function syncAiProofControls() {
|
|
1171
|
+
const busy = state.aiProofBusy;
|
|
1172
|
+
const enabled = canAiProof() && !busy;
|
|
1173
|
+
for (const action of ["correct", "refine"]) {
|
|
1174
|
+
const item = document.querySelector(`[data-studio-action='${action}']`);
|
|
1175
|
+
if (!item) continue;
|
|
1176
|
+
item.disabled = !enabled;
|
|
1177
|
+
const label = item.querySelector(".editor-ai-proof-label");
|
|
1178
|
+
if (!label) continue;
|
|
1179
|
+
if (busy) label.textContent = "Working…";
|
|
1180
|
+
else label.textContent = action === "refine" ? "Refine" : "Correct";
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function closeEditorMenus() {
|
|
1185
|
+
state.insertMenuOpen = false;
|
|
1186
|
+
const insert = $("#insert-menu");
|
|
1187
|
+
if (insert) insert.hidden = true;
|
|
1188
|
+
for (const btn of document.querySelectorAll(
|
|
1189
|
+
".editor-insert-btn[aria-haspopup='menu']"
|
|
1190
|
+
)) {
|
|
1191
|
+
btn.setAttribute("aria-expanded", "false");
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function flashAiProofHint(message) {
|
|
1196
|
+
state.aiProofHint = message;
|
|
1197
|
+
const host = $("#ai-proof-hint");
|
|
1198
|
+
if (host) {
|
|
1199
|
+
host.textContent = message;
|
|
1200
|
+
host.hidden = !message;
|
|
1201
|
+
}
|
|
1202
|
+
clearTimeout(state._aiProofHintTimer);
|
|
1203
|
+
if (!message) return;
|
|
1204
|
+
state._aiProofHintTimer = setTimeout(() => {
|
|
1205
|
+
state.aiProofHint = "";
|
|
1206
|
+
const elHint = $("#ai-proof-hint");
|
|
1207
|
+
if (elHint) {
|
|
1208
|
+
elHint.textContent = "";
|
|
1209
|
+
elHint.hidden = true;
|
|
1210
|
+
}
|
|
1211
|
+
}, 3200);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Body selection for AI Proof. Source uses markdown offsets; Visual uses TipTap positions.
|
|
1216
|
+
* @returns {{ kind: "source"|"visual", text: string, start?: number, end?: number, from?: number, to?: number } | null}
|
|
1217
|
+
*/
|
|
1218
|
+
function getBodySelection() {
|
|
1219
|
+
if (state.bodyMode === "source") {
|
|
1220
|
+
const source = $("#body-source");
|
|
1221
|
+
if (!source) return null;
|
|
1222
|
+
const start = source.selectionStart;
|
|
1223
|
+
const end = source.selectionEnd;
|
|
1224
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start === end) {
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
syncBodyFromEditor();
|
|
1228
|
+
const full = state.body || "";
|
|
1229
|
+
return { kind: "source", start, end, text: full.slice(start, end) };
|
|
1230
|
+
}
|
|
1231
|
+
if (state.bodyMode === "visual" && state.bodyEditor?.editor) {
|
|
1232
|
+
const ed = state.bodyEditor.editor;
|
|
1233
|
+
const { from, to, empty } = ed.state.selection;
|
|
1234
|
+
if (empty || from === to) return null;
|
|
1235
|
+
const text = ed.state.doc.textBetween(from, to, "\n\n");
|
|
1236
|
+
if (!String(text).trim()) return null;
|
|
1237
|
+
return { kind: "visual", from, to, text };
|
|
1238
|
+
}
|
|
1239
|
+
return null;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** Apply patches from end → start so earlier offsets stay valid. */
|
|
1243
|
+
function applyPatchesToText(text, patches) {
|
|
1244
|
+
const list = Array.isArray(patches) ? [...patches] : [];
|
|
1245
|
+
list.sort((a, b) => (b.start || 0) - (a.start || 0));
|
|
1246
|
+
let out = text;
|
|
1247
|
+
for (const p of list) {
|
|
1248
|
+
const start = Number(p.start);
|
|
1249
|
+
const end = Number(p.end);
|
|
1250
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || end < start) continue;
|
|
1251
|
+
const original = String(p.original ?? "");
|
|
1252
|
+
const replacement = String(p.replacement ?? "");
|
|
1253
|
+
if (out.slice(start, end) !== original) continue;
|
|
1254
|
+
out = out.slice(0, start) + replacement + out.slice(end);
|
|
1255
|
+
}
|
|
1256
|
+
return out;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function replaceBodySelection(sel, nextText) {
|
|
1260
|
+
if (sel.kind === "source") {
|
|
1261
|
+
const full = state.body || "";
|
|
1262
|
+
state.body = full.slice(0, sel.start) + nextText + full.slice(sel.end);
|
|
1263
|
+
const source = $("#body-source");
|
|
1264
|
+
if (source) {
|
|
1265
|
+
source.value = state.body;
|
|
1266
|
+
const caret = sel.start + nextText.length;
|
|
1267
|
+
source.focus();
|
|
1268
|
+
source.setSelectionRange(caret, caret);
|
|
1269
|
+
}
|
|
1270
|
+
markDirty();
|
|
1271
|
+
refreshPreview();
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (sel.kind === "visual" && state.bodyEditor?.editor) {
|
|
1275
|
+
state.bodyEditor.editor
|
|
1276
|
+
.chain()
|
|
1277
|
+
.focus()
|
|
1278
|
+
.insertContentAt({ from: sel.from, to: sel.to }, nextText)
|
|
1279
|
+
.run();
|
|
1280
|
+
syncBodyFromEditor();
|
|
1281
|
+
markDirty();
|
|
1282
|
+
refreshPreview();
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/**
|
|
1287
|
+
* Resolve the body range for Correct / Refine.
|
|
1288
|
+
* Spec §9.1: current selection when non-empty; otherwise the full body.
|
|
1289
|
+
* @returns {{ kind: "source"|"visual", text: string, start?: number, end?: number, from?: number, to?: number, wholeBody?: boolean } | null}
|
|
1290
|
+
*/
|
|
1291
|
+
function resolveAiProofTarget() {
|
|
1292
|
+
// Prefer the selection captured on mousedown — clicking the toolbar
|
|
1293
|
+
// otherwise blurs the editor and clears the live range before onClick.
|
|
1294
|
+
const live = state.aiProofSelection || getBodySelection();
|
|
1295
|
+
state.aiProofSelection = null;
|
|
1296
|
+
if (live) return live;
|
|
1297
|
+
|
|
1298
|
+
syncBodyFromEditor();
|
|
1299
|
+
const full = state.body || "";
|
|
1300
|
+
if (!String(full).trim()) return null;
|
|
1301
|
+
|
|
1302
|
+
if (state.bodyMode === "source") {
|
|
1303
|
+
return {
|
|
1304
|
+
kind: "source",
|
|
1305
|
+
start: 0,
|
|
1306
|
+
end: full.length,
|
|
1307
|
+
text: full,
|
|
1308
|
+
wholeBody: true,
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
if (state.bodyMode === "visual" && state.bodyEditor?.editor) {
|
|
1312
|
+
const ed = state.bodyEditor.editor;
|
|
1313
|
+
const from = 0;
|
|
1314
|
+
const to = ed.state.doc.content.size;
|
|
1315
|
+
return {
|
|
1316
|
+
kind: "visual",
|
|
1317
|
+
from,
|
|
1318
|
+
to,
|
|
1319
|
+
text: full,
|
|
1320
|
+
wholeBody: true,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* AC-PRF-01 / AC-PRF-16 — Correct or Refine on selection, else full body.
|
|
1328
|
+
* @param {"correct"|"refine"} mode
|
|
1329
|
+
*/
|
|
1330
|
+
async function startRewrite(mode) {
|
|
1331
|
+
closeEditorMenus();
|
|
1332
|
+
flashAiProofHint("");
|
|
1333
|
+
if (!canAiProof()) {
|
|
1334
|
+
flashAiProofHint("Set language in front matter before AI Proof.");
|
|
1335
|
+
syncCommitButtons();
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const sel = resolveAiProofTarget();
|
|
1340
|
+
if (!sel) {
|
|
1341
|
+
flashAiProofHint("Body is empty.");
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
syncBodyFromEditor();
|
|
1346
|
+
const language = String(state.fields.language || "").trim();
|
|
1347
|
+
const full = state.body || "";
|
|
1348
|
+
let context = null;
|
|
1349
|
+
if (mode === "refine" && sel.kind === "source" && !sel.wholeBody) {
|
|
1350
|
+
context = {
|
|
1351
|
+
before: full.slice(Math.max(0, sel.start - 2000), sel.start),
|
|
1352
|
+
after: full.slice(sel.end, Math.min(full.length, sel.end + 2000)),
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const apiSelection =
|
|
1357
|
+
sel.kind === "source"
|
|
1358
|
+
? { start: sel.start, end: sel.end }
|
|
1359
|
+
: { start: 0, end: sel.text.length };
|
|
1360
|
+
|
|
1361
|
+
const scope = sel.wholeBody ? "body" : "selection";
|
|
1362
|
+
state.aiProofBusy = true;
|
|
1363
|
+
state.status =
|
|
1364
|
+
mode === "refine" ? `Refining ${scope}…` : `Correcting ${scope}…`;
|
|
1365
|
+
syncAiProofControls();
|
|
1366
|
+
|
|
1367
|
+
try {
|
|
1368
|
+
const result = await studioApi.rewrite({
|
|
1369
|
+
mode,
|
|
1370
|
+
path: state.file,
|
|
1371
|
+
language,
|
|
1372
|
+
text: sel.text,
|
|
1373
|
+
selection: apiSelection,
|
|
1374
|
+
context,
|
|
1375
|
+
});
|
|
1376
|
+
const patches = Array.isArray(result.patches) ? result.patches : [];
|
|
1377
|
+
const stats = result.stats || {};
|
|
1378
|
+
if (!patches.length) {
|
|
1379
|
+
if (Number(stats.proposed) > 0) {
|
|
1380
|
+
state.status = `No safe changes to apply (${stats.proposed} proposed, all dropped).`;
|
|
1381
|
+
} else {
|
|
1382
|
+
state.status = `No changes proposed for the ${scope}.`;
|
|
1383
|
+
}
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
let nextText;
|
|
1388
|
+
if (sel.kind === "visual" && sel.wholeBody) {
|
|
1389
|
+
// Spec §16.1: apply on markdown body, then re-hydrate TipTap.
|
|
1390
|
+
nextText = applyPatchesToText(full, patches);
|
|
1391
|
+
if (nextText === full) {
|
|
1392
|
+
state.status = `No changes proposed for the ${scope}.`;
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
state.body = nextText;
|
|
1396
|
+
state.bodyEditor?.setMarkdown?.(nextText);
|
|
1397
|
+
markDirty();
|
|
1398
|
+
refreshPreview();
|
|
1399
|
+
} else {
|
|
1400
|
+
nextText = applyPatchesToText(sel.text, patches);
|
|
1401
|
+
if (nextText === sel.text) {
|
|
1402
|
+
state.status = `No changes proposed for the ${scope}.`;
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
replaceBodySelection(sel, nextText);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const dropped =
|
|
1409
|
+
Number(stats.dropped) > 0 ? `, ${stats.dropped} dropped` : "";
|
|
1410
|
+
state.status =
|
|
1411
|
+
mode === "refine"
|
|
1412
|
+
? `Refined ${scope} (${patches.length}${dropped}).`
|
|
1413
|
+
: `Corrected ${scope} (${patches.length}${dropped}).`;
|
|
1414
|
+
} catch (err) {
|
|
1415
|
+
captureError(err);
|
|
1416
|
+
const detail = state.error;
|
|
1417
|
+
state.error = detail ? `AI Proof failed. ${detail}` : "AI Proof failed.";
|
|
1418
|
+
} finally {
|
|
1419
|
+
state.aiProofBusy = false;
|
|
1420
|
+
syncAiProofControls();
|
|
1421
|
+
syncCommitButtons();
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
function syncCommitButtons() {
|
|
1426
|
+
const save = document.querySelector("[data-studio-action='save']");
|
|
1427
|
+
const discard = document.querySelector("[data-studio-action='discard']");
|
|
1428
|
+
if (save) {
|
|
1429
|
+
save.disabled = state.saving || !state.file || !state.dirty;
|
|
1430
|
+
save.textContent = state.saving ? "Saving…" : state.dirty ? "Save" : "Saved";
|
|
1431
|
+
save.title = state.saving
|
|
1432
|
+
? "Saving this draft"
|
|
1433
|
+
: state.dirty
|
|
1434
|
+
? "Commit this draft"
|
|
1435
|
+
: "No edits to commit yet";
|
|
1436
|
+
}
|
|
1437
|
+
if (discard) {
|
|
1438
|
+
discard.disabled = !state.file || !state.dirty || !state.baseline;
|
|
1439
|
+
}
|
|
1440
|
+
syncAiProofControls();
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function renderMain() {
|
|
1444
|
+
const body = $("#studio-body");
|
|
1445
|
+
if (!body) return;
|
|
1446
|
+
body.innerHTML = "";
|
|
1447
|
+
const modeClass =
|
|
1448
|
+
state.file && !isYamlCollection() ? ` view-${state.bodyMode}` : "";
|
|
1449
|
+
body.className =
|
|
1450
|
+
"studio-body" + (state.file ? "" : " list-only") + modeClass;
|
|
1451
|
+
|
|
1452
|
+
if (state.fileLoading) {
|
|
1453
|
+
body.append(
|
|
1454
|
+
el("div", { className: "pane" }, [
|
|
1455
|
+
el("p", { className: "loading-msg", text: "Loading content…" }),
|
|
1456
|
+
])
|
|
1457
|
+
);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
if (!state.collection) {
|
|
1462
|
+
if (state.mediaOpen) {
|
|
1463
|
+
body.append(renderMediaPane(mediaOpts()));
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
body.append(renderStudioMap());
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
if (!state.file) {
|
|
1471
|
+
body.append(renderListPane());
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (isYamlCollection()) {
|
|
1476
|
+
body.append(renderYamlEditorPane(), renderPreviewPane());
|
|
1477
|
+
refreshPreview();
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
body.append(renderEditorPane(), renderPreviewPane());
|
|
1482
|
+
mountBodyEditor();
|
|
1483
|
+
refreshPreview();
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function isYamlCollection(col = state.collection) {
|
|
1487
|
+
return col?.format === "yaml";
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function syncYamlSourceFromStructured() {
|
|
1491
|
+
const col = state.collection;
|
|
1492
|
+
const kind = yamlEditKind(col);
|
|
1493
|
+
try {
|
|
1494
|
+
if (kind === "list") {
|
|
1495
|
+
state.body = dumpYaml(state.yamlList || []);
|
|
1496
|
+
} else if (kind === "map") {
|
|
1497
|
+
if (col.root_key) {
|
|
1498
|
+
const doc =
|
|
1499
|
+
state.yamlDoc && typeof state.yamlDoc === "object"
|
|
1500
|
+
? { ...state.yamlDoc }
|
|
1501
|
+
: {};
|
|
1502
|
+
doc[col.root_key] = pruneEmptyFields(
|
|
1503
|
+
{ ...state.fields },
|
|
1504
|
+
col.fields
|
|
1505
|
+
);
|
|
1506
|
+
state.body = dumpYaml(doc);
|
|
1507
|
+
} else {
|
|
1508
|
+
state.body = dumpYaml(
|
|
1509
|
+
pruneEmptyFields({ ...state.fields }, col.fields)
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
} catch (err) {
|
|
1514
|
+
captureError(err);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
function applyYamlSourceToStructured() {
|
|
1519
|
+
const col = state.collection;
|
|
1520
|
+
const kind = yamlEditKind(col);
|
|
1521
|
+
clearError();
|
|
1522
|
+
try {
|
|
1523
|
+
const doc = loadYaml(state.body);
|
|
1524
|
+
if (kind === "list") {
|
|
1525
|
+
state.yamlList = asObjectList(doc);
|
|
1526
|
+
state.fields = {};
|
|
1527
|
+
} else if (kind === "map") {
|
|
1528
|
+
if (col.root_key) {
|
|
1529
|
+
const root = asObjectMap(doc);
|
|
1530
|
+
state.yamlDoc = root;
|
|
1531
|
+
state.fields = asObjectMap(root[col.root_key]);
|
|
1532
|
+
} else {
|
|
1533
|
+
state.yamlDoc = null;
|
|
1534
|
+
state.fields = asObjectMap(doc);
|
|
1535
|
+
}
|
|
1536
|
+
state.yamlList = null;
|
|
1537
|
+
}
|
|
1538
|
+
return true;
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
captureError(err);
|
|
1541
|
+
state.status = "Invalid YAML — fix source before switching";
|
|
1542
|
+
return false;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function setYamlUiMode(mode) {
|
|
1547
|
+
if (mode === state.yamlUiMode) return;
|
|
1548
|
+
if (mode === "source") {
|
|
1549
|
+
syncYamlSourceFromStructured();
|
|
1550
|
+
state.yamlUiMode = "source";
|
|
1551
|
+
renderShell();
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
// structured
|
|
1555
|
+
const source = $("#yaml-source");
|
|
1556
|
+
if (source) state.body = source.value;
|
|
1557
|
+
if (!applyYamlSourceToStructured()) {
|
|
1558
|
+
renderShell();
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
state.yamlUiMode = "structured";
|
|
1562
|
+
renderShell();
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function renderYamlEditorChrome(kind) {
|
|
1566
|
+
const structured = state.yamlUiMode === "structured" && kind !== "source";
|
|
1567
|
+
const canStructure = kind === "list" || kind === "map";
|
|
1568
|
+
if (!canStructure) {
|
|
1569
|
+
return el("div", { className: "editor-chrome yaml-chrome" }, [
|
|
1570
|
+
el("p", {
|
|
1571
|
+
className: "yaml-chrome-hint",
|
|
1572
|
+
text: "Free-form YAML — edit the source directly.",
|
|
1573
|
+
}),
|
|
1574
|
+
]);
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
return el("div", { className: "editor-chrome yaml-chrome" }, [
|
|
1578
|
+
el("div", {
|
|
1579
|
+
className: "editor-mode-tabs",
|
|
1580
|
+
role: "tablist",
|
|
1581
|
+
"aria-label": "YAML editor mode",
|
|
1582
|
+
}, [
|
|
1583
|
+
el("button", {
|
|
1584
|
+
className: "editor-mode-tab",
|
|
1585
|
+
type: "button",
|
|
1586
|
+
role: "tab",
|
|
1587
|
+
text: "Fields",
|
|
1588
|
+
title: "Structured field editors",
|
|
1589
|
+
"aria-selected": structured ? "true" : "false",
|
|
1590
|
+
onClick: () => setYamlUiMode("structured"),
|
|
1591
|
+
}),
|
|
1592
|
+
el("button", {
|
|
1593
|
+
className: "editor-mode-tab",
|
|
1594
|
+
type: "button",
|
|
1595
|
+
role: "tab",
|
|
1596
|
+
text: "YAML",
|
|
1597
|
+
title: "Raw YAML source",
|
|
1598
|
+
"aria-selected": !structured ? "true" : "false",
|
|
1599
|
+
onClick: () => setYamlUiMode("source"),
|
|
1600
|
+
}),
|
|
1601
|
+
]),
|
|
1602
|
+
el("p", {
|
|
1603
|
+
className: "yaml-chrome-hint",
|
|
1604
|
+
text:
|
|
1605
|
+
kind === "list"
|
|
1606
|
+
? "List of entries in one YAML file. Reorder, add, or remove items."
|
|
1607
|
+
: "Structured YAML fields — not a Markdown article body.",
|
|
1608
|
+
}),
|
|
1609
|
+
]);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function renderYamlListEditor() {
|
|
1613
|
+
const col = state.collection;
|
|
1614
|
+
const fields = nonBodyFields(col);
|
|
1615
|
+
const list = Array.isArray(state.yamlList) ? state.yamlList : [];
|
|
1616
|
+
const host = el("div", { className: "yaml-list", id: "yaml-list" });
|
|
1617
|
+
|
|
1618
|
+
list.forEach((raw, index) => {
|
|
1619
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
1620
|
+
list[index] = {};
|
|
1621
|
+
}
|
|
1622
|
+
const item = list[index];
|
|
1623
|
+
const card = el("div", { className: "yaml-list-item" });
|
|
1624
|
+
const primaryField = fields[0];
|
|
1625
|
+
const primaryVal = primaryField
|
|
1626
|
+
? item[primaryField.name] || `${col.label} ${index + 1}`
|
|
1627
|
+
: `${col.label} ${index + 1}`;
|
|
1628
|
+
card.append(
|
|
1629
|
+
el("div", { className: "yaml-list-item-head" }, [
|
|
1630
|
+
el("span", {
|
|
1631
|
+
className: "yaml-list-item-index",
|
|
1632
|
+
text: String(primaryVal),
|
|
1633
|
+
}),
|
|
1634
|
+
el("div", { className: "yaml-list-item-actions" }, [
|
|
1635
|
+
el("button", {
|
|
1636
|
+
className: "btn yaml-list-btn",
|
|
1637
|
+
type: "button",
|
|
1638
|
+
text: "↑",
|
|
1639
|
+
title: "Move up",
|
|
1640
|
+
disabled: index === 0,
|
|
1641
|
+
onClick: () => {
|
|
1642
|
+
if (index === 0) return;
|
|
1643
|
+
const next = [...list];
|
|
1644
|
+
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
|
1645
|
+
state.yamlList = next;
|
|
1646
|
+
markDirty();
|
|
1647
|
+
renderShell();
|
|
1648
|
+
},
|
|
1649
|
+
}),
|
|
1650
|
+
el("button", {
|
|
1651
|
+
className: "btn yaml-list-btn",
|
|
1652
|
+
type: "button",
|
|
1653
|
+
text: "↓",
|
|
1654
|
+
title: "Move down",
|
|
1655
|
+
disabled: index === list.length - 1,
|
|
1656
|
+
onClick: () => {
|
|
1657
|
+
if (index >= list.length - 1) return;
|
|
1658
|
+
const next = [...list];
|
|
1659
|
+
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
|
1660
|
+
state.yamlList = next;
|
|
1661
|
+
markDirty();
|
|
1662
|
+
renderShell();
|
|
1663
|
+
},
|
|
1664
|
+
}),
|
|
1665
|
+
el("button", {
|
|
1666
|
+
className: "btn yaml-list-btn",
|
|
1667
|
+
type: "button",
|
|
1668
|
+
text: "Remove",
|
|
1669
|
+
onClick: () => {
|
|
1670
|
+
state.yamlList = list.filter((_, i) => i !== index);
|
|
1671
|
+
markDirty();
|
|
1672
|
+
renderShell();
|
|
1673
|
+
},
|
|
1674
|
+
}),
|
|
1675
|
+
]),
|
|
1676
|
+
])
|
|
1677
|
+
);
|
|
1678
|
+
const form = el("div", { className: "form-grid yaml-list-item-fields" });
|
|
1679
|
+
for (const field of fields) {
|
|
1680
|
+
form.append(
|
|
1681
|
+
renderField(field, {
|
|
1682
|
+
target: item,
|
|
1683
|
+
idPrefix: `yml-${index}-`,
|
|
1684
|
+
onChange: () => {
|
|
1685
|
+
markDirty();
|
|
1686
|
+
},
|
|
1687
|
+
})
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
card.append(form);
|
|
1691
|
+
host.append(card);
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
host.append(
|
|
1695
|
+
el("button", {
|
|
1696
|
+
className: "btn btn-primary",
|
|
1697
|
+
type: "button",
|
|
1698
|
+
text: "Add item",
|
|
1699
|
+
onClick: () => {
|
|
1700
|
+
state.yamlList = [
|
|
1701
|
+
...(state.yamlList || []),
|
|
1702
|
+
emptyItemFromFields(fields),
|
|
1703
|
+
];
|
|
1704
|
+
markDirty();
|
|
1705
|
+
renderShell();
|
|
1706
|
+
},
|
|
1707
|
+
})
|
|
1708
|
+
);
|
|
1709
|
+
|
|
1710
|
+
if (!list.length) {
|
|
1711
|
+
host.prepend(
|
|
1712
|
+
el("p", {
|
|
1713
|
+
className: "yaml-empty",
|
|
1714
|
+
text: "No items yet. Add one to start this list.",
|
|
1715
|
+
})
|
|
1716
|
+
);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
return host;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function renderYamlMapEditor() {
|
|
1723
|
+
const form = el("div", { className: "form-grid", id: "yaml-form" });
|
|
1724
|
+
for (const field of nonBodyFields(state.collection)) {
|
|
1725
|
+
form.append(renderField(field));
|
|
1726
|
+
}
|
|
1727
|
+
return form;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function renderYamlSourceEditor() {
|
|
1731
|
+
return el("div", { className: "field yaml-source-stack" }, [
|
|
1732
|
+
el("label", { className: "field-heading", text: "YAML source" }),
|
|
1733
|
+
el("textarea", {
|
|
1734
|
+
id: "yaml-source",
|
|
1735
|
+
className: "yaml-source editor-surface",
|
|
1736
|
+
text: state.body,
|
|
1737
|
+
"aria-label": "YAML source",
|
|
1738
|
+
spellcheck: "false",
|
|
1739
|
+
onInput: (e) => {
|
|
1740
|
+
state.body = e.target.value;
|
|
1741
|
+
markDirty();
|
|
1742
|
+
refreshPreview();
|
|
1743
|
+
},
|
|
1744
|
+
}),
|
|
1745
|
+
]);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
function renderYamlEditorPane() {
|
|
1749
|
+
const col = state.collection;
|
|
1750
|
+
const kind = yamlEditKind(col) || "source";
|
|
1751
|
+
const useStructured =
|
|
1752
|
+
state.yamlUiMode === "structured" && (kind === "list" || kind === "map");
|
|
1753
|
+
|
|
1754
|
+
const backTarget = col.type === "file" ? "Map" : "List";
|
|
1755
|
+
const body = el("div", { className: "yaml-editor-body" });
|
|
1756
|
+
if (useStructured && kind === "list") {
|
|
1757
|
+
body.append(renderYamlListEditor());
|
|
1758
|
+
} else if (useStructured && kind === "map") {
|
|
1759
|
+
body.append(renderYamlMapEditor());
|
|
1760
|
+
} else {
|
|
1761
|
+
body.append(renderYamlSourceEditor());
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
return el("div", { className: "pane pane-editor pane-yaml" }, [
|
|
1765
|
+
el("button", {
|
|
1766
|
+
className: "btn btn-nav",
|
|
1767
|
+
type: "button",
|
|
1768
|
+
title: `Back to ${backTarget}`,
|
|
1769
|
+
onClick: () => {
|
|
1770
|
+
destroyBodyEditor();
|
|
1771
|
+
if (col.type === "file") {
|
|
1772
|
+
goHome();
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
state.file = null;
|
|
1776
|
+
state.baseline = null;
|
|
1777
|
+
state.dirty = false;
|
|
1778
|
+
state.insertMenuOpen = false;
|
|
1779
|
+
renderShell();
|
|
1780
|
+
},
|
|
1781
|
+
}, [
|
|
1782
|
+
materialIcon("chevron_left", "btn-nav-icon"),
|
|
1783
|
+
el("span", { text: backTarget }),
|
|
1784
|
+
]),
|
|
1785
|
+
el("header", { className: "yaml-editor-header" }, [
|
|
1786
|
+
el("h1", {
|
|
1787
|
+
className: "yaml-editor-title",
|
|
1788
|
+
text: col.label,
|
|
1789
|
+
}),
|
|
1790
|
+
el("p", {
|
|
1791
|
+
className: "yaml-editor-path",
|
|
1792
|
+
text: formatPath(state.file || col.path),
|
|
1793
|
+
}),
|
|
1794
|
+
]),
|
|
1795
|
+
renderYamlEditorChrome(kind),
|
|
1796
|
+
body,
|
|
1797
|
+
]);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
function renderStudioMap() {
|
|
1801
|
+
const groups = collectionGroups(state.schema);
|
|
1802
|
+
const sections = [];
|
|
1803
|
+
|
|
1804
|
+
for (const [group, cols] of groups) {
|
|
1805
|
+
const cards = cols.map((col) =>
|
|
1806
|
+
el("button", {
|
|
1807
|
+
className: "studio-map-card",
|
|
1808
|
+
type: "button",
|
|
1809
|
+
title: collectionHint(col),
|
|
1810
|
+
onClick: () => {
|
|
1811
|
+
state.navOpenGroups.add(group);
|
|
1812
|
+
selectCollection(col);
|
|
1813
|
+
},
|
|
1814
|
+
}, [
|
|
1815
|
+
materialIcon(
|
|
1816
|
+
col.type === "file" ? "draft" : GROUP_ICONS[group] || "folder_open",
|
|
1817
|
+
"studio-map-card-icon"
|
|
1818
|
+
),
|
|
1819
|
+
el("span", { className: "studio-map-card-body" }, [
|
|
1820
|
+
el("span", { className: "studio-map-card-title", text: col.label }),
|
|
1821
|
+
el("span", {
|
|
1822
|
+
className: "studio-map-card-meta",
|
|
1823
|
+
text: formatPath(col.path) || col.name,
|
|
1824
|
+
}),
|
|
1825
|
+
]),
|
|
1826
|
+
])
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
sections.push(
|
|
1830
|
+
el("section", { className: "studio-map-group" }, [
|
|
1831
|
+
el("div", { className: "studio-map-group-head" }, [
|
|
1832
|
+
materialIcon(GROUP_ICONS[group] || "folder", "studio-map-group-icon"),
|
|
1833
|
+
el("h2", { className: "studio-map-group-label", text: group }),
|
|
1834
|
+
]),
|
|
1835
|
+
el("div", { className: "studio-map-grid" }, cards),
|
|
1836
|
+
])
|
|
1837
|
+
);
|
|
1838
|
+
|
|
1839
|
+
// Insert Media section after Portfolio (or after References if no Portfolio).
|
|
1840
|
+
if (group === "Portfolio" || (group === "References" && !groups.has("Portfolio"))) {
|
|
1841
|
+
sections.push(renderMediaMapSection());
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
if (!groups.has("Portfolio") && !groups.has("References")) {
|
|
1846
|
+
sections.push(renderMediaMapSection());
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
// Split groups across two columns so wide screens read as a ledger board,
|
|
1850
|
+
// not a single skinny stack in empty space.
|
|
1851
|
+
const splitAt = Math.ceil(sections.length / 2);
|
|
1852
|
+
const colA = sections.slice(0, splitAt);
|
|
1853
|
+
const colB = sections.slice(splitAt);
|
|
1854
|
+
|
|
1855
|
+
return el("div", { className: "pane studio-map" }, [
|
|
1856
|
+
el("span", {
|
|
1857
|
+
className: "studio-shard studio-shard--lead",
|
|
1858
|
+
"aria-hidden": "true",
|
|
1859
|
+
}),
|
|
1860
|
+
el("span", {
|
|
1861
|
+
className: "studio-shard studio-shard--beta studio-shard--trail",
|
|
1862
|
+
"aria-hidden": "true",
|
|
1863
|
+
}),
|
|
1864
|
+
el("header", { className: "studio-map-header" }, [
|
|
1865
|
+
el("p", { className: "studio-map-kicker", text: "Studio" }),
|
|
1866
|
+
el("h1", { text: "Studio map" }),
|
|
1867
|
+
el("p", {
|
|
1868
|
+
className: "studio-map-lede",
|
|
1869
|
+
text: "Pick a collection, page, or the media library. Groups match the left navigation.",
|
|
1870
|
+
}),
|
|
1871
|
+
]),
|
|
1872
|
+
el("div", { className: "studio-map-board", role: "navigation", "aria-label": "Collections map" }, [
|
|
1873
|
+
el("div", { className: "studio-map-col" }, colA),
|
|
1874
|
+
colB.length
|
|
1875
|
+
? el("div", { className: "studio-map-col" }, colB)
|
|
1876
|
+
: null,
|
|
1877
|
+
]),
|
|
1878
|
+
]);
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function renderMediaMapSection() {
|
|
1882
|
+
return el("section", { className: "studio-map-group" }, [
|
|
1883
|
+
el("div", { className: "studio-map-group-head" }, [
|
|
1884
|
+
materialIcon(GROUP_ICONS.Media || "photo_library", "studio-map-group-icon"),
|
|
1885
|
+
el("h2", { className: "studio-map-group-label", text: "Media" }),
|
|
1886
|
+
]),
|
|
1887
|
+
el("div", { className: "studio-map-grid" }, [
|
|
1888
|
+
el("button", {
|
|
1889
|
+
className: "studio-map-card",
|
|
1890
|
+
type: "button",
|
|
1891
|
+
title: "Browse and upload under content/media",
|
|
1892
|
+
onClick: () => openMediaLibrary(),
|
|
1893
|
+
}, [
|
|
1894
|
+
materialIcon("photo_library", "studio-map-card-icon"),
|
|
1895
|
+
el("span", { className: "studio-map-card-body" }, [
|
|
1896
|
+
el("span", { className: "studio-map-card-title", text: "Library" }),
|
|
1897
|
+
el("span", {
|
|
1898
|
+
className: "studio-map-card-meta",
|
|
1899
|
+
text: formatPath("content/media"),
|
|
1900
|
+
}),
|
|
1901
|
+
]),
|
|
1902
|
+
]),
|
|
1903
|
+
]),
|
|
1904
|
+
]);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function renderListPane() {
|
|
1908
|
+
const query = currentListQuery();
|
|
1909
|
+
const sortId = currentListSortId();
|
|
1910
|
+
const pageSizeId = currentListPageSizeId();
|
|
1911
|
+
const tagFilter = currentListTag();
|
|
1912
|
+
const writing = isWritingCollection();
|
|
1913
|
+
const filtered = applyListControls(state.items, {
|
|
1914
|
+
query,
|
|
1915
|
+
sortId,
|
|
1916
|
+
tag: writing ? tagFilter : "",
|
|
1917
|
+
});
|
|
1918
|
+
const pageInfo = paginateItems(filtered, {
|
|
1919
|
+
pageSizeId,
|
|
1920
|
+
page: currentListPage(),
|
|
1921
|
+
});
|
|
1922
|
+
// Clamp stored page if filter/sort shrunk the result set.
|
|
1923
|
+
if (pageInfo.page !== currentListPage()) {
|
|
1924
|
+
setListPage(pageInfo.page);
|
|
1925
|
+
}
|
|
1926
|
+
const visible = pageInfo.items;
|
|
1927
|
+
|
|
1928
|
+
const header = renderListHeader(state.collection, {
|
|
1929
|
+
entryCount: state.listLoading ? null : state.items.length,
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
if (state.listLoading) {
|
|
1933
|
+
return el("div", { className: "pane" }, [
|
|
1934
|
+
header,
|
|
1935
|
+
el("p", { className: "loading-msg", text: "Loading content…" }),
|
|
1936
|
+
]);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
if (state.error && !state.items.length) {
|
|
1940
|
+
return el("div", { className: "pane" }, [
|
|
1941
|
+
header,
|
|
1942
|
+
el("div", { className: "list-error" }, [
|
|
1943
|
+
el("p", {
|
|
1944
|
+
className: "list-error-title",
|
|
1945
|
+
text: "Could not load entries.",
|
|
1946
|
+
}),
|
|
1947
|
+
el("p", { className: "list-error-message", text: state.error }),
|
|
1948
|
+
state.errorDetail
|
|
1949
|
+
? el("pre", {
|
|
1950
|
+
className: "list-error-detail",
|
|
1951
|
+
text: state.errorDetail,
|
|
1952
|
+
})
|
|
1953
|
+
: null,
|
|
1954
|
+
]),
|
|
1955
|
+
]);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
const rows = visible.map((item) => renderLedgerRow(item, writing));
|
|
1959
|
+
|
|
1960
|
+
const search = el("input", {
|
|
1961
|
+
className: "list-search-input",
|
|
1962
|
+
type: "search",
|
|
1963
|
+
value: query,
|
|
1964
|
+
placeholder: "Search title, key, path…",
|
|
1965
|
+
"aria-label": "Search entries",
|
|
1966
|
+
title: "Filter this list by title, key, filename, or path",
|
|
1967
|
+
onInput: (e) => {
|
|
1968
|
+
const name = state.collection?.name;
|
|
1969
|
+
if (!name) return;
|
|
1970
|
+
state.listQueryByCollection[name] = e.target.value;
|
|
1971
|
+
resetListPage();
|
|
1972
|
+
renderMain();
|
|
1973
|
+
const next = $("#list-search-input");
|
|
1974
|
+
if (next) {
|
|
1975
|
+
next.focus();
|
|
1976
|
+
const len = next.value.length;
|
|
1977
|
+
next.setSelectionRange(len, len);
|
|
1978
|
+
}
|
|
1979
|
+
},
|
|
1980
|
+
});
|
|
1981
|
+
search.id = "list-search-input";
|
|
1982
|
+
|
|
1983
|
+
const sortSelect = el("select", {
|
|
1984
|
+
className: "list-sort-select",
|
|
1985
|
+
"aria-label": "Sort entries",
|
|
1986
|
+
title: "Sort entries in this collection",
|
|
1987
|
+
onChange: (e) => {
|
|
1988
|
+
setListSortId(e.target.value);
|
|
1989
|
+
renderMain();
|
|
1990
|
+
},
|
|
1991
|
+
});
|
|
1992
|
+
for (const opt of LIST_SORT_OPTIONS) {
|
|
1993
|
+
sortSelect.append(
|
|
1994
|
+
el("option", {
|
|
1995
|
+
value: opt.id,
|
|
1996
|
+
text: opt.label,
|
|
1997
|
+
selected: opt.id === sortId,
|
|
1998
|
+
})
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const pageSizeSelect = el("select", {
|
|
2003
|
+
className: "list-page-size-select",
|
|
2004
|
+
"aria-label": "Entries per page",
|
|
2005
|
+
title: "How many entries to show per page",
|
|
2006
|
+
onChange: (e) => {
|
|
2007
|
+
setListPageSizeId(e.target.value);
|
|
2008
|
+
renderMain();
|
|
2009
|
+
},
|
|
2010
|
+
});
|
|
2011
|
+
for (const opt of LIST_PAGE_SIZE_OPTIONS) {
|
|
2012
|
+
pageSizeSelect.append(
|
|
2013
|
+
el("option", {
|
|
2014
|
+
value: opt.id,
|
|
2015
|
+
text: opt.label,
|
|
2016
|
+
selected: opt.id === pageSizeId,
|
|
2017
|
+
})
|
|
2018
|
+
);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
const controls = el("div", { className: "list-controls" }, [
|
|
2022
|
+
search,
|
|
2023
|
+
el("label", { className: "list-sort-label" }, [
|
|
2024
|
+
el("span", { className: "list-sort-caption", text: "Sort" }),
|
|
2025
|
+
sortSelect,
|
|
2026
|
+
]),
|
|
2027
|
+
el("label", { className: "list-sort-label" }, [
|
|
2028
|
+
el("span", { className: "list-sort-caption", text: "Show" }),
|
|
2029
|
+
pageSizeSelect,
|
|
2030
|
+
]),
|
|
2031
|
+
]);
|
|
2032
|
+
|
|
2033
|
+
const tagChips = writing ? renderTagFilterChips(state.items, tagFilter) : null;
|
|
2034
|
+
|
|
2035
|
+
let emptyNode = null;
|
|
2036
|
+
if (!filtered.length) {
|
|
2037
|
+
if (state.error) {
|
|
2038
|
+
emptyNode = el("div", { className: "list-error" }, [
|
|
2039
|
+
el("p", {
|
|
2040
|
+
className: "list-error-title",
|
|
2041
|
+
text: "Could not load entries.",
|
|
2042
|
+
}),
|
|
2043
|
+
el("p", { className: "list-error-message", text: state.error }),
|
|
2044
|
+
state.errorDetail
|
|
2045
|
+
? el("pre", {
|
|
2046
|
+
className: "list-error-detail",
|
|
2047
|
+
text: state.errorDetail,
|
|
2048
|
+
})
|
|
2049
|
+
: null,
|
|
2050
|
+
]);
|
|
2051
|
+
} else if (state.items.length === 0) {
|
|
2052
|
+
emptyNode = el("p", {
|
|
2053
|
+
className: "list-empty",
|
|
2054
|
+
text: "No entries in this collection.",
|
|
2055
|
+
});
|
|
2056
|
+
} else {
|
|
2057
|
+
emptyNode = el("p", {
|
|
2058
|
+
className: "list-empty",
|
|
2059
|
+
text: tagFilter
|
|
2060
|
+
? "No entries match this tag filter."
|
|
2061
|
+
: "No entries match this search.",
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
const showPager =
|
|
2067
|
+
pageInfo.pageSize != null && pageInfo.pageCount > 1 && filtered.length > 0;
|
|
2068
|
+
const pager = showPager
|
|
2069
|
+
? el("div", { className: "list-pager", role: "navigation", "aria-label": "List pages" }, [
|
|
2070
|
+
el("button", {
|
|
2071
|
+
className: "btn list-pager-btn",
|
|
2072
|
+
type: "button",
|
|
2073
|
+
text: "Prev",
|
|
2074
|
+
title: "Previous page",
|
|
2075
|
+
disabled: pageInfo.page <= 1,
|
|
2076
|
+
onClick: () => {
|
|
2077
|
+
if (pageInfo.page <= 1) return;
|
|
2078
|
+
setListPage(pageInfo.page - 1);
|
|
2079
|
+
renderMain();
|
|
2080
|
+
},
|
|
2081
|
+
}),
|
|
2082
|
+
el("span", {
|
|
2083
|
+
className: "list-pager-status",
|
|
2084
|
+
text: `Page ${pageInfo.page} of ${pageInfo.pageCount}`,
|
|
2085
|
+
}),
|
|
2086
|
+
el("button", {
|
|
2087
|
+
className: "btn list-pager-btn",
|
|
2088
|
+
type: "button",
|
|
2089
|
+
text: "Next",
|
|
2090
|
+
title: "Next page",
|
|
2091
|
+
disabled: pageInfo.page >= pageInfo.pageCount,
|
|
2092
|
+
onClick: () => {
|
|
2093
|
+
if (pageInfo.page >= pageInfo.pageCount) return;
|
|
2094
|
+
setListPage(pageInfo.page + 1);
|
|
2095
|
+
renderMain();
|
|
2096
|
+
},
|
|
2097
|
+
}),
|
|
2098
|
+
])
|
|
2099
|
+
: null;
|
|
2100
|
+
|
|
2101
|
+
// Title already shows total entry count — list-count only when range/filter adds info.
|
|
2102
|
+
let countText = "";
|
|
2103
|
+
if (state.items.length > 0) {
|
|
2104
|
+
const filteredNote = query || tagFilter;
|
|
2105
|
+
if (!filtered.length) {
|
|
2106
|
+
countText = filteredNote ? `0 of ${state.items.length}` : "";
|
|
2107
|
+
} else if (pageInfo.pageSize == null) {
|
|
2108
|
+
countText = filteredNote
|
|
2109
|
+
? `${filtered.length} of ${state.items.length}`
|
|
2110
|
+
: "";
|
|
2111
|
+
} else {
|
|
2112
|
+
const range = `${pageInfo.from}–${pageInfo.to} of ${filtered.length}`;
|
|
2113
|
+
countText = filteredNote
|
|
2114
|
+
? `${range} (filtered from ${state.items.length})`
|
|
2115
|
+
: range;
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
const columnHead = writing
|
|
2120
|
+
? el("div", { className: "ledger-columns", "aria-hidden": "true" }, [
|
|
2121
|
+
el("span", { className: "ledger-col-thumb" }),
|
|
2122
|
+
el("span", { className: "ledger-col-manuscript", text: "Manuscript" }),
|
|
2123
|
+
el("span", { className: "ledger-col-taxonomy", text: "Taxonomy" }),
|
|
2124
|
+
el("span", { className: "ledger-col-ops", text: "Operations" }),
|
|
2125
|
+
])
|
|
2126
|
+
: null;
|
|
2127
|
+
|
|
2128
|
+
return el("div", { className: "pane" }, [
|
|
2129
|
+
header,
|
|
2130
|
+
controls,
|
|
2131
|
+
tagChips,
|
|
2132
|
+
countText
|
|
2133
|
+
? el("p", { className: "list-count", text: countText })
|
|
2134
|
+
: null,
|
|
2135
|
+
columnHead,
|
|
2136
|
+
el("div", { className: "ledger-list" }, rows.length ? rows : [emptyNode]),
|
|
2137
|
+
pager,
|
|
2138
|
+
]);
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function renderTagFilterChips(items, activeTag) {
|
|
2142
|
+
const tags = collectDistinctTags(items);
|
|
2143
|
+
if (!tags.length) return null;
|
|
2144
|
+
const chips = [
|
|
2145
|
+
el("button", {
|
|
2146
|
+
className:
|
|
2147
|
+
"list-tag-chip" + (!activeTag ? " is-active" : ""),
|
|
2148
|
+
type: "button",
|
|
2149
|
+
text: `All (${items.length})`,
|
|
2150
|
+
"aria-pressed": !activeTag ? "true" : "false",
|
|
2151
|
+
onClick: () => {
|
|
2152
|
+
setListTag("");
|
|
2153
|
+
renderMain();
|
|
2154
|
+
},
|
|
2155
|
+
}),
|
|
2156
|
+
];
|
|
2157
|
+
for (const tag of tags) {
|
|
2158
|
+
const count = items.filter((item) =>
|
|
2159
|
+
itemTags(item).some((t) => t.toLowerCase() === tag.toLowerCase())
|
|
2160
|
+
).length;
|
|
2161
|
+
if (!count) continue;
|
|
2162
|
+
const active = activeTag.toLowerCase() === tag.toLowerCase();
|
|
2163
|
+
chips.push(
|
|
2164
|
+
el("button", {
|
|
2165
|
+
className: "list-tag-chip" + (active ? " is-active" : ""),
|
|
2166
|
+
type: "button",
|
|
2167
|
+
text: `${tag} (${count})`,
|
|
2168
|
+
"aria-pressed": active ? "true" : "false",
|
|
2169
|
+
onClick: () => {
|
|
2170
|
+
setListTag(active ? "" : tag);
|
|
2171
|
+
renderMain();
|
|
2172
|
+
},
|
|
2173
|
+
})
|
|
2174
|
+
);
|
|
2175
|
+
}
|
|
2176
|
+
return el("div", {
|
|
2177
|
+
className: "list-tag-filters",
|
|
2178
|
+
role: "group",
|
|
2179
|
+
"aria-label": "Filter by tag",
|
|
2180
|
+
}, chips);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function languageChip(itemOrFields) {
|
|
2184
|
+
const lang = String(itemOrFields?.language || "").trim();
|
|
2185
|
+
if (!lang) return null;
|
|
2186
|
+
return el("span", {
|
|
2187
|
+
className: "ledger-lang",
|
|
2188
|
+
text: lang.toUpperCase(),
|
|
2189
|
+
title: `Language: ${lang}`,
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function taxonomyChips(item) {
|
|
2194
|
+
const tags = itemTags(item);
|
|
2195
|
+
if (!tags.length) return null;
|
|
2196
|
+
return el(
|
|
2197
|
+
"div",
|
|
2198
|
+
{ className: "ledger-taxonomy" },
|
|
2199
|
+
tags.map((tag) =>
|
|
2200
|
+
el("span", { className: "ledger-tag", text: tag })
|
|
2201
|
+
)
|
|
2202
|
+
);
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
function renderLedgerRow(item, writing) {
|
|
2206
|
+
const metaText = formatEntryListMeta(item, state.collection);
|
|
2207
|
+
const open = () => openFile(item.path);
|
|
2208
|
+
|
|
2209
|
+
if (!writing) {
|
|
2210
|
+
const titleBlock = el("div", { className: "ledger-copy" }, [
|
|
2211
|
+
el("div", {
|
|
2212
|
+
className: "ledger-title",
|
|
2213
|
+
text: item.title || item.name,
|
|
2214
|
+
}),
|
|
2215
|
+
metaText
|
|
2216
|
+
? el("div", { className: "ledger-meta", text: metaText })
|
|
2217
|
+
: null,
|
|
2218
|
+
]);
|
|
2219
|
+
return el(
|
|
2220
|
+
"button",
|
|
2221
|
+
{
|
|
2222
|
+
className: "ledger-row",
|
|
2223
|
+
type: "button",
|
|
2224
|
+
title: entryHint(item),
|
|
2225
|
+
onClick: open,
|
|
2226
|
+
},
|
|
2227
|
+
[thumbnailNode(item), titleBlock]
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const titleRow = el("div", { className: "ledger-title-row" }, [
|
|
2232
|
+
publishStatusPill(item),
|
|
2233
|
+
el("div", {
|
|
2234
|
+
className: "ledger-title",
|
|
2235
|
+
text: item.title || item.name,
|
|
2236
|
+
}),
|
|
2237
|
+
]);
|
|
2238
|
+
|
|
2239
|
+
const manuscript = el("div", { className: "ledger-copy" }, [
|
|
2240
|
+
titleRow,
|
|
2241
|
+
metaText
|
|
2242
|
+
? el("div", { className: "ledger-meta", text: metaText })
|
|
2243
|
+
: null,
|
|
2244
|
+
]);
|
|
2245
|
+
|
|
2246
|
+
const taxonomy = taxonomyChips(item) || el("div", { className: "ledger-taxonomy" });
|
|
2247
|
+
|
|
2248
|
+
const ops = el("div", { className: "ledger-ops", "aria-hidden": "true" }, [
|
|
2249
|
+
el("span", { className: "ledger-ops-edit", text: "Edit" }),
|
|
2250
|
+
materialIcon("chevron_right", "ledger-ops-icon"),
|
|
2251
|
+
]);
|
|
2252
|
+
|
|
2253
|
+
return el(
|
|
2254
|
+
"div",
|
|
2255
|
+
{
|
|
2256
|
+
className: "ledger-row ledger-row--writing",
|
|
2257
|
+
role: "button",
|
|
2258
|
+
tabindex: "0",
|
|
2259
|
+
title: entryHint(item),
|
|
2260
|
+
onClick: open,
|
|
2261
|
+
onKeydown: (e) => {
|
|
2262
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
2263
|
+
e.preventDefault();
|
|
2264
|
+
open();
|
|
2265
|
+
}
|
|
2266
|
+
},
|
|
2267
|
+
},
|
|
2268
|
+
[thumbnailNode(item), manuscript, taxonomy, ops]
|
|
2269
|
+
);
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
function nonBodyFields(collection) {
|
|
2273
|
+
return (collection.fields || []).filter(
|
|
2274
|
+
(f) => !f.hidden && !fieldIsBody(f) && f.name !== "layout"
|
|
2275
|
+
);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
function bodyFields(collection) {
|
|
2279
|
+
const bodies = (collection.fields || []).filter((f) => fieldIsBody(f));
|
|
2280
|
+
if (bodies.length) return bodies;
|
|
2281
|
+
// Raw markdown files without a body field: single source textarea (not TipTap chrome).
|
|
2282
|
+
if (collection.format === "raw" && !collection.root_key) {
|
|
2283
|
+
return [{ name: "body", label: "Content", component: "markdown_body" }];
|
|
2284
|
+
}
|
|
2285
|
+
// YAML never borrows the markdown body editor — see renderYamlEditorPane.
|
|
2286
|
+
return [];
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function collectionHasField(col, name) {
|
|
2290
|
+
return (col?.fields || []).some((field) => field.name === name);
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
function fieldValuePresent(value) {
|
|
2294
|
+
if (value == null) return false;
|
|
2295
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
2296
|
+
if (typeof value === "boolean") return true;
|
|
2297
|
+
return String(value).trim() !== "";
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/** Open front-matter when dirty or a required meta field is empty. */
|
|
2301
|
+
function shouldFrontMatterOpen() {
|
|
2302
|
+
if (state.frontMatterOpen != null) return !!state.frontMatterOpen;
|
|
2303
|
+
if (state.dirty) return true;
|
|
2304
|
+
for (const field of nonBodyFields(state.collection)) {
|
|
2305
|
+
if (!field.required) continue;
|
|
2306
|
+
if (!fieldValuePresent(state.fields?.[field.name])) return true;
|
|
2307
|
+
}
|
|
2308
|
+
return false;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function renderListHeader(collection, { entryCount = null } = {}) {
|
|
2312
|
+
const group = collection?.group || "Content";
|
|
2313
|
+
const label = collection?.label || collection?.name || "Index";
|
|
2314
|
+
const titleRow = [
|
|
2315
|
+
el("h1", { className: "list-pane-title", text: label }),
|
|
2316
|
+
];
|
|
2317
|
+
if (entryCount != null && entryCount >= 0) {
|
|
2318
|
+
titleRow.push(
|
|
2319
|
+
el("span", {
|
|
2320
|
+
className: "list-pane-count",
|
|
2321
|
+
text:
|
|
2322
|
+
entryCount === 1 ? "1 entry" : `${entryCount} entries`,
|
|
2323
|
+
})
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
return el("header", { className: "list-register" }, [
|
|
2327
|
+
el("p", { className: "list-register-kicker" }, [
|
|
2328
|
+
el("span", { className: "list-register-mark", "aria-hidden": "true" }),
|
|
2329
|
+
el("span", { text: `Register // ${group}` }),
|
|
2330
|
+
el("span", { className: "list-register-sep", "aria-hidden": "true", text: "/" }),
|
|
2331
|
+
el("span", { text: `${label} index` }),
|
|
2332
|
+
]),
|
|
2333
|
+
el("div", { className: "list-register-title-row" }, titleRow),
|
|
2334
|
+
]);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
function renderEditorPane() {
|
|
2338
|
+
const fields = nonBodyFields(state.collection);
|
|
2339
|
+
const bodies = bodyFields(state.collection);
|
|
2340
|
+
|
|
2341
|
+
const form = el("div", { className: "form-grid", id: "meta-form" });
|
|
2342
|
+
|
|
2343
|
+
for (const field of fields) {
|
|
2344
|
+
form.append(renderField(field));
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
const fmOpen = shouldFrontMatterOpen();
|
|
2348
|
+
const frontMatter =
|
|
2349
|
+
fields.length > 0
|
|
2350
|
+
? el(
|
|
2351
|
+
"details",
|
|
2352
|
+
{
|
|
2353
|
+
className: "front-matter",
|
|
2354
|
+
open: fmOpen,
|
|
2355
|
+
onToggle: (e) => {
|
|
2356
|
+
state.frontMatterOpen = !!e.target.open;
|
|
2357
|
+
},
|
|
2358
|
+
},
|
|
2359
|
+
[
|
|
2360
|
+
el("summary", { className: "front-matter-summary" }, [
|
|
2361
|
+
el("span", {
|
|
2362
|
+
className: "front-matter-summary-label",
|
|
2363
|
+
text: "Front-matter",
|
|
2364
|
+
}),
|
|
2365
|
+
el("span", {
|
|
2366
|
+
className: "front-matter-summary-hint",
|
|
2367
|
+
text: fmOpen ? "Collapse" : "Expand",
|
|
2368
|
+
}),
|
|
2369
|
+
]),
|
|
2370
|
+
form,
|
|
2371
|
+
]
|
|
2372
|
+
)
|
|
2373
|
+
: null;
|
|
2374
|
+
|
|
2375
|
+
const bodyWrap = el("div", { className: "field body-editor-stack", id: "body-wrap" });
|
|
2376
|
+
if (bodies.length) {
|
|
2377
|
+
const primary = bodies[0];
|
|
2378
|
+
const mode = state.bodyMode;
|
|
2379
|
+
const visual = mode === "visual";
|
|
2380
|
+
// On wide screens Preview is a side panel; keep a Visual surface mounted under Preview.
|
|
2381
|
+
const showVisualSurface = visual || mode === "preview";
|
|
2382
|
+
const showSourceSurface = mode === "source";
|
|
2383
|
+
|
|
2384
|
+
const tiptapHost = el("div", {
|
|
2385
|
+
id: "tiptap-host",
|
|
2386
|
+
className: "tiptap editor-surface",
|
|
2387
|
+
});
|
|
2388
|
+
if (!showVisualSurface) tiptapHost.hidden = true;
|
|
2389
|
+
|
|
2390
|
+
const source = el("textarea", {
|
|
2391
|
+
id: "body-source",
|
|
2392
|
+
className: "body-source editor-surface",
|
|
2393
|
+
text: state.body,
|
|
2394
|
+
"aria-label": "Markdown source",
|
|
2395
|
+
onInput: (e) => {
|
|
2396
|
+
state.body = e.target.value;
|
|
2397
|
+
markDirty();
|
|
2398
|
+
refreshPreview();
|
|
2399
|
+
},
|
|
2400
|
+
});
|
|
2401
|
+
if (!showSourceSurface) source.hidden = true;
|
|
2402
|
+
|
|
2403
|
+
bodyWrap.append(
|
|
2404
|
+
el("label", { text: primary.label || "Body" }),
|
|
2405
|
+
renderEditorChrome(mode),
|
|
2406
|
+
el("div", { className: "editor-pane-fill" }, [tiptapHost, source])
|
|
2407
|
+
);
|
|
2408
|
+
if (bodies.length > 1) {
|
|
2409
|
+
for (const extra of bodies.slice(1)) {
|
|
2410
|
+
bodyWrap.append(
|
|
2411
|
+
el("label", {
|
|
2412
|
+
text: extra.label || extra.name,
|
|
2413
|
+
style: "margin-top:1rem",
|
|
2414
|
+
}),
|
|
2415
|
+
el("textarea", {
|
|
2416
|
+
"data-extra-body": extra.name,
|
|
2417
|
+
text: state.fields[extra.name] || "",
|
|
2418
|
+
onInput: (e) => {
|
|
2419
|
+
state.fields[extra.name] = e.target.value;
|
|
2420
|
+
markDirty();
|
|
2421
|
+
},
|
|
2422
|
+
})
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
const bodyStats =
|
|
2429
|
+
bodies.length > 0
|
|
2430
|
+
? renderBodyStats(state.body)
|
|
2431
|
+
: null;
|
|
2432
|
+
|
|
2433
|
+
const mastPills = [];
|
|
2434
|
+
if (collectionHasField(state.collection, "published")) {
|
|
2435
|
+
mastPills.push(publishStatusPill(state.fields));
|
|
2436
|
+
}
|
|
2437
|
+
// Language field on the form is SOT — skip mast chip when that field exists.
|
|
2438
|
+
if (!collectionHasField(state.collection, "language")) {
|
|
2439
|
+
const lang = languageChip(state.fields);
|
|
2440
|
+
if (lang) mastPills.push(lang);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
return el("div", { className: "pane pane-editor" }, [
|
|
2444
|
+
el("div", { className: "editor-mast" }, [
|
|
2445
|
+
el("div", { className: "editor-mast-lead" }, [
|
|
2446
|
+
el("button", {
|
|
2447
|
+
className: "btn btn-back",
|
|
2448
|
+
type: "button",
|
|
2449
|
+
title: "Back to collection list",
|
|
2450
|
+
"aria-label": "Back to collection list",
|
|
2451
|
+
onClick: () => {
|
|
2452
|
+
destroyBodyEditor();
|
|
2453
|
+
state.file = null;
|
|
2454
|
+
state.baseline = null;
|
|
2455
|
+
state.dirty = false;
|
|
2456
|
+
state.insertMenuOpen = false;
|
|
2457
|
+
state.frontMatterOpen = null;
|
|
2458
|
+
renderShell();
|
|
2459
|
+
},
|
|
2460
|
+
}, [materialIcon("chevron_left", "btn-nav-icon")]),
|
|
2461
|
+
mastPills.length
|
|
2462
|
+
? el("div", { className: "editor-mast-pills" }, mastPills)
|
|
2463
|
+
: null,
|
|
2464
|
+
]),
|
|
2465
|
+
]),
|
|
2466
|
+
frontMatter,
|
|
2467
|
+
bodyWrap,
|
|
2468
|
+
bodyStats,
|
|
2469
|
+
]);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function bodyTextStats(raw) {
|
|
2473
|
+
const text = String(raw || "");
|
|
2474
|
+
const chars = text.length;
|
|
2475
|
+
const trimmed = text.trim();
|
|
2476
|
+
const words = trimmed ? trimmed.split(/\s+/).filter(Boolean).length : 0;
|
|
2477
|
+
const paras = trimmed
|
|
2478
|
+
? trimmed.split(/\n\s*\n/).filter((p) => p.trim()).length
|
|
2479
|
+
: 0;
|
|
2480
|
+
return { chars, words, paras };
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
function renderBodyStats(raw) {
|
|
2484
|
+
const { chars, words, paras } = bodyTextStats(raw);
|
|
2485
|
+
return el("div", { className: "editor-body-stats", id: "editor-body-stats" }, [
|
|
2486
|
+
el("span", { text: `Chars: ${chars}` }),
|
|
2487
|
+
el("span", { className: "editor-body-stats-sep", "aria-hidden": "true", text: "·" }),
|
|
2488
|
+
el("span", { text: `Words: ${words}` }),
|
|
2489
|
+
el("span", { className: "editor-body-stats-sep", "aria-hidden": "true", text: "·" }),
|
|
2490
|
+
el("span", { text: `Paras: ${paras}` }),
|
|
2491
|
+
]);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
function refreshBodyStats() {
|
|
2495
|
+
const host = $("#editor-body-stats");
|
|
2496
|
+
if (!host) return;
|
|
2497
|
+
const { chars, words, paras } = bodyTextStats(state.body);
|
|
2498
|
+
host.replaceChildren(
|
|
2499
|
+
el("span", { text: `Chars: ${chars}` }),
|
|
2500
|
+
el("span", { className: "editor-body-stats-sep", "aria-hidden": "true", text: "·" }),
|
|
2501
|
+
el("span", { text: `Words: ${words}` }),
|
|
2502
|
+
el("span", { className: "editor-body-stats-sep", "aria-hidden": "true", text: "·" }),
|
|
2503
|
+
el("span", { text: `Paras: ${paras}` })
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
function renderEditorChrome(mode) {
|
|
2508
|
+
const visual = mode === "visual";
|
|
2509
|
+
const source = mode === "source";
|
|
2510
|
+
const preview = mode === "preview";
|
|
2511
|
+
|
|
2512
|
+
const modeTabs = el("div", {
|
|
2513
|
+
className: "editor-mode-tabs",
|
|
2514
|
+
role: "tablist",
|
|
2515
|
+
"aria-label": "Editor mode",
|
|
2516
|
+
}, [
|
|
2517
|
+
el("button", {
|
|
2518
|
+
className: "editor-mode-tab",
|
|
2519
|
+
type: "button",
|
|
2520
|
+
role: "tab",
|
|
2521
|
+
text: "Visual",
|
|
2522
|
+
title: "Rich text with Liquid blocks",
|
|
2523
|
+
"aria-selected": visual ? "true" : "false",
|
|
2524
|
+
onClick: () => {
|
|
2525
|
+
if (visual) return;
|
|
2526
|
+
syncBodyFromEditor();
|
|
2527
|
+
state.bodyMode = "visual";
|
|
2528
|
+
state.lastEditorMode = "visual";
|
|
2529
|
+
closeEditorMenus();
|
|
2530
|
+
renderShell();
|
|
2531
|
+
},
|
|
2532
|
+
}),
|
|
2533
|
+
el("button", {
|
|
2534
|
+
className: "editor-mode-tab",
|
|
2535
|
+
type: "button",
|
|
2536
|
+
role: "tab",
|
|
2537
|
+
text: "Source",
|
|
2538
|
+
title: "Edit raw Markdown source",
|
|
2539
|
+
"aria-selected": source ? "true" : "false",
|
|
2540
|
+
onClick: () => {
|
|
2541
|
+
if (source) return;
|
|
2542
|
+
syncBodyFromEditor();
|
|
2543
|
+
state.bodyMode = "source";
|
|
2544
|
+
state.lastEditorMode = "source";
|
|
2545
|
+
closeEditorMenus();
|
|
2546
|
+
renderShell();
|
|
2547
|
+
},
|
|
2548
|
+
}),
|
|
2549
|
+
el("button", {
|
|
2550
|
+
className: "editor-mode-tab editor-mode-tab--preview",
|
|
2551
|
+
type: "button",
|
|
2552
|
+
role: "tab",
|
|
2553
|
+
text: "Preview",
|
|
2554
|
+
title: "Rendered side preview (small screens)",
|
|
2555
|
+
"aria-selected": preview ? "true" : "false",
|
|
2556
|
+
onClick: () => {
|
|
2557
|
+
if (preview) return;
|
|
2558
|
+
syncBodyFromEditor();
|
|
2559
|
+
if (state.bodyMode === "visual" || state.bodyMode === "source") {
|
|
2560
|
+
state.lastEditorMode = state.bodyMode;
|
|
2561
|
+
}
|
|
2562
|
+
state.bodyMode = "preview";
|
|
2563
|
+
closeEditorMenus();
|
|
2564
|
+
renderShell();
|
|
2565
|
+
},
|
|
2566
|
+
}),
|
|
2567
|
+
]);
|
|
2568
|
+
|
|
2569
|
+
const chromeRow = el("div", { className: "editor-chrome-row" }, [
|
|
2570
|
+
modeTabs,
|
|
2571
|
+
el("div", { className: "editor-chrome-actions" }, [
|
|
2572
|
+
visual || source ? renderAiProofControl() : null,
|
|
2573
|
+
visual ? renderInsertControl() : null,
|
|
2574
|
+
]),
|
|
2575
|
+
]);
|
|
2576
|
+
|
|
2577
|
+
return el("div", { className: "editor-chrome" }, [
|
|
2578
|
+
chromeRow,
|
|
2579
|
+
el("p", {
|
|
2580
|
+
id: "ai-proof-hint",
|
|
2581
|
+
className: "ai-proof-hint",
|
|
2582
|
+
text: state.aiProofHint || "",
|
|
2583
|
+
hidden: !state.aiProofHint,
|
|
2584
|
+
"aria-live": "polite",
|
|
2585
|
+
}),
|
|
2586
|
+
visual ? renderFormatToolbar() : null,
|
|
2587
|
+
]);
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
function promptIncludeAttrs(def) {
|
|
2591
|
+
const attrs = {};
|
|
2592
|
+
if (def.name === "figure") {
|
|
2593
|
+
attrs.src = prompt("Image URL (HTTPS or /media/…)") || "";
|
|
2594
|
+
attrs.caption = prompt("Caption") || "";
|
|
2595
|
+
} else if (def.name === "youtube") {
|
|
2596
|
+
attrs.id = prompt("YouTube id") || "";
|
|
2597
|
+
} else if (def.name === "xcite" || def.name === "cite") {
|
|
2598
|
+
attrs.key = prompt("Key") || "";
|
|
2599
|
+
} else {
|
|
2600
|
+
for (const f of def.fields) {
|
|
2601
|
+
const v = prompt(f);
|
|
2602
|
+
if (v) attrs[f] = v;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
return attrs;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function renderInsertControl() {
|
|
2609
|
+
const menu = el("div", {
|
|
2610
|
+
className: "editor-insert-menu",
|
|
2611
|
+
id: "insert-menu",
|
|
2612
|
+
role: "menu",
|
|
2613
|
+
});
|
|
2614
|
+
if (!state.insertMenuOpen) menu.hidden = true;
|
|
2615
|
+
|
|
2616
|
+
for (const def of INCLUDE_DEFS.filter((d) => !d.blockBody)) {
|
|
2617
|
+
menu.append(
|
|
2618
|
+
el("button", {
|
|
2619
|
+
className: "editor-insert-item",
|
|
2620
|
+
type: "button",
|
|
2621
|
+
role: "menuitem",
|
|
2622
|
+
title: def.label,
|
|
2623
|
+
onClick: () => {
|
|
2624
|
+
const attrs = promptIncludeAttrs(def);
|
|
2625
|
+
closeEditorMenus();
|
|
2626
|
+
if (state.bodyEditor) state.bodyEditor.insertInclude(def.name, attrs);
|
|
2627
|
+
else renderMain();
|
|
2628
|
+
},
|
|
2629
|
+
}, [
|
|
2630
|
+
materialIcon(INCLUDE_ICONS[def.name] || "add", "editor-insert-icon"),
|
|
2631
|
+
el("span", { text: def.label }),
|
|
2632
|
+
])
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
return el("div", { className: "editor-insert" }, [
|
|
2637
|
+
el("button", {
|
|
2638
|
+
className: "btn editor-insert-btn",
|
|
2639
|
+
type: "button",
|
|
2640
|
+
"aria-haspopup": "menu",
|
|
2641
|
+
"aria-expanded": state.insertMenuOpen ? "true" : "false",
|
|
2642
|
+
title: "Insert a Liquid include block",
|
|
2643
|
+
onClick: (e) => {
|
|
2644
|
+
e.stopPropagation();
|
|
2645
|
+
const next = !state.insertMenuOpen;
|
|
2646
|
+
state.insertMenuOpen = next;
|
|
2647
|
+
const panel = $("#insert-menu");
|
|
2648
|
+
const btn = e.currentTarget;
|
|
2649
|
+
if (panel) panel.hidden = !next;
|
|
2650
|
+
btn.setAttribute("aria-expanded", next ? "true" : "false");
|
|
2651
|
+
},
|
|
2652
|
+
}, [
|
|
2653
|
+
materialIcon("add"),
|
|
2654
|
+
el("span", { text: "Insert" }),
|
|
2655
|
+
materialIcon("expand_more", "editor-insert-caret"),
|
|
2656
|
+
]),
|
|
2657
|
+
menu,
|
|
2658
|
+
]);
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
function renderAiProofControl() {
|
|
2662
|
+
// Two flat buttons, not a dropdown: opening a menu steals focus from the
|
|
2663
|
+
// body and clears the selection before Correct/Refine can read it.
|
|
2664
|
+
const items = [
|
|
2665
|
+
{
|
|
2666
|
+
action: "correct",
|
|
2667
|
+
label: "Correct",
|
|
2668
|
+
icon: "check",
|
|
2669
|
+
title: "Spelling, grammar, idiom, and punctuation (selection or full body)",
|
|
2670
|
+
},
|
|
2671
|
+
{
|
|
2672
|
+
action: "refine",
|
|
2673
|
+
label: "Refine",
|
|
2674
|
+
icon: "edit",
|
|
2675
|
+
title: "Clarity and flow (selection or full body), using writing guidelines",
|
|
2676
|
+
},
|
|
2677
|
+
];
|
|
2678
|
+
|
|
2679
|
+
return el(
|
|
2680
|
+
"div",
|
|
2681
|
+
{ className: "editor-ai-proof" },
|
|
2682
|
+
items.map((item) =>
|
|
2683
|
+
el(
|
|
2684
|
+
"button",
|
|
2685
|
+
{
|
|
2686
|
+
className: "btn editor-insert-btn",
|
|
2687
|
+
type: "button",
|
|
2688
|
+
title: item.title,
|
|
2689
|
+
"data-studio-action": item.action,
|
|
2690
|
+
disabled: !canAiProof() || state.aiProofBusy,
|
|
2691
|
+
onMouseDown: (e) => {
|
|
2692
|
+
// Keep the editor focused and snapshot the range before blur.
|
|
2693
|
+
e.preventDefault();
|
|
2694
|
+
state.aiProofSelection = getBodySelection();
|
|
2695
|
+
},
|
|
2696
|
+
onClick: () => {
|
|
2697
|
+
flashAiProofHint("");
|
|
2698
|
+
void startRewrite(item.action);
|
|
2699
|
+
},
|
|
2700
|
+
},
|
|
2701
|
+
[
|
|
2702
|
+
materialIcon(item.icon),
|
|
2703
|
+
el("span", {
|
|
2704
|
+
className: "editor-ai-proof-label",
|
|
2705
|
+
text: state.aiProofBusy ? "Working…" : item.label,
|
|
2706
|
+
}),
|
|
2707
|
+
]
|
|
2708
|
+
)
|
|
2709
|
+
)
|
|
2710
|
+
);
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
function editorChain() {
|
|
2714
|
+
return state.bodyEditor?.editor?.chain().focus();
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
function toolbarBtn(opts) {
|
|
2718
|
+
return el("button", {
|
|
2719
|
+
className: "editor-toolbar-btn",
|
|
2720
|
+
type: "button",
|
|
2721
|
+
title: opts.title,
|
|
2722
|
+
"aria-label": opts.title,
|
|
2723
|
+
onClick: opts.onClick,
|
|
2724
|
+
}, [
|
|
2725
|
+
opts.icon ? materialIcon(opts.icon) : null,
|
|
2726
|
+
opts.text ? el("span", { text: opts.text }) : null,
|
|
2727
|
+
]);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
function toolbarSep() {
|
|
2731
|
+
return el("span", { className: "editor-toolbar-sep", "aria-hidden": "true" });
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
function renderFormatToolbar() {
|
|
2735
|
+
const colorSelect = el("select", {
|
|
2736
|
+
className: "editor-toolbar-select",
|
|
2737
|
+
title: "Text color",
|
|
2738
|
+
"aria-label": "Text color",
|
|
2739
|
+
onChange: (e) => {
|
|
2740
|
+
const chain = editorChain();
|
|
2741
|
+
if (!chain) return;
|
|
2742
|
+
const value = e.target.value;
|
|
2743
|
+
if (!value) chain.unsetColor().run();
|
|
2744
|
+
else chain.setColor(value).run();
|
|
2745
|
+
},
|
|
2746
|
+
});
|
|
2747
|
+
for (const c of TEXT_COLORS) {
|
|
2748
|
+
colorSelect.append(el("option", { value: c.value, text: c.label }));
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2751
|
+
return el("div", {
|
|
2752
|
+
className: "editor-format-toolbar",
|
|
2753
|
+
role: "toolbar",
|
|
2754
|
+
"aria-label": "Formatting",
|
|
2755
|
+
}, [
|
|
2756
|
+
toolbarBtn({
|
|
2757
|
+
title: "Paragraph",
|
|
2758
|
+
icon: "notes",
|
|
2759
|
+
onClick: () => editorChain()?.setParagraph().run(),
|
|
2760
|
+
}),
|
|
2761
|
+
toolbarBtn({
|
|
2762
|
+
title: "Heading 1",
|
|
2763
|
+
text: "H1",
|
|
2764
|
+
onClick: () => editorChain()?.toggleHeading({ level: 1 }).run(),
|
|
2765
|
+
}),
|
|
2766
|
+
toolbarBtn({
|
|
2767
|
+
title: "Heading 2",
|
|
2768
|
+
text: "H2",
|
|
2769
|
+
onClick: () => editorChain()?.toggleHeading({ level: 2 }).run(),
|
|
2770
|
+
}),
|
|
2771
|
+
toolbarBtn({
|
|
2772
|
+
title: "Heading 3",
|
|
2773
|
+
text: "H3",
|
|
2774
|
+
onClick: () => editorChain()?.toggleHeading({ level: 3 }).run(),
|
|
2775
|
+
}),
|
|
2776
|
+
toolbarBtn({
|
|
2777
|
+
title: "Heading 4",
|
|
2778
|
+
text: "H4",
|
|
2779
|
+
onClick: () => editorChain()?.toggleHeading({ level: 4 }).run(),
|
|
2780
|
+
}),
|
|
2781
|
+
toolbarBtn({
|
|
2782
|
+
title: "Blockquote",
|
|
2783
|
+
icon: "format_quote",
|
|
2784
|
+
onClick: () => editorChain()?.toggleBlockquote().run(),
|
|
2785
|
+
}),
|
|
2786
|
+
toolbarSep(),
|
|
2787
|
+
toolbarBtn({
|
|
2788
|
+
title: "Bold",
|
|
2789
|
+
icon: "format_bold",
|
|
2790
|
+
onClick: () => editorChain()?.toggleBold().run(),
|
|
2791
|
+
}),
|
|
2792
|
+
toolbarBtn({
|
|
2793
|
+
title: "Italic",
|
|
2794
|
+
icon: "format_italic",
|
|
2795
|
+
onClick: () => editorChain()?.toggleItalic().run(),
|
|
2796
|
+
}),
|
|
2797
|
+
toolbarBtn({
|
|
2798
|
+
title: "Strikethrough",
|
|
2799
|
+
icon: "format_strikethrough",
|
|
2800
|
+
onClick: () => editorChain()?.toggleStrike().run(),
|
|
2801
|
+
}),
|
|
2802
|
+
toolbarSep(),
|
|
2803
|
+
colorSelect,
|
|
2804
|
+
toolbarSep(),
|
|
2805
|
+
toolbarBtn({
|
|
2806
|
+
title: "Lowercase",
|
|
2807
|
+
text: "aa",
|
|
2808
|
+
onClick: () => state.bodyEditor?.transformSelectionCase("lower"),
|
|
2809
|
+
}),
|
|
2810
|
+
toolbarBtn({
|
|
2811
|
+
title: "Uppercase",
|
|
2812
|
+
text: "AA",
|
|
2813
|
+
onClick: () => state.bodyEditor?.transformSelectionCase("upper"),
|
|
2814
|
+
}),
|
|
2815
|
+
toolbarBtn({
|
|
2816
|
+
title: "Title case",
|
|
2817
|
+
text: "Aa",
|
|
2818
|
+
onClick: () => state.bodyEditor?.transformSelectionCase("title"),
|
|
2819
|
+
}),
|
|
2820
|
+
]);
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
function coerceBooleanField(value, fallback) {
|
|
2824
|
+
if (value === true || value === "true") return true;
|
|
2825
|
+
if (value === false || value === "false") return false;
|
|
2826
|
+
if (fallback === true || fallback === "true") return true;
|
|
2827
|
+
if (fallback === false || fallback === "false") return false;
|
|
2828
|
+
return false;
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
function renderField(field, opts = {}) {
|
|
2832
|
+
const name = field.name;
|
|
2833
|
+
const target = opts.target || state.fields;
|
|
2834
|
+
const onChange = opts.onChange || (() => {
|
|
2835
|
+
markDirty();
|
|
2836
|
+
});
|
|
2837
|
+
const value = target[name] ?? field.default ?? "";
|
|
2838
|
+
const wrap = el("div", {
|
|
2839
|
+
className: "field" + (name === "title" ? " field--title" : ""),
|
|
2840
|
+
});
|
|
2841
|
+
const inputId = `field-${opts.idPrefix || ""}${name}`;
|
|
2842
|
+
|
|
2843
|
+
// Section name always on its own line (booleans included).
|
|
2844
|
+
wrap.append(
|
|
2845
|
+
el("label", {
|
|
2846
|
+
className: "field-heading",
|
|
2847
|
+
text: field.label || name,
|
|
2848
|
+
htmlFor: inputId,
|
|
2849
|
+
})
|
|
2850
|
+
);
|
|
2851
|
+
|
|
2852
|
+
if (field.type === "boolean") {
|
|
2853
|
+
const raw = Object.prototype.hasOwnProperty.call(target, name)
|
|
2854
|
+
? target[name]
|
|
2855
|
+
: field.default;
|
|
2856
|
+
const checked = coerceBooleanField(raw, field.default);
|
|
2857
|
+
const box = el("input", {
|
|
2858
|
+
id: inputId,
|
|
2859
|
+
type: "checkbox",
|
|
2860
|
+
className: "field-checkbox",
|
|
2861
|
+
checked,
|
|
2862
|
+
onChange: (e) => {
|
|
2863
|
+
target[name] = !!e.target.checked;
|
|
2864
|
+
onChange();
|
|
2865
|
+
},
|
|
2866
|
+
});
|
|
2867
|
+
const hint = fieldHint(field);
|
|
2868
|
+
wrap.classList.add("field--boolean");
|
|
2869
|
+
wrap.append(
|
|
2870
|
+
el("label", { className: "field-check-row", htmlFor: inputId }, [
|
|
2871
|
+
box,
|
|
2872
|
+
el("span", {
|
|
2873
|
+
className: "field-check-text",
|
|
2874
|
+
text: hint || "On",
|
|
2875
|
+
}),
|
|
2876
|
+
])
|
|
2877
|
+
);
|
|
2878
|
+
return wrap;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
if (field.type === "select" && field.options?.values) {
|
|
2882
|
+
const multiple = !!field.options.multiple;
|
|
2883
|
+
const select = el("select", {
|
|
2884
|
+
id: inputId,
|
|
2885
|
+
multiple,
|
|
2886
|
+
onChange: (e) => {
|
|
2887
|
+
if (multiple) {
|
|
2888
|
+
target[name] = [...e.target.selectedOptions].map((o) => o.value);
|
|
2889
|
+
} else {
|
|
2890
|
+
target[name] = e.target.value;
|
|
2891
|
+
}
|
|
2892
|
+
onChange();
|
|
2893
|
+
},
|
|
2894
|
+
});
|
|
2895
|
+
for (const opt of field.options.values) {
|
|
2896
|
+
const v = typeof opt === "object" ? opt.value : opt;
|
|
2897
|
+
const selected = multiple
|
|
2898
|
+
? String(value).includes(v) || (Array.isArray(value) && value.includes(v))
|
|
2899
|
+
: String(value) === String(v);
|
|
2900
|
+
select.append(el("option", { value: v, text: v, selected }));
|
|
2901
|
+
}
|
|
2902
|
+
wrap.append(select);
|
|
2903
|
+
} else if (field.type === "object" && field.list) {
|
|
2904
|
+
if (!Array.isArray(target[name])) target[name] = Array.isArray(value) ? value : [];
|
|
2905
|
+
wrap.append(
|
|
2906
|
+
renderObjectListEditor(field, target[name], {
|
|
2907
|
+
onChange: (next) => {
|
|
2908
|
+
target[name] = next;
|
|
2909
|
+
markDirty();
|
|
2910
|
+
renderShell();
|
|
2911
|
+
},
|
|
2912
|
+
idPrefix: `${opts.idPrefix || ""}${name}-`,
|
|
2913
|
+
})
|
|
2914
|
+
);
|
|
2915
|
+
} else if (field.list && (field.type === "string" || !field.type)) {
|
|
2916
|
+
const textValue = Array.isArray(value) ? value.join("\n") : String(value || "");
|
|
2917
|
+
wrap.append(
|
|
2918
|
+
el("textarea", {
|
|
2919
|
+
id: inputId,
|
|
2920
|
+
text: textValue,
|
|
2921
|
+
placeholder: "One item per line",
|
|
2922
|
+
onInput: (e) => {
|
|
2923
|
+
target[name] = e.target.value
|
|
2924
|
+
.split("\n")
|
|
2925
|
+
.map((s) => s.trim())
|
|
2926
|
+
.filter(Boolean);
|
|
2927
|
+
onChange();
|
|
2928
|
+
},
|
|
2929
|
+
})
|
|
2930
|
+
);
|
|
2931
|
+
} else if (field.type === "text" || field.type === "image") {
|
|
2932
|
+
wrap.append(
|
|
2933
|
+
el(field.type === "text" ? "textarea" : "input", {
|
|
2934
|
+
id: inputId,
|
|
2935
|
+
type: field.type === "image" ? "url" : undefined,
|
|
2936
|
+
value: value,
|
|
2937
|
+
text: field.type === "text" ? value : undefined,
|
|
2938
|
+
placeholder:
|
|
2939
|
+
field.type === "image"
|
|
2940
|
+
? "https://… or /media/images/…"
|
|
2941
|
+
: undefined,
|
|
2942
|
+
onInput: (e) => {
|
|
2943
|
+
target[name] = e.target.value;
|
|
2944
|
+
onChange();
|
|
2945
|
+
},
|
|
2946
|
+
})
|
|
2947
|
+
);
|
|
2948
|
+
} else {
|
|
2949
|
+
wrap.append(
|
|
2950
|
+
el("input", {
|
|
2951
|
+
id: inputId,
|
|
2952
|
+
type: field.type === "number" ? "number" : field.type === "date" ? "date" : "text",
|
|
2953
|
+
value: value,
|
|
2954
|
+
onInput: (e) => {
|
|
2955
|
+
target[name] = e.target.value;
|
|
2956
|
+
onChange();
|
|
2957
|
+
},
|
|
2958
|
+
})
|
|
2959
|
+
);
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
if (field.description) {
|
|
2963
|
+
const hint = fieldHint(field);
|
|
2964
|
+
if (hint) wrap.append(el("p", { className: "hint", text: hint }));
|
|
2965
|
+
}
|
|
2966
|
+
return wrap;
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
/** Nested list-of-objects (e.g. skills.technical). Mutates the live array. */
|
|
2970
|
+
function renderObjectListEditor(field, items, { onChange, idPrefix }) {
|
|
2971
|
+
const list = Array.isArray(items) ? items : [];
|
|
2972
|
+
const host = el("div", { className: "yaml-object-list" });
|
|
2973
|
+
const commitStructural = (next) => {
|
|
2974
|
+
onChange(next, { structural: true });
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2977
|
+
list.forEach((raw, index) => {
|
|
2978
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
2979
|
+
list[index] = {};
|
|
2980
|
+
}
|
|
2981
|
+
const item = list[index];
|
|
2982
|
+
const card = el("div", { className: "yaml-list-item" });
|
|
2983
|
+
card.append(
|
|
2984
|
+
el("div", { className: "yaml-list-item-head" }, [
|
|
2985
|
+
el("span", {
|
|
2986
|
+
className: "yaml-list-item-index",
|
|
2987
|
+
text: `${field.label || field.name} ${index + 1}`,
|
|
2988
|
+
}),
|
|
2989
|
+
el("div", { className: "yaml-list-item-actions" }, [
|
|
2990
|
+
el("button", {
|
|
2991
|
+
className: "btn yaml-list-btn",
|
|
2992
|
+
type: "button",
|
|
2993
|
+
text: "↑",
|
|
2994
|
+
title: "Move up",
|
|
2995
|
+
disabled: index === 0,
|
|
2996
|
+
onClick: () => {
|
|
2997
|
+
if (index === 0) return;
|
|
2998
|
+
const next = [...list];
|
|
2999
|
+
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
|
3000
|
+
commitStructural(next);
|
|
3001
|
+
},
|
|
3002
|
+
}),
|
|
3003
|
+
el("button", {
|
|
3004
|
+
className: "btn yaml-list-btn",
|
|
3005
|
+
type: "button",
|
|
3006
|
+
text: "↓",
|
|
3007
|
+
title: "Move down",
|
|
3008
|
+
disabled: index === list.length - 1,
|
|
3009
|
+
onClick: () => {
|
|
3010
|
+
if (index >= list.length - 1) return;
|
|
3011
|
+
const next = [...list];
|
|
3012
|
+
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
|
3013
|
+
commitStructural(next);
|
|
3014
|
+
},
|
|
3015
|
+
}),
|
|
3016
|
+
el("button", {
|
|
3017
|
+
className: "btn yaml-list-btn",
|
|
3018
|
+
type: "button",
|
|
3019
|
+
text: "Remove",
|
|
3020
|
+
onClick: () => {
|
|
3021
|
+
commitStructural(list.filter((_, i) => i !== index));
|
|
3022
|
+
},
|
|
3023
|
+
}),
|
|
3024
|
+
]),
|
|
3025
|
+
])
|
|
3026
|
+
);
|
|
3027
|
+
const sub = el("div", { className: "form-grid yaml-list-item-fields" });
|
|
3028
|
+
for (const subField of field.fields || []) {
|
|
3029
|
+
if (subField.hidden) continue;
|
|
3030
|
+
sub.append(
|
|
3031
|
+
renderField(subField, {
|
|
3032
|
+
target: item,
|
|
3033
|
+
idPrefix: `${idPrefix}${index}-`,
|
|
3034
|
+
onChange: () => {
|
|
3035
|
+
markDirty();
|
|
3036
|
+
},
|
|
3037
|
+
})
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
card.append(sub);
|
|
3041
|
+
host.append(card);
|
|
3042
|
+
});
|
|
3043
|
+
|
|
3044
|
+
host.append(
|
|
3045
|
+
el("button", {
|
|
3046
|
+
className: "btn",
|
|
3047
|
+
type: "button",
|
|
3048
|
+
text: `Add ${field.label || "item"}`,
|
|
3049
|
+
onClick: () => {
|
|
3050
|
+
commitStructural([...list, emptyItemFromFields(field.fields || [])]);
|
|
3051
|
+
},
|
|
3052
|
+
})
|
|
3053
|
+
);
|
|
3054
|
+
return host;
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
function renderPreviewPane() {
|
|
3058
|
+
const width =
|
|
3059
|
+
typeof window !== "undefined" && window.innerWidth
|
|
3060
|
+
? `${Math.round(window.innerWidth)}px`
|
|
3061
|
+
: "";
|
|
3062
|
+
const metaParts = [el("span", { className: "preview-pane-label", text: "Preview" })];
|
|
3063
|
+
if (width) {
|
|
3064
|
+
metaParts.push(
|
|
3065
|
+
el("span", { className: "preview-pane-sep", "aria-hidden": "true", text: "·" }),
|
|
3066
|
+
el("span", {
|
|
3067
|
+
className: "preview-pane-meta",
|
|
3068
|
+
text: `Viewport ${width}`,
|
|
3069
|
+
})
|
|
3070
|
+
);
|
|
3071
|
+
}
|
|
3072
|
+
return el("div", { className: "pane pane-preview" }, [
|
|
3073
|
+
el("div", { className: "preview-pane-head" }, metaParts),
|
|
3074
|
+
el("div", {
|
|
3075
|
+
className: "preview-folio",
|
|
3076
|
+
}, [
|
|
3077
|
+
el("div", {
|
|
3078
|
+
className: "preview-frame",
|
|
3079
|
+
id: "preview-frame",
|
|
3080
|
+
html: state.previewHtml,
|
|
3081
|
+
}),
|
|
3082
|
+
]),
|
|
3083
|
+
]);
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
function mountBodyEditor() {
|
|
3087
|
+
const host = $("#tiptap-host");
|
|
3088
|
+
if (!host) return;
|
|
3089
|
+
destroyBodyEditor();
|
|
3090
|
+
|
|
3091
|
+
// Preview tab keeps the Visual surface mounted on wide screens; skip TipTap on Source.
|
|
3092
|
+
if (state.bodyMode === "source") {
|
|
3093
|
+
host.hidden = true;
|
|
3094
|
+
return;
|
|
3095
|
+
}
|
|
3096
|
+
host.hidden = false;
|
|
3097
|
+
|
|
3098
|
+
const content =
|
|
3099
|
+
state.bodyMode === "preview" && state.lastEditorMode === "source"
|
|
3100
|
+
? state.body
|
|
3101
|
+
: state.body;
|
|
3102
|
+
|
|
3103
|
+
state.bodyEditor = createBodyEditor(host, {
|
|
3104
|
+
content,
|
|
3105
|
+
onUpdate: (md) => {
|
|
3106
|
+
state.body = md;
|
|
3107
|
+
markDirty();
|
|
3108
|
+
refreshPreview();
|
|
3109
|
+
},
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
function syncBodyFromEditor() {
|
|
3114
|
+
let next = null;
|
|
3115
|
+
if (state.bodyEditor && state.bodyMode !== "source") {
|
|
3116
|
+
next = state.bodyEditor.getMarkdown();
|
|
3117
|
+
}
|
|
3118
|
+
const source = $("#body-source");
|
|
3119
|
+
if (source && state.bodyMode === "source") {
|
|
3120
|
+
next = source.value;
|
|
3121
|
+
}
|
|
3122
|
+
if (next == null) return;
|
|
3123
|
+
const loaded = state.baseline?.body;
|
|
3124
|
+
if (typeof loaded === "string" && next === roundTripMarkdown(loaded)) {
|
|
3125
|
+
state.body = loaded;
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
state.body = next;
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
function destroyBodyEditor() {
|
|
3132
|
+
if (state.bodyEditor) {
|
|
3133
|
+
state.bodyEditor.destroy();
|
|
3134
|
+
state.bodyEditor = null;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
/** Debounced cite/xcite existence for side-preview pills (neutral → green/red). */
|
|
3139
|
+
const citeLookupTimers = new Map();
|
|
3140
|
+
const citeLookupStatus = new Map();
|
|
3141
|
+
const CITE_LOOKUP_MS = 1000;
|
|
3142
|
+
|
|
3143
|
+
async function citeKeyExists(kind, key) {
|
|
3144
|
+
const q = String(key || "").trim();
|
|
3145
|
+
if (!q) return false;
|
|
3146
|
+
try {
|
|
3147
|
+
const data =
|
|
3148
|
+
kind === "xcite"
|
|
3149
|
+
? await studioApi.searchXcite(q)
|
|
3150
|
+
: await studioApi.searchCite(q);
|
|
3151
|
+
const results = data?.results || [];
|
|
3152
|
+
const needle = q.toLowerCase();
|
|
3153
|
+
return results.some(
|
|
3154
|
+
(r) => String(r.key || "").trim().toLowerCase() === needle
|
|
3155
|
+
);
|
|
3156
|
+
} catch {
|
|
3157
|
+
return false;
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
function paintCitePill(el, status) {
|
|
3162
|
+
el.classList.remove("studio-ref-pill--ok", "studio-ref-pill--missing");
|
|
3163
|
+
if (status === "ok") el.classList.add("studio-ref-pill--ok");
|
|
3164
|
+
else if (status === "missing") el.classList.add("studio-ref-pill--missing");
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
function scheduleCiteLookups(frame) {
|
|
3168
|
+
if (!frame) return;
|
|
3169
|
+
const present = new Set();
|
|
3170
|
+
const byId = new Map();
|
|
3171
|
+
|
|
3172
|
+
for (const el of frame.querySelectorAll("[data-cite-check]")) {
|
|
3173
|
+
const kind = el.getAttribute("data-cite-check") || "cite";
|
|
3174
|
+
const key = String(el.getAttribute("data-key") || "").trim();
|
|
3175
|
+
const id = `${kind}:${key}`;
|
|
3176
|
+
present.add(id);
|
|
3177
|
+
if (!byId.has(id)) byId.set(id, []);
|
|
3178
|
+
byId.get(id).push(el);
|
|
3179
|
+
|
|
3180
|
+
if (!key) {
|
|
3181
|
+
paintCitePill(el, "missing");
|
|
3182
|
+
continue;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
const known = citeLookupStatus.get(id);
|
|
3186
|
+
if (known === "ok" || known === "missing") {
|
|
3187
|
+
paintCitePill(el, known);
|
|
3188
|
+
} else {
|
|
3189
|
+
paintCitePill(el, null);
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
for (const id of [...citeLookupStatus.keys()]) {
|
|
3194
|
+
if (!present.has(id)) {
|
|
3195
|
+
citeLookupStatus.delete(id);
|
|
3196
|
+
const t = citeLookupTimers.get(id);
|
|
3197
|
+
if (t) clearTimeout(t);
|
|
3198
|
+
citeLookupTimers.delete(id);
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
for (const [id, els] of byId) {
|
|
3203
|
+
const key = id.slice(id.indexOf(":") + 1);
|
|
3204
|
+
const kind = id.startsWith("xcite:") ? "xcite" : "cite";
|
|
3205
|
+
if (!key) continue;
|
|
3206
|
+
if (citeLookupStatus.get(id) === "ok" || citeLookupStatus.get(id) === "missing") {
|
|
3207
|
+
continue;
|
|
3208
|
+
}
|
|
3209
|
+
// Do not restart the timer on unrelated body edits — only on new/changed keys.
|
|
3210
|
+
if (citeLookupTimers.has(id)) continue;
|
|
3211
|
+
citeLookupTimers.set(
|
|
3212
|
+
id,
|
|
3213
|
+
setTimeout(async () => {
|
|
3214
|
+
citeLookupTimers.delete(id);
|
|
3215
|
+
const exists = await citeKeyExists(kind, key);
|
|
3216
|
+
const status = exists ? "ok" : "missing";
|
|
3217
|
+
citeLookupStatus.set(id, status);
|
|
3218
|
+
const root = $("#preview-frame");
|
|
3219
|
+
if (!root) return;
|
|
3220
|
+
for (const el of root.querySelectorAll(
|
|
3221
|
+
`[data-cite-check="${kind}"][data-key="${CSS.escape(key)}"]`
|
|
3222
|
+
)) {
|
|
3223
|
+
paintCitePill(el, status);
|
|
3224
|
+
}
|
|
3225
|
+
void els;
|
|
3226
|
+
}, CITE_LOOKUP_MS)
|
|
3227
|
+
);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
function refreshPreview() {
|
|
3232
|
+
const frame = $("#preview-frame");
|
|
3233
|
+
if (isYamlCollection()) {
|
|
3234
|
+
state.previewHtml = renderYamlPreviewHtml();
|
|
3235
|
+
} else {
|
|
3236
|
+
state.previewHtml = renderPreview(state.body || "");
|
|
3237
|
+
}
|
|
3238
|
+
if (frame) {
|
|
3239
|
+
// innerHTML wipe resets scroll; keep the reader's place while typing.
|
|
3240
|
+
const scrollTop = frame.scrollTop;
|
|
3241
|
+
const scrollLeft = frame.scrollLeft;
|
|
3242
|
+
frame.innerHTML = state.previewHtml;
|
|
3243
|
+
frame.scrollTop = scrollTop;
|
|
3244
|
+
frame.scrollLeft = scrollLeft;
|
|
3245
|
+
if (!isYamlCollection()) {
|
|
3246
|
+
scheduleCiteLookups(frame);
|
|
3247
|
+
void Promise.resolve(mountMermaidPreview(frame)).then(() => {
|
|
3248
|
+
// Mermaid layout can shift height after the sync restore above.
|
|
3249
|
+
if ($("#preview-frame") === frame) {
|
|
3250
|
+
frame.scrollTop = scrollTop;
|
|
3251
|
+
frame.scrollLeft = scrollLeft;
|
|
3252
|
+
}
|
|
3253
|
+
});
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
function escapePreviewText(value) {
|
|
3259
|
+
return String(value ?? "")
|
|
3260
|
+
.replace(/&/g, "&")
|
|
3261
|
+
.replace(/</g, "<")
|
|
3262
|
+
.replace(/>/g, ">");
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
function renderYamlPreviewHtml() {
|
|
3266
|
+
const col = state.collection;
|
|
3267
|
+
const kind = yamlEditKind(col);
|
|
3268
|
+
let text = "";
|
|
3269
|
+
try {
|
|
3270
|
+
if (state.yamlUiMode === "source" || kind === "source") {
|
|
3271
|
+
text = state.body || "";
|
|
3272
|
+
} else if (kind === "list") {
|
|
3273
|
+
text = dumpYaml(state.yamlList || []);
|
|
3274
|
+
} else if (kind === "map" && col.root_key) {
|
|
3275
|
+
const doc =
|
|
3276
|
+
state.yamlDoc && typeof state.yamlDoc === "object"
|
|
3277
|
+
? { ...state.yamlDoc }
|
|
3278
|
+
: {};
|
|
3279
|
+
doc[col.root_key] = pruneEmptyFields({ ...state.fields }, col.fields);
|
|
3280
|
+
text = dumpYaml(doc);
|
|
3281
|
+
} else if (kind === "map") {
|
|
3282
|
+
text = dumpYaml(pruneEmptyFields({ ...state.fields }, col.fields));
|
|
3283
|
+
} else {
|
|
3284
|
+
text = state.body || "";
|
|
3285
|
+
}
|
|
3286
|
+
} catch (err) {
|
|
3287
|
+
return `<p class="yaml-preview-error">${escapePreviewText(err.message || err)}</p>`;
|
|
3288
|
+
}
|
|
3289
|
+
return `<pre class="yaml-preview-pre">${escapePreviewText(text)}</pre>`;
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
let mermaidLoader = null;
|
|
3293
|
+
|
|
3294
|
+
function loadMermaid() {
|
|
3295
|
+
if (window.mermaid) return Promise.resolve(window.mermaid);
|
|
3296
|
+
if (mermaidLoader) return mermaidLoader;
|
|
3297
|
+
mermaidLoader = import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs")
|
|
3298
|
+
.then((mod) => {
|
|
3299
|
+
const mermaid = mod.default;
|
|
3300
|
+
window.mermaid = mermaid;
|
|
3301
|
+
mermaid.initialize({
|
|
3302
|
+
startOnLoad: false,
|
|
3303
|
+
theme: document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "default",
|
|
3304
|
+
securityLevel: "strict",
|
|
3305
|
+
});
|
|
3306
|
+
return mermaid;
|
|
3307
|
+
})
|
|
3308
|
+
.catch((err) => {
|
|
3309
|
+
mermaidLoader = null;
|
|
3310
|
+
throw err;
|
|
3311
|
+
});
|
|
3312
|
+
return mermaidLoader;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function mermaidFallback(node, message) {
|
|
3316
|
+
const source = node.dataset.mermaidSource || node.textContent || "";
|
|
3317
|
+
node.dataset.mermaidSource = source;
|
|
3318
|
+
node.className = "studio-mermaid-fallback";
|
|
3319
|
+
node.removeAttribute("data-processed");
|
|
3320
|
+
const errLine = message ? `Mermaid: ${message}\n\n` : "Mermaid (unrendered)\n\n";
|
|
3321
|
+
node.innerHTML = "";
|
|
3322
|
+
const pre = document.createElement("pre");
|
|
3323
|
+
pre.className = "studio-mermaid-fallback__pre";
|
|
3324
|
+
pre.textContent = errLine + source;
|
|
3325
|
+
node.append(pre);
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
/** Convert ```mermaid fences to diagrams; boxed monospace fallback on failure. */
|
|
3329
|
+
async function mountMermaidPreview(root) {
|
|
3330
|
+
if (!root) return;
|
|
3331
|
+
const nodes = [];
|
|
3332
|
+
root.querySelectorAll("pre code.language-mermaid").forEach((code) => {
|
|
3333
|
+
const source = code.textContent || "";
|
|
3334
|
+
const div = document.createElement("div");
|
|
3335
|
+
div.className = "mermaid studio-mermaid";
|
|
3336
|
+
div.dataset.mermaidSource = source;
|
|
3337
|
+
div.textContent = source;
|
|
3338
|
+
const pre = code.closest("pre");
|
|
3339
|
+
if (pre) pre.replaceWith(div);
|
|
3340
|
+
else code.replaceWith(div);
|
|
3341
|
+
nodes.push(div);
|
|
3342
|
+
});
|
|
3343
|
+
root
|
|
3344
|
+
.querySelectorAll(".mermaid[data-mermaid-source], .studio-mermaid-fallback[data-mermaid-source]")
|
|
3345
|
+
.forEach((node) => {
|
|
3346
|
+
if (!nodes.includes(node)) nodes.push(node);
|
|
3347
|
+
});
|
|
3348
|
+
if (!nodes.length) return;
|
|
3349
|
+
|
|
3350
|
+
let mermaid;
|
|
3351
|
+
try {
|
|
3352
|
+
mermaid = await loadMermaid();
|
|
3353
|
+
} catch (err) {
|
|
3354
|
+
for (const node of nodes) {
|
|
3355
|
+
mermaidFallback(node, err?.message || "failed to load Mermaid");
|
|
3356
|
+
}
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
mermaid.initialize({
|
|
3361
|
+
startOnLoad: false,
|
|
3362
|
+
theme: document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "default",
|
|
3363
|
+
securityLevel: "strict",
|
|
3364
|
+
});
|
|
3365
|
+
|
|
3366
|
+
for (const node of nodes) {
|
|
3367
|
+
const source = node.dataset.mermaidSource || "";
|
|
3368
|
+
node.className = "mermaid studio-mermaid";
|
|
3369
|
+
node.removeAttribute("data-processed");
|
|
3370
|
+
node.innerHTML = "";
|
|
3371
|
+
node.textContent = source;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
try {
|
|
3375
|
+
await mermaid.run({ nodes });
|
|
3376
|
+
} catch (err) {
|
|
3377
|
+
for (const node of nodes) {
|
|
3378
|
+
if (!node.querySelector("svg")) {
|
|
3379
|
+
mermaidFallback(node, err?.message || "render failed");
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
for (const node of nodes) {
|
|
3385
|
+
if (node.classList.contains("studio-mermaid-fallback")) continue;
|
|
3386
|
+
if (!node.querySelector("svg")) {
|
|
3387
|
+
mermaidFallback(node, "render produced no diagram");
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
function isWritingCollection(col = state.collection) {
|
|
3393
|
+
const name = col?.name;
|
|
3394
|
+
return name === "articles" || name === "posts";
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
/** Front-matter `published`; missing/true → published (export default). */
|
|
3398
|
+
function entryIsPublished(item) {
|
|
3399
|
+
const v = item?.published;
|
|
3400
|
+
if (v === false || v === "false") return false;
|
|
3401
|
+
return true;
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function publishStatusPill(item) {
|
|
3405
|
+
const published = entryIsPublished(item);
|
|
3406
|
+
return el("span", {
|
|
3407
|
+
className:
|
|
3408
|
+
"ledger-status " +
|
|
3409
|
+
(published ? "ledger-status--published" : "ledger-status--draft"),
|
|
3410
|
+
text: published ? "Published" : "Draft",
|
|
3411
|
+
title: published ? "Published (exported)" : "Draft (excluded from export)",
|
|
3412
|
+
});
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3415
|
+
async function selectCollection(col) {
|
|
3416
|
+
destroyBodyEditor();
|
|
3417
|
+
state.mediaOpen = false;
|
|
3418
|
+
state.mediaItems = [];
|
|
3419
|
+
state.collection = col;
|
|
3420
|
+
state.file = null;
|
|
3421
|
+
state.baseline = null;
|
|
3422
|
+
state.frontMatterOpen = null;
|
|
3423
|
+
state.items = [];
|
|
3424
|
+
state.insertMenuOpen = false;
|
|
3425
|
+
closeNavMenu();
|
|
3426
|
+
clearError();
|
|
3427
|
+
state.listLoading = col.type !== "file";
|
|
3428
|
+
state.fileLoading = col.type === "file";
|
|
3429
|
+
state.status = `Loading ${col.label}…`;
|
|
3430
|
+
renderShell();
|
|
3431
|
+
try {
|
|
3432
|
+
if (col.type === "file") {
|
|
3433
|
+
await openFile(col.path);
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3436
|
+
const cached = cachedTreeItems(col);
|
|
3437
|
+
if (cached) {
|
|
3438
|
+
state.items = cached;
|
|
3439
|
+
rememberNavCount(col, cached.length);
|
|
3440
|
+
state.listLoading = false;
|
|
3441
|
+
state.status = "";
|
|
3442
|
+
renderShell();
|
|
3443
|
+
return;
|
|
3444
|
+
}
|
|
3445
|
+
const data = await studioApi.tree(col.path);
|
|
3446
|
+
state.items = data.items || [];
|
|
3447
|
+
rememberTree(col, state.items);
|
|
3448
|
+
state.listLoading = false;
|
|
3449
|
+
// Entry count lives beside the list title — do not repeat it in the toolbar.
|
|
3450
|
+
state.status = "";
|
|
3451
|
+
} catch (err) {
|
|
3452
|
+
state.items = [];
|
|
3453
|
+
state.listLoading = false;
|
|
3454
|
+
captureError(err);
|
|
3455
|
+
}
|
|
3456
|
+
renderShell();
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3459
|
+
async function openFile(path) {
|
|
3460
|
+
destroyBodyEditor();
|
|
3461
|
+
state.insertMenuOpen = false;
|
|
3462
|
+
state.fileLoading = true;
|
|
3463
|
+
state.file = null;
|
|
3464
|
+
state.baseline = null;
|
|
3465
|
+
state.frontMatterOpen = null;
|
|
3466
|
+
state.status = `Opening ${path}…`;
|
|
3467
|
+
clearError();
|
|
3468
|
+
state.yamlDoc = null;
|
|
3469
|
+
state.yamlList = null;
|
|
3470
|
+
renderShell();
|
|
3471
|
+
try {
|
|
3472
|
+
const data = await studioApi.getFile(path);
|
|
3473
|
+
state.file = path;
|
|
3474
|
+
state.sha = data.sha;
|
|
3475
|
+
const split = splitDocument(data.content);
|
|
3476
|
+
const col = state.collection;
|
|
3477
|
+
if (col.format === "yaml") {
|
|
3478
|
+
state.body = data.content;
|
|
3479
|
+
const kind = yamlEditKind(col);
|
|
3480
|
+
if (kind === "list") {
|
|
3481
|
+
const doc = loadYaml(data.content);
|
|
3482
|
+
state.yamlList = asObjectList(doc);
|
|
3483
|
+
state.fields = {};
|
|
3484
|
+
state.yamlDoc = null;
|
|
3485
|
+
state.yamlUiMode = "structured";
|
|
3486
|
+
} else if (kind === "map") {
|
|
3487
|
+
if (col.root_key) {
|
|
3488
|
+
const doc = asObjectMap(loadYaml(data.content));
|
|
3489
|
+
state.yamlDoc = doc;
|
|
3490
|
+
state.fields = asObjectMap(doc[col.root_key]);
|
|
3491
|
+
} else {
|
|
3492
|
+
state.yamlDoc = null;
|
|
3493
|
+
state.fields = asObjectMap(loadYaml(data.content));
|
|
3494
|
+
}
|
|
3495
|
+
state.yamlList = null;
|
|
3496
|
+
state.yamlUiMode = "structured";
|
|
3497
|
+
} else {
|
|
3498
|
+
state.fields = {};
|
|
3499
|
+
state.yamlList = null;
|
|
3500
|
+
state.yamlUiMode = "source";
|
|
3501
|
+
}
|
|
3502
|
+
} else if (col.format === "raw") {
|
|
3503
|
+
state.body = data.content;
|
|
3504
|
+
state.fields = {};
|
|
3505
|
+
} else {
|
|
3506
|
+
state.fields = { ...split.fm };
|
|
3507
|
+
state.body = split.body;
|
|
3508
|
+
}
|
|
3509
|
+
state.dirty = false;
|
|
3510
|
+
state.fileLoading = false;
|
|
3511
|
+
state.listLoading = false;
|
|
3512
|
+
state.status = path;
|
|
3513
|
+
captureBaseline();
|
|
3514
|
+
} catch (err) {
|
|
3515
|
+
state.fileLoading = false;
|
|
3516
|
+
state.listLoading = false;
|
|
3517
|
+
state.baseline = null;
|
|
3518
|
+
captureError(err);
|
|
3519
|
+
}
|
|
3520
|
+
renderShell();
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
/** Deep-ish snapshot of the current entry for Discard. */
|
|
3524
|
+
function captureBaseline() {
|
|
3525
|
+
state.baseline = {
|
|
3526
|
+
fields: structuredClone(state.fields || {}),
|
|
3527
|
+
body: state.body,
|
|
3528
|
+
yamlDoc: state.yamlDoc ? structuredClone(state.yamlDoc) : null,
|
|
3529
|
+
yamlList: state.yamlList ? structuredClone(state.yamlList) : null,
|
|
3530
|
+
yamlUiMode: state.yamlUiMode,
|
|
3531
|
+
sha: state.sha,
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
/** Revert the open entry to the last loaded / saved baseline; stay on the page. */
|
|
3536
|
+
function discardChanges() {
|
|
3537
|
+
if (!state.file || !state.baseline) return;
|
|
3538
|
+
const b = state.baseline;
|
|
3539
|
+
destroyBodyEditor();
|
|
3540
|
+
state.fields = structuredClone(b.fields || {});
|
|
3541
|
+
state.body = b.body;
|
|
3542
|
+
state.yamlDoc = b.yamlDoc ? structuredClone(b.yamlDoc) : null;
|
|
3543
|
+
state.yamlList = b.yamlList ? structuredClone(b.yamlList) : null;
|
|
3544
|
+
state.yamlUiMode = b.yamlUiMode || "structured";
|
|
3545
|
+
state.sha = b.sha;
|
|
3546
|
+
state.dirty = false;
|
|
3547
|
+
state.insertMenuOpen = false;
|
|
3548
|
+
if (state.bodyMode === "preview") {
|
|
3549
|
+
/* keep preview tab */
|
|
3550
|
+
} else if (state.bodyMode !== "visual" && state.bodyMode !== "source") {
|
|
3551
|
+
state.bodyMode = state.lastEditorMode || "visual";
|
|
3552
|
+
}
|
|
3553
|
+
clearError();
|
|
3554
|
+
state.status = `Reverted ${state.file}`;
|
|
3555
|
+
renderShell();
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
async function saveCurrent() {
|
|
3559
|
+
if (!state.file || state.saving) return;
|
|
3560
|
+
syncBodyFromEditor();
|
|
3561
|
+
if (isYamlCollection()) {
|
|
3562
|
+
const source = $("#yaml-source");
|
|
3563
|
+
if (source) state.body = source.value;
|
|
3564
|
+
}
|
|
3565
|
+
state.saving = true;
|
|
3566
|
+
state.status = "Saving…";
|
|
3567
|
+
clearError();
|
|
3568
|
+
renderShell();
|
|
3569
|
+
try {
|
|
3570
|
+
let content;
|
|
3571
|
+
const col = state.collection;
|
|
3572
|
+
if (col.format === "raw") {
|
|
3573
|
+
content = state.body;
|
|
3574
|
+
} else if (col.format === "yaml") {
|
|
3575
|
+
const kind = yamlEditKind(col);
|
|
3576
|
+
const useSource =
|
|
3577
|
+
state.yamlUiMode === "source" || kind === "source";
|
|
3578
|
+
if (useSource) {
|
|
3579
|
+
// Validate before commit so we do not push broken YAML.
|
|
3580
|
+
loadYaml(state.body);
|
|
3581
|
+
content = state.body.endsWith("\n") ? state.body : state.body + "\n";
|
|
3582
|
+
} else if (kind === "list") {
|
|
3583
|
+
content = dumpYaml(state.yamlList || []);
|
|
3584
|
+
} else if (kind === "map" && col.root_key) {
|
|
3585
|
+
const doc =
|
|
3586
|
+
state.yamlDoc && typeof state.yamlDoc === "object"
|
|
3587
|
+
? { ...state.yamlDoc }
|
|
3588
|
+
: {};
|
|
3589
|
+
doc[col.root_key] = pruneEmptyFields(
|
|
3590
|
+
{ ...state.fields },
|
|
3591
|
+
col.fields
|
|
3592
|
+
);
|
|
3593
|
+
content = dumpYaml(doc);
|
|
3594
|
+
} else if (kind === "map") {
|
|
3595
|
+
content = dumpYaml(
|
|
3596
|
+
pruneEmptyFields({ ...state.fields }, col.fields)
|
|
3597
|
+
);
|
|
3598
|
+
} else {
|
|
3599
|
+
content = state.body.endsWith("\n") ? state.body : state.body + "\n";
|
|
3600
|
+
}
|
|
3601
|
+
} else {
|
|
3602
|
+
const original = (await studioApi.getFile(state.file)).content;
|
|
3603
|
+
const fields = { ...state.fields };
|
|
3604
|
+
content = rebuildDocument(original, {
|
|
3605
|
+
fields,
|
|
3606
|
+
body: state.body,
|
|
3607
|
+
format: col.format,
|
|
3608
|
+
});
|
|
3609
|
+
}
|
|
3610
|
+
if (!content.endsWith("\n")) content += "\n";
|
|
3611
|
+
const result = await studioApi.putFile({
|
|
3612
|
+
path: state.file,
|
|
3613
|
+
content,
|
|
3614
|
+
sha: state.sha,
|
|
3615
|
+
message: `studio: update ${state.file}`,
|
|
3616
|
+
});
|
|
3617
|
+
state.sha = result.content?.sha || state.sha;
|
|
3618
|
+
state.dirty = false;
|
|
3619
|
+
clearError();
|
|
3620
|
+
state.status = `Saved ${state.file}`;
|
|
3621
|
+
captureBaseline();
|
|
3622
|
+
// List thumbs/titles may have changed — drop session tree for this collection.
|
|
3623
|
+
if (col?.name) invalidateTreeCache(col.name);
|
|
3624
|
+
} catch (err) {
|
|
3625
|
+
captureError(err);
|
|
3626
|
+
} finally {
|
|
3627
|
+
state.saving = false;
|
|
3628
|
+
renderShell();
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
|
|
3632
|
+
async function bootApp(clerk) {
|
|
3633
|
+
try {
|
|
3634
|
+
state.session = await studioApi.session();
|
|
3635
|
+
} catch (err) {
|
|
3636
|
+
const code = err?.data?.error || "";
|
|
3637
|
+
if (code === "not_allowlisted") {
|
|
3638
|
+
renderPendingReviewGate(clerk, err?.data?.email || clerkUserEmail(clerk));
|
|
3639
|
+
return;
|
|
3640
|
+
}
|
|
3641
|
+
state.error = `Session failed: ${err.message}`;
|
|
3642
|
+
renderAuthGate(clerk);
|
|
3643
|
+
if (state.error) {
|
|
3644
|
+
app.append(el("p", { className: "status-pill error", text: state.error }));
|
|
3645
|
+
}
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
try {
|
|
3650
|
+
state.schema = await loadSchema();
|
|
3651
|
+
} catch (err) {
|
|
3652
|
+
state.error = String(err.message || err);
|
|
3653
|
+
app.innerHTML = "";
|
|
3654
|
+
app.append(el("p", { className: "auth-gate status-pill error", text: state.error }));
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
renderShell();
|
|
3659
|
+
|
|
3660
|
+
scheduleTreePrefetch();
|
|
3661
|
+
|
|
3662
|
+
if (clerk && !clerk.dev && clerk.addListener) {
|
|
3663
|
+
clerk.addListener(({ user }) => {
|
|
3664
|
+
if (!user) {
|
|
3665
|
+
destroyBodyEditor();
|
|
3666
|
+
renderAuthGate(clerk);
|
|
3667
|
+
}
|
|
3668
|
+
});
|
|
3669
|
+
}
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
async function main() {
|
|
3673
|
+
initTheme();
|
|
3674
|
+
const clerk = await initClerk();
|
|
3675
|
+
|
|
3676
|
+
if (clerk.dev) {
|
|
3677
|
+
await bootApp(clerk);
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
if (clerk.user) {
|
|
3682
|
+
await bootApp(clerk);
|
|
3683
|
+
} else {
|
|
3684
|
+
renderAuthGate(clerk);
|
|
3685
|
+
clerk.addListener(async ({ user }) => {
|
|
3686
|
+
if (user) await bootApp(clerk);
|
|
3687
|
+
});
|
|
3688
|
+
}
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
main().catch((err) => {
|
|
3692
|
+
app.textContent = String(err.stack || err);
|
|
3693
|
+
});
|