@jxsuite/studio 2.0.1 → 2.1.0
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.
- package/canvas.html +21 -0
- package/dist/chunks/Overlay.dev-qfjzkms8.js +23 -0
- package/dist/chunks/{Overlay.dev-cszx7yn5.js.map → Overlay.dev-qfjzkms8.js.map} +1 -1
- package/dist/chunks/{adapter-z8fkmbmv.js → adapter-qfnesn5v.js} +2 -2
- package/dist/chunks/{cf-settings-mmyv436d.js → cf-settings-73sh5z4p.js} +3 -3
- package/dist/chunks/{components-799jpvhm.js → components-2bpqp4mq.js} +5 -7
- package/dist/chunks/{components-799jpvhm.js.map → components-2bpqp4mq.js.map} +1 -1
- package/dist/chunks/content-source-34gnkbnn.js +38 -0
- package/dist/chunks/content-source-34gnkbnn.js.map +9 -0
- package/dist/chunks/convert-to-repeater-t8kxre5s.js +258 -0
- package/dist/chunks/convert-to-repeater-t8kxre5s.js.map +10 -0
- package/dist/chunks/extension-sections-6mqydz35.js +301 -0
- package/dist/chunks/extension-sections-6mqydz35.js.map +11 -0
- package/dist/chunks/files-8kn321h1.js +70 -0
- package/dist/chunks/files-8kn321h1.js.map +9 -0
- package/dist/chunks/{format-host-8jxwts2p.js → format-host-mkwsmt8d.js} +3 -3
- package/dist/chunks/git-panel-tjbr191d.js +51 -0
- package/dist/chunks/git-panel-tjbr191d.js.map +9 -0
- package/dist/chunks/{index-baeg2x3y.js → index-7pxchcce.js} +4 -3
- package/dist/chunks/{index-baeg2x3y.js.map → index-7pxchcce.js.map} +3 -3
- package/dist/chunks/{jx-validate-qx8degj7.js → jx-validate-v6qgknrs.js} +3 -3
- package/dist/chunks/layers-panel-87pb25km.js +58 -0
- package/dist/chunks/layers-panel-87pb25km.js.map +9 -0
- package/dist/chunks/{monaco-setup-zjdp9zdw.js → monaco-setup-e0yh25j4.js} +4 -75
- package/dist/chunks/{monaco-setup-zjdp9zdw.js.map → monaco-setup-e0yh25j4.js.map} +3 -4
- package/dist/chunks/publish-panel-m0fcgdrt.js +21 -0
- package/dist/chunks/publish-panel-m0fcgdrt.js.map +9 -0
- package/dist/chunks/quick-search-rhs6cbsb.js +53 -0
- package/dist/chunks/quick-search-rhs6cbsb.js.map +9 -0
- package/dist/chunks/right-panel-zpdxd98t.js +37 -0
- package/dist/chunks/right-panel-zpdxd98t.js.map +9 -0
- package/dist/chunks/section-registry-zmckaxf4.js +30 -0
- package/dist/chunks/section-registry-zmckaxf4.js.map +9 -0
- package/dist/chunks/sp-overlay.dev-9ran0sfq.js +18 -0
- package/dist/chunks/sp-overlay.dev-9ran0sfq.js.map +9 -0
- package/dist/chunks/sp-popover.dev-rebmdncr.js +9 -0
- package/dist/chunks/sp-popover.dev-rebmdncr.js.map +9 -0
- package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js +15 -0
- package/dist/chunks/sp-progress-circle.dev-5vkppfkt.js.map +9 -0
- package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js +15 -0
- package/dist/chunks/sp-tooltip.dev-ykqrg8h5.js.map +9 -0
- package/dist/chunks/{sp-tray.dev-pb2fsd65.js → sp-tray.dev-ey59vfh7.js} +10 -8
- package/dist/chunks/{sp-tray.dev-pb2fsd65.js.map → sp-tray.dev-ey59vfh7.js.map} +2 -2
- package/dist/chunks/{studio-9qr6fq7p.js → studio-0rqqhrdy.js} +10 -378
- package/dist/chunks/{studio-9qr6fq7p.js.map → studio-0rqqhrdy.js.map} +4 -14
- package/dist/chunks/{studio-5x6tqqec.js → studio-2sj1jj3v.js} +10 -8
- package/dist/chunks/{studio-5x6tqqec.js.map → studio-2sj1jj3v.js.map} +2 -2
- package/dist/chunks/{studio-h3fw0wn8.js → studio-3fk1ac9z.js} +6 -4
- package/dist/chunks/{studio-h3fw0wn8.js.map → studio-3fk1ac9z.js.map} +2 -2
- package/dist/chunks/studio-41exgzhq.js +6690 -0
- package/dist/chunks/studio-41exgzhq.js.map +38 -0
- package/dist/chunks/{studio-7v2bpweq.js → studio-4mqb9a0j.js} +6 -4
- package/dist/chunks/{studio-7v2bpweq.js.map → studio-4mqb9a0j.js.map} +2 -2
- package/dist/chunks/studio-50k77cca.js +1787 -0
- package/dist/chunks/studio-50k77cca.js.map +18 -0
- package/dist/chunks/{studio-56v3zwk6.js → studio-6a0h7sne.js} +10 -8
- package/dist/chunks/{studio-56v3zwk6.js.map → studio-6a0h7sne.js.map} +2 -2
- package/dist/chunks/studio-6f0am8f2.js +53 -0
- package/dist/chunks/studio-6f0am8f2.js.map +10 -0
- package/dist/chunks/studio-6nmjc5qk.js +7002 -0
- package/dist/chunks/studio-6nmjc5qk.js.map +32 -0
- package/dist/chunks/studio-7afd21nw.js +67 -0
- package/dist/chunks/studio-7afd21nw.js.map +10 -0
- package/dist/chunks/{studio-xze4zrs1.js → studio-7bmdvzne.js} +2 -2
- package/dist/chunks/studio-7bmdvzne.js.map +10 -0
- package/dist/chunks/studio-8jpzqk5s.js +128 -0
- package/dist/chunks/studio-8jpzqk5s.js.map +10 -0
- package/dist/chunks/studio-94m77tgk.js +1482 -0
- package/dist/chunks/studio-94m77tgk.js.map +13 -0
- package/dist/chunks/studio-9je6w5sq.js +1000 -0
- package/dist/chunks/studio-9je6w5sq.js.map +16 -0
- package/dist/chunks/{studio-w7c6kvk7.js → studio-a7pzveq1.js} +2 -2
- package/dist/chunks/{studio-gs6kqdt1.js → studio-adn0z1k3.js} +14 -8
- package/dist/chunks/{studio-gs6kqdt1.js.map → studio-adn0z1k3.js.map} +3 -3
- package/dist/chunks/{studio-kr88ryr7.js → studio-bsxxx3x2.js} +17 -15
- package/dist/chunks/{studio-kr88ryr7.js.map → studio-bsxxx3x2.js.map} +7 -6
- package/dist/chunks/studio-bw5dz6km.js +608 -0
- package/dist/chunks/studio-bw5dz6km.js.map +21 -0
- package/dist/chunks/studio-c6s5getc.js +4232 -0
- package/dist/chunks/studio-c6s5getc.js.map +39 -0
- package/dist/chunks/studio-cjm7fdjf.js +451 -0
- package/dist/chunks/studio-cjm7fdjf.js.map +14 -0
- package/dist/chunks/{studio-cx91mnh8.js → studio-ckwz4d3x.js} +53 -53
- package/dist/chunks/{studio-cx91mnh8.js.map → studio-ckwz4d3x.js.map} +5 -5
- package/dist/chunks/studio-dg7zyt5x.js +28 -0
- package/dist/chunks/studio-dg7zyt5x.js.map +10 -0
- package/dist/chunks/studio-dzcyvvvz.js +166 -0
- package/dist/chunks/studio-dzcyvvvz.js.map +10 -0
- package/dist/chunks/{studio-7kqe41sa.js → studio-evkr5d3b.js} +15 -7
- package/dist/chunks/{studio-7kqe41sa.js.map → studio-evkr5d3b.js.map} +5 -4
- package/dist/chunks/studio-f12h6177.js +411 -0
- package/dist/chunks/studio-f12h6177.js.map +13 -0
- package/dist/chunks/studio-hbp1s8m9.js +1817 -0
- package/dist/chunks/studio-hbp1s8m9.js.map +14 -0
- package/dist/chunks/studio-k2sxjph5.js +15295 -0
- package/dist/chunks/studio-k2sxjph5.js.map +235 -0
- package/dist/chunks/studio-myje51t3.js +385 -0
- package/dist/chunks/studio-myje51t3.js.map +12 -0
- package/dist/chunks/studio-na6rwzhw.js +353 -0
- package/dist/chunks/studio-na6rwzhw.js.map +11 -0
- package/dist/chunks/{studio-4k0m4fmp.js → studio-p471gg5n.js} +126 -3
- package/dist/chunks/studio-p471gg5n.js.map +14 -0
- package/dist/chunks/{studio-9bjwf0d4.js → studio-rgwcpast.js} +5 -46
- package/dist/chunks/{studio-9bjwf0d4.js.map → studio-rgwcpast.js.map} +4 -5
- package/dist/chunks/{studio-esrewxap.js → studio-s519zc99.js} +3 -3
- package/dist/chunks/studio-sas7mt3k.js +75 -0
- package/dist/chunks/studio-sas7mt3k.js.map +10 -0
- package/dist/chunks/studio-t5wqwzdh.js +74 -0
- package/dist/chunks/studio-t5wqwzdh.js.map +10 -0
- package/dist/chunks/{studio-9qpspwfn.js → studio-temv5bw4.js} +5 -5
- package/dist/chunks/{studio-9qpspwfn.js.map → studio-temv5bw4.js.map} +1 -1
- package/dist/chunks/{studio-qkvt37c0.js → studio-tr20m8g8.js} +669 -18
- package/dist/chunks/studio-tr20m8g8.js.map +9 -0
- package/dist/chunks/{studio-v7jkjqng.js → studio-v96qfnww.js} +5514 -4291
- package/dist/chunks/studio-v96qfnww.js.map +54 -0
- package/dist/chunks/studio-vcetc91k.js +659 -0
- package/dist/chunks/studio-vcetc91k.js.map +10 -0
- package/dist/chunks/{studio-jm0z592w.js → studio-vmymjefp.js} +2972 -4937
- package/dist/chunks/studio-vmymjefp.js.map +98 -0
- package/dist/chunks/{studio-bghqfmea.js → studio-wwseqyw0.js} +2 -2
- package/dist/chunks/studio-x9hmy5kr.js +390 -0
- package/dist/chunks/studio-x9hmy5kr.js.map +10 -0
- package/dist/chunks/studio-xa0ce0n7.js +335 -0
- package/dist/chunks/studio-xa0ce0n7.js.map +10 -0
- package/dist/chunks/studio-xm3dbxvn.js +378 -0
- package/dist/chunks/studio-xm3dbxvn.js.map +19 -0
- package/dist/chunks/{studio-nqw433kv.js → studio-xmvbhg31.js} +2 -2
- package/dist/chunks/studio-zv37yrfa.js +1054 -0
- package/dist/chunks/studio-zv37yrfa.js.map +13 -0
- package/dist/chunks/{ws-client-q7hkz3j9.js → ws-client-654ts66j.js} +6 -4
- package/dist/chunks/ws-client-654ts66j.js.map +11 -0
- package/dist/iframe-entry.js +668 -230
- package/dist/studio.js +21080 -32585
- package/index.html +35 -4685
- package/package.json +17 -15
- package/src/about/about-modal.ts +42 -11
- package/src/browse/library-commands.ts +240 -0
- package/src/browse/library-layouts.ts +369 -0
- package/src/browse/library-model.ts +396 -0
- package/src/browse/library-pane.ts +1049 -0
- package/src/browse/library-preview.ts +252 -0
- package/src/browse/library-source.ts +150 -0
- package/src/canvas/canvas-helpers.ts +33 -9
- package/src/canvas/canvas-live-render.ts +45 -29
- package/src/canvas/canvas-patcher.ts +151 -31
- package/src/canvas/canvas-perf.ts +46 -7
- package/src/canvas/canvas-render.ts +1092 -220
- package/src/canvas/canvas-surface.ts +240 -0
- package/src/canvas/canvas-utils.ts +1166 -124
- package/src/canvas/iframe-drop.ts +2 -1
- package/src/canvas/iframe-entry.ts +241 -4
- package/src/canvas/iframe-host.ts +1226 -143
- package/src/canvas/iframe-inline-edit.ts +31 -0
- package/src/canvas/iframe-interaction.ts +76 -16
- package/src/canvas/iframe-keys.ts +107 -45
- package/src/canvas/iframe-overlay.ts +38 -1
- package/src/canvas/iframe-protocol.ts +137 -2
- package/src/canvas/iframe-render.ts +70 -2
- package/src/canvas/iframe-slash.ts +6 -0
- package/src/canvas/path-mapping.ts +56 -6
- package/src/canvas/surface-registry.ts +292 -0
- package/src/collab/collab-commands.ts +244 -0
- package/src/collab/collab-session.ts +198 -32
- package/src/collab/collab-state.ts +28 -2
- package/src/collab/presence-chips.ts +100 -9
- package/src/commands/active-registry.ts +42 -0
- package/src/commands/app-commands.ts +176 -0
- package/src/commands/budget.ts +158 -0
- package/src/commands/command-args.ts +253 -0
- package/src/commands/context.ts +387 -0
- package/src/commands/defaults.ts +580 -0
- package/src/commands/keymap.ts +571 -0
- package/src/commands/levels.ts +345 -0
- package/src/commands/live-context.ts +193 -0
- package/src/commands/reference.ts +227 -0
- package/src/commands/registry.ts +293 -0
- package/src/component-props.ts +3 -2
- package/src/content/draft-state.ts +106 -0
- package/src/content/entry-commands.ts +282 -0
- package/src/content/entry-editor.ts +327 -0
- package/src/content/entry-fields.ts +116 -0
- package/src/content/entry-model.ts +175 -0
- package/src/editor/canvas-context-menu.ts +17 -11
- package/src/editor/canvas-slash-bridge.ts +11 -4
- package/src/editor/context-menu.ts +804 -204
- package/src/editor/convert-to-component.ts +31 -10
- package/src/editor/convert-to-repeater.ts +3 -2
- package/src/editor/inline-edit-apply.ts +5 -5
- package/src/editor/inline-edit.ts +86 -50
- package/src/editor/insert-zone-action.ts +1 -1
- package/src/editor/shortcuts.ts +1043 -329
- package/src/editor/slash-menu.ts +1 -0
- package/src/files/elements.ts +166 -0
- package/src/files/file-ops.ts +178 -31
- package/src/files/files.ts +898 -268
- package/src/files/fs-events.ts +21 -0
- package/src/files/media-meta.ts +203 -0
- package/src/files/media-paths.ts +141 -0
- package/src/files/media-upload.ts +7 -3
- package/src/files/media-usage.ts +189 -0
- package/src/github/github-auth.ts +235 -33
- package/src/github/github-publish.ts +80 -21
- package/src/grid/grid-controller.ts +152 -12
- package/src/grid/grid-layout.ts +225 -20
- package/src/grid/grid-open.ts +120 -0
- package/src/grid/grid-panel.ts +554 -39
- package/src/grid/grid-source.ts +27 -1
- package/src/grid/redirects-grid.ts +599 -0
- package/src/grid/redirects.ts +398 -0
- package/src/grid/sources/content-source.ts +108 -2
- package/src/i18n/i18n-commands.ts +322 -0
- package/src/new-project/add-repo-modal.ts +8 -10
- package/src/new-project/import-tab.ts +7 -0
- package/src/new-project/location-fields.ts +9 -1
- package/src/new-project/new-project-modal.ts +221 -245
- package/src/packages/jxsuite-update.ts +97 -48
- package/src/packages/pull-package-sync.ts +18 -9
- package/src/page-params.ts +2 -1
- package/src/panels/activity-bar.ts +152 -91
- package/src/panels/activity-panel.ts +481 -0
- package/src/panels/ai-chat/chat-markdown.ts +8 -3
- package/src/panels/ai-chat/chat-view.ts +245 -18
- package/src/panels/ai-chat/composer.ts +31 -5
- package/src/panels/ai-chat/sessions-view.ts +17 -10
- package/src/panels/ai-panel.ts +279 -53
- package/src/panels/block-action-bar.ts +1115 -224
- package/src/panels/bottom-dock.ts +337 -0
- package/src/panels/canvas-dnd-bridge.ts +37 -8
- package/src/panels/chat-panel.ts +29 -12
- package/src/panels/data-explorer.ts +254 -112
- package/src/panels/data-grid.ts +10 -4
- package/src/panels/dnd.ts +28 -51
- package/src/panels/editors.ts +434 -107
- package/src/panels/elements-panel.ts +66 -2
- package/src/panels/empty-state.ts +112 -0
- package/src/panels/events-panel.ts +596 -82
- package/src/panels/formula-workspace.ts +408 -218
- package/src/panels/frontmatter-fields.ts +51 -10
- package/src/panels/frontmatter-panel.ts +357 -79
- package/src/panels/git-panel.ts +375 -164
- package/src/panels/head-panel.ts +791 -85
- package/src/panels/i18n-panel.ts +592 -0
- package/src/panels/imports-panel.ts +95 -85
- package/src/panels/jump-bar.ts +584 -0
- package/src/panels/layers-panel.ts +1106 -328
- package/src/panels/left-panel.ts +109 -204
- package/src/panels/navigator-panels.ts +131 -0
- package/src/panels/overlays.ts +7 -2
- package/src/panels/pane-context.ts +1477 -0
- package/src/panels/pane-grid.ts +406 -0
- package/src/panels/panel-registry.ts +340 -0
- package/src/panels/panel-scheduler.ts +75 -0
- package/src/panels/problems-panel.ts +234 -0
- package/src/panels/properties-panel.ts +946 -1139
- package/src/panels/provenance.ts +258 -0
- package/src/panels/quick-search.ts +791 -122
- package/src/panels/right-panel.ts +228 -102
- package/src/panels/seo-modal.ts +395 -0
- package/src/panels/settings-pane.ts +203 -0
- package/src/panels/signals-panel.ts +354 -137
- package/src/panels/statement-editor.ts +41 -25
- package/src/panels/statusbar.ts +342 -106
- package/src/panels/style-panel.ts +974 -463
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-doc.ts +4 -2
- package/src/panels/stylebook-layers-panel.ts +3 -2
- package/src/panels/stylebook-panel.ts +51 -26
- package/src/panels/tab-strip.ts +1058 -78
- package/src/panels/target-line.ts +274 -0
- package/src/panels/toolbar.ts +631 -484
- package/src/panels/welcome-screen.ts +301 -198
- package/src/platform-errors.ts +26 -3
- package/src/platform.ts +96 -2
- package/src/platforms/cloud.ts +71 -8
- package/src/platforms/devserver.ts +92 -29
- package/src/publish/deploy-checklist.ts +383 -0
- package/src/publish/pages-service.ts +10 -1
- package/src/publish/publish-commands.ts +218 -0
- package/src/publish/publish-panel.ts +110 -16
- package/src/recent-projects.ts +3 -2
- package/src/services/a11y-report.ts +505 -0
- package/src/services/ai-models.ts +30 -1
- package/src/services/ai-project-tools.ts +29 -10
- package/src/services/ai-system-prompt.ts +53 -14
- package/src/services/ai-tools.ts +39 -3
- package/src/services/ai-writes.ts +154 -0
- package/src/services/announce.ts +97 -0
- package/src/services/automation.ts +424 -175
- package/src/services/cem-export.ts +9 -4
- package/src/services/clock.ts +61 -0
- package/src/services/document-assistant.ts +36 -8
- package/src/services/idle.ts +179 -0
- package/src/services/import-client.ts +26 -0
- package/src/services/monaco-buffer.ts +627 -0
- package/src/services/monaco-lazy.ts +36 -0
- package/src/services/notify.ts +271 -0
- package/src/services/profile.ts +155 -0
- package/src/services/references.ts +0 -0
- package/src/services/token-lint.ts +3 -2
- package/src/services/tool-executor.ts +53 -7
- package/src/services/trusted-types.ts +123 -0
- package/src/settings/contexts-section.ts +427 -0
- package/src/settings/contributed-section.ts +149 -11
- package/src/settings/css-vars-editor.ts +306 -349
- package/src/settings/defs-editor.ts +93 -34
- package/src/settings/dependencies-editor.ts +7 -7
- package/src/settings/extension-sections.ts +37 -5
- package/src/settings/general-settings.ts +204 -121
- package/src/settings/locales-section.ts +347 -0
- package/src/settings/preferences-accounts.ts +140 -0
- package/src/settings/preferences-dialog.ts +620 -0
- package/src/settings/preferences-keymap.ts +270 -0
- package/src/settings/project-sections.ts +260 -0
- package/src/settings/section-registry.ts +169 -0
- package/src/settings/settings-document.ts +385 -0
- package/src/shell.ts +1549 -0
- package/src/site-context.ts +45 -20
- package/src/state.ts +14 -275
- package/src/store.ts +88 -43
- package/src/studio.ts +790 -346
- package/src/style/live-preview.ts +36 -0
- package/src/style/project-styles.ts +349 -0
- package/src/style/token-ref.ts +123 -0
- package/src/tabs/patch-ops.ts +16 -4
- package/src/tabs/project-config.ts +416 -0
- package/src/tabs/selection.ts +323 -0
- package/src/tabs/tab.ts +182 -47
- package/src/tabs/transact.ts +209 -28
- package/src/types.ts +68 -13
- package/src/ui/ai-credentials-form.ts +39 -35
- package/src/ui/ai-managed-connect.ts +7 -1
- package/src/ui/dynamic-slot.ts +224 -81
- package/src/ui/expression-editor.ts +71 -63
- package/src/ui/field-input.ts +4 -6
- package/src/ui/field-row.ts +215 -38
- package/src/ui/form-controls.ts +194 -7
- package/src/ui/layers.ts +492 -69
- package/src/ui/media-picker.ts +115 -25
- package/src/ui/panel-resize.ts +109 -91
- package/src/ui/progress-modal.ts +131 -50
- package/src/ui/regions.ts +462 -0
- package/src/ui/schema-form.ts +229 -45
- package/src/ui/spectrum.ts +24 -8
- package/src/ui/timing.ts +49 -0
- package/src/ui/value-selector.ts +20 -0
- package/src/ui/value-source.ts +348 -0
- package/src/ui/virtual-window.ts +400 -0
- package/src/utils/edit-display.ts +3 -2
- package/src/utils/inherited-style.ts +67 -37
- package/src/view.ts +64 -78
- package/src/workspace/pane-derive.ts +1258 -0
- package/src/workspace/session.ts +317 -0
- package/src/workspace/workspace.ts +1345 -23
- package/styles/canvas.css +653 -0
- package/styles/forced-colors.css +106 -0
- package/styles/inspector.css +1454 -0
- package/styles/overlays.css +2704 -0
- package/styles/panels.css +1701 -0
- package/styles/shell.css +1423 -0
- package/styles/tokens.css +151 -0
- package/dist/chunks/Overlay.dev-cszx7yn5.js +0 -22
- package/dist/chunks/extension-sections-qr3drkkw.js +0 -531
- package/dist/chunks/extension-sections-qr3drkkw.js.map +0 -12
- package/dist/chunks/files-j8a1t06k.js +0 -50
- package/dist/chunks/files-j8a1t06k.js.map +0 -9
- package/dist/chunks/git-panel-xtq5z81x.js +0 -28
- package/dist/chunks/git-panel-xtq5z81x.js.map +0 -9
- package/dist/chunks/layers-panel-ancn2wpq.js +0 -26
- package/dist/chunks/layers-panel-ancn2wpq.js.map +0 -9
- package/dist/chunks/settings-modal-17ttr96g.js +0 -29
- package/dist/chunks/settings-modal-17ttr96g.js.map +0 -9
- package/dist/chunks/sp-overlay.dev-earn5as8.js +0 -17
- package/dist/chunks/sp-overlay.dev-earn5as8.js.map +0 -9
- package/dist/chunks/sp-popover.dev-frxtfejn.js +0 -8
- package/dist/chunks/sp-popover.dev-frxtfejn.js.map +0 -9
- package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js +0 -14
- package/dist/chunks/sp-progress-circle.dev-7tvw34h8.js.map +0 -9
- package/dist/chunks/sp-tooltip.dev-a9fywesm.js +0 -21
- package/dist/chunks/sp-tooltip.dev-a9fywesm.js.map +0 -10
- package/dist/chunks/studio-0xgs7xrs.js +0 -429
- package/dist/chunks/studio-0xgs7xrs.js.map +0 -13
- package/dist/chunks/studio-4d8hzcz5.js +0 -3427
- package/dist/chunks/studio-4d8hzcz5.js.map +0 -26
- package/dist/chunks/studio-4k0m4fmp.js.map +0 -13
- package/dist/chunks/studio-9smfzqhc.js +0 -19
- package/dist/chunks/studio-9smfzqhc.js.map +0 -10
- package/dist/chunks/studio-jm0z592w.js.map +0 -115
- package/dist/chunks/studio-m2t4mdg5.js +0 -1854
- package/dist/chunks/studio-m2t4mdg5.js.map +0 -18
- package/dist/chunks/studio-n4p11dfy.js +0 -853
- package/dist/chunks/studio-n4p11dfy.js.map +0 -12
- package/dist/chunks/studio-qkvt37c0.js.map +0 -9
- package/dist/chunks/studio-sjh1wt1a.js +0 -3744
- package/dist/chunks/studio-sjh1wt1a.js.map +0 -22
- package/dist/chunks/studio-v7jkjqng.js.map +0 -46
- package/dist/chunks/studio-xq0t7n5t.js +0 -140
- package/dist/chunks/studio-xq0t7n5t.js.map +0 -10
- package/dist/chunks/studio-xze4zrs1.js.map +0 -10
- package/dist/chunks/studio-yfcstdzy.js +0 -399
- package/dist/chunks/studio-yfcstdzy.js.map +0 -12
- package/dist/chunks/studio-zp7jncs8.js +0 -469
- package/dist/chunks/studio-zp7jncs8.js.map +0 -14
- package/dist/chunks/ws-client-q7hkz3j9.js.map +0 -11
- package/src/browse/browse-modal.ts +0 -69
- package/src/browse/browse.ts +0 -904
- package/src/canvas/canvas-diff.ts +0 -216
- package/src/canvas/nested-site-style.ts +0 -58
- package/src/new-project/design-fields.ts +0 -264
- package/src/new-project/templates.ts +0 -61
- package/src/panels/tab-bar.ts +0 -548
- package/src/settings/settings-modal.ts +0 -203
- /package/dist/chunks/{adapter-z8fkmbmv.js.map → adapter-qfnesn5v.js.map} +0 -0
- /package/dist/chunks/{cf-settings-mmyv436d.js.map → cf-settings-73sh5z4p.js.map} +0 -0
- /package/dist/chunks/{format-host-8jxwts2p.js.map → format-host-mkwsmt8d.js.map} +0 -0
- /package/dist/chunks/{jx-validate-qx8degj7.js.map → jx-validate-v6qgknrs.js.map} +0 -0
- /package/dist/chunks/{studio-w7c6kvk7.js.map → studio-a7pzveq1.js.map} +0 -0
- /package/dist/chunks/{studio-esrewxap.js.map → studio-s519zc99.js.map} +0 -0
- /package/dist/chunks/{studio-bghqfmea.js.map → studio-wwseqyw0.js.map} +0 -0
- /package/dist/chunks/{studio-nqw433kv.js.map → studio-xmvbhg31.js.map} +0 -0
package/src/panels/head-panel.ts
CHANGED
|
@@ -11,14 +11,21 @@ import { renderFieldRow } from "../ui/field-row";
|
|
|
11
11
|
import { spTextArea, spTextField } from "../ui/field-input";
|
|
12
12
|
import { renderMediaPicker } from "../ui/media-picker";
|
|
13
13
|
import { projectState, renderOnly } from "../store";
|
|
14
|
-
import type { DirEntry } from "../types";
|
|
14
|
+
import type { DirEntry, JsonValue } from "../types";
|
|
15
15
|
import { activeTab } from "../workspace/workspace";
|
|
16
|
+
import { activeRegistry } from "../commands/active-registry";
|
|
17
|
+
import { clearProblems, notify } from "../services/notify";
|
|
18
|
+
import { renderEmptyState } from "./empty-state";
|
|
19
|
+
import { registerPanel } from "./panel-registry";
|
|
20
|
+
import { mutateUpdateFrontmatter, transact } from "../tabs/transact";
|
|
16
21
|
import { collectFmFields, renderFmField } from "./frontmatter-fields";
|
|
17
22
|
import { isGoogleFontEntry, isGoogleFontPreconnect } from "../utils/google-fonts";
|
|
18
|
-
import { invalidateLayoutCache } from "../site-context";
|
|
23
|
+
import { getEffectiveLayoutPath, invalidateLayoutCache, resolveLayoutDoc } from "../site-context";
|
|
19
24
|
import { getPlatform } from "../platform";
|
|
25
|
+
import { pageRoute } from "./tab-strip";
|
|
20
26
|
|
|
21
27
|
import type { JxHeadEntry, JxMutableNode } from "@jxsuite/schema/types";
|
|
28
|
+
import type { Tab } from "../tabs/tab";
|
|
22
29
|
import type { TemplateResult } from "lit-html";
|
|
23
30
|
|
|
24
31
|
interface MetaField {
|
|
@@ -51,20 +58,27 @@ async function loadLayoutEntries() {
|
|
|
51
58
|
layoutEntries = [];
|
|
52
59
|
}
|
|
53
60
|
renderOnly("leftPanel");
|
|
61
|
+
renderOnly("frontmatterPanel", "seoModal");
|
|
54
62
|
}
|
|
55
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Forget every cached fact about layouts this module holds — the picker's listing AND the effective
|
|
66
|
+
* layout's `$head`, because "the layouts changed" is one event and answering it in two halves is
|
|
67
|
+
* how a preview ends up attributing a deleted layout's description to the open page.
|
|
68
|
+
*/
|
|
56
69
|
export function invalidateLayoutPickerCache() {
|
|
57
70
|
layoutEntries = null;
|
|
71
|
+
invalidateLayoutHeadCache();
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
// ─── Field definitions ───────────────────────────────────────────────────
|
|
61
75
|
|
|
62
|
-
const PAGE_FIELDS: MetaField[] = [
|
|
76
|
+
export const PAGE_FIELDS: MetaField[] = [
|
|
63
77
|
{ attr: "name", key: "description", label: "Description" },
|
|
64
78
|
{ attr: "name", key: "viewport", label: "Viewport" },
|
|
65
79
|
];
|
|
66
80
|
|
|
67
|
-
const OG_FIELDS: MetaField[] = [
|
|
81
|
+
export const OG_FIELDS: MetaField[] = [
|
|
68
82
|
{ attr: "property", key: "og:title", label: "Title" },
|
|
69
83
|
{
|
|
70
84
|
attr: "property",
|
|
@@ -79,8 +93,17 @@ const OG_FIELDS: MetaField[] = [
|
|
|
79
93
|
/** Set of `name`/`property` values managed by the structured forms. */
|
|
80
94
|
const MANAGED_META_KEYS = new Set([...PAGE_FIELDS, ...OG_FIELDS].map((f) => f.key));
|
|
81
95
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Frontmatter keys the Document Header card owns with a dedicated control, so the generic field
|
|
98
|
+
* list must not print them a second time.
|
|
99
|
+
*
|
|
100
|
+
* **The one policy.** `frontmatter-panel.ts` used to pass an EMPTY reserved set while this module
|
|
101
|
+
* passed `{title}`, so the same key rendered as a bare Obsidian-style property above the canvas and
|
|
102
|
+
* as the Page panel's Title field at the same time, with two different commit paths. Merging the
|
|
103
|
+
* two field sets was only safe once one of the two policies won, and it is this one: a document has
|
|
104
|
+
* ONE title, and the surface that gives it a named row is the one that owns the key.
|
|
105
|
+
*/
|
|
106
|
+
export const RESERVED_FM_KEYS = new Set(["title"]);
|
|
84
107
|
|
|
85
108
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
86
109
|
|
|
@@ -106,7 +129,7 @@ function findMetaEntry(head: JxHeadEntry[], attr: "name" | "property", key: stri
|
|
|
106
129
|
* @param {string} rel
|
|
107
130
|
* @returns {JxHeadEntry | undefined}
|
|
108
131
|
*/
|
|
109
|
-
function findLinkEntry(head: JxHeadEntry[], rel: string) {
|
|
132
|
+
export function findLinkEntry(head: JxHeadEntry[], rel: string) {
|
|
110
133
|
if (!head) {
|
|
111
134
|
return;
|
|
112
135
|
}
|
|
@@ -119,7 +142,7 @@ function findLinkEntry(head: JxHeadEntry[], rel: string) {
|
|
|
119
142
|
* @param {JxHeadEntry} entry
|
|
120
143
|
* @returns {boolean}
|
|
121
144
|
*/
|
|
122
|
-
function isManagedEntry(entry: JxHeadEntry) {
|
|
145
|
+
export function isManagedEntry(entry: JxHeadEntry) {
|
|
123
146
|
if (!entry?.tagName) {
|
|
124
147
|
return false;
|
|
125
148
|
}
|
|
@@ -172,7 +195,7 @@ function upsertMeta(doc: JxMutableNode, attr: "name" | "property", key: string,
|
|
|
172
195
|
* @param {string} rel
|
|
173
196
|
* @param {string} href
|
|
174
197
|
*/
|
|
175
|
-
function upsertLink(doc: JxMutableNode, rel: string, href: string) {
|
|
198
|
+
export function upsertLink(doc: JxMutableNode, rel: string, href: string) {
|
|
176
199
|
if (!doc.$head) {
|
|
177
200
|
doc.$head = [];
|
|
178
201
|
}
|
|
@@ -197,7 +220,7 @@ function upsertLink(doc: JxMutableNode, rel: string, href: string) {
|
|
|
197
220
|
* @param {JxHeadEntry} entry
|
|
198
221
|
* @returns {string}
|
|
199
222
|
*/
|
|
200
|
-
function entryLabel(entry: JxHeadEntry) {
|
|
223
|
+
export function entryLabel(entry: JxHeadEntry) {
|
|
201
224
|
if (!entry?.tagName) {
|
|
202
225
|
return "unknown";
|
|
203
226
|
}
|
|
@@ -226,11 +249,528 @@ function entryLabel(entry: JxHeadEntry) {
|
|
|
226
249
|
* @param {JxHeadEntry} entry
|
|
227
250
|
* @returns {string}
|
|
228
251
|
*/
|
|
229
|
-
function entryValue(entry: JxHeadEntry) {
|
|
252
|
+
export function entryValue(entry: JxHeadEntry) {
|
|
230
253
|
const a = entry?.attributes ?? {};
|
|
231
254
|
return String(a.content ?? a.href ?? a.src ?? entry?.textContent ?? "");
|
|
232
255
|
}
|
|
233
256
|
|
|
257
|
+
// ─── The merged `$head`, as a preview model ──────────────────────────────
|
|
258
|
+
|
|
259
|
+
/*
|
|
260
|
+
* Everything below answers ONE question — what actually reaches the browser? — and it is a
|
|
261
|
+
* different question from what this panel's fields edit. A page that inherits its description from
|
|
262
|
+
* the site is not a page missing a description, and the old SEO gap was that no surface could tell
|
|
263
|
+
* the two apart. The contract mirrored here is `packages/compiler/src/site/head-merger.ts`:
|
|
264
|
+
* site → layout → page, later wins, keyed by `name` / `property` / `rel`. Studio does not depend
|
|
265
|
+
* on `@jxsuite/compiler`, so the merge is restated for the handful of keys the previews read
|
|
266
|
+
* rather than copied wholesale; `tests/head-panel.test.ts` pins each rule against that file.
|
|
267
|
+
*/
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Which layer of the cascade supplied a previewed value.
|
|
271
|
+
*
|
|
272
|
+
* `build` is the compiler's own contribution — the `"Jx Site"` title fallback, the canonical link
|
|
273
|
+
* and `og:url` it derives from the site URL and the route. It is a donor like any other, and saying
|
|
274
|
+
* so is what stops the preview from claiming the author wrote something they did not.
|
|
275
|
+
*/
|
|
276
|
+
export type HeadSource = "page" | "layout" | "site" | "build" | "none";
|
|
277
|
+
|
|
278
|
+
/** One value that reaches the browser, and where it came from. */
|
|
279
|
+
export interface ResolvedHeadField {
|
|
280
|
+
/** The emitted value, or `""` when the merged head carries none. */
|
|
281
|
+
value: string;
|
|
282
|
+
source: HeadSource;
|
|
283
|
+
/**
|
|
284
|
+
* The donor's name in the words a reader would use — `"Base"`, `"Site head"`, `"Site name"`,
|
|
285
|
+
* `"the build"`. `null` for `page` (nothing to name; the author is looking at it) and `none`.
|
|
286
|
+
*/
|
|
287
|
+
donor: string | null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The three `$head` arrays the build merges, plus the name of the layout supplying the middle one. */
|
|
291
|
+
export interface HeadLayers {
|
|
292
|
+
site: JxHeadEntry[];
|
|
293
|
+
layout: JxHeadEntry[];
|
|
294
|
+
page: JxHeadEntry[];
|
|
295
|
+
layoutName: string | null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The title the build emits when neither the page nor the site names one (`head-merger.ts`). */
|
|
299
|
+
export const BUILD_FALLBACK_TITLE = "Jx Site";
|
|
300
|
+
|
|
301
|
+
const NOT_SUPPLIED: ResolvedHeadField = { donor: null, source: "none", value: "" };
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The `content` of the LAST matching meta entry in one layer, or `null` when the layer is silent.
|
|
305
|
+
*
|
|
306
|
+
* Last rather than first, because the merger folds a layer into a keyed map in array order: a layer
|
|
307
|
+
* that lists `description` twice emits the second one.
|
|
308
|
+
*/
|
|
309
|
+
function metaContentIn(head: JxHeadEntry[], attr: "name" | "property", key: string): string | null {
|
|
310
|
+
let found: string | null = null;
|
|
311
|
+
for (const entry of head) {
|
|
312
|
+
if (entry?.tagName === "meta" && entry?.attributes?.[attr] === key) {
|
|
313
|
+
found = String(entry.attributes?.content ?? "");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return found;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Resolve one meta key through site → layout → page.
|
|
321
|
+
*
|
|
322
|
+
* A page entry whose `content` is empty still counts as the page speaking: the merged map is keyed
|
|
323
|
+
* by `meta:<key>`, so an empty page entry SHADOWS the site's. The preview reports the empty result,
|
|
324
|
+
* which is what the browser gets.
|
|
325
|
+
*
|
|
326
|
+
* @param {HeadLayers} layers
|
|
327
|
+
* @param {"name" | "property"} attr
|
|
328
|
+
* @param {string} key
|
|
329
|
+
* @returns {ResolvedHeadField}
|
|
330
|
+
*/
|
|
331
|
+
export function resolveMetaField(
|
|
332
|
+
layers: HeadLayers,
|
|
333
|
+
attr: "name" | "property",
|
|
334
|
+
key: string,
|
|
335
|
+
): ResolvedHeadField {
|
|
336
|
+
const page = metaContentIn(layers.page, attr, key);
|
|
337
|
+
if (page !== null) {
|
|
338
|
+
return { donor: null, source: "page", value: page };
|
|
339
|
+
}
|
|
340
|
+
const layout = metaContentIn(layers.layout, attr, key);
|
|
341
|
+
if (layout !== null) {
|
|
342
|
+
return { donor: layers.layoutName ?? "the layout", source: "layout", value: layout };
|
|
343
|
+
}
|
|
344
|
+
const site = metaContentIn(layers.site, attr, key);
|
|
345
|
+
if (site !== null) {
|
|
346
|
+
return { donor: "Site head", source: "site", value: site };
|
|
347
|
+
}
|
|
348
|
+
return NOT_SUPPLIED;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Resolve `<title>`: the page's `title` property, then the site's `name`, then `"Jx Site"`.
|
|
353
|
+
*
|
|
354
|
+
* A layout never supplies one. `site/site-build.ts` reads `pageDoc.title ?? layoutDoc._pageTitle`,
|
|
355
|
+
* and `_pageTitle` is a carrier for the PAGE's title through layout distribution, not a layout
|
|
356
|
+
* value of its own. A `<title>` entry inside any `$head` does not supply one either — the merger
|
|
357
|
+
* overwrites the `title` key from its context after the layers are folded in, which is what
|
|
358
|
+
* {@link seoWarnings}' `head-title-ignored` exists to say out loud.
|
|
359
|
+
*
|
|
360
|
+
* @param {string} pageTitle
|
|
361
|
+
* @param {string} [siteName]
|
|
362
|
+
* @returns {ResolvedHeadField}
|
|
363
|
+
*/
|
|
364
|
+
export function resolveTitleField(pageTitle: string, siteName?: string): ResolvedHeadField {
|
|
365
|
+
const own = pageTitle.trim();
|
|
366
|
+
if (own) {
|
|
367
|
+
return { donor: null, source: "page", value: own };
|
|
368
|
+
}
|
|
369
|
+
const site = siteName?.trim();
|
|
370
|
+
if (site) {
|
|
371
|
+
return { donor: "Site name", source: "site", value: site };
|
|
372
|
+
}
|
|
373
|
+
return { donor: "the build", source: "build", value: BUILD_FALLBACK_TITLE };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** The URL a result row prints, and the canonical the build derives — or the honest absence of one. */
|
|
377
|
+
export interface SeoUrl {
|
|
378
|
+
/** `<link rel="canonical">` / `og:url`, or `null` when Project Settings names no site URL. */
|
|
379
|
+
href: string | null;
|
|
380
|
+
/** `example.com › blog › hello` — the breadcrumb a search result prints. */
|
|
381
|
+
crumb: string;
|
|
382
|
+
/** The bare host a social card prints, or `""` when unknown. */
|
|
383
|
+
host: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The canonical URL and its two printed forms.
|
|
388
|
+
*
|
|
389
|
+
* `head-merger.ts` emits a canonical link and `og:url` only when BOTH a site URL and a route exist,
|
|
390
|
+
* so this returns `href: null` in every case where the build would emit neither.
|
|
391
|
+
*
|
|
392
|
+
* @param {string | null} route
|
|
393
|
+
* @param {string} [siteUrl]
|
|
394
|
+
* @returns {SeoUrl}
|
|
395
|
+
*/
|
|
396
|
+
export function resolveSeoUrl(route: string | null, siteUrl?: string): SeoUrl {
|
|
397
|
+
const path = route ?? "";
|
|
398
|
+
const segments = path.split("/").filter((s) => s !== "");
|
|
399
|
+
if (!siteUrl || route === null) {
|
|
400
|
+
return { crumb: path || "/", host: "", href: null };
|
|
401
|
+
}
|
|
402
|
+
let absolute: URL;
|
|
403
|
+
try {
|
|
404
|
+
absolute = new URL(path, siteUrl);
|
|
405
|
+
} catch {
|
|
406
|
+
// A malformed `url` in project.json is a settings problem, not a preview crash — fall back to
|
|
407
|
+
// The route, exactly as a build with no site URL would.
|
|
408
|
+
return { crumb: path || "/", host: "", href: null };
|
|
409
|
+
}
|
|
410
|
+
const { host } = absolute;
|
|
411
|
+
return {
|
|
412
|
+
crumb: [host, ...segments].join(" › "),
|
|
413
|
+
host,
|
|
414
|
+
href: absolute.href,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** One row of the resolved-field list: the value, its provenance, and its character budget. */
|
|
419
|
+
export interface SeoField extends ResolvedHeadField {
|
|
420
|
+
/** The head key — `title`, `description`, `og:image`, … */
|
|
421
|
+
key: string;
|
|
422
|
+
label: string;
|
|
423
|
+
/** The width at which the surface that shows it truncates, or `null` when nobody counts it. */
|
|
424
|
+
limit: number | null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Character budgets, in the only role a number is allowed here: a counter's reference line.
|
|
429
|
+
*
|
|
430
|
+
* There is deliberately no score. A single figure out of a hundred aggregates unrelated facts into
|
|
431
|
+
* a verdict, and the verdict is what gets optimised (plan §14). A count beside a limit says the
|
|
432
|
+
* same thing without pretending to rank it.
|
|
433
|
+
*/
|
|
434
|
+
export const SEO_LIMITS: Readonly<Record<string, number>> = {
|
|
435
|
+
description: 160,
|
|
436
|
+
"og:description": 200,
|
|
437
|
+
"og:title": 60,
|
|
438
|
+
title: 60,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/** Counts what a reader sees as one character. See {@link visibleLength}. */
|
|
442
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter("en-US", { granularity: "grapheme" });
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* How long a value looks, in the units the person reading the counter has in mind.
|
|
446
|
+
*
|
|
447
|
+
* `String.length` counts UTF-16 code units, which is not a count of anything a person can see: an
|
|
448
|
+
* emoji is 2, a flag is 4, and "👩🚀" is 5 — so a title with three emoji reported eleven
|
|
449
|
+
* characters of budget spent on three glyphs, and the warning beside it was wrong by the same
|
|
450
|
+
* margin. Counting **grapheme clusters** (UAX #29) counts what renders.
|
|
451
|
+
*
|
|
452
|
+
* **What this deliberately does not do is model pixel width.** "iiiii" and "WWWWW" are both five,
|
|
453
|
+
* and a search result truncates on width — but width depends on the font the search engine chooses,
|
|
454
|
+
* at a size it chooses, and any table of per-character widths here would be a fabricated number
|
|
455
|
+
* presented as a measurement. A character count next to a documented limit is an honest
|
|
456
|
+
* approximation; a pixel count would not be.
|
|
457
|
+
*
|
|
458
|
+
* @param {string} value
|
|
459
|
+
* @returns {number}
|
|
460
|
+
*/
|
|
461
|
+
export function visibleLength(value: string): number {
|
|
462
|
+
let count = 0;
|
|
463
|
+
for (const _ of GRAPHEME_SEGMENTER.segment(value)) {
|
|
464
|
+
count += 1;
|
|
465
|
+
}
|
|
466
|
+
return count;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** A named thing that is wrong, or absent. Never summed — the list IS the report. */
|
|
470
|
+
export interface SeoWarning {
|
|
471
|
+
/** Stable id, so a test and a shot name the same warning. */
|
|
472
|
+
id: string;
|
|
473
|
+
/** The head key the warning is about, so the field row can carry it. */
|
|
474
|
+
field: string;
|
|
475
|
+
message: string;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The Problems `source` the SEO warnings carry, so a re-run replaces rather than stacks. */
|
|
479
|
+
export const SEO_PROBLEM_SOURCE = "Search appearance";
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* File the SEO warnings as Problems as well as rendering them in the modal.
|
|
483
|
+
*
|
|
484
|
+
* The modal is a window someone has to open. A page shipped with no description is a fact worth
|
|
485
|
+
* knowing whether or not you thought to look, and Problems is where this app already keeps facts
|
|
486
|
+
* that outlive the frame you were not watching — which is the same argument the accessibility
|
|
487
|
+
* report makes, and the redirects checks before it.
|
|
488
|
+
*
|
|
489
|
+
* Cleared first, so a fixed page stops being listed; keyed by warning id, so the two surfaces name
|
|
490
|
+
* the same thing.
|
|
491
|
+
*
|
|
492
|
+
* @param {SeoPreview} preview
|
|
493
|
+
* @param {string} [path] The document's path, so a row can open the file it is about.
|
|
494
|
+
* @returns {number} How many Problems were filed.
|
|
495
|
+
*/
|
|
496
|
+
export function reportSeoProblems(preview: SeoPreview, path?: string): number {
|
|
497
|
+
clearProblems((record) => record.source === SEO_PROBLEM_SOURCE);
|
|
498
|
+
for (const warning of preview.warnings) {
|
|
499
|
+
notify.warn(warning.message, {
|
|
500
|
+
action: "document.openSeo",
|
|
501
|
+
key: `seo.${warning.id}`,
|
|
502
|
+
source: SEO_PROBLEM_SOURCE,
|
|
503
|
+
tier: "problem",
|
|
504
|
+
...(path === undefined ? {} : { path }),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return preview.warnings.length;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Everything the two preview cards, the field list and the warning list render. */
|
|
511
|
+
export interface SeoPreview {
|
|
512
|
+
url: SeoUrl;
|
|
513
|
+
/** In render order: `title`, `description`, then the four OpenGraph keys. */
|
|
514
|
+
fields: SeoField[];
|
|
515
|
+
warnings: SeoWarning[];
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Look one field up by key — the previews read four of the six by name. */
|
|
519
|
+
export function seoField(preview: SeoPreview, key: string): SeoField {
|
|
520
|
+
return (
|
|
521
|
+
preview.fields.find((f) => f.key === key) ?? {
|
|
522
|
+
...NOT_SUPPLIED,
|
|
523
|
+
key,
|
|
524
|
+
label: key,
|
|
525
|
+
limit: SEO_LIMITS[key] ?? null,
|
|
526
|
+
}
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Whether any layer declares a `<title>` element, which the merger discards. */
|
|
531
|
+
function hasTitleEntry(layers: HeadLayers): boolean {
|
|
532
|
+
return [...layers.site, ...layers.layout, ...layers.page].some((e) => e?.tagName === "title");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* The named warnings, in the order they are rendered.
|
|
537
|
+
*
|
|
538
|
+
* Every one of them states a consequence rather than a grade, and every one is checkable against
|
|
539
|
+
* `head-merger.ts`. "Missing" is decided on the MERGED value, so a page that inherits a description
|
|
540
|
+
* from the site is never told it has none — the defect §9.2 names by name.
|
|
541
|
+
*
|
|
542
|
+
* @param {SeoPreview} preview — `warnings` is ignored; this computes it.
|
|
543
|
+
* @param {HeadLayers} layers
|
|
544
|
+
* @returns {SeoWarning[]}
|
|
545
|
+
*/
|
|
546
|
+
export function seoWarnings(
|
|
547
|
+
preview: Omit<SeoPreview, "warnings">,
|
|
548
|
+
layers: HeadLayers,
|
|
549
|
+
): SeoWarning[] {
|
|
550
|
+
const warnings: SeoWarning[] = [];
|
|
551
|
+
const get = (key: string) => preview.fields.find((f) => f.key === key);
|
|
552
|
+
|
|
553
|
+
const title = get("title");
|
|
554
|
+
if (title?.source === "build") {
|
|
555
|
+
warnings.push({
|
|
556
|
+
field: "title",
|
|
557
|
+
id: "title-missing",
|
|
558
|
+
message: `Neither this page nor the project names a title, so the build ships “${BUILD_FALLBACK_TITLE}”.`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
if (get("description")?.value.trim() === "") {
|
|
562
|
+
warnings.push({
|
|
563
|
+
field: "description",
|
|
564
|
+
id: "description-missing",
|
|
565
|
+
message:
|
|
566
|
+
"No description reaches this page from the site, its layout or the page itself — a " +
|
|
567
|
+
"result row shows whatever text the engine picks instead.",
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (get("og:title")?.value.trim() === "") {
|
|
571
|
+
warnings.push({
|
|
572
|
+
field: "og:title",
|
|
573
|
+
id: "og-title-missing",
|
|
574
|
+
message: "No og:title — a shared link carries no headline of its own.",
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (get("og:description")?.value.trim() === "") {
|
|
578
|
+
warnings.push({
|
|
579
|
+
field: "og:description",
|
|
580
|
+
id: "og-description-missing",
|
|
581
|
+
message: "No og:description — a shared link carries no summary of its own.",
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
if (get("og:image")?.value.trim() === "") {
|
|
585
|
+
warnings.push({
|
|
586
|
+
field: "og:image",
|
|
587
|
+
id: "og-image-missing",
|
|
588
|
+
message: "No og:image — a shared link renders as a text-only card.",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
for (const field of preview.fields) {
|
|
592
|
+
const length = visibleLength(field.value);
|
|
593
|
+
if (field.limit !== null && length > field.limit) {
|
|
594
|
+
const kind = field.key.endsWith("description") ? "summaries" : "headlines";
|
|
595
|
+
warnings.push({
|
|
596
|
+
field: field.key,
|
|
597
|
+
id: `${field.key}-long`,
|
|
598
|
+
message: `${field.label} is ${length} characters; ${kind} are cut near ${field.limit}.`,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (preview.url.href === null) {
|
|
603
|
+
warnings.push({
|
|
604
|
+
field: "url",
|
|
605
|
+
id: "site-url-missing",
|
|
606
|
+
message:
|
|
607
|
+
"Project Settings names no site URL, so the build emits no canonical link and no og:url.",
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
if (hasTitleEntry(layers)) {
|
|
611
|
+
warnings.push({
|
|
612
|
+
field: "title",
|
|
613
|
+
id: "head-title-ignored",
|
|
614
|
+
message:
|
|
615
|
+
"A <title> element in $head is discarded — the build writes the title from the " +
|
|
616
|
+
"document's own title property.",
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
return warnings;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Build the whole preview from the three layers, the page title, the route and the site config.
|
|
624
|
+
*
|
|
625
|
+
* Pure, and the reason the previews are testable without a stage: everything asynchronous
|
|
626
|
+
* (resolving the layout document) happens in {@link layoutHeadEntries} before this is called.
|
|
627
|
+
*
|
|
628
|
+
* @param {HeadLayers} layers
|
|
629
|
+
* @param {{ pageTitle: string; route: string | null; siteName?: string; siteUrl?: string }} ctx
|
|
630
|
+
* @returns {SeoPreview}
|
|
631
|
+
*/
|
|
632
|
+
export function buildSeoPreview(
|
|
633
|
+
layers: HeadLayers,
|
|
634
|
+
ctx: { pageTitle: string; route: string | null; siteName?: string; siteUrl?: string },
|
|
635
|
+
): SeoPreview {
|
|
636
|
+
const field = (key: string, label: string, resolved: ResolvedHeadField): SeoField => ({
|
|
637
|
+
...resolved,
|
|
638
|
+
key,
|
|
639
|
+
label,
|
|
640
|
+
limit: SEO_LIMITS[key] ?? null,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
const fields: SeoField[] = [
|
|
644
|
+
field("title", "Title", resolveTitleField(ctx.pageTitle, ctx.siteName)),
|
|
645
|
+
field("description", "Description", resolveMetaField(layers, "name", "description")),
|
|
646
|
+
field("og:title", "Social title", resolveMetaField(layers, "property", "og:title")),
|
|
647
|
+
field(
|
|
648
|
+
"og:description",
|
|
649
|
+
"Social description",
|
|
650
|
+
resolveMetaField(layers, "property", "og:description"),
|
|
651
|
+
),
|
|
652
|
+
field("og:image", "Social image", resolveMetaField(layers, "property", "og:image")),
|
|
653
|
+
field("og:type", "Social type", resolveMetaField(layers, "property", "og:type")),
|
|
654
|
+
];
|
|
655
|
+
const url = resolveSeoUrl(ctx.route, ctx.siteUrl);
|
|
656
|
+
const withoutWarnings = { fields, url };
|
|
657
|
+
return { ...withoutWarnings, warnings: seoWarnings(withoutWarnings, layers) };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ─── The layout's `$head` ────────────────────────────────────────────────
|
|
661
|
+
|
|
662
|
+
/*
|
|
663
|
+
* The middle layer is the only one the studio does not already hold in memory: it lives in a file.
|
|
664
|
+
* It is fetched once per layout path and cached here, and the fetch rides `platform.readFile`, so
|
|
665
|
+
* `probe.idle()`'s platform source already accounts for it — no new in-flight declaration.
|
|
666
|
+
*/
|
|
667
|
+
|
|
668
|
+
let _layoutHeadPath: string | null = null;
|
|
669
|
+
let _layoutHead: JxHeadEntry[] = [];
|
|
670
|
+
let _layoutHeadPending: string | null = null;
|
|
671
|
+
|
|
672
|
+
/** Drop the cached layout head, so the next preview re-reads the file. */
|
|
673
|
+
export function invalidateLayoutHeadCache(): void {
|
|
674
|
+
_layoutHeadPath = null;
|
|
675
|
+
_layoutHead = [];
|
|
676
|
+
_layoutHeadPending = null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function loadLayoutHead(path: string): Promise<void> {
|
|
680
|
+
if (_layoutHeadPending === path) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
_layoutHeadPending = path;
|
|
684
|
+
const doc = await resolveLayoutDoc(path);
|
|
685
|
+
if (_layoutHeadPending !== path) {
|
|
686
|
+
// A different layout was asked for while this one was in flight; that request owns the cache.
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
_layoutHeadPending = null;
|
|
690
|
+
_layoutHeadPath = path;
|
|
691
|
+
_layoutHead = doc?.$head ?? [];
|
|
692
|
+
// THREE surfaces show the merged head now, and the layout layer arrives asynchronously: the
|
|
693
|
+
// Document Header card, and Search appearance, which is where the previews went. A modal left
|
|
694
|
+
// Off this list renders the page's own head with the layout layer missing — which reads as
|
|
695
|
+
// "you have no description" on a page that inherits one.
|
|
696
|
+
renderOnly("frontmatterPanel", "seoModal");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* A layout path as a reader would name it — `./layouts/blog-post.json` → `Blog Post`.
|
|
701
|
+
*
|
|
702
|
+
* @param {string} path
|
|
703
|
+
* @returns {string}
|
|
704
|
+
*/
|
|
705
|
+
export function layoutDisplayName(path: string): string {
|
|
706
|
+
return (
|
|
707
|
+
path
|
|
708
|
+
.replace(/^\.\//, "")
|
|
709
|
+
.replace(/^layouts\//, "")
|
|
710
|
+
.replace(/\.json$/, "")
|
|
711
|
+
.replaceAll(/[-_/]+/g, " ")
|
|
712
|
+
.replaceAll(/\b\w/g, (c) => c.toUpperCase()) || path
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The effective layout's `$head`, and the name to print for it.
|
|
718
|
+
*
|
|
719
|
+
* Returns empty on the first call for a layout and schedules the read; the card repaints when it
|
|
720
|
+
* lands. Showing nothing briefly is the honest state — the alternative is attributing the layout's
|
|
721
|
+
* entries to the page.
|
|
722
|
+
*
|
|
723
|
+
* @param {Tab | null} tab — the tab whose document the entries are being resolved FOR.
|
|
724
|
+
* @param {string | false} [docLayout]
|
|
725
|
+
* @returns {{ entries: JxHeadEntry[]; name: string | null }}
|
|
726
|
+
*/
|
|
727
|
+
export function layoutHeadEntries(
|
|
728
|
+
tab: Tab | null,
|
|
729
|
+
docLayout?: string | false,
|
|
730
|
+
): {
|
|
731
|
+
entries: JxHeadEntry[];
|
|
732
|
+
name: string | null;
|
|
733
|
+
} {
|
|
734
|
+
const path = isPageDocument(tab) ? getEffectiveLayoutPath(docLayout) : null;
|
|
735
|
+
if (path === null) {
|
|
736
|
+
return { entries: [], name: null };
|
|
737
|
+
}
|
|
738
|
+
const name = layoutDisplayName(path);
|
|
739
|
+
if (path === _layoutHeadPath) {
|
|
740
|
+
return { entries: _layoutHead, name };
|
|
741
|
+
}
|
|
742
|
+
void loadLayoutHead(path);
|
|
743
|
+
return { entries: [], name };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The preview for the document the card is showing.
|
|
748
|
+
*
|
|
749
|
+
* @param {Tab | null} tab — the tab the card was drawn for; its route and its layout, not the
|
|
750
|
+
* focused pane's.
|
|
751
|
+
* @param {JxMutableNode} doc — the head-bearing view of the document (`buildHeadDoc` for content).
|
|
752
|
+
* @returns {SeoPreview}
|
|
753
|
+
*/
|
|
754
|
+
export function seoPreviewFor(tab: Tab | null, doc: JxMutableNode): SeoPreview {
|
|
755
|
+
const config = projectState?.projectConfig;
|
|
756
|
+
const layout = layoutHeadEntries(tab, doc.$layout);
|
|
757
|
+
const path = tab?.documentPath;
|
|
758
|
+
return buildSeoPreview(
|
|
759
|
+
{
|
|
760
|
+
layout: layout.entries,
|
|
761
|
+
layoutName: layout.name,
|
|
762
|
+
page: doc.$head ?? [],
|
|
763
|
+
site: config?.$head ?? [],
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
pageTitle: typeof doc.title === "string" ? doc.title : "",
|
|
767
|
+
route: path ? pageRoute(path) : null,
|
|
768
|
+
...(config?.name === undefined ? {} : { siteName: config.name }),
|
|
769
|
+
...(config?.url === undefined ? {} : { siteUrl: config.url }),
|
|
770
|
+
},
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
234
774
|
// ─── Field renderers ─────────────────────────────────────────────────────
|
|
235
775
|
|
|
236
776
|
/**
|
|
@@ -241,7 +781,7 @@ function entryValue(entry: JxHeadEntry) {
|
|
|
241
781
|
* @param {(fn: (doc: JxMutableNode) => void) => void} applyMutation
|
|
242
782
|
* @returns {import("lit-html").TemplateResult}
|
|
243
783
|
*/
|
|
244
|
-
function renderMetaFieldRow(
|
|
784
|
+
export function renderMetaFieldRow(
|
|
245
785
|
field: MetaField,
|
|
246
786
|
head: JxHeadEntry[],
|
|
247
787
|
applyMutation: (fn: (doc: JxMutableNode) => void) => void,
|
|
@@ -316,77 +856,10 @@ export function renderHeadTemplate({
|
|
|
316
856
|
const isContent = tab?.doc.mode === "content";
|
|
317
857
|
const frontmatterSection = isContent ? renderFrontmatterSection() : nothing;
|
|
318
858
|
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
(tab.documentPath.startsWith("pages/") || tab.documentPath.startsWith("./pages/"));
|
|
324
|
-
|
|
325
|
-
let layoutSection: TemplateResult | symbol = nothing;
|
|
326
|
-
if (isPage) {
|
|
327
|
-
if (layoutEntries === null) {
|
|
328
|
-
void loadLayoutEntries();
|
|
329
|
-
} else {
|
|
330
|
-
const currentLayout = doc.$layout;
|
|
331
|
-
const defaultLayout = projectState?.projectConfig?.defaults?.layout;
|
|
332
|
-
const displayValue = currentLayout === false ? "__none__" : currentLayout || "__default__";
|
|
333
|
-
const defaultLabel = defaultLayout
|
|
334
|
-
? defaultLayout
|
|
335
|
-
.replace(/^\.\/layouts\//, "")
|
|
336
|
-
.replace(/\.json$/, "")
|
|
337
|
-
.replaceAll(/[-_]+/g, " ")
|
|
338
|
-
.replaceAll(/\b\w/g, (c: string) => c.toUpperCase())
|
|
339
|
-
: "";
|
|
340
|
-
|
|
341
|
-
layoutSection = html`
|
|
342
|
-
<div class="imports-section">
|
|
343
|
-
<div class="imports-section-header">
|
|
344
|
-
<span class="imports-section-title">Layout</span>
|
|
345
|
-
</div>
|
|
346
|
-
<div class="head-section-body">
|
|
347
|
-
${renderFieldRow({
|
|
348
|
-
hasValue: currentLayout !== undefined,
|
|
349
|
-
label: "Layout",
|
|
350
|
-
onClear: () =>
|
|
351
|
-
applyMutation((d: JxMutableNode) => {
|
|
352
|
-
delete d.$layout;
|
|
353
|
-
}),
|
|
354
|
-
prop: "layout",
|
|
355
|
-
widget: html`
|
|
356
|
-
<sp-picker
|
|
357
|
-
size="s"
|
|
358
|
-
value=${displayValue}
|
|
359
|
-
@change=${(e: Event) => {
|
|
360
|
-
const val = (e.target as HTMLInputElement).value;
|
|
361
|
-
applyMutation((d: JxMutableNode) => {
|
|
362
|
-
if (val === "__default__") {
|
|
363
|
-
delete d.$layout;
|
|
364
|
-
} else if (val === "__none__") {
|
|
365
|
-
d.$layout = false;
|
|
366
|
-
} else {
|
|
367
|
-
d.$layout = val;
|
|
368
|
-
}
|
|
369
|
-
});
|
|
370
|
-
invalidateLayoutCache();
|
|
371
|
-
}}
|
|
372
|
-
>
|
|
373
|
-
<sp-menu-item value="__default__"
|
|
374
|
-
>Default${defaultLabel ? ` (${defaultLabel})` : ""}</sp-menu-item
|
|
375
|
-
>
|
|
376
|
-
<sp-menu-item value="__none__">None</sp-menu-item>
|
|
377
|
-
<sp-menu-divider></sp-menu-divider>
|
|
378
|
-
${layoutEntries.map(
|
|
379
|
-
(l: { name: string; path: string }) =>
|
|
380
|
-
html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
|
|
381
|
-
)}
|
|
382
|
-
</sp-picker>
|
|
383
|
-
`,
|
|
384
|
-
})}
|
|
385
|
-
</div>
|
|
386
|
-
</div>
|
|
387
|
-
`;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
859
|
+
// `tab` is the FOCUSED tab, and legitimately so: this template is the Navigator's Page panel,
|
|
860
|
+
// Which is drawn once for the shell and follows the focus by design. Spelling it here is what
|
|
861
|
+
// Lets `renderLayoutSection` and everything under it take a tab instead of asking.
|
|
862
|
+
const layoutSection = renderLayoutSection(tab, doc, applyMutation);
|
|
390
863
|
|
|
391
864
|
return html`
|
|
392
865
|
<div class="imports-panel">
|
|
@@ -396,6 +869,20 @@ export function renderHeadTemplate({
|
|
|
396
869
|
<div class="imports-section">
|
|
397
870
|
<div class="imports-section-header">
|
|
398
871
|
<span class="imports-section-title">Page</span>
|
|
872
|
+
<!-- The SECOND door to Search appearance; the Document Header card has the other. Two
|
|
873
|
+
moments, one capability — both run document.openSeo, so neither surface owns it and
|
|
874
|
+
the palette has it by name. -->
|
|
875
|
+
<sp-action-button
|
|
876
|
+
quiet
|
|
877
|
+
size="xs"
|
|
878
|
+
class="head-seo-btn"
|
|
879
|
+
title="Preview how this page appears in search and when shared"
|
|
880
|
+
@click=${() => {
|
|
881
|
+
void activeRegistry()?.run("document.openSeo");
|
|
882
|
+
}}
|
|
883
|
+
>
|
|
884
|
+
Search appearance…
|
|
885
|
+
</sp-action-button>
|
|
399
886
|
</div>
|
|
400
887
|
<div class="head-section-body">
|
|
401
888
|
${renderFieldRow({
|
|
@@ -485,7 +972,12 @@ export function renderHeadTemplate({
|
|
|
485
972
|
})}
|
|
486
973
|
</div>
|
|
487
974
|
`
|
|
488
|
-
:
|
|
975
|
+
: renderEmptyState({
|
|
976
|
+
compact: true,
|
|
977
|
+
message:
|
|
978
|
+
"Custom tags add your own meta, link and script elements to this page — " +
|
|
979
|
+
"analytics, verification, a webfont. Add one below.",
|
|
980
|
+
})
|
|
489
981
|
}
|
|
490
982
|
|
|
491
983
|
<!-- Add custom tag form -->
|
|
@@ -549,6 +1041,128 @@ export function renderHeadTemplate({
|
|
|
549
1041
|
`;
|
|
550
1042
|
}
|
|
551
1043
|
|
|
1044
|
+
// ─── Layout picker ───────────────────────────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Whether `tab`'s document is a page of a site project — the only documents a layout applies to.
|
|
1048
|
+
*
|
|
1049
|
+
* **It takes the tab, and that is the whole of finding 3.** It was zero-argument and read
|
|
1050
|
+
* `activeTab.value?.documentPath`, while both of its important callers had already been handed a
|
|
1051
|
+
* tab: `hasDocumentHeader(tab)` inspects THAT tab's frontmatter, title and `$head` and then fell
|
|
1052
|
+
* through to here for the "a page always has one" rule, and `documentHeaderTemplate(tab, paneId)`
|
|
1053
|
+
* gates the Layout picker on it. Both directions were visible with two panes — a page in the
|
|
1054
|
+
* unfocused pane lost its Title and Route because the focused tab was a component, and a bare
|
|
1055
|
+
* component GAINED a header card because the focused tab was a page — and the Layout picker
|
|
1056
|
+
* appeared or vanished in the pane you were editing according to the document in the other one.
|
|
1057
|
+
*
|
|
1058
|
+
* A caller whose subject genuinely IS the focused document (the Navigator's Page panel) passes
|
|
1059
|
+
* `activeTab.value`, where a reviewer can see it. The same bargain `setCanvasMode` made.
|
|
1060
|
+
*
|
|
1061
|
+
* @param {Tab | null} tab
|
|
1062
|
+
* @returns {boolean}
|
|
1063
|
+
*/
|
|
1064
|
+
export function isPageDocument(tab: Tab | null): boolean {
|
|
1065
|
+
const path = tab?.documentPath;
|
|
1066
|
+
return Boolean(
|
|
1067
|
+
path &&
|
|
1068
|
+
projectState?.isSiteProject &&
|
|
1069
|
+
(path.startsWith("pages/") || path.startsWith("./pages/")),
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* The layout picker as ONE field row, so the Page panel and the Document Header card render the
|
|
1075
|
+
* same control rather than two that drift.
|
|
1076
|
+
*
|
|
1077
|
+
* Returns `nothing` while the layouts directory is still being listed; the listing schedules a
|
|
1078
|
+
* re-render of both surfaces when it lands.
|
|
1079
|
+
*
|
|
1080
|
+
* @param {JxMutableNode} doc
|
|
1081
|
+
* @param {(fn: (doc: JxMutableNode) => void) => void} applyMutation
|
|
1082
|
+
* @returns {TemplateResult | typeof nothing}
|
|
1083
|
+
*/
|
|
1084
|
+
export function renderLayoutPickerRow(
|
|
1085
|
+
doc: JxMutableNode,
|
|
1086
|
+
applyMutation: (fn: (doc: JxMutableNode) => void) => void,
|
|
1087
|
+
): TemplateResult | typeof nothing {
|
|
1088
|
+
if (layoutEntries === null) {
|
|
1089
|
+
void loadLayoutEntries();
|
|
1090
|
+
return nothing;
|
|
1091
|
+
}
|
|
1092
|
+
const currentLayout = doc.$layout;
|
|
1093
|
+
const defaultLayout = projectState?.projectConfig?.defaults?.layout;
|
|
1094
|
+
const displayValue = currentLayout === false ? "__none__" : currentLayout || "__default__";
|
|
1095
|
+
const defaultLabel = defaultLayout
|
|
1096
|
+
? defaultLayout
|
|
1097
|
+
.replace(/^\.\/layouts\//, "")
|
|
1098
|
+
.replace(/\.json$/, "")
|
|
1099
|
+
.replaceAll(/[-_]+/g, " ")
|
|
1100
|
+
.replaceAll(/\b\w/g, (c: string) => c.toUpperCase())
|
|
1101
|
+
: "";
|
|
1102
|
+
const entries = layoutEntries;
|
|
1103
|
+
return renderFieldRow({
|
|
1104
|
+
hasValue: currentLayout !== undefined,
|
|
1105
|
+
label: "Layout",
|
|
1106
|
+
onClear: () =>
|
|
1107
|
+
applyMutation((d: JxMutableNode) => {
|
|
1108
|
+
delete d.$layout;
|
|
1109
|
+
}),
|
|
1110
|
+
prop: "layout",
|
|
1111
|
+
widget: html`
|
|
1112
|
+
<sp-picker
|
|
1113
|
+
size="s"
|
|
1114
|
+
value=${displayValue}
|
|
1115
|
+
@change=${(e: Event) => {
|
|
1116
|
+
const val = (e.target as HTMLInputElement).value;
|
|
1117
|
+
applyMutation((d: JxMutableNode) => {
|
|
1118
|
+
if (val === "__default__") {
|
|
1119
|
+
delete d.$layout;
|
|
1120
|
+
} else if (val === "__none__") {
|
|
1121
|
+
d.$layout = false;
|
|
1122
|
+
} else {
|
|
1123
|
+
d.$layout = val;
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
invalidateLayoutCache();
|
|
1127
|
+
}}
|
|
1128
|
+
>
|
|
1129
|
+
<sp-menu-item value="__default__"
|
|
1130
|
+
>Default${defaultLabel ? ` (${defaultLabel})` : ""}</sp-menu-item
|
|
1131
|
+
>
|
|
1132
|
+
<sp-menu-item value="__none__">None</sp-menu-item>
|
|
1133
|
+
<sp-menu-divider></sp-menu-divider>
|
|
1134
|
+
${entries.map(
|
|
1135
|
+
(l: { name: string; path: string }) =>
|
|
1136
|
+
html`<sp-menu-item value=${l.path}>${l.name}</sp-menu-item>`,
|
|
1137
|
+
)}
|
|
1138
|
+
</sp-picker>
|
|
1139
|
+
`,
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** The Page panel's boxed wrapper around {@link renderLayoutPickerRow}. */
|
|
1144
|
+
function renderLayoutSection(
|
|
1145
|
+
tab: Tab | null,
|
|
1146
|
+
doc: JxMutableNode,
|
|
1147
|
+
applyMutation: (fn: (doc: JxMutableNode) => void) => void,
|
|
1148
|
+
): TemplateResult | typeof nothing {
|
|
1149
|
+
if (!isPageDocument(tab)) {
|
|
1150
|
+
return nothing;
|
|
1151
|
+
}
|
|
1152
|
+
const row = renderLayoutPickerRow(doc, applyMutation);
|
|
1153
|
+
if (row === nothing) {
|
|
1154
|
+
return nothing;
|
|
1155
|
+
}
|
|
1156
|
+
return html`
|
|
1157
|
+
<div class="imports-section">
|
|
1158
|
+
<div class="imports-section-header">
|
|
1159
|
+
<span class="imports-section-title">Layout</span>
|
|
1160
|
+
</div>
|
|
1161
|
+
<div class="head-section-body">${row}</div>
|
|
1162
|
+
</div>
|
|
1163
|
+
`;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
552
1166
|
// ─── Frontmatter section ────────────────────────────────────────────────
|
|
553
1167
|
|
|
554
1168
|
function renderFrontmatterSection() {
|
|
@@ -575,8 +1189,100 @@ function renderFrontmatterSection() {
|
|
|
575
1189
|
>
|
|
576
1190
|
</div>
|
|
577
1191
|
<div class="head-section-body">
|
|
578
|
-
${fields.map((f) => renderFmField(f.field, f.entry, f.value, requiredFields))}
|
|
1192
|
+
${fields.map((f) => renderFmField(tab, f.field, f.entry, f.value, requiredFields))}
|
|
579
1193
|
</div>
|
|
580
1194
|
</div>
|
|
581
1195
|
`;
|
|
582
1196
|
}
|
|
1197
|
+
|
|
1198
|
+
/** Overlay content-mode frontmatter title/`$head` onto the document the panel edits. */
|
|
1199
|
+
export function buildHeadDoc(doc: JxMutableNode, fm: Record<string, unknown>): JxMutableNode {
|
|
1200
|
+
const title = fm.title as string | undefined;
|
|
1201
|
+
const $head = fm.$head as JxHeadEntry[] | undefined;
|
|
1202
|
+
return {
|
|
1203
|
+
...doc,
|
|
1204
|
+
...(title === undefined ? {} : { title }),
|
|
1205
|
+
...($head === undefined ? {} : { $head }),
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* The mutation path for a content-mode document, where title and `$head` live in frontmatter.
|
|
1211
|
+
*
|
|
1212
|
+
* The panel edits a `JxMutableNode`; a markdown page's head fields are frontmatter keys. This
|
|
1213
|
+
* adapts one to the other in the module that owns both, instead of in the Navigator orchestrator
|
|
1214
|
+
* that owns neither.
|
|
1215
|
+
*
|
|
1216
|
+
* **`tab` is a parameter for the same reason {@link renderFmField}'s is.** It resolved
|
|
1217
|
+
* `activeTab.value` itself, and the Document Header card calls it for the CONTENT branch of every
|
|
1218
|
+
* mutation it makes — Title, Clear title, the Layout picker — as does Search appearance, which is
|
|
1219
|
+
* where the head fields went and which takes its tab the same way. The card is drawn per pane, so
|
|
1220
|
+
* on a markdown page the card in one pane retitled the document in the other. The comment beside
|
|
1221
|
+
* the card's JSON branch has claimed since P8 that this was fixed; the fix reached the JSON branch
|
|
1222
|
+
* only, and this is the half that was left.
|
|
1223
|
+
*
|
|
1224
|
+
* @param {Tab | null} tab The document to commit into.
|
|
1225
|
+
* @param {() => void} rerender
|
|
1226
|
+
* @param {(doc: JxMutableNode) => void} fn
|
|
1227
|
+
*/
|
|
1228
|
+
export function applyContentMutation(
|
|
1229
|
+
tab: Tab | null,
|
|
1230
|
+
rerender: () => void,
|
|
1231
|
+
fn: (doc: JxMutableNode) => void,
|
|
1232
|
+
): void {
|
|
1233
|
+
if (!tab) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
const fmNow = (tab.doc.content?.frontmatter ?? {}) as Record<string, unknown>;
|
|
1237
|
+
const fmHead = fmNow.$head as JxHeadEntry[] | undefined;
|
|
1238
|
+
const tmp: JxMutableNode = {
|
|
1239
|
+
...(typeof fmNow.title === "string" ? { title: fmNow.title } : {}),
|
|
1240
|
+
...(fmHead ? { $head: [...fmHead] } : {}),
|
|
1241
|
+
};
|
|
1242
|
+
fn(tmp);
|
|
1243
|
+
if (tmp.title !== fmNow.title) {
|
|
1244
|
+
mutateUpdateFrontmatter(tab, "title", tmp.title as JsonValue);
|
|
1245
|
+
}
|
|
1246
|
+
const newHead = tmp.$head && tmp.$head.length > 0 ? tmp.$head : undefined;
|
|
1247
|
+
// JxHeadEntry[] is JSON document content by construction.
|
|
1248
|
+
mutateUpdateFrontmatter(tab, "$head", newHead as JsonValue);
|
|
1249
|
+
rerender();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Contribute the Page panel.
|
|
1254
|
+
*
|
|
1255
|
+
* `level: "document"` — title, description, social card and custom `$head` entries are the open
|
|
1256
|
+
* document's. The id is `page` now: "head" named an HTML element, and §3.2's DOCUMENT group calls
|
|
1257
|
+
* the surface Page. P3.10 moves these fields into the in-stage Document Header card; until then the
|
|
1258
|
+
* record is what keeps the surface addressable under one name.
|
|
1259
|
+
*/
|
|
1260
|
+
export function registerPagePanel(): void {
|
|
1261
|
+
registerPanel({
|
|
1262
|
+
id: "page",
|
|
1263
|
+
title: "Page",
|
|
1264
|
+
level: "document",
|
|
1265
|
+
dock: "navigator",
|
|
1266
|
+
icon: "sp-icon-view-all-tags",
|
|
1267
|
+
requiresDocument: "Open a page to edit its title, description and social preview.",
|
|
1268
|
+
render: (ctx) => {
|
|
1269
|
+
const doc = ctx.doc!;
|
|
1270
|
+
const isContent = doc.mode === "content";
|
|
1271
|
+
const fm = doc.content?.frontmatter ?? {};
|
|
1272
|
+
// Through `deps`, not the local binding: `studio.ts` owns the wiring, and the Navigator has
|
|
1273
|
+
// Injected these renderers since before the registry existed.
|
|
1274
|
+
return ctx.deps.renderHeadTemplate({
|
|
1275
|
+
// The Navigator's Page panel IS an app-level surface: it shows the focused document by
|
|
1276
|
+
// Definition, so it spells that out at the call site now instead of leaving it to a helper
|
|
1277
|
+
// Two other surfaces share.
|
|
1278
|
+
applyMutation: isContent
|
|
1279
|
+
? (fn) => applyContentMutation(activeTab.value, ctx.rerender, fn)
|
|
1280
|
+
: (fn) => {
|
|
1281
|
+
transact(activeTab.value, fn);
|
|
1282
|
+
},
|
|
1283
|
+
document: isContent ? buildHeadDoc(doc.document, fm) : doc.document,
|
|
1284
|
+
renderLeftPanel: ctx.rerender,
|
|
1285
|
+
});
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
}
|