Repository: usemarble/marble Branch: main Commit: 711d2595ae00 Files: 730 Total size: 2.3 MB Directory structure: gitextract_4dlc520n/ ├── .cursor/ │ └── rules/ │ ├── mintlify.mdc │ └── ultracite.mdc ├── .dockerignore ├── .github/ │ ├── CONTRIBUTING.md │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── pull_request_template.md │ └── workflows/ │ ├── code-quality.yml │ └── tests.yml ├── .gitignore ├── .husky/ │ └── commit-msg ├── .npmrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── apps/ │ ├── api/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── cache.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── crypto.ts │ │ │ │ ├── db.ts │ │ │ │ ├── events.ts │ │ │ │ ├── media.ts │ │ │ │ ├── polar.ts │ │ │ │ ├── posts.ts │ │ │ │ ├── redis.ts │ │ │ │ ├── sanitize.ts │ │ │ │ ├── usage.ts │ │ │ │ └── workspace.ts │ │ │ ├── middleware/ │ │ │ │ ├── analytics.ts │ │ │ │ ├── authorization.ts │ │ │ │ ├── cache.ts │ │ │ │ ├── key-authorization.ts │ │ │ │ ├── legacy-analytics.ts │ │ │ │ ├── ratelimit.ts │ │ │ │ └── system.ts │ │ │ ├── routes/ │ │ │ │ ├── authors.ts │ │ │ │ ├── cache.ts │ │ │ │ ├── categories.ts │ │ │ │ ├── events.ts │ │ │ │ ├── invalidate.ts │ │ │ │ ├── media.ts │ │ │ │ ├── posts.ts │ │ │ │ └── tags.ts │ │ │ ├── schemas/ │ │ │ │ ├── authors.ts │ │ │ │ ├── categories.ts │ │ │ │ ├── common.ts │ │ │ │ ├── media.ts │ │ │ │ ├── posts.ts │ │ │ │ └── tags.ts │ │ │ ├── types/ │ │ │ │ └── env.ts │ │ │ └── validations/ │ │ │ ├── authors.ts │ │ │ ├── categories.ts │ │ │ ├── json.ts │ │ │ ├── misc.ts │ │ │ ├── posts.ts │ │ │ └── tags.ts │ │ ├── tsconfig.json │ │ └── wrangler.jsonc │ ├── cms/ │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── README.md │ │ ├── components.json │ │ ├── next.config.ts │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── public/ │ │ │ └── manifest.json │ │ ├── src/ │ │ │ ├── app/ │ │ │ │ ├── (auth)/ │ │ │ │ │ ├── join/ │ │ │ │ │ │ └── [id]/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── login/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── new/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── register/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── reset/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── verify/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── (main)/ │ │ │ │ │ ├── [workspace]/ │ │ │ │ │ │ ├── (dashboard)/ │ │ │ │ │ │ │ ├── (home)/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── authors/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── categories/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ │ ├── media/ │ │ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── posts/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── settings/ │ │ │ │ │ │ │ │ ├── account/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── appearance/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── billing/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── fields/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── general/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── keys/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── members/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ └── webhooks/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── tags/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── (editor)/ │ │ │ │ │ │ │ ├── editor/ │ │ │ │ │ │ │ │ └── p/ │ │ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ └── new/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── layout.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ └── set-workspace-cookie.tsx │ │ │ │ │ └── layout.tsx │ │ │ │ ├── (share)/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── share/ │ │ │ │ │ └── [token]/ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── api/ │ │ │ │ │ ├── accounts/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── ai/ │ │ │ │ │ │ └── suggestions/ │ │ │ │ │ │ ├── prompt.ts │ │ │ │ │ │ └── route.tsx │ │ │ │ │ ├── auth/ │ │ │ │ │ │ └── [...all]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── authors/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── categories/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── fields/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── keys/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── media/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── editor/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── metrics/ │ │ │ │ │ │ ├── publishing/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── usage/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── polar/ │ │ │ │ │ │ └── success/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── posts/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ ├── fields/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── import/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── share/ │ │ │ │ │ │ ├── [token]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── tags/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── upload/ │ │ │ │ │ │ ├── complete/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── user/ │ │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── webhooks/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── test/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── workspaces/ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── layout.tsx │ │ │ │ ├── not-found.tsx │ │ │ │ ├── providers.tsx │ │ │ │ └── robots.ts │ │ │ ├── components/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── login-form.tsx │ │ │ │ │ ├── register-form.tsx │ │ │ │ │ ├── reset/ │ │ │ │ │ │ ├── reset-form.tsx │ │ │ │ │ │ └── reset-request-form.tsx │ │ │ │ │ └── verify-form.tsx │ │ │ │ ├── authors/ │ │ │ │ │ ├── author-modals.tsx │ │ │ │ │ ├── author-sheet.tsx │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── table-actions.tsx │ │ │ │ ├── billing/ │ │ │ │ │ ├── success-modal.tsx │ │ │ │ │ └── upgrade-modal.tsx │ │ │ │ ├── categories/ │ │ │ │ │ ├── category-modals.tsx │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── table-actions.tsx │ │ │ │ ├── editor/ │ │ │ │ │ ├── ai/ │ │ │ │ │ │ └── readability-suggestions.tsx │ │ │ │ │ ├── editor-data-provider.tsx │ │ │ │ │ ├── editor-header.tsx │ │ │ │ │ ├── editor-page.tsx │ │ │ │ │ ├── editor-sidebar.tsx │ │ │ │ │ ├── editor.tsx │ │ │ │ │ ├── fields/ │ │ │ │ │ │ ├── author-selector.tsx │ │ │ │ │ │ ├── category-selector.tsx │ │ │ │ │ │ ├── cover-image-selector.tsx │ │ │ │ │ │ ├── custom-fields-section.tsx │ │ │ │ │ │ ├── description-field.tsx │ │ │ │ │ │ ├── featured-field.tsx │ │ │ │ │ │ ├── field-info.tsx │ │ │ │ │ │ ├── publish-date-field.tsx │ │ │ │ │ │ ├── slug-field.tsx │ │ │ │ │ │ ├── status-field.tsx │ │ │ │ │ │ └── tag-selector.tsx │ │ │ │ │ ├── footer/ │ │ │ │ │ │ └── metadata-footer.tsx │ │ │ │ │ ├── link-selector.tsx │ │ │ │ │ ├── share-modal.tsx │ │ │ │ │ ├── tabs/ │ │ │ │ │ │ ├── analysis-tab.tsx │ │ │ │ │ │ └── metadata-tab.tsx │ │ │ │ │ └── textarea-autosize.tsx │ │ │ │ ├── fields/ │ │ │ │ │ ├── create-custom-field.tsx │ │ │ │ │ ├── custom-field-row.tsx │ │ │ │ │ ├── delete-custom-field.tsx │ │ │ │ │ ├── edit-custom-field.tsx │ │ │ │ │ └── field-options-input.tsx │ │ │ │ ├── home/ │ │ │ │ │ ├── api-usage-card.tsx │ │ │ │ │ ├── media-usage-card.tsx │ │ │ │ │ ├── publishing-activity-card.tsx │ │ │ │ │ └── webhook-usage-card.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── marble.tsx │ │ │ │ │ └── social/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── invoice/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ └── table-actions.tsx │ │ │ │ ├── keys/ │ │ │ │ │ ├── api-key-modal.tsx │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ ├── delete-key.tsx │ │ │ │ │ └── table-actions.tsx │ │ │ │ ├── layout/ │ │ │ │ │ ├── header-sidebar-trigger.tsx │ │ │ │ │ ├── page-header.tsx │ │ │ │ │ └── wrapper.tsx │ │ │ │ ├── media/ │ │ │ │ │ ├── crop-image-modal.tsx │ │ │ │ │ ├── delete-modal.tsx │ │ │ │ │ ├── file-upload-input.tsx │ │ │ │ │ ├── media-actions.tsx │ │ │ │ │ ├── media-card.tsx │ │ │ │ │ ├── media-columns.tsx │ │ │ │ │ ├── media-controls.tsx │ │ │ │ │ ├── media-data-table.tsx │ │ │ │ │ ├── media-gallery.tsx │ │ │ │ │ ├── media-table-toolbar.tsx │ │ │ │ │ ├── upload-modal.tsx │ │ │ │ │ └── video-player.tsx │ │ │ │ ├── nav/ │ │ │ │ │ ├── announcements.tsx │ │ │ │ │ ├── app-breadcrumb.tsx │ │ │ │ │ ├── app-sidebar.tsx │ │ │ │ │ ├── create-workspace-dialog.tsx │ │ │ │ │ ├── nav-extra.tsx │ │ │ │ │ ├── nav-main.tsx │ │ │ │ │ ├── nav-settings.tsx │ │ │ │ │ ├── nav-user.tsx │ │ │ │ │ ├── sidebar-footer-content.tsx │ │ │ │ │ ├── theme-toggle.tsx │ │ │ │ │ ├── upgrade-card.tsx │ │ │ │ │ ├── whats-new-card.tsx │ │ │ │ │ └── workspace-switcher.tsx │ │ │ │ ├── posts/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-grid.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ ├── data-view.tsx │ │ │ │ │ ├── import-item-form.tsx │ │ │ │ │ ├── import-modal.tsx │ │ │ │ │ ├── post-actions.tsx │ │ │ │ │ └── post-modals.tsx │ │ │ │ ├── settings/ │ │ │ │ │ ├── account.tsx │ │ │ │ │ ├── delete-account.tsx │ │ │ │ │ ├── fields/ │ │ │ │ │ │ ├── delete.tsx │ │ │ │ │ │ ├── id.tsx │ │ │ │ │ │ ├── logo.tsx │ │ │ │ │ │ ├── name.tsx │ │ │ │ │ │ ├── slug.tsx │ │ │ │ │ │ └── timezone.tsx │ │ │ │ │ ├── section.tsx │ │ │ │ │ └── theme.tsx │ │ │ │ ├── share/ │ │ │ │ │ ├── prose.tsx │ │ │ │ │ └── screens.tsx │ │ │ │ ├── shared/ │ │ │ │ │ ├── container.tsx │ │ │ │ │ ├── dropzone.tsx │ │ │ │ │ ├── icons.tsx │ │ │ │ │ ├── page-loader.tsx │ │ │ │ │ └── pending-state.tsx │ │ │ │ ├── tags/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ ├── table-actions.tsx │ │ │ │ │ └── tag-modals.tsx │ │ │ │ ├── team/ │ │ │ │ │ ├── columns.tsx │ │ │ │ │ ├── data-table.tsx │ │ │ │ │ ├── invite-button.tsx │ │ │ │ │ ├── invite-modal.tsx │ │ │ │ │ ├── invite-section.tsx │ │ │ │ │ ├── leave-workspace.tsx │ │ │ │ │ ├── profile-sheet.tsx │ │ │ │ │ ├── table-actions.tsx │ │ │ │ │ └── team-modals.tsx │ │ │ │ ├── ui/ │ │ │ │ │ ├── async-button.tsx │ │ │ │ │ ├── copy-button.tsx │ │ │ │ │ ├── data-table-pagination.tsx │ │ │ │ │ ├── error-message.tsx │ │ │ │ │ ├── gauge.tsx │ │ │ │ │ ├── hidden-scrollbar.tsx │ │ │ │ │ ├── last-used-badge.tsx │ │ │ │ │ ├── loading-spinner.tsx │ │ │ │ │ ├── segmented-progress.tsx │ │ │ │ │ └── timezone-selector.tsx │ │ │ │ └── webhooks/ │ │ │ │ ├── create-webhook.tsx │ │ │ │ ├── delete-webhook.tsx │ │ │ │ ├── edit-webhook.tsx │ │ │ │ ├── webhook-actions.tsx │ │ │ │ ├── webhook-card.tsx │ │ │ │ ├── webhook-columns.tsx │ │ │ │ └── webhook-data-table.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-debounce.ts │ │ │ │ ├── use-isomorphic-layout-effect.ts │ │ │ │ ├── use-localstorage.ts │ │ │ │ ├── use-media-actions.ts │ │ │ │ ├── use-media-query.ts │ │ │ │ ├── use-mobile.tsx │ │ │ │ ├── use-plan.ts │ │ │ │ ├── use-readability.ts │ │ │ │ └── use-workspace-id.ts │ │ │ ├── lib/ │ │ │ │ ├── actions/ │ │ │ │ │ ├── checks.ts │ │ │ │ │ ├── email.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── workspace.ts │ │ │ │ ├── ai/ │ │ │ │ │ └── readability.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── access.ts │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── redirect.ts │ │ │ │ │ ├── server.ts │ │ │ │ │ ├── session.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── workspace.ts │ │ │ │ ├── blurhash.ts │ │ │ │ ├── cache/ │ │ │ │ │ └── invalidate.ts │ │ │ │ ├── cache.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── custom-fields.ts │ │ │ │ ├── data/ │ │ │ │ │ └── post.ts │ │ │ │ ├── events/ │ │ │ │ │ └── dispatch.ts │ │ │ │ ├── media/ │ │ │ │ │ └── upload.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── plans.ts │ │ │ │ ├── polar/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── customer.created.ts │ │ │ │ │ ├── subscription.canceled.ts │ │ │ │ │ ├── subscription.created.ts │ │ │ │ │ ├── subscription.revoked.ts │ │ │ │ │ ├── subscription.updated.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── queries/ │ │ │ │ │ ├── keys.ts │ │ │ │ │ ├── user.ts │ │ │ │ │ └── workspace.ts │ │ │ │ ├── r2.ts │ │ │ │ ├── ratelimit.ts │ │ │ │ ├── redis.ts │ │ │ │ ├── search-params.ts │ │ │ │ └── validations/ │ │ │ │ ├── auth.ts │ │ │ │ ├── authors.ts │ │ │ │ ├── editor.ts │ │ │ │ ├── fields.ts │ │ │ │ ├── keys.ts │ │ │ │ ├── post.ts │ │ │ │ ├── settings.ts │ │ │ │ ├── tags.ts │ │ │ │ ├── upload.ts │ │ │ │ ├── webhook.ts │ │ │ │ └── workspace.ts │ │ │ ├── providers/ │ │ │ │ ├── user.tsx │ │ │ │ └── workspace.tsx │ │ │ ├── proxy.ts │ │ │ ├── styles/ │ │ │ │ ├── editor.css │ │ │ │ └── globals.css │ │ │ ├── types/ │ │ │ │ ├── author.ts │ │ │ │ ├── dashboard.ts │ │ │ │ ├── fields.ts │ │ │ │ ├── icons.ts │ │ │ │ ├── media.ts │ │ │ │ ├── misc.ts │ │ │ │ ├── share.ts │ │ │ │ ├── user.ts │ │ │ │ ├── webhook.ts │ │ │ │ └── workspace.ts │ │ │ └── utils/ │ │ │ ├── author.tsx │ │ │ ├── editor.ts │ │ │ ├── fetch/ │ │ │ │ └── client.ts │ │ │ ├── keys.ts │ │ │ ├── media.ts │ │ │ ├── readability.ts │ │ │ ├── site.ts │ │ │ ├── string.ts │ │ │ ├── usage/ │ │ │ │ └── media.ts │ │ │ └── workspace/ │ │ │ ├── client.ts │ │ │ ├── constants.ts │ │ │ └── server.ts │ │ └── tsconfig.json │ ├── jobs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── consumers/ │ │ │ │ ├── deliveries.ts │ │ │ │ ├── dlq.ts │ │ │ │ └── events.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── db.ts │ │ │ │ ├── formats.ts │ │ │ │ ├── signing.ts │ │ │ │ └── usage.ts │ │ │ ├── scheduled/ │ │ │ │ └── cleanup.ts │ │ │ └── types/ │ │ │ └── env.ts │ │ ├── tsconfig.json │ │ └── wrangler.jsonc │ ├── mcp/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── public/ │ │ │ ├── home.js │ │ │ └── styles.css │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── icons.tsx │ │ │ │ └── mcp-clients.tsx │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── api.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── instructions.ts │ │ │ │ ├── mcp.ts │ │ │ │ └── media.ts │ │ │ ├── routes/ │ │ │ │ ├── home.tsx │ │ │ │ └── mcp.ts │ │ │ ├── server.ts │ │ │ ├── tools/ │ │ │ │ ├── authors.ts │ │ │ │ ├── categories.ts │ │ │ │ ├── media.ts │ │ │ │ ├── posts.ts │ │ │ │ ├── shared.ts │ │ │ │ └── tags.ts │ │ │ └── types.ts │ │ ├── tsconfig.json │ │ └── wrangler.jsonc │ └── web/ │ ├── .gitignore │ ├── .vscode/ │ │ └── launch.json │ ├── README.md │ ├── astro.config.mjs │ ├── package.json │ ├── public/ │ │ ├── robots.txt │ │ └── site.webmanifest │ ├── src/ │ │ ├── components/ │ │ │ ├── BlogHeader.astro │ │ │ ├── CategoryCard.astro │ │ │ ├── CategoryFilter.astro │ │ │ ├── ChangelogCard.astro │ │ │ ├── Container.astro │ │ │ ├── Footer.astro │ │ │ ├── Head.astro │ │ │ ├── Header.astro │ │ │ ├── PostCard.astro │ │ │ ├── PricingCard.astro │ │ │ ├── Prose.astro │ │ │ ├── ScrollToTop.astro │ │ │ ├── Welcome.astro │ │ │ ├── icons/ │ │ │ │ ├── Collab.astro │ │ │ │ ├── Discord.astro │ │ │ │ ├── Github.astro │ │ │ │ ├── Logo.astro │ │ │ │ ├── Media.astro │ │ │ │ ├── WordMark.astro │ │ │ │ ├── WordMarkAlt.astro │ │ │ │ ├── X.astro │ │ │ │ ├── brand/ │ │ │ │ │ ├── Bounty.astro │ │ │ │ │ ├── Candle.astro │ │ │ │ │ ├── Databuddy.astro │ │ │ │ │ ├── Helix.astro │ │ │ │ │ ├── Ia.astro │ │ │ │ │ ├── Mantlz.astro │ │ │ │ │ └── Opencut.astro │ │ │ │ └── sponsors/ │ │ │ │ ├── Neon.astro │ │ │ │ ├── Upstash.astro │ │ │ │ └── Vercel.astro │ │ │ ├── sections/ │ │ │ │ └── Pricing.astro │ │ │ └── ui/ │ │ │ ├── AccordionItem.astro │ │ │ └── Button.astro │ │ ├── content/ │ │ │ └── pages/ │ │ │ ├── privacy.md │ │ │ └── terms.md │ │ ├── content.config.ts │ │ ├── layouts/ │ │ │ ├── BlogLayout.astro │ │ │ └── Layout.astro │ │ ├── lib/ │ │ │ ├── accordion.ts │ │ │ ├── constants/ │ │ │ │ ├── faqs.ts │ │ │ │ ├── landing.ts │ │ │ │ ├── navigation.ts │ │ │ │ ├── site.ts │ │ │ │ └── tracking.ts │ │ │ ├── marble.ts │ │ │ ├── schemas.ts │ │ │ ├── seo.ts │ │ │ ├── site.ts │ │ │ └── utils.ts │ │ ├── pages/ │ │ │ ├── 404.astro │ │ │ ├── blog/ │ │ │ │ ├── [slug].astro │ │ │ │ ├── category/ │ │ │ │ │ └── [slug].astro │ │ │ │ └── index.astro │ │ │ ├── changelog/ │ │ │ │ ├── [slug].astro │ │ │ │ └── index.astro │ │ │ ├── contributors/ │ │ │ │ └── index.astro │ │ │ ├── index.astro │ │ │ ├── pricing/ │ │ │ │ └── index.astro │ │ │ ├── privacy/ │ │ │ │ └── index.astro │ │ │ ├── rss.xml.ts │ │ │ ├── sponsors/ │ │ │ │ └── index.astro │ │ │ └── terms/ │ │ │ └── index.astro │ │ └── styles/ │ │ └── globals.css │ ├── tailwind.config.ts │ └── tsconfig.json ├── biome.jsonc ├── commitlint.config.ts ├── docker-compose.yml ├── package.json ├── packages/ │ ├── db/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── prisma/ │ │ │ ├── migrations/ │ │ │ │ ├── 0_init/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250831193214_add_author_table/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250907120320_make_new_primary_author_required/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250907125704_drop_legacy_user_author_fields/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250907194746_rename_author_fields_to_final_names/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250908090455_make_primary_author_optional/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250909162749_make_published_at_optional/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250909171017_make_published_at_required/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250911083948_add_slack_payload_format/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250915114755_add_database_indices/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250919210238_add_share_link_table/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250923212858_add_ai_editor_preferences/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250924180405_add_missing_better_auth_indices/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20250927161627_add_author_social_links/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251114225009_add_usage_event_table/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251116173412_new_media_enum_and_alt_text_column/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251201001521_add_api_keys/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20251210213108_subscription_history/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20260331143009_add_fields/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20260505135201_add_media_metadata/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20260508223056_add_notification_preferences/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20260511_rename_webhook_to_webhook_endpoint/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20260513192507_add_workspace_events/ │ │ │ │ │ └── migration.sql │ │ │ │ ├── 20260515000100_add_subscription_polar_event_ordering/ │ │ │ │ │ └── migration.sql │ │ │ │ └── migration_lock.toml │ │ │ └── schema.prisma │ │ ├── prisma.config.ts │ │ ├── src/ │ │ │ ├── browser.ts │ │ │ ├── client.ts │ │ │ ├── hyperdrive.ts │ │ │ ├── index.ts │ │ │ └── workers.ts │ │ └── tsconfig.json │ ├── demo-markdown.md │ ├── editor/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── color-picker.tsx │ │ │ │ ├── editor-character-count.tsx │ │ │ │ ├── editor-content.tsx │ │ │ │ ├── editor-provider.tsx │ │ │ │ ├── editor-table-menus.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── twitter.tsx │ │ │ │ │ └── youtube.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── marks/ │ │ │ │ │ ├── editor-clear-formatting.tsx │ │ │ │ │ ├── editor-link-selector.tsx │ │ │ │ │ ├── editor-mark-bold.tsx │ │ │ │ │ ├── editor-mark-code.tsx │ │ │ │ │ ├── editor-mark-highlight.tsx │ │ │ │ │ ├── editor-mark-italic.tsx │ │ │ │ │ ├── editor-mark-strike.tsx │ │ │ │ │ ├── editor-mark-subscript.tsx │ │ │ │ │ ├── editor-mark-superscript.tsx │ │ │ │ │ ├── editor-mark-text-color.tsx │ │ │ │ │ ├── editor-mark-underline.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── menus/ │ │ │ │ │ ├── block-handle-menu.tsx │ │ │ │ │ ├── bubble-menu.tsx │ │ │ │ │ ├── floating-menu.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── nodes/ │ │ │ │ │ ├── editor-align-selector.tsx │ │ │ │ │ ├── editor-align.tsx │ │ │ │ │ ├── editor-node-bullet-list.tsx │ │ │ │ │ ├── editor-node-code.tsx │ │ │ │ │ ├── editor-node-heading1.tsx │ │ │ │ │ ├── editor-node-heading2.tsx │ │ │ │ │ ├── editor-node-heading3.tsx │ │ │ │ │ ├── editor-node-ordered-list.tsx │ │ │ │ │ ├── editor-node-quote.tsx │ │ │ │ │ ├── editor-node-table.tsx │ │ │ │ │ ├── editor-node-task-list.tsx │ │ │ │ │ ├── editor-node-text.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── rich-text-field.tsx │ │ │ │ └── ui/ │ │ │ │ ├── editor-button.tsx │ │ │ │ ├── editor-selector.tsx │ │ │ │ └── index.ts │ │ │ ├── extensions/ │ │ │ │ ├── code-block/ │ │ │ │ │ ├── code-block-comp.tsx │ │ │ │ │ ├── code-block-view.tsx │ │ │ │ │ ├── code-block.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── extension-kit.ts │ │ │ │ ├── figure/ │ │ │ │ │ ├── figure-view.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── image-upload/ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── image-upload-comp.tsx │ │ │ │ │ ├── image-upload-view.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── markdown-input/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── slash-command/ │ │ │ │ │ ├── groups.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menu-list.tsx │ │ │ │ │ └── slash-command.ts │ │ │ │ ├── table/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── menus/ │ │ │ │ │ │ ├── table-column/ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ └── table-row/ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── table-cell.ts │ │ │ │ │ ├── table-header.ts │ │ │ │ │ ├── table-row.ts │ │ │ │ │ ├── table.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── twitter/ │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── twitter-comp.tsx │ │ │ │ │ ├── twitter-upload.ts │ │ │ │ │ └── twitter-view.tsx │ │ │ │ ├── video/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── video-view.tsx │ │ │ │ ├── video-upload/ │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── video-upload-comp.tsx │ │ │ │ │ └── video-upload-view.tsx │ │ │ │ └── youtube/ │ │ │ │ ├── youtube-comp.tsx │ │ │ │ ├── youtube-upload.ts │ │ │ │ └── youtube-view.tsx │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── index.ts │ │ │ │ ├── lowlight.ts │ │ │ │ └── utils.ts │ │ │ ├── styles/ │ │ │ │ ├── color-picker.css │ │ │ │ ├── table.css │ │ │ │ └── task-list.css │ │ │ └── types/ │ │ │ └── index.ts │ │ └── tsconfig.json │ ├── email/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── button.tsx │ │ │ │ └── footer.tsx │ │ │ ├── emails/ │ │ │ │ ├── founder.tsx │ │ │ │ ├── invite.tsx │ │ │ │ ├── reset.tsx │ │ │ │ ├── usage-limit.tsx │ │ │ │ ├── verify.tsx │ │ │ │ └── welcome.tsx │ │ │ ├── index.ts │ │ │ └── lib/ │ │ │ ├── config.ts │ │ │ ├── dev.ts │ │ │ └── send.ts │ │ └── tsconfig.json │ ├── events/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── utils/ │ │ │ ├── demo.ts │ │ │ ├── envelope.ts │ │ │ ├── events.ts │ │ │ └── resources.ts │ │ └── tsconfig.json │ ├── parser/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── src/ │ │ │ └── tiptap.ts │ │ ├── tests/ │ │ │ └── tiptap-parser.test.ts │ │ └── tsconfig.json │ ├── tsconfig/ │ │ ├── base.json │ │ ├── nextjs.json │ │ ├── package.json │ │ └── react-library.json │ ├── ui/ │ │ ├── README.md │ │ ├── components.json │ │ ├── package.json │ │ ├── postcss.config.mjs │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── alert-dialog.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── calendar.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── command.tsx │ │ │ │ ├── dialog.tsx │ │ │ │ ├── drawer.tsx │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ ├── input-otp.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── kibo-ui/ │ │ │ │ │ ├── contribution-graph/ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── image-crop/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── pagination.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── progress.tsx │ │ │ │ ├── radio-group.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── select.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── sheet.tsx │ │ │ │ ├── sidebar.tsx │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── textarea.tsx │ │ │ │ ├── toggle-group.tsx │ │ │ │ ├── toggle.tsx │ │ │ │ └── tooltip.tsx │ │ │ ├── hooks/ │ │ │ │ └── use-mobile.ts │ │ │ ├── lib/ │ │ │ │ └── utils.ts │ │ │ └── styles/ │ │ │ └── globals.css │ │ └── tsconfig.json │ └── utils/ │ ├── package.json │ ├── src/ │ │ ├── constants/ │ │ │ ├── api-key.ts │ │ │ ├── plans.ts │ │ │ ├── pricing.ts │ │ │ └── site.ts │ │ ├── functions/ │ │ │ ├── api-key.ts │ │ │ ├── highlight.ts │ │ │ └── webhooks.ts │ │ ├── index.ts │ │ └── types/ │ │ └── api-key.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── skills-lock.json ├── tsconfig.json └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursor/rules/mintlify.mdc ================================================ --- alwaysApply: true --- # Mintlify technical writing rule You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices. ## Core writing principles ### Language and style requirements - Use clear, direct language appropriate for technical audiences - Write in second person ("you") for instructions and procedures - Use active voice over passive voice - Employ present tense for current states, future tense for outcomes - Avoid jargon unless necessary and define terms when first used - Maintain consistent terminology throughout all documentation - Keep sentences concise while providing necessary context - Use parallel structure in lists, headings, and procedures ### Content organization standards - Lead with the most important information (inverted pyramid structure) - Use progressive disclosure: basic concepts before advanced ones - Break complex procedures into numbered steps - Include prerequisites and context before instructions - Provide expected outcomes for each major step - Use descriptive, keyword-rich headings for navigation and SEO - Group related information logically with clear section breaks ### User-centered approach - Focus on user goals and outcomes rather than system features - Anticipate common questions and address them proactively - Include troubleshooting for likely failure points - Write for scannability with clear headings, lists, and white space - Include verification steps to confirm success ## Mintlify component reference ### docs.json - Refer to the [docs.json schema](https://mintlify.com/docs.json) when building the docs.json file and site navigation ### Callout components #### Note - Additional helpful information Supplementary information that supports the main content without interrupting flow #### Tip - Best practices and pro tips Expert advice, shortcuts, or best practices that enhance user success #### Warning - Important cautions Critical information about potential issues, breaking changes, or destructive actions #### Info - Neutral contextual information Background information, context, or neutral announcements #### Check - Success confirmations Positive confirmations, successful completions, or achievement indicators ### Code components #### Single code block Example of a single code block: ```javascript config.js const apiConfig = { baseURL: 'https://api.example.com', timeout: 5000, headers: { 'Authorization': `Bearer ${process.env.API_TOKEN}` } }; ``` #### Code group with multiple languages Example of a code group: ```javascript Node.js const response = await fetch('/api/endpoint', { headers: { Authorization: `Bearer ${apiKey}` } }); ``` ```python Python import requests response = requests.get('/api/endpoint', headers={'Authorization': f'Bearer {api_key}'}) ``` ```curl cURL curl -X GET '/api/endpoint' \ -H 'Authorization: Bearer YOUR_API_KEY' ``` #### Request/response examples Example of request/response documentation: ```bash cURL curl -X POST 'https://api.example.com/users' \ -H 'Content-Type: application/json' \ -d '{"name": "John Doe", "email": "john@example.com"}' ``` ```json Success { "id": "user_123", "name": "John Doe", "email": "john@example.com", "created_at": "2024-01-15T10:30:00Z" } ``` ### Structural components #### Steps for procedures Example of step-by-step instructions: Run `npm install` to install required packages. Verify installation by running `npm list`. Create a `.env` file with your API credentials. ```bash API_KEY=your_api_key_here ``` Never commit API keys to version control. #### Tabs for alternative content Example of tabbed content: ```bash brew install node npm install -g package-name ``` ```powershell choco install nodejs npm install -g package-name ``` ```bash sudo apt install nodejs npm npm install -g package-name ``` #### Accordions for collapsible content Example of accordion groups: - **Firewall blocking**: Ensure ports 80 and 443 are open - **Proxy configuration**: Set HTTP_PROXY environment variable - **DNS resolution**: Try using 8.8.8.8 as DNS server ```javascript const config = { performance: { cache: true, timeout: 30000 }, security: { encryption: 'AES-256' } }; ``` ### Cards and columns for emphasizing information Example of cards and card groups: Complete walkthrough from installation to your first API call in under 10 minutes. Learn how to authenticate requests using API keys or JWT tokens. Understand rate limits and best practices for high-volume usage. ### API documentation components #### Parameter fields Example of parameter documentation: Unique identifier for the user. Must be a valid UUID v4 format. User's email address. Must be valid and unique within the system. Maximum number of results to return. Range: 1-100. Bearer token for API authentication. Format: `Bearer YOUR_API_KEY` #### Response fields Example of response field documentation: Unique identifier assigned to the newly created user. ISO 8601 formatted timestamp of when the user was created. List of permission strings assigned to this user. #### Expandable nested fields Example of nested field documentation: Complete user object with all associated data. User profile information including personal details. User's first name as entered during registration. URL to user's profile picture. Returns null if no avatar is set. ### Media and advanced components #### Frames for images Wrap all images in frames: Main dashboard showing analytics overview Analytics dashboard with charts #### Videos Use the HTML video element for self-hosted video content: Embed YouTube videos using iframe elements: #### Tooltips Example of tooltip usage: API #### Updates Use updates for changelogs: ## New features - Added bulk user import feature - Improved error messages with actionable suggestions ## Bug fixes - Fixed pagination issue with large datasets - Resolved authentication timeout problems ## Required page structure Every documentation page must begin with YAML frontmatter: ```yaml --- title: "Clear, specific, keyword-rich title" description: "Concise description explaining page purpose and value" --- ``` ## Content quality standards ### Code examples requirements - Always include complete, runnable examples that users can copy and execute - Show proper error handling and edge case management - Use realistic data instead of placeholder values - Include expected outputs and results for verification - Test all code examples thoroughly before publishing - Specify language and include filename when relevant - Add explanatory comments for complex logic - Never include real API keys or secrets in code examples ### API documentation requirements - Document all parameters including optional ones with clear descriptions - Show both success and error response examples with realistic data - Include rate limiting information with specific limits - Provide authentication examples showing proper format - Explain all HTTP status codes and error handling - Cover complete request/response cycles ### Accessibility requirements - Include descriptive alt text for all images and diagrams - Use specific, actionable link text instead of "click here" - Ensure proper heading hierarchy starting with H2 - Provide keyboard navigation considerations - Use sufficient color contrast in examples and visuals - Structure content for easy scanning with headers and lists ## Component selection logic - Use **Steps** for procedures and sequential instructions - Use **Tabs** for platform-specific content or alternative approaches - Use **CodeGroup** when showing the same concept in multiple programming languages - Use **Accordions** for progressive disclosure of information - Use **RequestExample/ResponseExample** specifically for API endpoint documentation - Use **ParamField** for API parameters, **ResponseField** for API responses - Use **Expandable** for nested object properties or hierarchical information ================================================ FILE: .cursor/rules/ultracite.mdc ================================================ --- description: Ultracite Rules - AI-Ready Formatter and Linter globs: "**/*.{ts,tsx,js,jsx}" alwaysApply: true --- # Project Context Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. ## Key Principles - Zero configuration required - Subsecond performance - Maximum type safety - AI-friendly code generation - Always use pnpm as the package manager ## Before Writing Code 1. Analyze existing patterns in the codebase 2. Consider edge cases and error scenarios 3. Follow the rules below strictly 4. Validate accessibility requirements ## Rules ### Accessibility (a11y) - Don't use `accessKey` attribute on any HTML element. - Don't set `aria-hidden="true"` on focusable elements. - Don't add ARIA roles, states, and properties to elements that don't support them. - Don't use distracting elements like `` or ``. - Only use the `scope` prop on `` elements. - Don't assign non-interactive ARIA roles to interactive HTML elements. - Make sure label elements have text content and are associated with an input. - Don't assign interactive ARIA roles to non-interactive HTML elements. - Don't assign `tabIndex` to non-interactive HTML elements. - Don't use positive integers for `tabIndex` property. - Don't include "image", "picture", or "photo" in img alt prop. - Don't use explicit role property that's the same as the implicit/default role. - Make static elements with click handlers use a valid role attribute. - Always include a `title` element for SVG elements. - Give all elements requiring alt text meaningful information for screen readers. - Make sure anchors have content that's accessible to screen readers. - Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. - Include all required ARIA attributes for elements with ARIA roles. - Make sure ARIA properties are valid for the element's supported roles. - Always include a `type` attribute for button elements. - Make elements with interactive roles and handlers focusable. - Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). - Always include a `lang` attribute on the html element. - Always include a `title` attribute for iframe elements. - Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. - Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. - Include caption tracks for audio and video elements. - Use semantic elements instead of role attributes in JSX. - Make sure all anchors are valid and navigable. - Ensure all ARIA properties (`aria-*`) are valid. - Use valid, non-abstract ARIA roles for elements with ARIA roles. - Use valid ARIA state and property values. - Use valid values for the `autocomplete` attribute on input elements. - Use correct ISO language/country codes for the `lang` attribute. ### Code Complexity and Quality - Don't use consecutive spaces in regular expression literals. - Don't use the `arguments` object. - Don't use primitive type aliases or misleading types. - Don't use the comma operator. - Don't use empty type parameters in type aliases and interfaces. - Don't write functions that exceed a given Cognitive Complexity score. - Don't nest describe() blocks too deeply in test files. - Don't use unnecessary boolean casts. - Don't use unnecessary callbacks with flatMap. - Use for...of statements instead of Array.forEach. - Don't create classes that only have static members (like a static namespace). - Don't use this and super in static contexts. - Don't use unnecessary catch clauses. - Don't use unnecessary constructors. - Don't use unnecessary continue statements. - Don't export empty modules that don't change anything. - Don't use unnecessary escape sequences in regular expression literals. - Don't use unnecessary fragments. - Don't use unnecessary labels. - Don't use unnecessary nested block statements. - Don't rename imports, exports, and destructured assignments to the same name. - Don't use unnecessary string or template literal concatenation. - Don't use String.raw in template literals when there are no escape sequences. - Don't use useless case statements in switch statements. - Don't use ternary operators when simpler alternatives exist. - Don't use useless `this` aliasing. - Don't use any or unknown as type constraints. - Don't initialize variables to undefined. - Don't use the void operators (they're not familiar). - Use arrow functions instead of function expressions. - Use Date.now() to get milliseconds since the Unix Epoch. - Use .flatMap() instead of map().flat() when possible. - Use literal property access instead of computed property access. - Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. - Use concise optional chaining instead of chained logical expressions. - Use regular expression literals instead of the RegExp constructor when possible. - Don't use number literal object member names that aren't base 10 or use underscore separators. - Remove redundant terms from logical expressions. - Use while loops instead of for loops when you don't need initializer and update expressions. - Don't pass children as props. - Don't reassign const variables. - Don't use constant expressions in conditions. - Don't use `Math.min` and `Math.max` to clamp values when the result is constant. - Don't return a value from a constructor. - Don't use empty character classes in regular expression literals. - Don't use empty destructuring patterns. - Don't call global object properties as functions. - Don't declare functions and vars that are accessible outside their block. - Make sure builtins are correctly instantiated. - Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. - Don't use variables and function parameters before they're declared. - Don't use 8 and 9 escape sequences in string literals. - Don't use literal numbers that lose precision. ### React and JSX Best Practices - Don't use the return value of React.render. - Make sure all dependencies are correctly specified in React hooks. - Make sure all React hooks are called from the top level of component functions. - Don't forget key props in iterators and collection literals. - Don't destructure props inside JSX components in Solid projects. - Don't define React components inside other components. - Don't use event handlers on non-interactive elements. - Don't assign to React component props. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. - Don't use dangerous JSX props. - Don't use Array index in keys. - Don't insert comments as text nodes. - Don't assign JSX properties multiple times. - Don't add extra closing tags for components without children. - Use `<>...` instead of `...`. - Watch out for possible "wrong" semicolons inside JSX elements. ### Correctness and Safety - Don't assign a value to itself. - Don't return a value from a setter. - Don't compare expressions that modify string case with non-compliant values. - Don't use lexical declarations in switch clauses. - Don't use variables that haven't been declared in the document. - Don't write unreachable code. - Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. - Don't use control flow statements in finally blocks. - Don't use optional chaining where undefined values aren't allowed. - Don't have unused function parameters. - Don't have unused imports. - Don't have unused labels. - Don't have unused private class members. - Don't have unused variables. - Make sure void (self-closing) elements don't have children. - Don't return a value from a function with the return type 'void' - Use isNaN() when checking for NaN. - Make sure "for" loop update clauses move the counter in the right direction. - Make sure typeof expressions are compared to valid values. - Make sure generator functions contain yield. - Don't use await inside loops. - Don't use bitwise operators. - Don't use expressions where the operation doesn't change the value. - Make sure Promise-like statements are handled appropriately. - Don't use __dirname and __filename in the global scope. - Prevent import cycles. - Don't use configured elements. - Don't hardcode sensitive data like API keys and tokens. - Don't let variable declarations shadow variables from outer scopes. - Don't use the TypeScript directive @ts-ignore. - Prevent duplicate polyfills from Polyfill.io. - Don't use useless backreferences in regular expressions that always match empty strings. - Don't use unnecessary escapes in string literals. - Don't use useless undefined. - Make sure getters and setters for the same property are next to each other in class and object definitions. - Make sure object literals are declared consistently (defaults to explicit definitions). - Use static Response methods instead of new Response() constructor when possible. - Make sure switch-case statements are exhaustive. - Make sure the `preconnect` attribute is used when using Google Fonts. - Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. - Make sure iterable callbacks return consistent values. - Use `with { type: "json" }` for JSON module imports. - Use numeric separators in numeric literals. - Use object spread instead of `Object.assign()` when constructing new objects. - Always use the radix argument when using `parseInt()`. - Make sure JSDoc comment lines start with a single asterisk, except for the first one. - Include a description parameter for `Symbol()`. - Don't use spread (`...`) syntax on accumulators. - Don't use the `delete` operator. - Don't access namespace imports dynamically. - Don't use namespace imports. - Declare regex literals at the top level. - Don't use `target="_blank"` without `rel="noopener"`. ### TypeScript Best Practices - Don't use TypeScript enums. - Don't export imported variables. - Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. - Don't use TypeScript namespaces. - Don't use non-null assertions with the `!` postfix operator. - Don't use parameter properties in class constructors. - Don't use user-defined types. - Use `as const` instead of literal types and type annotations. - Use either `T[]` or `Array` consistently. - Initialize each enum member value explicitly. - Use `export type` for types. - Use `import type` for types. - Make sure all enum members are literal values. - Don't use TypeScript const enum. - Don't declare empty interfaces. - Don't let variables evolve into any type through reassignments. - Don't use the any type. - Don't misuse the non-null assertion operator (!) in TypeScript files. - Don't use implicit any type on variable declarations. - Don't merge interfaces and classes unsafely. - Don't use overload signatures that aren't next to each other. - Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. ### Style and Consistency - Don't use global `eval()`. - Don't use callbacks in asynchronous tests and hooks. - Don't use negation in `if` statements that have `else` clauses. - Don't use nested ternary expressions. - Don't reassign function parameters. - This rule lets you specify global variable names you don't want to use in your application. - Don't use specified modules when loaded by import or require. - Don't use constants whose value is the upper-case version of their name. - Use `String.slice()` instead of `String.substr()` and `String.substring()`. - Don't use template literals if you don't need interpolation or special-character handling. - Don't use `else` blocks when the `if` block breaks early. - Don't use yoda expressions. - Don't use Array constructors. - Use `at()` instead of integer index access. - Follow curly brace conventions. - Use `else if` instead of nested `if` statements in `else` clauses. - Use single `if` statements instead of nested `if` clauses. - Use `new` for all builtins except `String`, `Number`, and `Boolean`. - Use consistent accessibility modifiers on class properties and methods. - Use `const` declarations for variables that are only assigned once. - Put default function parameters and optional function parameters last. - Include a `default` clause in switch statements. - Use the `**` operator instead of `Math.pow`. - Use `for-of` loops when you need the index to extract an item from the iterated array. - Use `node:assert/strict` over `node:assert`. - Use the `node:` protocol for Node.js builtin modules. - Use Number properties instead of global ones. - Use assignment operator shorthand where possible. - Use function types instead of object types with call signatures. - Use template literals over string concatenation. - Use `new` when throwing an error. - Don't throw non-Error values. - Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. - Use standard constants instead of approximated literals. - Don't assign values in expressions. - Don't use async functions as Promise executors. - Don't reassign exceptions in catch clauses. - Don't reassign class members. - Don't compare against -0. - Don't use labeled statements that aren't loops. - Don't use void type outside of generic or return types. - Don't use console. - Don't use control characters and escape sequences that match control characters in regular expression literals. - Don't use debugger. - Don't assign directly to document.cookie. - Use `===` and `!==`. - Don't use duplicate case labels. - Don't use duplicate class members. - Don't use duplicate conditions in if-else-if chains. - Don't use two keys with the same name inside objects. - Don't use duplicate function parameter names. - Don't have duplicate hooks in describe blocks. - Don't use empty block statements and static blocks. - Don't let switch clauses fall through. - Don't reassign function declarations. - Don't allow assignments to native objects and read-only global variables. - Use Number.isFinite instead of global isFinite. - Use Number.isNaN instead of global isNaN. - Don't assign to imported bindings. - Don't use irregular whitespace characters. - Don't use labels that share a name with a variable. - Don't use characters made with multiple code points in character class syntax. - Make sure to use new and constructor properly. - Don't use shorthand assign when the variable appears on both sides. - Don't use octal escape sequences in string literals. - Don't use Object.prototype builtins directly. - Don't redeclare variables, functions, classes, and types in the same scope. - Don't have redundant "use strict". - Don't compare things where both sides are exactly the same. - Don't let identifiers shadow restricted names. - Don't use sparse arrays (arrays with holes). - Don't use template literal placeholder syntax in regular strings. - Don't use the then property. - Don't use unsafe negation. - Don't use var. - Don't use with statements in non-strict contexts. - Make sure async functions actually use await. - Make sure default clauses in switch statements come last. - Make sure to pass a message value when creating a built-in error. - Make sure get methods always return a value. - Use a recommended display strategy with Google Fonts. - Make sure for-in loops include an if statement. - Use Array.isArray() instead of instanceof Array. - Make sure to use the digits argument with Number#toFixed(). - Make sure to use the "use strict" directive in script files. ### Next.js Specific Rules - Don't use `` elements in Next.js projects. - Don't use `` elements in Next.js projects. - Don't import next/document outside of pages/_document.jsx in Next.js projects. - Don't use the next/head module in pages/_document.js on Next.js projects. ### Phosphor Icons - Always use the Phosphor Icons package. - Always use icon as the suffix for the icon component e.g. `UploadSimpleIcon` instead of `UploadSimple`. ### Testing Best Practices - Don't use export or module.exports in test files. - Don't use focused tests. - Make sure the assertion function, like expect, is placed inside an it() function call. - Don't use disabled tests. ## Common Tasks - `npx ultracite init` - Initialize Ultracite in your project - `npx ultracite fix` - Format and fix code automatically - `npx ultracite check` - Check for issues without fixing ## Example: Error Handling ```typescript // ✅ Good: Comprehensive error handling try { const result = await fetchData(); return { success: true, data: result }; } catch (error) { console.error('API call failed:', error); return { success: false, error: error.message }; } // ❌ Bad: Swallowing errors try { return await fetchData(); } catch (e) { console.log(e); } ``` ================================================ FILE: .dockerignore ================================================ .git node_modules pnpm-store **/.next **/.turbo **/dist **/.output **/.vercel .env .env.* !.env.example !**/.env.example coverage coverage/** playwright-report playwright-report/** *.log *.swp *.DS_Store ================================================ FILE: .github/CONTRIBUTING.md ================================================ # Contributing to Marble Thanks for your interest in contributing! This guide explains how to get Marble running locally and how to submit high-quality contributions. ## Prerequisites Before you start, make sure you have the following installed or available: - **Node.js** ≥ 20.x - **pnpm** ≥ 10.x (install with `npm i -g pnpm`) - **PostgreSQL** database (we use [Neon](https://neon.tech) in examples) - **Redis** database (we use [Upstash](https://upstash.com)) - **Google** and **GitHub** OAuth apps (for authentication) - **Cloudflare** account with R2 enabled (for media uploads) - **Optional**: [Polar](https://sandbox.polar.sh) sandbox account if you want to test payments --- ## Structure This repository is a monorepo and is structured as follows: ```text / ├── apps/ │ ├── api/ → Hono REST API │ ├── cms/ → Next.js dashboard │ ├── docs/ → Mintlify documentation │ └── web/ → Astro marketing site ├── packages/ │ ├── db/ → Prisma schema + client (shared by api & cms) │ ├── editor/ → Tiptap-based rich text editor │ ├── email/ → Email templates │ ├── parser/ → Content parsing utilities │ ├── tsconfig/ → Shared TypeScript configurations │ ├── ui/ → shadcn/ui components (shared UI library) │ └── utils/ → Shared utilities ├── .npmrc ├── package.json ├── pnpm-workspace.yaml ├── README.md └── turbo.json ``` ### Apps This directory contains the source code for all related applications: - **api**: [Hono](https://hono.dev) REST API for content delivery - **cms**: [Next.js](https://nextjs.org) app for the dashboard - **docs**: [Mintlify](https://mintlify.com) documentation site - **web**: [Astro](https://astro.build) app for the marketing website ### Packages Packages contain internal shared modules used across different applications: - **db**: Prisma schema and client shared between the `api` and `cms` apps - **editor**: Tiptap-based rich text editor used in the CMS - **email**: Email templates for notifications and transactional emails - **parser**: Content parsing utilities - **tsconfig**: TypeScript configurations shared across the monorepo - **ui**: shadcn/ui components used in the `cms` app - **utils**: Shared utilities used across apps and packages ## Getting Started 1. [Fork](https://github.com/usemarble/marble/fork/) this repository to your own account - Visit Marble repository - Click the "Fork" button in the top right - [Clone](https://help.github.com/articles/cloning-a-repository/) the fork to your local device. ```bash git clone https://github.com/YOUR-USERNAME/marble.git cd marble ``` - add the original repo as upstream ```bash git remote add upstream https://github.com/usemarble/marble.git ``` 2. Install Dependencies ```bash pnpm install ``` 3. Configure Environment Variables Each app/package that uses environment variables has an example env file. You’ll need to copy and fill those out: ```bash cp apps/api/.dev.vars.example apps/api/.dev.vars cp apps/cms/.env.example apps/cms/.env cp apps/web/.env.example apps/web/.env cp packages/db/.env.example packages/db/.env ``` You'll need: - A Postgres connection string (either neon or use docker to self host) - Redis credentials from Upstash (see below) - Google and GitHub OAuth credentials (how to get these) - A BetterAuth secret - Cloudflare R2 credentials for file uploads (see below) - Optional: If you want to test payments, set up a [Polar](https://sandbox.polar.sh) sandbox account and fill in the POLAR_* variables. 4. Database Setup ### Option 1: Use Neon (Hosted) We use Neon for the database. Create a Neon project and copy your connection string for Prisma (ensure it includes `sslmode=require`). - Paste it into the relevant env files: Example: ```bash DATABASE_URL="postgresql://:@/?sslmode=require" ``` - Paste it into the relevant env files: - `apps/api/.dev.vars` → `DATABASE_URL=` - `apps/cms/.env` → `DATABASE_URL=` - `packages/db/.env` → `DATABASE_URL=` - Run migrations: ```bash pnpm db:migrate ``` ### Option 2: Use Docker (Local) Prerequisites: Docker Desktop (macOS/Windows) or Docker Engine + Docker Compose v2 (Linux). Start a local Postgres and run migrations: ```bash # from repo root pnpm docker:up # wait for the DB to be healthy (one of): # pnpm docker:logs # watch for "database system is ready to accept connections" # docker compose ps # ensure STATUS is "healthy" ## Alternatively, if your Docker Compose version supports it: # docker compose up -d --wait pnpm db:migrate ``` If you’re using the local Docker DB, set `DATABASE_URL` in these env files: - `apps/api/.dev.vars` - `apps/cms/.env` - `packages/db/.env` Example: ```bash DATABASE_URL=postgresql://usemarble:justusemarble@localhost:5432/marble ``` Note: These credentials are for local development only. Do not use them in production. This will: - Build (if needed) and start the Postgres container defined in `docker-compose.yml`. - Expose Postgres on port `5432` using the credentials from the compose file. - Persist data in the `marble_pgdata` Docker volume. - Note: If you already have a local Postgres on port 5432, stop it or adjust the port mapping in `docker-compose.yml`. Useful commands: ```bash pnpm docker:logs # follow DB logs pnpm docker:down # stop containers pnpm docker:clean # stop and remove volumes (DESTROYS local data) ``` ### Google OAuth Create a project in the Google Cloud Console. Follow [the first step](https://www.better-auth.com/docs/authentication/google) in the Better Auth documentation to set up Google OAuth and set the values for `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. ### GitHub OAuth If you would rather use github you can follow [the first step](https://www.better-auth.com/docs/authentication/github#get-your-github-credentials) in the better auth docs to setup Github oAuth and set the environment values for `GITHUB_ID` and `GITHUB_SECRET` ### Set up Cloudflare R2 for media uploads To use media uploads in Marble, you’ll need to set up a Cloudflare R2 bucket. Here's a step-by-step guide to help you configure everything properly: - Go to your Cloudflare dashboard - Select your account and navigate to R2 from the sidebar - Click "Create Bucket" - Name your bucket (e.g. marble-media) - Hit 'Create" - switch to the settings tab and enable "public development url" - copy the value to `CLOUDFLARE_PUBLIC_URL` - Set your bucket name to `CLOUDFLARE_BUCKET_NAME` - Go back to your R2 buckets overview and click "API" - from the dropdown select "Use r2 with apis" - then copy the api url and set to `CLOUDFLARE_S3_ENDPOINT` - Below the url click "Create api Tokens" - Select "Create user API Token" - For permissions select "admin read and write" - Leave everything else as default and click "Create user API Token" - Copy the values to `CLOUDFLARE_SECRET_ACCESS_KEY` and `CLOUDFLARE_ACCESS_KEY_ID` respectively ## Set up Redis ### Option 1: Use Upstash Redis Marble uses Redis for rate limiting, session caching, and analytics. We use [Upstash](https://upstash.com) for serverless Redis. Here's how to set it up: - Go to [Upstash Console](https://console.upstash.com) and sign in or create an account - Click "Create Database" - Give your database a name (e.g. marble-redis) - Select a region close to your primary deployment region - Leave the type as "Regional" (free tier) - Click "Create" - Once created, scroll down to the "REST API" section - Copy the "UPSTASH_REDIS_REST_URL" value and set it to `REDIS_URL` in your environment files - Copy the "UPSTASH_REDIS_REST_TOKEN" value and set it to `REDIS_TOKEN` in your environment files You'll need to add these to: - `apps/api/.dev.vars` → `REDIS_URL=` and `REDIS_TOKEN=` - `apps/cms/.env` → `REDIS_URL=` and `REDIS_TOKEN=` ### Option 2: Docker (Local) Use the repository's Docker Compose services to run Redis locally (native Redis + Upstash-compatible HTTP bridge): ```bash # from repo root pnpm docker:up pnpm docker:ps ``` Expected Redis services: - `redis` on `localhost:6379` - `serverless-redis-http` on `localhost:8079` Set these in your env files: - `apps/api/.dev.vars` → `REDIS_URL=http://localhost:8079` and `REDIS_TOKEN=justusemarble` - `apps/cms/.env` → `REDIS_URL=http://localhost:8079` and `REDIS_TOKEN=justusemarble` These values match the local defaults in: - `apps/api/.dev.vars.example` - `apps/cms/.env.example` - `docker-compose.yml` (`SRH_TOKEN=justusemarble`, `8079:80`, `6379:6379`) Stop services when done: ```bash pnpm docker:down ``` ## Running the Apps From the root you can run all apps ```bash pnpm dev ``` or just one ```bash pnpm cms:dev pnpm api:dev pnpm web:dev pnpm docs:dev ``` ## Contributing to docs The documentation lives in `apps/docs` and is built with [Mintlify](https://mintlify.com). To contribute: ### Prerequisites - Node.js v20.17.0+ (same as the main project) - No database, Redis, or OAuth setup required ### Option A: From the monorepo root (recommended) After `pnpm install`, run: ```bash pnpm docs:dev ``` This uses the Mintlify CLI from the workspace—no global install needed. Docs preview at `http://localhost:3000`. ### Option B: Manual setup If you prefer the CLI globally: ```bash pnpm add -g mint cd apps/docs mint dev ``` ### Port conflicts If port 3000 is in use (e.g. by another app), use a different port: ```bash mint dev --port 3333 ``` ### Useful commands From `apps/docs`: - `mint broken-links` — Find broken internal links - `mint a11y` — Check accessibility (contrast, alt text) - `mint validate` — Validate the build (useful for CI) ### Project structure See [apps/docs/README.md](../apps/docs/README.md) for the docs file layout. ## Agent skills (optional) The repo root has a [`skills-lock.json`](../skills-lock.json) file that pins [Agent Skills](https://skills.sh/) (for example Cloudflare, Tiptap, Vercel labs, and Resend-related packages). Installed skill files live under gitignored paths (for example `.agents/skills/`, `.cursor/skills/`, and `.claude/skills/`) so they are not committed. After cloning, you can restore the same skills from the lockfile from the repository root: ```bash npx skills experimental_install ``` `experimental_install` is the current CLI command that reads `skills-lock.json`. Adding a package updates the lockfile — for example: ```bash npx skills add https://github.com/cloudflare/skills npx skills add ueberdosis/tiptap npx skills add vercel-labs/agent-skills npx skills add resend/email-best-practices ``` If you add or change skills for the team, commit the updated `skills-lock.json`. ## Making changes 1. Create a new branch for your changes ```bash git checkout -b feature/your-feature ``` 2. Before committing your changes make sure to run the lint and format commands (via ultracite/Biome) to catch any issues ```bash pnpm format ``` or if you would rather fix issues yourself, run the following to list problems without fixing ```bash pnpm lint ``` 3. Test your changes and make sure they work and run a build ```bash pnpm build ``` 4. If your build succeeds you can go ahead and make a commit using conventional [commit messages](https://www.conventionalcommits.org/en/v1.0.0/) ```bash git commit -m "fix(cms): fix sidebar overflow issue" ``` ## Pull Request Guidelines - Your PR should reference an issue (if applicable) or clearly describe its impact on the project. [see how to Link a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) - Include a clear description of the changes - Keep PRs small and focused. Large PRs are harder to review and may be rejected or delayed. - Ensure consistency with the existing codebase. Use ultracite (Biome) for linting and formatting. - Include tests if applicable - Update documentation if your changes affect usage or API behavior. ## Code Style - Follow the existing code formatting in the project (use ultracite/Biome for consistency). - Write clear, self-documenting code - Add comments only when necessary to explain complex logic - Use meaningful variable and function names ## Reporting Issues - Use the GitHub issue tracker - Check if the issue already exists before creating a new one - Provide a clear description of the issue - Include steps to reproduce the issue ## Need Help? Feel free to open an issue for questions or join our [discord](https://discord.gg/gU44Pmwqkx). Thank you for contributing! ================================================ FILE: .github/FUNDING.yml ================================================ github: [usemarble] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/pull_request_template.md ================================================ ## Description ## Motivation and Context ## How to Test ## Screenshots (if applicable) ## Video Demo (if applicable) ## Types of Changes - [ ] 🐛 Bug fix (non-breaking change that fixes an issue) - [ ] ✨ New feature (non-breaking change that adds functionality) - [ ] ⚠️ Breaking change (fix or feature that alters existing functionality) - [ ] 🎨 UI/UX Improvements - [ ] ⚡ Performance Enhancement - [ ] 📖 Documentation (updates to README, docs, or comments) ================================================ FILE: .github/workflows/code-quality.yml ================================================ name: Code quality on: push: pull_request: jobs: quality: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - name: Install Node.js uses: actions/setup-node@v4 - uses: pnpm/action-setup@v4 name: Install pnpm - name: Install dependencies run: pnpm install --ignore-scripts - name: Run lint run: pnpm lint ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: pull_request: workflow_dispatch: jobs: tests: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v5 - name: Install Node.js uses: actions/setup-node@v4 - uses: pnpm/action-setup@v4 name: Install pnpm - name: Install dependencies run: pnpm install --ignore-scripts - name: Run tests run: pnpm test ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # Dependencies node_modules .pnp .pnp.js # Local env files .env .env.local .env.development.local .env.test.local .env.production.local # Testing coverage # Turbo .turbo # Vercel .vercel # Cloudflare .wrangler # Build Outputs .next/ out/ build dist packages/db/src/generated/ .idea # Debug npm-debug.log* yarn-debug.log* yarn-error.log* # Misc .DS_Store *.pem # Agent skills (install locally via npx skills add) .agents/skills/ .agent/skills/ .cursor/skills/ .claude/skills/ docs # Local only references school/ ================================================ FILE: .husky/commit-msg ================================================ pnpm exec commitlint --edit "$1" ================================================ FILE: .npmrc ================================================ public-hoist-pattern[]=*prisma* ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "biomejs.biome", "astro-build.astro-vscode", "bradlc.vscode-tailwindcss", "Prisma.prisma" ] } ================================================ FILE: .vscode/settings.json ================================================ { "tailwindCSS.experimental.configFile": { "./apps/web/src/styles/globals.css": ["./apps/web/src/**"], "./packages/ui/src/styles/globals.css": [ "./packages/ui/src/**", "./packages/editor/src/**", "./apps/cms/src/**" ] }, "tailwindCSS.includeLanguages": { "astro": "html" }, "tailwindCSS.classFunctions": ["cn", "clsx"], "files.associations": { "*.css": "tailwindcss" }, "tailwindCSS.experimental.classRegex": [ [ "(?:export\\s+)?const\\s+[A-Za-z0-9_$]+\\s*=\\s*([\\s\\S]*?);", "[\"'`]([^\"'`]*)[\"'`]" ], "return\\s+[\"'`]([^\"'`]*)[\"'`]" ], "editor.defaultFormatter": "biomejs.biome", "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", "source.fixAll.biome": "explicit" }, "editor.formatOnSave": true, "editor.formatOnPaste": true, "search.exclude": { "**/node_modules": true }, "[prisma]": { "editor.defaultFormatter": "Prisma.prisma" }, "[javascriptreact]": { "editor.defaultFormatter": "biomejs.biome" }, "[json]": { "editor.defaultFormatter": "biomejs.biome" }, "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, "[css]": { "editor.defaultFormatter": "biomejs.biome" }, "[graphql]": { "editor.defaultFormatter": "biomejs.biome" }, "typescript.tsdk": "node_modules/typescript/lib", "emmet.showExpandedAbbreviation": "never" } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at support@marblecms.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================

Marble

Vercel OSS Program

Super simple way to publish articles, product updates and changelogs to your site

--- ## ✨ Features - Create, edit, and manage posts in a beautiful editor - Organise content with **tags** and **categories** - Upload and embed **images** and **videos** in posts - **Readability insights** and AI-powered writing suggestions in the editor - **Realtime webhooks** to trigger workflows when content changes - Fetch your content anywhere through the **REST API** ## 🛠 Local Development & Contributing Want to run Marble locally or contribute a feature? Check out our [Contributing Guide](./.github/CONTRIBUTING.md) for a step-by-step guide covering setup, database configuration, and pull-request guidelines. ## Community & Support Have questions or feedback? - Join the [Discord](https://discord.gg/gU44Pmwqkx) - Follow us on [Twitter](https://twitter.com/usemarblecms) Feel free to open an issue for bugs or feature requests. ## License Marble is released under the [GNU Affero General Public License v3.0](./LICENSE.md). ================================================ FILE: apps/api/.gitignore ================================================ # prod dist/ # dev .yarn/ !.yarn/releases .vscode/* !.vscode/launch.json !.vscode/*.code-snippets .idea/workspace.xml .idea/usage.statistics.xml .idea/shelf # deps node_modules/ .wrangler # env .env .env.production .dev.vars # logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* # misc .DS_Store ================================================ FILE: apps/api/README.md ================================================ # API API endpoints users can fetch data from. ================================================ FILE: apps/api/package.json ================================================ { "name": "api", "version": "0.1.0", "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy --minify", "cf-typegen": "wrangler types --env-interface CloudflareBindings" }, "dependencies": { "@hono/zod-openapi": "^1.3.0", "@hono/zod-validator": "^0.7.6", "@marble/db": "workspace:*", "@marble/email": "workspace:*", "@marble/events": "workspace:*", "@marble/utils": "workspace:*", "@polar-sh/sdk": "^0.42.5", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.36.4", "hono": "^4.12.14", "image-size": "^2.0.2", "node-html-markdown": "^2.0.0", "resend": "^6.12.3", "sanitize-html": "^2.17.1", "zod": "^4.3.6" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260508.1", "@types/sanitize-html": "^2.16.1", "wrangler": "^4.90.0" } } ================================================ FILE: apps/api/src/app.ts ================================================ import { OpenAPIHono } from "@hono/zod-openapi"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { trimTrailingSlash } from "hono/trailing-slash"; import { FRAMER_PLUGIN_PATTERN, ROUTES } from "./lib/constants"; import { analytics } from "./middleware/analytics"; import { authorization } from "./middleware/authorization"; import { cache } from "./middleware/cache"; import { keyAuthorization } from "./middleware/key-authorization"; import { legacyAnalytics } from "./middleware/legacy-analytics"; import { ratelimit } from "./middleware/ratelimit"; import { systemAuth } from "./middleware/system"; import authorsRoutes from "./routes/authors"; import cacheRoutes from "./routes/cache"; import categoriesRoutes from "./routes/categories"; import eventsRoutes from "./routes/events"; import invalidateRoutes from "./routes/invalidate"; import mediaRoutes from "./routes/media"; import postsRoutes from "./routes/posts"; import tagsRoutes from "./routes/tags"; import type { ApiKeyApp, Env } from "./types/env"; const app = new OpenAPIHono<{ Bindings: Env }>(); // Global middleware — CORS must be first so preflight and cross-origin responses work app.use( "*", cors({ origin: (origin) => { if (!origin) { return "*"; } if (FRAMER_PLUGIN_PATTERN.test(origin)) { return origin; } return "*"; }, allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }) ); app.use("*", cache()); app.use(trimTrailingSlash()); // Internal System Routes (no API key, no analytics) app.use("/cache/invalidate", systemAuth()); app.route("/cache/invalidate", cacheRoutes); app.use("/internal/events", systemAuth()); app.route("/internal/events", eventsRoutes); // Legacy Workspace ID Routes (/v1/:workspaceId/*) // MUST be registered BEFORE apiKeyV1 to intercept workspace ID routes // Using standard Hono since these are deprecated and don't need to be in the spec const legacyV1 = new Hono<{ Bindings: Env }>(); legacyV1.use("/:workspaceId/*", ratelimit("workspace")); legacyV1.use("/:workspaceId/*", authorization()); legacyV1.use("/:workspaceId/*", legacyAnalytics()); legacyV1.route("/:workspaceId/tags", tagsRoutes); legacyV1.route("/:workspaceId/categories", categoriesRoutes); legacyV1.route("/:workspaceId/posts", postsRoutes); legacyV1.route("/:workspaceId/authors", authorsRoutes); // Mount legacy routes dispatcher - intercepts /v1/:workspaceId/* BEFORE apiKeyV1 app.use("/v1/:workspaceId/*", async (c, next) => { const path = c.req.path; const workspaceId = c.req.param("workspaceId"); // Check if this is a legacy workspace route (workspaceId is not a known resource) if (!ROUTES.includes(workspaceId)) { // Rewrite path (strip /v1 prefix) for legacy router const newPath = path.replace("/v1", ""); const newUrl = new URL(c.req.url); newUrl.pathname = newPath; const newRequest = new Request(newUrl.toString(), c.req.raw); return legacyV1.fetch(newRequest, c.env, c.executionCtx); } // If workspaceId is actually a resource name (posts, tags, etc.), skip this middleware // and let Hono continue to the next matching route (apiKeyV1) return next(); }); // API Key Routes (/v1/posts, /v1/tags, etc.) // Using OpenAPIHono to properly merge specs const apiKeyV1 = new OpenAPIHono(); apiKeyV1.use("*", ratelimit("apiKey")); apiKeyV1.use("*", keyAuthorization()); apiKeyV1.use("*", analytics()); // Mount routes with proper OpenAPIHono to enable spec merging apiKeyV1.route("/posts", postsRoutes); apiKeyV1.route("/categories", categoriesRoutes); apiKeyV1.route("/tags", tagsRoutes); apiKeyV1.route("/authors", authorsRoutes); apiKeyV1.route("/media", mediaRoutes); apiKeyV1.route("/cache/invalidate", invalidateRoutes); // Mount apiKeyV1 under /v1 to automatically merge OpenAPI specs app.route("/v1", apiKeyV1); // Redirect non-versioned routes to v1 app.use("/:workspaceId/*", async (c, next) => { const path = c.req.path; const workspaceId = c.req.param("workspaceId"); if ( path.startsWith("/v1/") || path === "/" || path === "/status" || path === "/openapi.json" ) { return next(); } const isWorkspaceRoute = ROUTES.some( (route) => path === `/${workspaceId}/${route}` || path.startsWith(`/${workspaceId}/${route}/`) ); if (isWorkspaceRoute) { const url = new URL(c.req.url); url.pathname = `/v1${path}`; return Response.redirect(url.toString(), 308); } return next(); }); // Redirect non-versioned API routes to v1 (e.g., /posts -> /v1/posts) app.use("/*", async (c, next) => { const path = c.req.path; const firstSegment = path.split("/").filter(Boolean)[0]; if (firstSegment && ROUTES.includes(firstSegment)) { const url = new URL(c.req.url); url.pathname = `/v1${path}`; return Response.redirect(url.toString(), 308); } return next(); }); app.get("/", (c) => c.text("Hello from marble")); app.get("/status", (c) => c.json({ status: "ok" })); app.doc("/openapi.json", { openapi: "3.1.0", info: { title: "Marble API", version: "1.0.0", description: "A headless CMS API for managing and delivering content programmatically.", }, servers: [{ url: "https://api.marblecms.com", description: "Production" }], security: [{ apiKey: [] }], }); app.openAPIRegistry.registerComponent("securitySchemes", "apiKey", { type: "apiKey", in: "header", name: "Authorization", description: "Your Marble API key", }); export default app; ================================================ FILE: apps/api/src/index.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: "required" */ export { default } from "./app"; ================================================ FILE: apps/api/src/lib/cache.ts ================================================ import { Redis } from "@upstash/redis/cloudflare"; /** Default cache TTL in seconds (1 hour) */ const DEFAULT_TTL = 3600; /** Cache key prefix for all cached data */ const CACHE_PREFIX = "cache"; /** Skip caching values larger than 8MB to stay under Upstash's 10MB request limit */ const MAX_CACHE_VALUE_BYTES = 8 * 1024 * 1024; export type CacheClient = ReturnType; /** * Create a cache client with helper methods for the cache-aside pattern. * Uses Upstash Redis for storage. */ export function createCacheClient(url: string, token: string) { const redis = new Redis({ url, token }); return { /** * Get a cached value by key */ async get(key: string): Promise { try { const value = await redis.get(key); if (value !== null) { console.log(`[Cache] HIT: ${key}`); } return value; } catch (error) { console.error(`[Cache] GET error for ${key}:`, error); return null; } }, /** * Set a cached value with optional TTL */ async set(key: string, value: T, ttl = DEFAULT_TTL): Promise { try { const serialized = JSON.stringify(value); if (serialized.length > MAX_CACHE_VALUE_BYTES) { console.warn( `[Cache] SKIP SET: ${key} is ${serialized.length} bytes, exceeds ${MAX_CACHE_VALUE_BYTES} byte limit` ); return; } await redis.set(key, value, { ex: ttl }); console.log(`[Cache] SET: ${key} (TTL: ${ttl}s)`); } catch (error) { console.error(`[Cache] SET error for ${key}:`, error); } }, /** * Cache-aside pattern: get from cache or fetch and cache */ async getOrSet( key: string, fetcher: () => Promise, ttl = DEFAULT_TTL ): Promise { try { const cached = await redis.get(key); if (cached !== null) { console.log(`[Cache] HIT: ${key}`); return cached; } console.log(`[Cache] MISS: ${key}`); const fresh = await fetcher(); const serialized = JSON.stringify(fresh); if (serialized.length > MAX_CACHE_VALUE_BYTES) { console.warn( `[Cache] SKIP SET: ${key} is ${serialized.length} bytes, exceeds ${MAX_CACHE_VALUE_BYTES} byte limit` ); return fresh; } await redis.set(key, fresh, { ex: ttl }); return fresh; } catch (error) { console.error(`[Cache] getOrSet error for ${key}:`, error); return fetcher(); } }, /** * Cache-aside pattern for count queries * Optimized for caching numeric count values */ async getOrSetCount( key: string, fetcher: () => Promise, ttl = DEFAULT_TTL ): Promise { try { const cached = await redis.get(key); if (cached !== null) { console.log(`[Cache] HIT (count): ${key}`); return cached; } console.log(`[Cache] MISS (count): ${key}`); const fresh = await fetcher(); await redis.set(key, fresh, { ex: ttl }); return fresh; } catch (error) { console.error(`[Cache] getOrSetCount error for ${key}:`, error); return fetcher(); } }, /** * Invalidate cache keys matching a pattern * Uses SCAN to find keys iteratively, then DEL to remove them in batches */ async invalidate(pattern: string): Promise { try { let cursor: string | number = "0"; const allKeys: string[] = []; const batchSize = 100; // Use SCAN to iterate through keys matching the pattern do { const [nextCursor, keys]: [string | number, string[]] = await redis.scan(cursor, { match: pattern, count: batchSize, }); cursor = nextCursor; if (Array.isArray(keys)) { allKeys.push(...keys); } } while (String(cursor) !== "0"); // Delete keys in batches to avoid large argument lists let deletedCount = 0; for (let i = 0; i < allKeys.length; i += batchSize) { const batch = allKeys.slice(i, i + batchSize); if (batch.length > 0) { const deleted = await redis.del(...batch); deletedCount += deleted; } } if (deletedCount > 0) { console.log(`[Cache] INVALIDATE: ${pattern} (${deletedCount} keys)`); } return deletedCount; } catch (error) { console.error(`[Cache] INVALIDATE error for ${pattern}:`, error); return 0; } }, /** * Invalidate all cache for a specific workspace */ async invalidateWorkspace(workspaceId: string): Promise { return this.invalidate(`${CACHE_PREFIX}:${workspaceId}:*`); }, /** * Invalidate cache for a specific resource type in a workspace */ async invalidateResource( workspaceId: string, resource: "posts" | "categories" | "tags" | "authors" | "media" ): Promise { return this.invalidate(`${CACHE_PREFIX}:${workspaceId}:${resource}:*`); }, }; } /** * Generate a cache key for a workspace resource */ export function cacheKey( workspaceId: string, resource: string, ...parts: string[] ): string { return [CACHE_PREFIX, workspaceId, resource, ...parts].join(":"); } /** * Generate a hash from query parameters for cache key uniqueness * Uses a polynomial rolling hash that works in both Node and Workers */ export function hashQueryParams(params: Record): string { const sorted = Object.keys(params) .sort() .reduce( (acc, key) => { const value = params[key]; if (value !== undefined && value !== null && value !== "") { acc[key] = value; } return acc; }, {} as Record ); // Use a polynomial rolling hash for consistent cross-platform hashing const str = JSON.stringify(sorted); const hash = Array.from(str).reduce((acc, char) => { const code = char.charCodeAt(0); return Math.abs((acc * 31 + code) % 2_147_483_647); }, 0); return hash.toString(36); } ================================================ FILE: apps/api/src/lib/constants.ts ================================================ /** * Available API route resources. * Used for route dispatching and redirect logic. */ export const ROUTES = [ "posts", "categories", "tags", "authors", "cache", "media", ]; export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; export const DEFAULT_CDN_URL = "https://cdn.marblecms.com"; export const ALLOWED_IMAGE_MIME_TYPES = [ "image/jpeg", "image/png", "image/gif", "image/webp", "image/avif", "image/svg+xml", ] as const; export const ALLOWED_VIDEO_MIME_TYPES = [ "video/mp4", "video/webm", "video/ogg", "video/quicktime", ] as const; export const ALLOWED_AUDIO_MIME_TYPES = [ "audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm", "audio/aac", "audio/flac", ] as const; export const ALLOWED_DOCUMENT_MIME_TYPES = [ "application/pdf", "text/plain", "text/csv", "application/json", "application/zip", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation", ] as const; export const ALLOWED_MEDIA_MIME_TYPES = [ ...ALLOWED_IMAGE_MIME_TYPES, ...ALLOWED_VIDEO_MIME_TYPES, ...ALLOWED_AUDIO_MIME_TYPES, ...ALLOWED_DOCUMENT_MIME_TYPES, ] as const; export type AllowedMediaMimeType = (typeof ALLOWED_MEDIA_MIME_TYPES)[number]; export const FRAMER_PLUGIN_ID = "4pj5owtk2qcexo6c1yt9kicye"; export const FRAMER_PLUGIN_PATTERN = new RegExp( `^https://${FRAMER_PLUGIN_ID}(-[a-zA-Z0-9]+)?\\.plugins\\.framercdn\\.com$` ); ================================================ FILE: apps/api/src/lib/crypto.ts ================================================ /** * Web Crypto API utilities for Cloudflare Workers * These functions use the native Web Crypto API instead of Node.js crypto * to avoid WASM polyfill issues in the Workers runtime */ /** * Hash an API key using SHA-256 via Web Crypto API * @param key - The plaintext API key to hash * @returns The SHA-256 hash of the key as a hex string (64 characters) */ export async function hashApiKey(key: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(key); const hashBuffer = await crypto.subtle.digest("SHA-256", data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); } ================================================ FILE: apps/api/src/lib/db.ts ================================================ import { createClient as createHyperdriveClient } from "@marble/db/hyperdrive"; import { createClient as createWorkersClient } from "@marble/db/workers"; import type { Env } from "@/types/env"; /** * Get the database connection string. * In development, uses DATABASE_URL directly to bypass Hyperdrive's local proxy * (which can have compatibility issues with Neon's serverless driver). * In production, uses Hyperdrive for connection pooling and latency optimization. */ export function getConnectionString(env: Env): string { // if (env.ENVIRONMENT === "development" && env.DATABASE_URL) { // return env.DATABASE_URL; // } if (!env.HYPERDRIVE?.connectionString) { throw new Error( "Database configuration error: no connection string available" ); } return env.HYPERDRIVE.connectionString; } /** * Create a Prisma client with the correct adapter for the current env. * - DATABASE_URL (dev or BYPASS_HYPERDRIVE): Neon serverless driver * - HYPERDRIVE: pg-worker driver (standard Postgres, Hyperdrive-compatible) */ export type DbClient = ReturnType; export function createDbClient(env: Env) { // const useDirect = env.ENVIRONMENT === "development"; // if (useDirect && env.DATABASE_URL) { // return createWorkersClient(env.DATABASE_URL); // } if (!env.HYPERDRIVE?.connectionString) { throw new Error( "Database configuration error: no connection string available" ); } return createHyperdriveClient(env.HYPERDRIVE.connectionString); } ================================================ FILE: apps/api/src/lib/events.ts ================================================ import type { createDbClient } from "@/lib/db"; import type { JsonObject } from "@/validations/json"; import type { WORKSPACE_EVENT_ACTOR_TYPES, WORKSPACE_EVENT_RESOURCE_TYPES, WORKSPACE_EVENT_SOURCES, WORKSPACE_EVENT_TYPES, } from "@/validations/misc"; interface EmitEventOptions { type: (typeof WORKSPACE_EVENT_TYPES)[number]; workspaceId: string; resourceType: (typeof WORKSPACE_EVENT_RESOURCE_TYPES)[number]; resourceId: string; source?: (typeof WORKSPACE_EVENT_SOURCES)[number]; actorType?: (typeof WORKSPACE_EVENT_ACTOR_TYPES)[number]; actorId?: string; payload?: JsonObject; } export async function emitEvent( db: ReturnType, queue: Queue, options: EmitEventOptions ) { const event = await db.workspaceEvent.create({ data: { type: options.type, workspaceId: options.workspaceId, source: options.source ?? "api", resourceType: options.resourceType, resourceId: options.resourceId, actorType: options.actorType, actorId: options.actorId, payload: options.payload ?? {}, }, }); await queue.send({ eventId: event.id }); return event; } ================================================ FILE: apps/api/src/lib/media.ts ================================================ import { imageSize } from "image-size"; import { DEFAULT_CDN_URL } from "./constants"; export type MediaType = "image" | "video" | "audio" | "document"; export interface MediaRecord { id: string; name: string; url: string; alt: string | null; size: number; mimeType: string | null; width: number | null; height: number | null; duration: number | null; blurHash: string | null; type: MediaType; createdAt: Date; updatedAt: Date; } export function serializeMedia(item: MediaRecord) { return { id: item.id, name: item.name, url: item.url, alt: item.alt, size: item.size, mimeType: item.mimeType, width: item.width, height: item.height, duration: item.duration, blurHash: item.blurHash, type: item.type, createdAt: item.createdAt.toISOString(), updatedAt: item.updatedAt.toISOString(), }; } export function getMediaType(mimeType: string): MediaType { if (mimeType.startsWith("image/")) { return "image"; } if (mimeType.startsWith("video/")) { return "video"; } if (mimeType.startsWith("audio/")) { return "audio"; } return "document"; } export function extensionFromFile(file: File) { const filename = file.name.trim(); const filenameExtension = filename.includes(".") ? filename.split(".").pop() : undefined; if (filenameExtension) { return filenameExtension.toLowerCase().replace(/[^a-z0-9]/g, ""); } return file.type.split("/")[1]?.split("+")[0] || "bin"; } export function publicUrl(envUrl: string | undefined, key: string) { const base = (envUrl || DEFAULT_CDN_URL).replace(/\/$/, ""); return `${base}/${key}`; } export function objectKeyFromUrl(url: string) { try { return new URL(url).pathname.replace(/^\/+/, ""); } catch { return null; } } export function getImageDimensions(buffer: ArrayBuffer) { try { const dimensions = imageSize(new Uint8Array(buffer)); return { width: dimensions.width, height: dimensions.height, }; } catch (error) { console.warn("Failed to read image dimensions:", error); return {}; } } ================================================ FILE: apps/api/src/lib/polar.ts ================================================ import { Polar } from "@polar-sh/sdk"; export function createPolarClient(accessToken: string, isProduction = false) { return new Polar({ server: isProduction ? "production" : "sandbox", accessToken, }); } ================================================ FILE: apps/api/src/lib/posts.ts ================================================ import { z } from "@hono/zod-openapi"; export const buildStatusFilter = (status: "published" | "draft" | "all") => status === "all" ? { status: { in: ["published", "draft"] as ("published" | "draft")[] } } : { status }; function castFieldValue( value: string, type: string ): string | number | boolean | string[] | null { switch (type) { case "number": { const num = Number.parseFloat(value); return Number.isNaN(num) ? null : num; } case "boolean": return value === "true"; case "multiselect": try { return z.array(z.string()).parse(JSON.parse(value)); } catch { return null; } default: return value; } } export function buildFieldsObject( fieldValues: Array<{ value: string; field: { key: string; type: string }; }>, allFields?: Array<{ key: string; type: string }> ): Record { const result: Record = {}; if (allFields) { for (const field of allFields) { result[field.key] = null; } } for (const fieldValue of fieldValues) { result[fieldValue.field.key] = castFieldValue( fieldValue.value, fieldValue.field.type ); } return result; } ================================================ FILE: apps/api/src/lib/redis.ts ================================================ import { Redis } from "@upstash/redis/cloudflare"; export function createRedisClient(url: string, token: string): Redis { return new Redis({ url, token }); } ================================================ FILE: apps/api/src/lib/sanitize.ts ================================================ import sanitize, { defaults } from "sanitize-html"; /** * Sanitize HTML content to prevent XSS attacks. * Uses the same configuration as the CMS editor to ensure consistency. * * - Strips ` ================================================ FILE: apps/web/src/components/CategoryCard.astro ================================================ --- import { Image } from "astro:assets"; import type { Post } from "@/lib/schemas"; interface Props { entry: Post; } const { entry } = Astro.props as Props; const { title, description, coverImage, publishedAt, authors, slug } = entry; const formattedDate = new Date(publishedAt).toLocaleDateString("en-US", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC", }); ---
  • {title}
    {authors[0].name}

    {title}

    {description}

    Read article
  • ================================================ FILE: apps/web/src/components/CategoryFilter.astro ================================================ --- import type { CollectionEntry } from "astro:content"; import ButtonComponent from "@/components/ui/Button.astro"; import { cn } from "@/lib/utils"; interface Props { categories: CollectionEntry<"categories">[]; } const { categories } = Astro.props as Props; const categoriesToShow = categories.filter( (category) => category.data.slug !== "legal" && category.data.slug !== "changelog" ); const currentPath = Astro.url.pathname.replace(/\/$/, ""); ---
    • All Posts
    • {categoriesToShow.map((category) => { const link = `/blog/category/${category.data.slug}`; const isActive = currentPath === link; return (
    • {category.data.name}
    • ) })}
    ================================================ FILE: apps/web/src/components/ChangelogCard.astro ================================================ --- import { Image } from "astro:assets"; import type { CollectionEntry } from "astro:content"; interface Props { entry: CollectionEntry<"changelog">; } const { entry } = Astro.props as Props; const { title, coverImage, publishedAt, authors, slug, tags } = entry.data; const formattedDate = new Date(publishedAt).toLocaleDateString("en-US", { day: "2-digit", month: "long", year: "numeric", timeZone: "UTC", }); ---
  • {coverImage && ( {title} )}

    {title}

    {authors[0].name}

    {authors[0].name}

    {tags.map((tag) => ( # {tag.name} ))}
  • ================================================ FILE: apps/web/src/components/Container.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; import { cn } from "@/lib/utils"; interface Props extends HTMLAttributes<"div"> {} const { class: classNames, ...attrs } = Astro.props; ---
    ================================================ FILE: apps/web/src/components/Footer.astro ================================================ --- import { FOOTER_SECTIONS, FOOTER_SOCIAL_LINKS, } from "@/lib/constants/navigation"; import Container from "./Container.astro"; import WordMark from "./icons/WordMark.astro"; const year = new Date().getFullYear(); const copyright = `© ${year} all rights reserved`; --- ================================================ FILE: apps/web/src/components/Head.astro ================================================ --- import { SITE } from "@/lib/constants/site"; import { buildSiteJsonLd, jsonLd } from "@/lib/seo"; import "@/styles/globals.css"; import { Font } from "astro:assets"; import { getSecret } from "astro:env/server"; import { ClientRouter } from "astro:transitions"; interface Props { title: string; description: string; image?: string; canonical?: string; structuredData?: unknown; } const { title, description, image = "/og.webp", canonical, structuredData, } = Astro.props; const canonicalURL = canonical ? new URL(canonical, Astro.site) : new URL(Astro.url.pathname, Astro.site); const databuddyClientId = getSecret("DATABUDDY_CLIENT_ID"); const siteStructuredData = buildSiteJsonLd({ title: SITE.TITLE, description: SITE.DESCRIPTION, url: SITE.URL, twitterUrl: SITE.TWITTER_URL, }); --- {title} ================================================ FILE: apps/web/src/components/PostCard.astro ================================================ --- import { Image } from "astro:assets"; import type { CollectionEntry } from "astro:content"; interface Props { entry: CollectionEntry<"posts">; } const { entry } = Astro.props as Props; const { title, description, coverImage, publishedAt, authors, slug } = entry.data; const formattedDate = new Date(publishedAt).toLocaleDateString("en-US", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC", }); ---
  • {title}

    {title}

    {description}

    Read article
  • ================================================ FILE: apps/web/src/components/PricingCard.astro ================================================ --- interface Props { title: string; description: string; price: string; features: string[]; button: { href: string; label: string; }; } ---
    ================================================ FILE: apps/web/src/components/Prose.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; import { cn } from "@/lib/utils"; interface Props extends HTMLAttributes<"article"> {} const { class: classNames, ...attrs } = Astro.props; ---
    ================================================ FILE: apps/web/src/components/ScrollToTop.astro ================================================ --- import { cn } from "@/lib/utils"; const { class: classNames } = Astro.props; --- ================================================ FILE: apps/web/src/components/Welcome.astro ================================================ --- import astroLogo from "../assets/astro.svg"; import background from "../assets/background.svg"; --- ================================================ FILE: apps/web/src/components/icons/Collab.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/Discord.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- Discord ================================================ FILE: apps/web/src/components/icons/Github.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- GitHub ================================================ FILE: apps/web/src/components/icons/Logo.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/Media.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/WordMark.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/WordMarkAlt.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/X.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- X ================================================ FILE: apps/web/src/components/icons/brand/Bounty.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/brand/Candle.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/brand/Databuddy.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/brand/Helix.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/brand/Ia.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/brand/Mantlz.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- Mantlz Logo ================================================ FILE: apps/web/src/components/icons/brand/Opencut.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/sponsors/Neon.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- Neon Logomark Color ================================================ FILE: apps/web/src/components/icons/sponsors/Upstash.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/icons/sponsors/Vercel.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; interface Props extends HTMLAttributes<"svg"> {} const { class: classNames, ...attrs } = Astro.props; --- ================================================ FILE: apps/web/src/components/sections/Pricing.astro ================================================ --- import { PRICING_PLANS } from "@marble/utils"; import Container from "@/components/Container.astro"; import Button from "@/components/ui/Button.astro"; import { cn } from "@/lib/utils"; ---

    Simple Pricing

    Choose a plan that fits your needs.

    Monthly Yearly
      {PRICING_PLANS.map((plan, index) => (
    • {index === 2 ? ( <>

      {plan.title}

      {plan.description}

      {plan.price.yearly} {' '} / year

        {plan.features.map((feature) => (
      • {feature}
      • ))}
      ) : ( <>

      {plan.title}

      {plan.description}

      {plan.price.yearly} {' '} / year

        {plan.features.map((feature) => (
      • {feature}
      • ))}
      )}
    • ))}
    ================================================ FILE: apps/web/src/components/ui/AccordionItem.astro ================================================ --- interface Props { open?: boolean; title: string; name?: string; class?: string; } const { open, title, name, class: className } = Astro.props; ---
    {title}
    ================================================ FILE: apps/web/src/components/ui/Button.astro ================================================ --- import type { HTMLAttributes } from "astro/types"; import { cn } from "@/lib/utils"; type Props = HTMLAttributes<"a"> & HTMLAttributes<"button"> & { variant?: "primary" | "secondary" | "outline" | "ghost"; size?: "default" | "sm" | "lg" | "icon"; }; const { variant = "primary", size = "default", class: className, href, ...rest } = Astro.props; const variants = { primary: "bg-accent text-white hover:bg-accent/90", secondary: "bg-muted text-muted-foreground hover:bg-muted/80", outline: "border border-input bg-background hover:bg-accent hover:text-white", ghost: "hover:bg-muted/50 hover:text-foreground", }; const sizes = { default: "h-11 px-8 py-2", sm: "h-9 px-3", lg: "h-12 px-8", icon: "h-10 w-10", }; const baseClass = "inline-flex items-center justify-center transition duration-300 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 outline-accent disabled:pointer-events-none disabled:opacity-50 cursor-pointer"; const classes = cn(baseClass, variants[variant], sizes[size], className); const Element = href ? "a" : "button"; --- ================================================ FILE: apps/web/src/content/pages/privacy.md ================================================ --- title: Privacy Policy published: 2024-12-12 description: Marble's Privacy Policy. lastUpdated: 2025-09-18 --- Marble ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, and safeguard your information when you use our service ("the Service"). ## 1. Information We Collect We collect only the information necessary to provide and improve our service: ### a. Account Information - **What we collect:** Basic details such as your name, email address, and OAuth provider IDs (e.g., Google, GitHub) when you sign up. - **Why we collect it:** To create and manage your account, authenticate you, and provide access to your workspaces. ### b. Workspace Data - **What we collect:** Content you create within the Service (e.g., posts, tags, workspace metadata). - **Why we collect it:** To deliver the core functionality of the CMS and allow collaboration. ### c. AI Features (Optional) - **What we process:** When you use our AI-powered features, your workspace data (e.g., “posts tagged ‘design’”) may be temporarily processed to generate responses. - **What we don’t do:** We do not use your data to train external AI models without your consent. ### d. Analytics Data - **What we collect:** Non-identifiable usage data such as page views, timestamps, and browser type. - **Provider:** We use Databuddy.cc, a GDPR-compliant analytics provider that does not track individuals. - **Why we collect it:** To monitor performance and improve the Service. ## 2. How We Use Your Information We use the collected information to: - Provide and operate the Service. - Enable authentication and account management. - Deliver AI-powered features at your request. - Monitor performance and usage trends. - Comply with legal obligations. We do not sell or rent your information to third parties. ## 3. Data Sharing We may share your information with trusted service providers (e.g., hosting providers, analytics, payment processors, AI infrastructure) who help us operate the Service. These providers are bound by strict confidentiality and data protection obligations. We may also share data if required by law. ## 4. Data Retention - Account data is retained for as long as your account is active. - Workspace data is retained until you delete it or request deletion. - Analytics data is retained only as long as necessary for performance monitoring. You may request deletion of your data at any time by contacting us at [support@marblecms.com](mailto:support@marblecms.com). ## 5. Your Rights Depending on your jurisdiction (e.g., GDPR, CCPA), you may have the right to: - Access and receive a copy of your data. - Request correction or deletion of your data. - Restrict or object to certain processing. - Export your data in a portable format. To exercise these rights, contact us at [support@marblecms.com](mailto:support@marblecms.com). ## 6. Cookies and Tracking We do not use cookies for tracking. Analytics is handled by Databuddy, which does not identify individual users. ## 7. Security Measures We use industry-standard measures, including encryption in transit and at rest, to protect your data. However, no system is 100% secure, and we cannot guarantee absolute protection. ## 8. Changes to This Policy We may update this Privacy Policy from time to time. If significant changes are made, we will notify you via the Service or email. ## 9. Contact Us If you have any questions or concerns, please contact us at: [support@marblecms.com](mailto:support@marblecms.com) --- By using the Service, you acknowledge that you have read and understood this Privacy Policy. ================================================ FILE: apps/web/src/content/pages/terms.md ================================================ --- title: Terms of Service published: 2024-12-12 description: Marble's Terms of Service. lastUpdated: 2025-09-18 --- ## 1. Acceptance of Terms By accessing or using Marble ("the Service"), you agree to these Terms of Service ("Terms"). If you do not agree, do not use the Service. ## 2. Definitions - **User:** An individual who creates an account or accesses the Service. - **Organization / Workspace:** A group account managed by one or more Users. - **Content:** Any data, text, images, or other materials uploaded or created through the Service. - **Subscription:** A paid plan granting access to certain features of the Service. ## 3. Eligibility & Accounts - You must be at least 16 years old to use the Service. - You are responsible for maintaining the confidentiality of your login credentials. - You agree to provide accurate information when creating an account or organization. - You are responsible for all activities under your account. ## 4. Content Ownership & License - You retain ownership of all Content you create. - By using the Service, you grant Marble a non-exclusive, worldwide license to host, display, and process your Content as necessary to operate the Service. - You represent that you have the rights to any Content you upload. - Marble retains all rights, title, and interest in its software, trademarks, and intellectual property. ## 5. Payments & Subscriptions - Some features require a paid Subscription. - Subscriptions are billed in advance on a recurring basis (monthly or yearly, as selected). - Payments are processed through third-party providers (e.g., Polar). Marble does not store payment card details. - All fees are non-refundable except as required by law. - If a payment fails, we may suspend or downgrade your access until payment is resolved. - We reserve the right to change pricing with reasonable prior notice. ## 6. Prohibited Activities You agree not to: - Use the Service for unlawful purposes. - Upload malicious software or harmful content. - Infringe on the rights of others, including intellectual property rights. - Attempt to gain unauthorized access to systems or data. ## 7. Data & Privacy - Your use of the Service is also governed by our [Privacy Policy](/privacy). - We may process personal data in accordance with applicable laws (e.g., GDPR, CCPA where applicable). - We do not sell your data. We may share data only with service providers necessary to operate the Service. ## 8. Service Availability - The Service is provided “as is” and “as available.” - We do not guarantee uninterrupted uptime or that the Service will be error-free. - We may modify, suspend, or discontinue the Service at any time without liability. ## 9. Indemnification You agree to indemnify and hold harmless Marble, its affiliates, and its contributors from any claims, damages, liabilities, or expenses arising out of: - Your use of the Service, - Your Content, or - Your violation of these Terms. ## 10. Limitation of Liability - To the maximum extent permitted by law, Marble shall not be liable for indirect, incidental, consequential, or punitive damages. - Marble’s total liability for any claim shall not exceed the amount you paid to us in the 12 months preceding the claim. ## 11. Modifications to Terms - We may update these Terms from time to time. - We will notify you of material changes via email or in-app notice. - Continued use of the Service after changes take effect constitutes acceptance. ## 12. Termination - We may suspend or terminate your account for violations of these Terms or misuse of the Service. - You may stop using the Service at any time. - Upon termination, your access will end but certain provisions (e.g., liability, ownership) will survive. ## 13. Governing Law & Dispute Resolution - These Terms are governed by the laws of the Federal Republic of Nigeria, without regard to conflict-of-law rules. - Any disputes will be resolved through binding arbitration or mediation where permitted, or in the courts of Nigeria. ## 14. Contact For questions or concerns about these Terms, contact us at: [support@marblecms.com](mailto:support@marblecms.com) ================================================ FILE: apps/web/src/content.config.ts ================================================ import { defineCollection } from "astro:content"; import { highlightContent } from "@marble/utils"; import { marble } from "./lib/marble"; import { categorySchema, postSchema } from "./lib/schemas"; const posts = defineCollection({ loader: async () => { const { result } = await marble.posts.list({ excludeCategories: ["legal", "changelog"], }); return Promise.all( result.posts.map(async (post) => ({ ...post, content: await highlightContent(post.content), })) ); }, schema: postSchema, }); const page = defineCollection({ loader: async () => { const { result } = await marble.posts.list({ categories: ["legal"] }); return result.posts.map((post) => ({ ...post, // Astro uses the id as a key to get the entry // We can't know the id of the post so we use the slug id: post.slug, })); }, schema: postSchema, }); const changelog = defineCollection({ loader: async () => { const { result } = await marble.posts.list({ categories: ["changelog"] }); return Promise.all( result.posts.map(async (post) => ({ ...post, id: post.slug, content: await highlightContent(post.content), })) ); }, schema: postSchema, }); const categories = defineCollection({ loader: async () => { const { result } = await marble.categories.list(); return result.categories.map((category) => ({ ...category, id: category.slug, })); }, schema: categorySchema, }); export const collections = { posts, page, changelog, categories, }; ================================================ FILE: apps/web/src/layouts/BlogLayout.astro ================================================ --- import SpeedInsights from "@vercel/speed-insights/astro"; import BlogHeader from "@/components/BlogHeader.astro"; import FooterComponent from "@/components/Footer.astro"; import HeadComponent from "@/components/Head.astro"; import ScrollToTop from "@/components/ScrollToTop.astro"; const { title, description, image, structuredData } = Astro.props; ---
    ================================================ FILE: apps/web/src/layouts/Layout.astro ================================================ --- import SpeedInsights from "@vercel/speed-insights/astro"; import FooterComponent from "@/components/Footer.astro"; import HeadComponent from "@/components/Head.astro"; import HeaderComponent from "@/components/Header.astro"; import ScrollToTop from "@/components/ScrollToTop.astro"; const { title, description, image, canonical, structuredData } = Astro.props; ---
    ================================================ FILE: apps/web/src/lib/accordion.ts ================================================ export const setAccordionHeight = ( accordions: NodeListOf ) => { const originalStates = Array.from(accordions).map( (accordion) => accordion.open ); for (const accordion of accordions) { accordion.classList.remove("accordion-item--animated"); resetAccordionHeight(accordion); assignHeight(accordion); } accordions.forEach((accordion, index) => { accordion.open = originalStates[index]; accordion.classList.add("accordion-item--animated"); }); }; const resetAccordionHeight = (accordion: HTMLDetailsElement) => { accordion.style.removeProperty("--accordion-item-expanded"); accordion.style.removeProperty("--accordion-item-collapsed"); }; const assignHeight = (accordion: HTMLDetailsElement) => { accordion.open = false; const collapsedHeight = accordion.offsetHeight; accordion.open = true; const expandedHeight = accordion.scrollHeight; accordion.style.setProperty( "--accordion-item-expanded", `${expandedHeight}px` ); accordion.style.setProperty( "--accordion-item-collapsed", `${collapsedHeight}px` ); }; export const debounce = ( callback: (...args: unknown[]) => void, delay: number ) => { let timeout: number; return (...args: unknown[]) => { clearTimeout(timeout); timeout = window.setTimeout(() => callback(...args), delay); }; }; export const handleResize = (callback: () => void) => { const debouncedCallback = debounce(callback, 300); window.addEventListener("resize", debouncedCallback); return () => { window.removeEventListener("resize", debouncedCallback); }; }; export const isTouchDevice = () => window.matchMedia("(pointer: coarse)").matches; let lastWidth: number; export const hasViewportWidthChanged = (): boolean => { if (typeof window !== "undefined") { const currentWidth = window.innerWidth; const widthChanged = currentWidth !== lastWidth; if (widthChanged) { lastWidth = currentWidth; } return widthChanged; } return false; }; ================================================ FILE: apps/web/src/lib/constants/faqs.ts ================================================ export const FAQs: { question: string; answer: string; }[] = [ { question: "What is Marble?", answer: "Marble is a headless CMS designed specifically for managing blogs, changelogs, and articles. It provides a simple interface for creating and organizing content, along with a powerful API to fetch and display it on your website or app.", }, { question: "How does Marble work?", answer: "Marble is a headless CMS that provides content management through a simple API. You can create, edit and manage content through our dashboard, then fetch it via our API to display on your website or app.", }, { question: "Is Marble free?", answer: "Yes, Marble is free to use with generous limits on all core features. We also offer paid plans for teams needing higher limits and advanced features.", }, { question: "Who is Marble for?", answer: "Marble is for developers, writers, and teams who want a simple, reliable CMS for content-driven sites without the complexity of traditional CMS platforms.", }, { question: "Do I need technical knowledge to use Marble?", answer: "No technical knowledge is required to use our content management dashboard. However, to integrate the API with your website or app, basic development experience is helpful. We provide detailed documentation and templates to make integration easy.", }, { question: "What kind of content can I manage?", answer: "Marble is primarily focused on managing blog posts, changelogs, articles, and static pages. We support rich text, images, and videos to help you create engaging content for your blog or documentation site.", }, { question: "Is there a limit on API requests?", answer: "Free accounts include 10.000 API requests per month. We implement fair usage policies to prevent abuse but typical usage patterns are well within our limits.", }, { question: "Can I import content from elsewhere?", answer: "Yes, you can import content from elsewhere by simply pasting a markdown file into the editor or using the import button on the posts page.", }, { question: "Is Marble SEO friendly?", answer: "Yes, Marble is SEO friendly. We provide a field for every data you might need to generate an SEO optimized page.", }, { question: "What frameworks work best with Marble?", answer: "Marble is framework agnostic but works best with frameworks that support server-side rendering (SSR) and static site generation (SSG).", }, { question: "Can I manage multiple blogs or projects?", answer: "Yes, you can manage multiple blogs or projects by creating multiple workspaces. Each workspace is independent and can have its own set of users and content.", }, { question: "Is Marble open source?", answer: "Yes, Marble is 100% open source. You can find the source code on GitHub.", }, ]; export const PRICING_FAQS: { question: string; answer: string; }[] = [ { question: "How are plans billed?", answer: "Our plans are billed per workspace. This means you can invite as many team members as your plan allows to a workspace without any extra charges per member.", }, { question: "Do you offer a free trial?", answer: "Yes! The Pro plan includes a 3-day free trial. You can try all Pro features risk-free for 3 days. If you don't cancel during the trial period, your subscription will automatically renew at the full price. You can cancel anytime during the trial period without being charged.", }, { question: "How do I get a refund?", answer: "To request a refund, please contact us at support@marblecms.com within 7 days of your purchase. We're also available on X at usemarblecms and on our Discord channel.", }, { question: "Can I change my plan later?", answer: "Yes, you can upgrade or downgrade your plan at any time from your workspace billing settings. Prorated charges or credits will be applied automatically.", }, { question: "What payment methods do you accept?", answer: "We accept all major credit cards, including Visa, Mastercard, and American Express. All payments are processed securely via Polar.", }, { question: "What happens when I downgrade my plan?", answer: "When you downgrade, you'll retain access to paid features until the end of your current billing cycle. Afterward, your workspace will be moved to the Free plan, and some features may become unavailable.", }, { question: "Can I cancel anytime?", answer: "Yes, you can cancel your subscription at any time from your workspace billing settings. Your subscription will remain active until the end of your current billing period, and you won't be charged for the next cycle.", }, { question: "What happens if I exceed my plan limits?", answer: "If you exceed your plan's API request, storage, or webhook limits, we'll notify you. For API requests, you may experience rate limiting. We recommend upgrading to Pro if you consistently exceed limits, or you can monitor your usage in the dashboard.", }, { question: "Can I have multiple workspaces on one plan?", answer: "Each workspace requires its own subscription. If you want multiple workspaces on a paid plan, you'll need to subscribe separately for each workspace. However, you can have unlimited workspaces on the free Hobby plan.", }, { question: "Is there a discount for annual billing?", answer: "Yes! When you choose annual billing, you save about 17% compared to monthly billing. The Pro plan is $200 per year (equivalent to about $16.67/month) instead of $20/month when billed monthly.", }, ]; ================================================ FILE: apps/web/src/lib/constants/landing.ts ================================================ import contentIntelImage from "../../assets/images/content-intelligence.png"; import apiImage from "../../assets/images/headless-api.png"; import mediaImage from "../../assets/images/media-management.png"; import webhooksImage from "../../assets/images/webhooks.png"; import Bounty from "../../components/icons/brand/Bounty.astro"; import Candle from "../../components/icons/brand/Candle.astro"; import Databuddy from "../../components/icons/brand/Databuddy.astro"; import Helix from "../../components/icons/brand/Helix.astro"; import Ia from "../../components/icons/brand/Ia.astro"; import Opencut from "../../components/icons/brand/Opencut.astro"; export const FEATURES = [ { title: "Simple Headless API", description: "Pull your content into any framework. Works seamlessly with Next.js, Astro, Nuxt, and more.", link: { text: "Learn more", href: "https://docs.marblecms.com/api/introduction", }, image: apiImage, }, { title: "Media Management", description: "Upload, organize, and manage your images and videos in one place. media files are served from a globally distributed CDN for instant loading.", link: { text: "Learn more", href: "https://docs.marblecms.com/guides/features/media", }, image: mediaImage, }, { title: "Realtime Webhooks", description: "Trigger external workflows instantly when your content changes. Integrate with your favorite tools.", link: { text: "Learn more", href: "https://docs.marblecms.com/guides/features/webhooks", }, image: webhooksImage, }, { title: "Content Intelligence", description: "Get Real-time readability scores, and optimization tips powered by AI to improve your writing.", link: { text: "Learn more", href: "https://docs.marblecms.com/guides/features/editor#analysis-tab", }, image: contentIntelImage, }, // { // title: "Simple Editor", // description: "Write and format content easily with an intuitive interface.", // }, // { // title: "Team Collaboration", // description: "Work together efficiently with shared workspaces.", // }, ]; export const USERS = [ { name: "I.A", url: "https://independent-arts.org", component: Ia, showWordmark: true, }, { name: "OpenCut", url: "https://opencut.app", component: Opencut, showWordmark: true, }, { name: "Bounty", url: "https://bounty.new", component: Bounty, showWordmark: false, }, { name: "Helix DB", url: "https://www.helix-db.com", component: Helix, showWordmark: true, }, { name: "Databuddy", url: "https://databuddy.cc", component: Databuddy, showWordmark: true, }, { name: "Candle", url: "https://www.trycandle.app/", component: Candle, showWordmark: false, }, ]; export const REVIEWS = [ { text: "The best decision I made so far building BookFlow was using @usemarblecms to manage my blogs. Super simple to integrate and offers analytics for posts", author: "Tech Nomad", role: "Developer", avatar: "/avatars/dauda.jpg", link: "https://x.com/dauda_kolo/status/1994699291365966178?s=20", }, { text: "The @usemarblecms writing experience is pretty good. A little rough around the edges but it’s certainly a good entry in the space.", author: "James Perkins", role: "CEO, Unkey", avatar: "/avatars/james.jpg", link: "https://x.com/jamesperkins/status/1953899259515773293?s=20", }, { text: "Marble is now great, I love the new drag and drop image feat, moving all my 3 posts to @usemarblecms 🫡", author: "Alex", role: "Developer", avatar: "/avatars/alex.jpg", link: "https://x.com/Cleverbilling/status/1957833083647885338?s=20", }, { text: "Another W for open-source 🫡", author: "joshtriedcoding", role: "Dev Rel, Upstash", avatar: "/avatars/josh.jpg", link: "https://x.com/joshtriedcoding/status/1954973778380820688?s=20", }, { text: "Only CMS i'll ever integrate again is @usemarblecms if needed for blogs others are just so fucking bloated nowadays and pain in the ass to integrate", author: "Valtteri", role: "Developer", avatar: "/avatars/valtteri.jpg", link: "https://x.com/vvaltterisa/status/1999549602668691822?s=20", }, { text: "Chat, which app is this? such clean UX,", author: "Moinul Moin", role: "Developer", avatar: "/avatars/moinul.jpg", link: "https://x.com/moinulmoin/status/1964969896884011362?s=20", }, ]; ================================================ FILE: apps/web/src/lib/constants/navigation.ts ================================================ import type { SvgComponent } from "astro/types"; import Discord from "../../components/icons/Discord.astro"; import Github from "../../components/icons/Github.astro"; import X from "../../components/icons/X.astro"; import { SITE } from "./site"; export interface Link { href: string; label: string; } export const SOCIAL_LINKS: Link[] = [ { href: "https://github.com/usemarble", label: "GitHub" }, { href: "https://x.com/usemarblecms", label: "Twitter" }, { href: "https://discord.gg/gU44Pmwqkx", label: "Discord" }, { href: "support@marblecms.com", label: "Email" }, { href: "/rss.xml", label: "RSS" }, ]; export interface FooterLink { label: string; href: string; external?: boolean; target?: string; rel?: string; icon?: SvgComponent; } export interface FooterSection { title: string; links: FooterLink[]; } export const FOOTER_SECTIONS: FooterSection[] = [ { title: "Product", links: [ { label: "Get Started", href: SITE.APP_URL, }, { label: "Pricing", href: "/pricing", }, { label: "Changelog", href: "/changelog", }, ], }, { title: "Resources", links: [ { label: "Blog", href: "/blog", }, { label: "Feed", href: "/rss.xml", }, { label: "Contributors", href: "/contributors", }, ], }, { title: "Developers", links: [ { label: "Documentation", href: "https://docs.marblecms.com", external: true, target: "_blank", rel: "noopener", }, { label: "Framer Plugin", href: "https://www.framer.com/marketplace/plugins/marble", external: true, target: "_blank", rel: "noopener", }, { label: "Raycast Extension", href: "https://www.raycast.com/dominikdev/marble", external: true, target: "_blank", rel: "noopener", }, { label: "Astro Example", href: "https://github.com/usemarble/astro-example", external: true, target: "_blank", rel: "noopener", }, { label: "Next.js Example", href: "https://github.com/usemarble/nextjs-example", external: true, target: "_blank", rel: "noopener", }, { label: "TanStack Example", href: "https://github.com/usemarble/tanstack-start-example", external: true, target: "_blank", rel: "noopener", }, ], }, { title: "Company", links: [ { label: "Contact", href: "mailto:support@marblecms.com", }, { label: "Terms", href: "/terms", }, { label: "Privacy", href: "/privacy", }, { label: "Sponsors", href: "/sponsors", }, ], }, ]; export const FOOTER_SOCIAL_LINKS: FooterLink[] = [ { label: "Twitter", href: "https://x.com/usemarblecms", external: true, target: "_blank", rel: "noopener", icon: X, }, { label: "Github", href: "https://github.com/usemarble", external: true, target: "_blank", rel: "noopener", icon: Github, }, { label: "Discord", href: "https://discord.marblecms.com", external: true, target: "_blank", rel: "noopener", icon: Discord, }, ]; ================================================ FILE: apps/web/src/lib/constants/site.ts ================================================ export interface Site { TITLE: string; DESCRIPTION: string; EMAIL: string; URL: string; APP_URL: string; TWITTER_URL: string; DISCORD_URL: string; } export const SITE: Site = { TITLE: "Marble", DESCRIPTION: "A simple headless CMS for managing your blog and media files.", EMAIL: "support@marblecms.com", URL: "https://marblecms.com", APP_URL: "https://app.marblecms.com", TWITTER_URL: "https://x.com/usemarblecms", DISCORD_URL: "https://discord.gg/gU44Pmwqkx", }; ================================================ FILE: apps/web/src/lib/constants/tracking.ts ================================================ import { SITE } from "./site"; export const REGISTER_URL = `${SITE.APP_URL}/register`; export const TRACKING_EVENTS = { signupClicked: "signup_clicked", } as const; ================================================ FILE: apps/web/src/lib/marble.ts ================================================ import { getSecret } from "astro:env/server"; import { Marble } from "@usemarble/sdk"; const key = getSecret("MARBLE_API_KEY"); if (!key) { throw new Error("Missing MARBLE_API_KEY in environment variables"); } export const marble = new Marble({ apiKey: key, }); ================================================ FILE: apps/web/src/lib/schemas.ts ================================================ import { z } from "astro/zod"; export const paginationSchema = z.object({ limit: z.number(), currentPage: z.number(), nextPage: z.number().nullable(), previousPage: z.number().nullable(), totalPages: z.number(), totalItems: z.number(), }); export const postSchema = z.object({ id: z.string(), slug: z.string(), title: z.string(), content: z.string(), featured: z.boolean(), description: z.string(), coverImage: z.string().nullable(), publishedAt: z.coerce.date(), updatedAt: z.coerce.date(), authors: z .array( z.object({ id: z.string(), name: z.string(), image: z.string().nullable(), bio: z.string().nullable(), role: z.string().nullable(), slug: z.string(), socials: z.array( z.object({ url: z.url(), platform: z.string(), }) ), }) ) .min(1), category: z .object({ id: z.string(), name: z.string(), slug: z.string(), description: z.string().nullable(), }) .nullable(), tags: z.array( z.object({ id: z.string(), name: z.string(), slug: z.string(), description: z.string().nullable(), }) ), }); export type Post = z.infer; export const postsSchema = z.object({ posts: z.array(postSchema), pagination: paginationSchema, }); export type Posts = z.infer; export const categorySchema = z.object({ id: z.string(), name: z.string(), slug: z.string(), description: z.string().nullable(), count: z.object({ posts: z.number().int(), }), }); export type Category = z.infer; ================================================ FILE: apps/web/src/lib/seo.ts ================================================ const DESCRIPTION_MAX_LENGTH = 160; export function cleanMetaDescription( description: string | null | undefined, fallback: string ) { const cleaned = (description || fallback).replace(/\s+/g, " ").trim(); if (cleaned.length <= DESCRIPTION_MAX_LENGTH) { return cleaned; } return `${cleaned.slice(0, DESCRIPTION_MAX_LENGTH - 1).trimEnd()}…`; } export function jsonLd(schema: unknown) { return JSON.stringify(schema).replace(/]*>/g, "") .replace(/\s+/g, " ") .trim(); } export function buildSiteJsonLd(site: { title: string; description: string; url: string; twitterUrl?: string; }) { return [ { "@context": "https://schema.org", "@type": "Organization", name: site.title, url: site.url, description: site.description, sameAs: [site.twitterUrl].filter(Boolean), }, { "@context": "https://schema.org", "@type": "WebSite", name: site.title, url: site.url, description: site.description, publisher: { "@type": "Organization", name: site.title, }, }, ]; } export function buildFaqJsonLd( faqs: Array<{ question: string; answer: string }> ) { return { "@context": "https://schema.org", "@type": "FAQPage", mainEntity: faqs.map((faq) => ({ "@type": "Question", name: faq.question, acceptedAnswer: { "@type": "Answer", text: stripHtml(faq.answer), }, })), }; } export function buildArticleJsonLd({ type = "BlogPosting", title, description, url, image, publishedAt, updatedAt, authors, siteTitle, siteUrl, }: { type?: "Article" | "BlogPosting" | "TechArticle"; title: string; description: string; url: string; image?: string | null; publishedAt: Date; updatedAt: Date; authors: Array<{ name: string; image?: string | null; }>; siteTitle: string; siteUrl: string; }) { return { "@context": "https://schema.org", "@type": type, headline: title, description, url, datePublished: publishedAt.toISOString(), dateModified: updatedAt.toISOString(), author: authors.map((author) => ({ "@type": "Person", name: author.name, ...(author.image && { image: author.image }), })), publisher: { "@type": "Organization", name: siteTitle, url: siteUrl, }, ...(image && { image }), }; } ================================================ FILE: apps/web/src/lib/site.ts ================================================ export const HERO_VARIATIONS = { default: { title: "Simple content management for modern apps", subtitle: "A clean, collaborative way to publish articles, changelogs, and product updates to your site", }, simple: { title: "Super simple headless CMS", subtitle: "Marble is a simple way to manage your blog and media. Write, upload, and publish with a clean interface and simple API.", }, smart: { title: "The smarter way to manage your blog", subtitle: "Streamline your content workflow with intuitive media management and a powerful editor.", }, }; export const HERO = HERO_VARIATIONS.simple; ================================================ FILE: apps/web/src/lib/utils.ts ================================================ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; export function calculateReadTime(content: string) { const wordsPerMinute = 200; const plainText = content.replace(/<[^>]*>/g, "").trim(); const wordCount = plainText.split(/\s+/).length; const readingTime = Math.ceil(wordCount / wordsPerMinute); return readingTime; } export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ================================================ FILE: apps/web/src/pages/404.astro ================================================ --- import Container from "@/components/Container.astro"; import Layout from "@/layouts/Layout.astro"; ---

    404

    Page not found

    Go back home
    ================================================ FILE: apps/web/src/pages/blog/[slug].astro ================================================ --- import { getCollection } from "astro:content"; import { YOUTUBE_VIDEO_ID } from "@marble/utils"; import Container from "@/components/Container.astro"; import Prose from "@/components/Prose.astro"; import ButtonComponent from "@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; import { REGISTER_URL, TRACKING_EVENTS } from "@/lib/constants/tracking"; import { buildArticleJsonLd, cleanMetaDescription } from "@/lib/seo"; import { calculateReadTime } from "@/lib/utils"; const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`; export const prerender = true; export async function getStaticPaths() { const blogEntries = await getCollection("posts"); return blogEntries.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry }, })); } const { entry } = Astro.props; const formattedDate = new Date(entry.data.publishedAt).toLocaleDateString( "en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC", } ); const readTime = calculateReadTime(entry.data.content); const description = cleanMetaDescription( entry.data.description, `Read ${entry.data.title} on the Marble blog.` ); const structuredData = buildArticleJsonLd({ type: "BlogPosting", title: entry.data.title, description, url: new URL(Astro.url.pathname, SITE.URL).toString(), image: entry.data.coverImage ? new URL(entry.data.coverImage, SITE.URL).toString() : undefined, publishedAt: entry.data.publishedAt, updatedAt: entry.data.updatedAt, authors: entry.data.authors, siteTitle: SITE.TITLE, siteUrl: SITE.URL, }); ---

    {entry.data.title}

    {readTime} {""}minute read

    Try Marble today.

    A simpler way to publish articles and manage your blog.

    Try Marble for free Watch Demo
    ================================================ FILE: apps/web/src/pages/blog/category/[slug].astro ================================================ --- import { getCollection } from "astro:content"; import CategoryCard from "@/components/CategoryCard.astro"; import CategoryFilter from "@/components/CategoryFilter.astro"; import Container from "@/components/Container.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; import { marble } from "@/lib/marble"; export const prerender = true; export async function getStaticPaths() { const blogEntries = await getCollection("categories"); return blogEntries .filter( (entry) => entry.data.slug !== "legal" && entry.data.slug !== "changelog" ) .map((entry) => ({ params: { slug: entry.data.slug }, props: { entry }, })); } const { entry } = Astro.props; const categoriesEntries = await getCollection("categories"); const { result } = await marble.posts.list({ categories: [entry.data.slug] }); const fillers = Array.from({ length: (3 - (result.posts.length % 3)) % 3, }); ---

    Blog

    {entry.data.description || "Updates, news, and guides from the team."}

    {result.posts.length > 0 ? (
      { result.posts.map((post) => ( )) } {fillers.map(() => (
    ) : (

    No posts found

    )}
    ================================================ FILE: apps/web/src/pages/blog/index.astro ================================================ --- import { getCollection } from "astro:content"; import CategoryFilter from "@/components/CategoryFilter.astro"; import Container from "@/components/Container.astro"; import PostCard from "@/components/PostCard.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; const unsortedPosts = await getCollection("posts"); const posts = unsortedPosts.sort( (a, b) => new Date(b.data.publishedAt).valueOf() - new Date(a.data.publishedAt).valueOf() ); const categories = await getCollection("categories"); const fillers = Array.from({ length: (3 - (posts.length % 3)) % 3 }); ---

    Blog

    Updates, news, and articles from Marble.

      {posts.map((post) => ( ))} {fillers.map(() => (
    ================================================ FILE: apps/web/src/pages/changelog/[slug].astro ================================================ --- import { getCollection } from "astro:content"; import { YOUTUBE_VIDEO_ID } from "@marble/utils"; import Container from "@/components/Container.astro"; import Prose from "@/components/Prose.astro"; import ButtonComponent from "@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; import { REGISTER_URL, TRACKING_EVENTS } from "@/lib/constants/tracking"; import { buildArticleJsonLd, cleanMetaDescription } from "@/lib/seo"; import { calculateReadTime } from "@/lib/utils"; const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`; export const prerender = true; export async function getStaticPaths() { const changelogEntries = await getCollection("changelog"); return changelogEntries.map((entry) => ({ params: { slug: entry.data.slug }, props: { entry }, })); } const { entry } = Astro.props; const formattedDate = new Date(entry.data.publishedAt).toLocaleDateString( "en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC", } ); const readTime = calculateReadTime(entry.data.content); const description = cleanMetaDescription( entry.data.description, `Read ${entry.data.title} in the Marble changelog.` ); const structuredData = buildArticleJsonLd({ type: "Article", title: entry.data.title, description, url: new URL(Astro.url.pathname, SITE.URL).toString(), image: entry.data.coverImage ? new URL(entry.data.coverImage, SITE.URL).toString() : undefined, publishedAt: entry.data.publishedAt, updatedAt: entry.data.updatedAt, authors: entry.data.authors, siteTitle: SITE.TITLE, siteUrl: SITE.URL, }); ---

    {entry.data.title}

    Try Marble today.

    A simpler way to publish articles and manage your blog.

    Try Marble for free Watch Demo
    ================================================ FILE: apps/web/src/pages/changelog/index.astro ================================================ --- import { getCollection } from "astro:content"; import ChangelogCard from "@/components/ChangelogCard.astro"; import Container from "@/components/Container.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; const unsortedEntries = await getCollection("changelog"); const entries = unsortedEntries.sort( (a, b) => new Date(b.data.publishedAt).valueOf() - new Date(a.data.publishedAt).valueOf() ); ---

    Changelog

    Product improvements and updates.

      {entries.map((entry) => ( ))}
    ================================================ FILE: apps/web/src/pages/contributors/index.astro ================================================ --- export const prerender = false; import { YOUTUBE_VIDEO_ID } from "@marble/utils"; import Container from "@/components/Container.astro"; import ButtonComponent from "@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; import { REGISTER_URL, TRACKING_EVENTS } from "@/lib/constants/tracking"; import { cn } from "@/lib/utils"; const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`; interface GitHubUser { login: string; id: number; avatar_url: string; html_url: string; contributions: number; type: string; } interface GitHubIssue { id: number; number: number; title: string; html_url: string; state: string; user: { login: string; avatar_url: string; html_url: string; }; created_at: string; labels: Array<{ name: string; color: string; }>; pull_request?: { url: string; }; } interface GitHubPR { id: number; number: number; title: string; html_url: string; state: string; user: { login: string; avatar_url: string; html_url: string; }; created_at: string; draft: boolean; } interface GitHubRepo { name: string; full_name: string; html_url: string; stargazers_count: number; forks_count: number; open_issues_count: number; language: string; description: string; private: boolean; } async function fetchGitHubData() { const baseUrl = "https://api.github.com"; const org = "usemarble"; const headers: Record = { Accept: "application/vnd.github+json", }; try { const [repoResponse, contributorsResponse, issuesResponse, prsResponse] = await Promise.all([ fetch(`${baseUrl}/repos/${org}/marble`, { headers }), fetch(`${baseUrl}/repos/${org}/marble/contributors?per_page=100`, { headers, }), fetch( `${baseUrl}/repos/${org}/marble/issues?state=open&per_page=20&sort=created`, { headers } ), fetch( `${baseUrl}/repos/${org}/marble/pulls?state=open&per_page=5&sort=created`, { headers } ), ]); const repo: GitHubRepo | null = repoResponse.ok ? await repoResponse.json() : null; const contributors: GitHubUser[] = contributorsResponse.ok ? (await contributorsResponse.json()).filter( (c: GitHubUser) => c.login !== "turbobot-temp" ) : []; const allIssues: GitHubIssue[] = issuesResponse.ok ? await issuesResponse.json() : []; const issues: GitHubIssue[] = allIssues .filter((issue) => !issue.pull_request) .slice(0, 5); const prs: GitHubPR[] = prsResponse.ok ? (await prsResponse.json()).slice(0, 5) : []; const totalStars = repo?.stargazers_count || 0; const totalForks = repo?.forks_count || 0; const totalIssues = repo?.open_issues_count || 0; return { repo, contributors: contributors .filter((c) => c.type === "User") .slice(0, 24), issues, prs, stats: { totalStars, totalForks, totalIssues, totalContributors: contributors.filter((c) => c.type === "User") .length, }, }; } catch (error) { console.error("Error fetching GitHub data:", error); return { repo: null, contributors: [], issues: [], prs: [], stats: { totalStars: 0, totalForks: 0, totalIssues: 0, totalContributors: 0, }, }; } } const githubData = await fetchGitHubData(); function formatDate(dateString: string) { return new Date(dateString).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC", }); } function getIssueTypeFromLabels( labels: Array<{ name: string; color: string }> ) { const bugLabel = labels.find((label) => label.name.toLowerCase().includes("bug") ); const featureLabel = labels.find( (label) => label.name.toLowerCase().includes("feature") || label.name.toLowerCase().includes("enhancement") ); const docLabel = labels.find((label) => label.name.toLowerCase().includes("doc") ); if (bugLabel) { return { type: "Bug", color: "bg-red-100 text-red-800" }; } if (featureLabel) { return { type: "Feature", color: "bg-blue-100 text-blue-800" }; } if (docLabel) { return { type: "Docs", color: "bg-green-100 text-green-800" }; } return { type: "Issue", color: "bg-yellow-100 text-yellow-800" }; } Astro.response.headers.set( "Cache-Control", "public, s-maxage=3600, stale-while-revalidate=60" ); ---

    Contributors & Community

    Meet the amazing developers and contributors who help build Marble.

    {githubData.stats.totalContributors}
    Contributors
    {githubData.stats.totalStars}
    Stars
    {githubData.stats.totalForks}
    Forks
    {githubData.stats.totalIssues}
    Open Issues

    Our Contributors.

    Thank you to all the amazing people who have contributed to Marble!

    {githubData.contributors.map((contributor) => ( {`Avatar {contributor.login} ))}

    Open Issues

    View all
    {githubData.issues.length === 0 ? (
    No open issues at the moment
    ) : ( githubData.issues.map((issue) => { const issueType = getIssueTypeFromLabels(issue.labels); return (
    {`Avatar
    {issueType.type} #{issue.number}
    {issue.title}

    by {issue.user.login} • {formatDate(issue.created_at)}

    ); }) )}

    Open Pull Requests

    View all
    {githubData.prs.length === 0 ? (
    No open pull requests at the moment
    ) : ( githubData.prs.map((pr) => (
    {`Avatar
    {pr.draft ? 'Draft' : 'Ready'} #{pr.number}
    {pr.title}

    by {pr.user.login} • {formatDate(pr.created_at)}

    )) )}

    Get started today.

    Create and manage content, so you can focus on what matters most.

    Try Marble for free Watch Demo
    ================================================ FILE: apps/web/src/pages/index.astro ================================================ --- import { Image } from "astro:assets"; import { YOUTUBE_VIDEO_ID } from "@marble/utils"; import heroImage from "@/assets/images/hero.png"; import Container from "@/components/Container.astro"; import Pricing from "@/components/sections/Pricing.astro"; import AccordionItem from "@/components/ui/AccordionItem.astro"; import ButtonComponent from "@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import { FAQs } from "@/lib/constants/faqs"; import { FEATURES, REVIEWS, USERS } from "@/lib/constants/landing"; import { SITE } from "@/lib/constants/site"; import { REGISTER_URL, TRACKING_EVENTS } from "@/lib/constants/tracking"; import { buildFaqJsonLd } from "@/lib/seo"; import { HERO } from "@/lib/site"; import { cn } from "@/lib/utils"; const YOUTUBE_WATCH_URL = `https://www.youtube.com/watch?v=${YOUTUBE_VIDEO_ID}`; const structuredData = buildFaqJsonLd(FAQs); ---

    {HERO.title}

    {HERO.subtitle}

    Publish your first post Watch Demo
    marble dashboard showing a chart and list of media files

    Everything you need to publish.

      {FEATURES.map((feature, index) => (
    • {feature.title}

      {feature.title}

      {feature.description}

      {feature.link && ( {feature.link.text} )}
    • ))}

    What people are saying

    Frequently asked Questions

    Answers to common questions about marblecms.

    {FAQs.map(faq => (

    ))}

    Try Marble today.

    A simpler way to publish articles and manage your blog.

    Try Marble for free Watch Demo
    ================================================ FILE: apps/web/src/pages/pricing/index.astro ================================================ --- import { PRICING_PLANS } from "@marble/utils"; import Container from "@/components/Container.astro"; import AccordionItem from "@/components/ui/AccordionItem.astro"; import Button from "@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import { PRICING_FAQS } from "@/lib/constants/faqs"; import { SITE } from "@/lib/constants/site"; import { buildFaqJsonLd } from "@/lib/seo"; import { cn } from "@/lib/utils"; const structuredData = buildFaqJsonLd(PRICING_FAQS); ---

    Pricing

    Simple pricing, cancel anytime.

    Monthly Yearly
      {PRICING_PLANS.map((plan, index) => (
    • {index === 2 ? ( <>

      {plan.title}

      {plan.description}

      {plan.price.yearly} {' '} / year

        {plan.features.map((feature) => (
      • {feature}
      • ))}
      ) : ( <>

      {plan.title}

      {plan.description}

      {plan.price.yearly} {' '} / year

        {plan.features.map((feature) => (
      • {feature}
      • ))}
      )}
    • ))}

    Frequently asked Questions

    Answers to common questions about pricing and billing.

    {PRICING_FAQS.map(faq => (

    ))}

    ================================================ FILE: apps/web/src/pages/privacy/index.astro ================================================ --- import { getEntry } from "astro:content"; import Container from "@/components/Container.astro"; import Prose from "@/components/Prose.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; const entry = await getEntry("page", "privacy"); if (!entry) { throw new Error("Page not found"); } const formattedDate = new Date(entry.data.updatedAt).toLocaleDateString( "en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC", } ); ---

    Privacy Policy.

    Last updated: {formattedDate}

    ================================================ FILE: apps/web/src/pages/rss.xml.ts ================================================ import { getCollection } from "astro:content"; import rss from "@astrojs/rss"; import type { APIContext } from "astro"; import { SITE } from "@/lib/constants/site"; export async function GET(context: APIContext) { const posts = await getCollection("posts"); const changelog = await getCollection("changelog"); const blogItems = posts.map((post) => ({ title: post.data.title, description: post.data.description, pubDate: new Date(post.data.publishedAt), link: `/blog/${post.data.slug}`, })); const changelogItems = changelog.map((entry) => ({ title: entry.data.title, description: entry.data.description, pubDate: new Date(entry.data.publishedAt), link: `/changelog/${entry.data.slug}`, })); const allItems = [...blogItems, ...changelogItems].sort( (a, b) => b.pubDate.valueOf() - a.pubDate.valueOf() ); return rss({ title: SITE.TITLE, description: SITE.DESCRIPTION, site: context.site ?? SITE.URL, items: allItems, }); } ================================================ FILE: apps/web/src/pages/sponsors/index.astro ================================================ --- import type { SvgComponent } from "astro/types"; import Container from "@/components/Container.astro"; import Neon from "@/components/icons/sponsors/Neon.astro"; import Upstash from "@/components/icons/sponsors/Upstash.astro"; import Vercel from "@/components/icons/sponsors/Vercel.astro"; import ButtonComponent from "@/components/ui/Button.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; interface Sponsor { url: string; name: string; description: string; icon: SvgComponent; } const sponsorsData: Sponsor[] = [ { name: "Upstash", url: "https://upstash.com?utm_source=marble", icon: Upstash, description: "Powers our caching, webhooks and rate limiting infrastructure.", }, { name: "Neon", url: "https://neon.com?utm_source=marble", icon: Neon, description: "Powers our entire database, literally.", }, { name: "Vercel", url: "https://vercel.com?utm_source=marble", icon: Vercel, description: "Powers our entire deployment and ai infrastructure.", }, ]; ---

    Our Sponsors & Supporters

    We're grateful to the amazing companies and organizations that support the development of Marble.

    Want to support Marble?

    If you would like to support Marble, feel free to reach out.

    Reach out on Twitter Join the Discord
    ================================================ FILE: apps/web/src/pages/terms/index.astro ================================================ --- import { getEntry } from "astro:content"; import Container from "@/components/Container.astro"; import Prose from "@/components/Prose.astro"; import Layout from "@/layouts/Layout.astro"; import { SITE } from "@/lib/constants/site"; const entry = await getEntry("page", "terms"); if (!entry) { throw new Error("Page not found"); } const formattedDate = new Date(entry.data.updatedAt).toLocaleDateString( "en-US", { year: "numeric", month: "long", day: "numeric", timeZone: "UTC", } ); ---

    Terms of service

    Last updated: {formattedDate}

    ================================================ FILE: apps/web/src/styles/globals.css ================================================ @import "tailwindcss"; /* Plugins (and related config) */ @plugin "@tailwindcss/typography"; @config "../../tailwind.config.ts"; :root { --background: hsl(60 11% 98%); --foreground: hsl(240 15% 14%); --primary: hsl(240 10% 14%); --primary-foreground: hsl(0 0% 98%); --muted: hsl(0, 0%, 92%); --muted-foreground: hsl(0, 0%, 25%); --accent: hsl(244 100% 65%); --accent-foreground: hsl(0 0% 9%); --border: hsl(0 0% 86%); /* radius */ --radius: 0.7rem; } @theme inline { --font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; --font-serif: var(--font-literata), ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; /* border radius */ --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); /* Palette mapped to root design tokens */ --color-background: var(--background); --color-foreground: var(--foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-border: var(--border); } /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the same as it did with Tailwind CSS v3. If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentcolor); } } @utility prose { & li p { margin: 0; } } @layer base { @media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth; } } html { color: var(--foreground); background: var(--background); font-family: var(--font-geist), system-ui, sans-serif; } * { @apply border-border selection:bg-border; } body { @apply bg-background text-foreground antialiased; } .faq-answer a { @apply text-foreground hover:text-primary underline font-medium; } } ================================================ FILE: apps/web/tailwind.config.ts ================================================ import type { Config } from "tailwindcss"; import defaultTheme from "tailwindcss/defaultTheme"; export default { theme: { extend: { fontFamily: { sans: ["var(--font-geist)", ...defaultTheme.fontFamily.sans], serif: ["var(--font-literata)", ...defaultTheme.fontFamily.serif], }, typography: () => ({ marble: { css: { "--tw-prose-bold": "var(--foreground)", "--tw-prose-counters": "var(--foreground)", "--tw-prose-bullets": "var(--muted-foreground)", "--tw-prose-quotes": "var(--foreground)", "--tw-prose-quote-borders": "var(--border)", "--tw-prose-captions": "var(--muted-foreground)", "--tw-prose-code": "var(--foreground)", "--tw-prose-code-bg": "var(--muted)", "--tw-prose-pre-code": "var(--color-zinc-100)", "--tw-prose-pre-bg": "var(--color-zinc-800)", "--tw-prose-th-borders": "var(--border)", "--tw-prose-td-borders": "var(--border)", "code:not(pre code)": { color: "var(--tw-prose-code)", backgroundColor: "var(--tw-prose-code-bg)", borderRadius: "0.375rem", paddingInline: "0.275rem", fontSize: "0.875rem", fontWeight: "600", display: "inline-block", }, }, }, DEFAULT: { css: { a: { "&:hover": { color: "var(--accent)", }, }, }, }, }), }, }, } satisfies Config; ================================================ FILE: apps/web/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist"], "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["./src/*"] } } } ================================================ FILE: biome.jsonc ================================================ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", "extends": [ "ultracite/biome/core", "ultracite/biome/react", "ultracite/biome/next", "ultracite/biome/astro" ], "html": { "experimentalFullSupportEnabled": true }, "files": { "includes": [ "!packages/ui/src", "packages/editor/src", "!apps/cms/src/hooks/use-mobile.tsx" ] }, "linter": { "rules": { "suspicious": { /* Needs more work to fix */ "noConsole": "off", /* Needs more work to fix */ "useAwait": "off", /* Allowed for Tailwind */ "noUnknownAtRules": "off" }, "style": { /* Needs more work to fix */ "noMagicNumbers": "off", /* Needs more work to fix */ "noNestedTernary": "off", /* Doesn't work with Astro */ "useFilenamingConvention": "off" }, "complexity": { /* Has false positives */ "useSimplifiedLogicExpression": "off", /* Needs more work to fix */ "noExcessiveCognitiveComplexity": "off" }, "nursery": { /* Needs more work to fix */ "noShadow": "off", /* Has false positives */ "noUnnecessaryConditions": "off" }, "performance": { /* Needs more work to fix */ "useTopLevelRegex": "off", "noNamespaceImport": "warn" }, "correctness": { /* Doesn't work with Astro */ "noUnusedImports": "off", /* Needs more work to fix */ "noUnusedVariables": "off" } } }, "css": { "parser": { "tailwindDirectives": true } }, "overrides": [ { "includes": ["apps/mcp/**/*.tsx"], "linter": { "rules": { "style": { "noHeadElement": "off" }, "performance": { "noImgElement": "off" } } } } ] } ================================================ FILE: commitlint.config.ts ================================================ export default { extends: ["@commitlint/config-conventional"], rules: { "type-enum": [ 2, "always", [ "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test", ], ], }, }; ================================================ FILE: docker-compose.yml ================================================ services: db: image: postgres:15 environment: POSTGRES_USER: usemarble POSTGRES_PASSWORD: justusemarble POSTGRES_DB: marble TZ: UTC ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] interval: 10s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped redis: image: redis restart: always ports: - "6379:6379" serverless-redis-http: ports: - "8079:80" image: hiett/serverless-redis-http:latest restart: always environment: SRH_MODE: env SRH_TOKEN: justusemarble SRH_CONNECTION_STRING: "redis://redis:6379" # Using `redis` hostname since they're in the same Docker network. volumes: pgdata: ================================================ FILE: package.json ================================================ { "name": "marblecms", "private": true, "scripts": { "docker:up": "docker compose up -d", "docker:down": "docker compose down", "docker:clean": "docker compose down -v", "docker:logs": "docker compose logs -f", "docker:ps": "docker compose ps", "docker:restart": "docker compose restart", "db:migrate": "pnpm --filter @marble/db db:migrate", "db:deploy": "pnpm --filter @marble/db db:deploy", "db:generate": "pnpm --filter @marble/db db:generate", "db:studio": "pnpm --filter @marble/db db:studio", "db:push": "pnpm --filter @marble/db db:push", "build": "turbo run build", "dev": "turbo run dev", "lint": "pnpx ultracite@latest check", "format": "pnpx ultracite@latest fix", "web:dev": "turbo run dev --filter=web", "cms:dev": "turbo run dev --filter=cms", "api:dev": "turbo run dev --filter=api", "docs:dev": "turbo run dev --filter=docs", "mcp:dev": "turbo run dev --filter=mcp", "test": "turbo run test", "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "2.3.2", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@marble/tsconfig": "workspace:*", "husky": "^9.1.7", "turbo": "^2.7.2", "typescript": "^5.9.3", "ultracite": "7.0.3" }, "engines": { "node": ">=22.12.0" }, "pnpm": { "overrides": { "@types/react": "19.2.14", "@types/react-dom": "19.2.3" } }, "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } ================================================ FILE: packages/db/.gitignore ================================================ node_modules # Keep environment variables out of version control .env scripts *.md ================================================ FILE: packages/db/package.json ================================================ { "name": "@marble/db", "version": "0.0.0", "type": "module", "exports": { ".": "./src/index.ts", "./client": "./src/client.ts", "./browser": "./src/browser.ts", "./workers": "./src/workers.ts", "./hyperdrive": "./src/hyperdrive.ts" }, "scripts": { "db:generate": "prisma generate", "db:push": "prisma db push --skip-generate", "db:studio": "prisma studio", "postinstall": "prisma generate", "db:migrate": "prisma migrate dev", "db:deploy": "prisma migrate deploy" }, "dependencies": { "@neondatabase/serverless": "^1.0.1", "@prisma/adapter-neon": "^7.0.0", "@prisma/adapter-pg": "^7.0.0", "@prisma/client": "^7.0.0", "pg": "^8.18.0", "ws": "^8.18.0" }, "devDependencies": { "@marble/tsconfig": "workspace:*", "@types/node": "^22.9.0", "@types/ws": "^8.5.13", "bufferutil": "^4.0.9", "dotenv": "^16.4.7", "prisma": "^7.0.0", "typescript": "^5.9.3" } } ================================================ FILE: packages/db/prisma/migrations/0_init/migration.sql ================================================ -- CreateSchema CREATE SCHEMA IF NOT EXISTS "public"; -- CreateEnum CREATE TYPE "public"."PostStatus" AS ENUM ('published', 'draft'); -- CreateEnum CREATE TYPE "public"."PlanType" AS ENUM ('team', 'pro'); -- CreateEnum CREATE TYPE "public"."SubscriptionStatus" AS ENUM ('active', 'cancelled', 'expired', 'trialing', 'past_due'); -- CreateEnum CREATE TYPE "public"."WebhookEvent" AS ENUM ('post_published', 'post_deleted', 'post_updated', 'category_created', 'category_updated', 'category_deleted', 'tag_created', 'tag_updated', 'tag_deleted', 'media_uploaded', 'media_deleted'); -- CreateEnum CREATE TYPE "public"."PayloadFormat" AS ENUM ('json', 'discord'); -- CreateEnum CREATE TYPE "public"."MediaType" AS ENUM ('image', 'video', 'audio', 'document'); -- CreateTable CREATE TABLE "public"."subscription" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "plan" "public"."PlanType" NOT NULL, "status" "public"."SubscriptionStatus" NOT NULL DEFAULT 'active', "updatedAt" TIMESTAMP(3) NOT NULL, "cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false, "canceledAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "currentPeriodEnd" TIMESTAMP(3) NOT NULL, "currentPeriodStart" TIMESTAMP(3) NOT NULL, "endedAt" TIMESTAMP(3), "endsAt" TIMESTAMP(3), "polarId" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, CONSTRAINT "subscription_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."workspace" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "slug" TEXT NOT NULL, "logo" TEXT, "metadata" TEXT, "description" TEXT, "subdomain" TEXT, "updatedAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "timezone" TEXT NOT NULL DEFAULT 'Europe/London', CONSTRAINT "workspace_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."post" ( "id" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "coverImage" TEXT, "contentJson" JSONB NOT NULL, "description" TEXT NOT NULL, "views" INTEGER NOT NULL DEFAULT 0, "workspaceId" TEXT NOT NULL, "slug" TEXT NOT NULL, "categoryId" TEXT NOT NULL, "status" "public"."PostStatus" NOT NULL DEFAULT 'draft', "featured" BOOLEAN NOT NULL DEFAULT false, "updatedAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "publishedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "attribution" JSONB, "primaryAuthorId" TEXT NOT NULL, CONSTRAINT "post_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."tag" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, "slug" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "workspaceId" TEXT NOT NULL, CONSTRAINT "tag_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."media" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "size" INTEGER NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "workspaceId" TEXT NOT NULL, "type" "public"."MediaType" NOT NULL DEFAULT 'image', CONSTRAINT "media_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."category" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, "slug" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "workspaceId" TEXT NOT NULL, CONSTRAINT "category_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."webhook" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "endpoint" TEXT NOT NULL, "secret" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT true, "workspaceId" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, "events" "public"."WebhookEvent"[], "format" "public"."PayloadFormat" NOT NULL DEFAULT 'json', CONSTRAINT "webhook_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."user" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "email" TEXT NOT NULL, "emailVerified" BOOLEAN NOT NULL, "image" TEXT, "createdAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "user_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."session" ( "id" TEXT NOT NULL, "expiresAt" TIMESTAMP(3) NOT NULL, "token" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL, "ipAddress" TEXT, "userAgent" TEXT, "userId" TEXT NOT NULL, "activeOrganizationId" TEXT, CONSTRAINT "session_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."account" ( "id" TEXT NOT NULL, "accountId" TEXT NOT NULL, "providerId" TEXT NOT NULL, "userId" TEXT NOT NULL, "accessToken" TEXT, "refreshToken" TEXT, "idToken" TEXT, "accessTokenExpiresAt" TIMESTAMP(3), "refreshTokenExpiresAt" TIMESTAMP(3), "scope" TEXT, "password" TEXT, "createdAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "account_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."verification" ( "id" TEXT NOT NULL, "identifier" TEXT NOT NULL, "value" TEXT NOT NULL, "expiresAt" TIMESTAMP(3) NOT NULL, "createdAt" TIMESTAMP(3), "updatedAt" TIMESTAMP(3), CONSTRAINT "verification_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."member" ( "id" TEXT NOT NULL, "organizationId" TEXT NOT NULL, "userId" TEXT NOT NULL, "role" TEXT, "createdAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "member_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."invitation" ( "id" TEXT NOT NULL, "organizationId" TEXT NOT NULL, "email" TEXT NOT NULL, "role" TEXT, "status" TEXT NOT NULL, "expiresAt" TIMESTAMP(3) NOT NULL, "inviterId" TEXT NOT NULL, CONSTRAINT "invitation_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."_PostToTag" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL, CONSTRAINT "_PostToTag_AB_pkey" PRIMARY KEY ("A","B") ); -- CreateTable CREATE TABLE "public"."_PostToUser" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL, CONSTRAINT "_PostToUser_AB_pkey" PRIMARY KEY ("A","B") ); -- CreateIndex CREATE UNIQUE INDEX "subscription_polarId_key" ON "public"."subscription"("polarId"); -- CreateIndex CREATE UNIQUE INDEX "subscription_workspaceId_key" ON "public"."subscription"("workspaceId"); -- CreateIndex CREATE UNIQUE INDEX "workspace_slug_key" ON "public"."workspace"("slug"); -- CreateIndex CREATE UNIQUE INDEX "workspace_subdomain_key" ON "public"."workspace"("subdomain"); -- CreateIndex CREATE UNIQUE INDEX "post_workspaceId_slug_key" ON "public"."post"("workspaceId", "slug"); -- CreateIndex CREATE UNIQUE INDEX "tag_workspaceId_slug_key" ON "public"."tag"("workspaceId", "slug"); -- CreateIndex CREATE UNIQUE INDEX "category_workspaceId_slug_key" ON "public"."category"("workspaceId", "slug"); -- CreateIndex CREATE UNIQUE INDEX "user_email_key" ON "public"."user"("email"); -- CreateIndex CREATE UNIQUE INDEX "session_token_key" ON "public"."session"("token"); -- CreateIndex CREATE INDEX "_PostToTag_B_index" ON "public"."_PostToTag"("B"); -- CreateIndex CREATE INDEX "_PostToUser_B_index" ON "public"."_PostToUser"("B"); -- AddForeignKey ALTER TABLE "public"."subscription" ADD CONSTRAINT "subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."subscription" ADD CONSTRAINT "subscription_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."post" ADD CONSTRAINT "post_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "public"."category"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."post" ADD CONSTRAINT "post_primaryAuthorId_fkey" FOREIGN KEY ("primaryAuthorId") REFERENCES "public"."user"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."post" ADD CONSTRAINT "post_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."tag" ADD CONSTRAINT "tag_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."media" ADD CONSTRAINT "media_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."category" ADD CONSTRAINT "category_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."webhook" ADD CONSTRAINT "webhook_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."member" ADD CONSTRAINT "member_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."member" ADD CONSTRAINT "member_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."invitation" ADD CONSTRAINT "invitation_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."invitation" ADD CONSTRAINT "invitation_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."_PostToTag" ADD CONSTRAINT "_PostToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."_PostToTag" ADD CONSTRAINT "_PostToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."_PostToUser" ADD CONSTRAINT "_PostToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."_PostToUser" ADD CONSTRAINT "_PostToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."user"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250831193214_add_author_table/migration.sql ================================================ -- AlterTable ALTER TABLE "public"."post" ADD COLUMN "newPrimaryAuthorId" TEXT; -- CreateTable CREATE TABLE "public"."author" ( "id" TEXT NOT NULL, "name" TEXT NOT NULL, "email" TEXT, "bio" TEXT, "image" TEXT, "role" TEXT, "slug" TEXT NOT NULL, "socials" JSONB, "workspaceId" TEXT NOT NULL, "userId" TEXT, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "author_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."_PostToAuthor" ( "A" TEXT NOT NULL, "B" TEXT NOT NULL, CONSTRAINT "_PostToAuthor_AB_pkey" PRIMARY KEY ("A","B") ); -- CreateIndex CREATE UNIQUE INDEX "author_workspaceId_userId_key" ON "public"."author"("workspaceId", "userId"); -- CreateIndex CREATE UNIQUE INDEX "author_workspaceId_slug_key" ON "public"."author"("workspaceId", "slug"); -- CreateIndex CREATE INDEX "_PostToAuthor_B_index" ON "public"."_PostToAuthor"("B"); -- AddForeignKey ALTER TABLE "public"."post" ADD CONSTRAINT "post_newPrimaryAuthorId_fkey" FOREIGN KEY ("newPrimaryAuthorId") REFERENCES "public"."author"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."author" ADD CONSTRAINT "author_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."author" ADD CONSTRAINT "author_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."_PostToAuthor" ADD CONSTRAINT "_PostToAuthor_A_fkey" FOREIGN KEY ("A") REFERENCES "public"."author"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."_PostToAuthor" ADD CONSTRAINT "_PostToAuthor_B_fkey" FOREIGN KEY ("B") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250907120320_make_new_primary_author_required/migration.sql ================================================ /* Warnings: - Made the column `newPrimaryAuthorId` on table `post` required. This step will fail if there are existing NULL values in that column. */ -- DropForeignKey ALTER TABLE "public"."post" DROP CONSTRAINT "post_newPrimaryAuthorId_fkey"; -- AlterTable ALTER TABLE "public"."post" ALTER COLUMN "newPrimaryAuthorId" SET NOT NULL; -- AddForeignKey ALTER TABLE "public"."post" ADD CONSTRAINT "post_newPrimaryAuthorId_fkey" FOREIGN KEY ("newPrimaryAuthorId") REFERENCES "public"."author"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250907125704_drop_legacy_user_author_fields/migration.sql ================================================ /* Warnings: - You are about to drop the column `primaryAuthorId` on the `post` table. All the data in the column will be lost. - You are about to drop the `_PostToUser` table. If the table is not empty, all the data it contains will be lost. */ -- DropForeignKey ALTER TABLE "public"."_PostToUser" DROP CONSTRAINT "_PostToUser_A_fkey"; -- DropForeignKey ALTER TABLE "public"."_PostToUser" DROP CONSTRAINT "_PostToUser_B_fkey"; -- DropForeignKey ALTER TABLE "public"."post" DROP CONSTRAINT "post_primaryAuthorId_fkey"; -- AlterTable ALTER TABLE "public"."post" DROP COLUMN "primaryAuthorId"; -- DropTable DROP TABLE "public"."_PostToUser"; ================================================ FILE: packages/db/prisma/migrations/20250907194746_rename_author_fields_to_final_names/migration.sql ================================================ /* Rename author fields to final names: - Rename newPrimaryAuthorId to primaryAuthorId - Update foreign key constraint names */ -- Drop the old foreign key constraint ALTER TABLE "public"."post" DROP CONSTRAINT "post_newPrimaryAuthorId_fkey"; -- Rename the column ALTER TABLE "public"."post" RENAME COLUMN "newPrimaryAuthorId" TO "primaryAuthorId"; -- Add the new foreign key constraint with the renamed column ALTER TABLE "public"."post" ADD CONSTRAINT "post_primaryAuthorId_fkey" FOREIGN KEY ("primaryAuthorId") REFERENCES "public"."author"("id") ON DELETE RESTRICT ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250908090455_make_primary_author_optional/migration.sql ================================================ -- DropForeignKey ALTER TABLE "public"."post" DROP CONSTRAINT "post_primaryAuthorId_fkey"; -- AlterTable ALTER TABLE "public"."post" ALTER COLUMN "primaryAuthorId" DROP NOT NULL; -- AddForeignKey ALTER TABLE "public"."post" ADD CONSTRAINT "post_primaryAuthorId_fkey" FOREIGN KEY ("primaryAuthorId") REFERENCES "public"."author"("id") ON DELETE SET NULL ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250909162749_make_published_at_optional/migration.sql ================================================ -- AlterTable ALTER TABLE "public"."post" ALTER COLUMN "publishedAt" DROP NOT NULL, ALTER COLUMN "publishedAt" DROP DEFAULT; ================================================ FILE: packages/db/prisma/migrations/20250909171017_make_published_at_required/migration.sql ================================================ /* Warnings: - Made the column `publishedAt` on table `post` required. This step will fail if there are existing NULL values in that column. */ -- AlterTable ALTER TABLE "public"."post" ALTER COLUMN "publishedAt" SET NOT NULL; ================================================ FILE: packages/db/prisma/migrations/20250911083948_add_slack_payload_format/migration.sql ================================================ -- AlterEnum ALTER TYPE "public"."PayloadFormat" ADD VALUE 'slack'; ================================================ FILE: packages/db/prisma/migrations/20250915114755_add_database_indices/migration.sql ================================================ -- CreateIndex CREATE INDEX "account_userId_idx" ON "public"."account"("userId"); -- CreateIndex CREATE INDEX "account_providerId_accountId_idx" ON "public"."account"("providerId", "accountId"); -- CreateIndex CREATE INDEX "author_workspaceId_isActive_idx" ON "public"."author"("workspaceId", "isActive"); -- CreateIndex CREATE INDEX "author_userId_idx" ON "public"."author"("userId"); -- CreateIndex CREATE INDEX "category_workspaceId_idx" ON "public"."category"("workspaceId"); -- CreateIndex CREATE INDEX "invitation_organizationId_idx" ON "public"."invitation"("organizationId"); -- CreateIndex CREATE INDEX "invitation_email_idx" ON "public"."invitation"("email"); -- CreateIndex CREATE INDEX "invitation_inviterId_idx" ON "public"."invitation"("inviterId"); -- CreateIndex CREATE INDEX "media_workspaceId_createdAt_idx" ON "public"."media"("workspaceId", "createdAt"); -- CreateIndex CREATE INDEX "media_workspaceId_type_idx" ON "public"."media"("workspaceId", "type"); -- CreateIndex CREATE INDEX "member_userId_idx" ON "public"."member"("userId"); -- CreateIndex CREATE INDEX "member_organizationId_idx" ON "public"."member"("organizationId"); -- CreateIndex CREATE INDEX "member_organizationId_userId_idx" ON "public"."member"("organizationId", "userId"); -- CreateIndex CREATE INDEX "post_workspaceId_status_idx" ON "public"."post"("workspaceId", "status"); -- CreateIndex CREATE INDEX "post_workspaceId_createdAt_idx" ON "public"."post"("workspaceId", "createdAt"); -- CreateIndex CREATE INDEX "post_workspaceId_status_publishedAt_idx" ON "public"."post"("workspaceId", "status", "publishedAt"); -- CreateIndex CREATE INDEX "post_categoryId_idx" ON "public"."post"("categoryId"); -- CreateIndex CREATE INDEX "session_userId_idx" ON "public"."session"("userId"); -- CreateIndex CREATE INDEX "session_activeOrganizationId_idx" ON "public"."session"("activeOrganizationId"); -- CreateIndex CREATE INDEX "subscription_userId_idx" ON "public"."subscription"("userId"); -- CreateIndex CREATE INDEX "subscription_status_idx" ON "public"."subscription"("status"); -- CreateIndex CREATE INDEX "tag_workspaceId_idx" ON "public"."tag"("workspaceId"); -- CreateIndex CREATE INDEX "webhook_workspaceId_idx" ON "public"."webhook"("workspaceId"); -- CreateIndex CREATE INDEX "webhook_workspaceId_enabled_idx" ON "public"."webhook"("workspaceId", "enabled"); ================================================ FILE: packages/db/prisma/migrations/20250919210238_add_share_link_table/migration.sql ================================================ -- CreateTable CREATE TABLE "public"."ShareLink" ( "id" TEXT NOT NULL, "token" TEXT NOT NULL, "postId" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "password" TEXT, "expiresAt" TIMESTAMP(3) NOT NULL, "isActive" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "ShareLink_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE UNIQUE INDEX "ShareLink_token_key" ON "public"."ShareLink"("token"); -- CreateIndex CREATE INDEX "ShareLink_postId_idx" ON "public"."ShareLink"("postId"); -- CreateIndex CREATE INDEX "ShareLink_workspaceId_idx" ON "public"."ShareLink"("workspaceId"); -- CreateIndex CREATE INDEX "ShareLink_expiresAt_idx" ON "public"."ShareLink"("expiresAt"); -- CreateIndex CREATE INDEX "ShareLink_isActive_idx" ON "public"."ShareLink"("isActive"); -- AddForeignKey ALTER TABLE "public"."ShareLink" ADD CONSTRAINT "ShareLink_postId_fkey" FOREIGN KEY ("postId") REFERENCES "public"."post"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."ShareLink" ADD CONSTRAINT "ShareLink_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250923212858_add_ai_editor_preferences/migration.sql ================================================ -- CreateTable CREATE TABLE "public"."editor_preferences" ( "id" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, CONSTRAINT "editor_preferences_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."ai" ( "id" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT false, "editorPreferencesId" TEXT NOT NULL, CONSTRAINT "ai_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE UNIQUE INDEX "editor_preferences_workspaceId_key" ON "public"."editor_preferences"("workspaceId"); -- CreateIndex CREATE UNIQUE INDEX "ai_editorPreferencesId_key" ON "public"."ai"("editorPreferencesId"); -- AddForeignKey ALTER TABLE "public"."editor_preferences" ADD CONSTRAINT "editor_preferences_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."ai" ADD CONSTRAINT "ai_editorPreferencesId_fkey" FOREIGN KEY ("editorPreferencesId") REFERENCES "public"."editor_preferences"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20250924180405_add_missing_better_auth_indices/migration.sql ================================================ -- CreateIndex CREATE INDEX "session_token_idx" ON "public"."session"("token"); -- CreateIndex CREATE INDEX "verification_identifier_idx" ON "public"."verification"("identifier"); ================================================ FILE: packages/db/prisma/migrations/20250927161627_add_author_social_links/migration.sql ================================================ /* Warnings: - You are about to drop the column `socials` on the `author` table. All the data in the column will be lost. */ -- AlterTable ALTER TABLE "public"."author" DROP COLUMN "socials"; -- CreateTable CREATE TABLE "public"."author_social" ( "id" TEXT NOT NULL, "authorId" TEXT NOT NULL, "platform" TEXT NOT NULL, "url" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "author_social_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE INDEX "author_social_authorId_idx" ON "public"."author_social"("authorId"); -- AddForeignKey ALTER TABLE "public"."author_social" ADD CONSTRAINT "author_social_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."author"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20251114225009_add_usage_event_table/migration.sql ================================================ -- CreateEnum CREATE TYPE "UsageEventType" AS ENUM ('api_request', 'media_upload', 'webhook_delivery'); -- CreateTable CREATE TABLE "usage_event" ( "id" TEXT NOT NULL, "type" "UsageEventType" NOT NULL, "workspaceId" TEXT NOT NULL, "endpoint" TEXT, "size" INTEGER, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "usage_event_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE INDEX "usage_event_workspaceId_type_createdAt_idx" ON "usage_event"("workspaceId", "type", "createdAt"); -- CreateIndex CREATE INDEX "usage_event_workspaceId_createdAt_idx" ON "usage_event"("workspaceId", "createdAt"); -- AddForeignKey ALTER TABLE "usage_event" ADD CONSTRAINT "usage_event_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20251116173412_new_media_enum_and_alt_text_column/migration.sql ================================================ -- AlterEnum ALTER TYPE "WebhookEvent" ADD VALUE 'media_updated'; -- AlterTable ALTER TABLE "media" ADD COLUMN "alt" TEXT; ================================================ FILE: packages/db/prisma/migrations/20251201001521_add_api_keys/migration.sql ================================================ -- CreateEnum CREATE TYPE "public"."ApiKeyType" AS ENUM ('public', 'private'); -- CreateEnum CREATE TYPE "public"."ApiScope" AS ENUM ('posts_read', 'posts_write', 'authors_read', 'authors_write', 'categories_read', 'categories_write', 'tags_read', 'tags_write', 'media_read', 'media_write'); -- CreateTable CREATE TABLE "public"."api_key" ( "id" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "userId" TEXT, "name" TEXT NOT NULL, "prefix" TEXT, "key" TEXT NOT NULL, "preview" TEXT NOT NULL, "type" "public"."ApiKeyType" NOT NULL DEFAULT 'public', "scopes" "public"."ApiScope"[] DEFAULT ARRAY[]::"public"."ApiScope"[], "requestCount" INTEGER NOT NULL DEFAULT 0, "enabled" BOOLEAN NOT NULL DEFAULT true, "rateLimitTimeWindow" INTEGER, "rateLimitMax" INTEGER, "lastRequest" TIMESTAMP(3), "lastUsed" TIMESTAMP(3), "expiresAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "api_key_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE UNIQUE INDEX "api_key_key_key" ON "public"."api_key"("key"); -- CreateIndex CREATE INDEX "api_key_workspaceId_idx" ON "public"."api_key"("workspaceId"); -- CreateIndex CREATE INDEX "api_key_workspaceId_createdAt_idx" ON "public"."api_key"("workspaceId", "createdAt"); -- CreateIndex CREATE INDEX "api_key_workspaceId_enabled_idx" ON "public"."api_key"("workspaceId", "enabled"); -- CreateIndex CREATE INDEX "api_key_workspaceId_type_idx" ON "public"."api_key"("workspaceId", "type"); -- CreateIndex CREATE INDEX "api_key_key_idx" ON "public"."api_key"("key"); -- AddForeignKey ALTER TABLE "public"."api_key" ADD CONSTRAINT "api_key_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."api_key" ADD CONSTRAINT "api_key_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE SET NULL ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20251210213108_subscription_history/migration.sql ================================================ /* Warnings: - The values [team] on the enum `PlanType` will be removed. If these variants are still used in the database, this will fail. - The values [cancelled] on the enum `SubscriptionStatus` will be removed. If these variants are still used in the database, this will fail. */ -- CreateEnum CREATE TYPE "public"."SubscriptionRecurringInterval" AS ENUM ('day', 'week', 'month', 'year'); -- AlterEnum BEGIN; CREATE TYPE "public"."PlanType_new" AS ENUM ('hobby', 'pro'); ALTER TABLE "public"."subscription" ALTER COLUMN "plan" TYPE "public"."PlanType_new" USING ("plan"::text::"public"."PlanType_new"); ALTER TYPE "public"."PlanType" RENAME TO "PlanType_old"; ALTER TYPE "public"."PlanType_new" RENAME TO "PlanType"; DROP TYPE "public"."PlanType_old"; COMMIT; -- AlterEnum BEGIN; CREATE TYPE "public"."SubscriptionStatus_new" AS ENUM ('active', 'expired', 'trialing', 'past_due', 'incomplete', 'incomplete_expired', 'unpaid', 'canceled'); ALTER TABLE "public"."subscription" ALTER COLUMN "status" DROP DEFAULT; ALTER TABLE "public"."subscription" ALTER COLUMN "status" TYPE "public"."SubscriptionStatus_new" USING ("status"::text::"public"."SubscriptionStatus_new"); ALTER TYPE "public"."SubscriptionStatus" RENAME TO "SubscriptionStatus_old"; ALTER TYPE "public"."SubscriptionStatus_new" RENAME TO "SubscriptionStatus"; DROP TYPE "public"."SubscriptionStatus_old"; COMMIT; -- DropIndex DROP INDEX "public"."subscription_workspaceId_key"; -- AlterTable ALTER TABLE "public"."invitation" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; -- AlterTable ALTER TABLE "public"."subscription" ADD COLUMN "amount" INTEGER NOT NULL DEFAULT 20, ADD COLUMN "currency" TEXT NOT NULL DEFAULT 'USD', ADD COLUMN "discountId" TEXT, ADD COLUMN "productId" TEXT, ADD COLUMN "recurringInterval" "public"."SubscriptionRecurringInterval" NOT NULL DEFAULT 'month', ADD COLUMN "startedAt" TIMESTAMP(3), ALTER COLUMN "status" DROP DEFAULT, ALTER COLUMN "cancelAtPeriodEnd" DROP DEFAULT; -- CreateIndex CREATE INDEX "subscription_workspaceId_status_idx" ON "public"."subscription"("workspaceId", "status"); ================================================ FILE: packages/db/prisma/migrations/20260331143009_add_fields/migration.sql ================================================ /* Warnings: - A unique constraint covering the columns `[id,workspaceId]` on the table `post` will be added. If there are existing duplicate values, this will fail. */ -- CreateEnum CREATE TYPE "public"."FieldType" AS ENUM ('text', 'number', 'boolean', 'date', 'richtext', 'select', 'multiselect'); -- CreateTable CREATE TABLE "public"."field" ( "id" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "key" TEXT NOT NULL, "name" TEXT NOT NULL, "description" TEXT, "type" "public"."FieldType" NOT NULL, "required" BOOLEAN NOT NULL DEFAULT false, "position" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "field_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."field_option" ( "id" TEXT NOT NULL, "fieldId" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "value" TEXT NOT NULL, "label" TEXT NOT NULL, "position" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "field_option_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "public"."field_value" ( "id" TEXT NOT NULL, "postId" TEXT NOT NULL, "fieldId" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "value" TEXT NOT NULL, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "field_value_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE INDEX "field_workspaceId_idx" ON "public"."field"("workspaceId"); -- CreateIndex CREATE UNIQUE INDEX "field_workspaceId_key_key" ON "public"."field"("workspaceId", "key"); -- CreateIndex CREATE UNIQUE INDEX "field_id_workspaceId_key" ON "public"."field"("id", "workspaceId"); -- CreateIndex CREATE INDEX "field_option_fieldId_idx" ON "public"."field_option"("fieldId"); -- CreateIndex CREATE INDEX "field_option_workspaceId_idx" ON "public"."field_option"("workspaceId"); -- CreateIndex CREATE INDEX "field_option_fieldId_position_idx" ON "public"."field_option"("fieldId", "position"); -- CreateIndex CREATE UNIQUE INDEX "field_option_fieldId_value_key" ON "public"."field_option"("fieldId", "value"); -- CreateIndex CREATE UNIQUE INDEX "field_option_id_workspaceId_key" ON "public"."field_option"("id", "workspaceId"); -- CreateIndex CREATE INDEX "field_value_postId_idx" ON "public"."field_value"("postId"); -- CreateIndex CREATE INDEX "field_value_fieldId_idx" ON "public"."field_value"("fieldId"); -- CreateIndex CREATE INDEX "field_value_workspaceId_idx" ON "public"."field_value"("workspaceId"); -- CreateIndex CREATE UNIQUE INDEX "field_value_postId_fieldId_key" ON "public"."field_value"("postId", "fieldId"); -- CreateIndex CREATE UNIQUE INDEX "post_id_workspaceId_key" ON "public"."post"("id", "workspaceId"); -- AddForeignKey ALTER TABLE "public"."field" ADD CONSTRAINT "field_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."field_option" ADD CONSTRAINT "field_option_fieldId_workspaceId_fkey" FOREIGN KEY ("fieldId", "workspaceId") REFERENCES "public"."field"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."field_option" ADD CONSTRAINT "field_option_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."field_value" ADD CONSTRAINT "field_value_postId_workspaceId_fkey" FOREIGN KEY ("postId", "workspaceId") REFERENCES "public"."post"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."field_value" ADD CONSTRAINT "field_value_fieldId_workspaceId_fkey" FOREIGN KEY ("fieldId", "workspaceId") REFERENCES "public"."field"("id", "workspaceId") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "public"."field_value" ADD CONSTRAINT "field_value_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20260505135201_add_media_metadata/migration.sql ================================================ -- AlterTable ALTER TABLE "media" ADD COLUMN "blurHash" TEXT, ADD COLUMN "duration" INTEGER, ADD COLUMN "height" INTEGER, ADD COLUMN "mimeType" TEXT, ADD COLUMN "width" INTEGER; ================================================ FILE: packages/db/prisma/migrations/20260508223056_add_notification_preferences/migration.sql ================================================ /* Warnings: - You are about to drop the `ai` table. If the table is not empty, all the data it contains will be lost. - You are about to drop the `editor_preferences` table. If the table is not empty, all the data it contains will be lost. */ -- DropForeignKey ALTER TABLE "ai" DROP CONSTRAINT "ai_editorPreferencesId_fkey"; -- DropForeignKey ALTER TABLE "editor_preferences" DROP CONSTRAINT "editor_preferences_workspaceId_fkey"; -- DropTable DROP TABLE "ai"; -- DropTable DROP TABLE "editor_preferences"; -- CreateTable CREATE TABLE "user_notification_preferences" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "marketing" BOOLEAN NOT NULL DEFAULT false, "product" BOOLEAN NOT NULL DEFAULT true, "marketingConsentedAt" TIMESTAMP(3), "marketingConsentSource" TEXT, "marketingUnsubscribedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "user_notification_preferences_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "workspace_notification_preferences" ( "id" TEXT NOT NULL, "memberId" TEXT NOT NULL, "usageAlerts" BOOLEAN NOT NULL DEFAULT true, "subscriptions" BOOLEAN NOT NULL DEFAULT true, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "workspace_notification_preferences_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE UNIQUE INDEX "user_notification_preferences_userId_key" ON "user_notification_preferences"("userId"); -- CreateIndex CREATE UNIQUE INDEX "workspace_notification_preferences_memberId_key" ON "workspace_notification_preferences"("memberId"); -- AddForeignKey ALTER TABLE "user_notification_preferences" ADD CONSTRAINT "user_notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "workspace_notification_preferences" ADD CONSTRAINT "workspace_notification_preferences_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "member"("id") ON DELETE CASCADE ON UPDATE CASCADE; ================================================ FILE: packages/db/prisma/migrations/20260511_rename_webhook_to_webhook_endpoint/migration.sql ================================================ -- Rename the enum type (no data change, all existing values are preserved) ALTER TYPE "WebhookEvent" RENAME TO "WorkspaceEventType"; -- Add the new post_created value to the enum ALTER TYPE "WorkspaceEventType" ADD VALUE IF NOT EXISTS 'post_created'; -- Rename the table ALTER TABLE "webhook" RENAME TO "webhook_endpoint"; -- Rename the endpoint column to url ALTER TABLE "webhook_endpoint" RENAME COLUMN "endpoint" TO "url"; ================================================ FILE: packages/db/prisma/migrations/20260513192507_add_workspace_events/migration.sql ================================================ -- CreateEnum CREATE TYPE "WorkspaceEventSource" AS ENUM ('dashboard', 'api', 'mcp', 'workflow', 'system'); -- CreateEnum CREATE TYPE "WorkspaceEventActorType" AS ENUM ('user', 'api_key', 'mcp', 'system'); -- CreateEnum CREATE TYPE "WorkspaceEventResourceType" AS ENUM ('post', 'category', 'tag', 'media', 'author', 'workspace'); -- CreateEnum CREATE TYPE "WebhookDeliveryStatus" AS ENUM ('pending', 'sending', 'success', 'retrying', 'failed'); -- CreateEnum CREATE TYPE "UsageAlertKind" AS ENUM ('warning', 'critical', 'exhausted'); -- AlterEnum -- This migration adds more than one value to an enum. -- With PostgreSQL versions 11 and earlier, this is not possible -- in a single migration. This can be worked around by creating -- multiple migrations, each migration adding only one value to -- the enum. ALTER TYPE "WorkspaceEventType" ADD VALUE 'post_unpublished'; ALTER TYPE "WorkspaceEventType" ADD VALUE 'author_created'; ALTER TYPE "WorkspaceEventType" ADD VALUE 'author_updated'; ALTER TYPE "WorkspaceEventType" ADD VALUE 'author_deleted'; -- AlterTable ALTER TABLE "webhook_endpoint" RENAME CONSTRAINT "webhook_pkey" TO "webhook_endpoint_pkey"; -- CreateTable CREATE TABLE "usage_alert" ( "id" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "type" "UsageEventType" NOT NULL, "kind" "UsageAlertKind" NOT NULL, "periodStart" TIMESTAMP(3) NOT NULL, "periodEnd" TIMESTAMP(3) NOT NULL, "emailSentTo" TEXT NOT NULL, "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "usage_alert_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "workspace_event" ( "id" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "type" "WorkspaceEventType" NOT NULL, "source" "WorkspaceEventSource" NOT NULL DEFAULT 'dashboard', "resourceType" "WorkspaceEventResourceType", "resourceId" TEXT, "actorType" "WorkspaceEventActorType", "actorId" TEXT, "payload" JSONB NOT NULL DEFAULT '{}', "processedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "workspace_event_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "webhook_delivery" ( "id" TEXT NOT NULL, "eventId" TEXT NOT NULL, "workspaceId" TEXT NOT NULL, "webhookEndpointId" TEXT NOT NULL, "url" TEXT NOT NULL, "status" "WebhookDeliveryStatus" NOT NULL DEFAULT 'pending', "isTest" BOOLEAN NOT NULL DEFAULT false, "attemptCount" INTEGER NOT NULL DEFAULT 0, "maxAttempts" INTEGER NOT NULL DEFAULT 3, "nextRetryAt" TIMESTAMP(3), "lastAttemptAt" TIMESTAMP(3), "deliveredAt" TIMESTAMP(3), "failedAt" TIMESTAMP(3), "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, CONSTRAINT "webhook_delivery_pkey" PRIMARY KEY ("id") ); -- CreateTable CREATE TABLE "webhook_delivery_attempt" ( "id" TEXT NOT NULL, "deliveryId" TEXT NOT NULL, "attemptNumber" INTEGER NOT NULL, "success" BOOLEAN NOT NULL DEFAULT false, "statusCode" INTEGER, "responseBody" TEXT, "errorMessage" TEXT, "durationMs" INTEGER, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "webhook_delivery_attempt_pkey" PRIMARY KEY ("id") ); -- CreateIndex CREATE INDEX "usage_alert_workspaceId_type_periodStart_periodEnd_idx" ON "usage_alert"("workspaceId", "type", "periodStart", "periodEnd"); -- CreateIndex CREATE UNIQUE INDEX "usage_alert_workspaceId_type_kind_periodStart_periodEnd_key" ON "usage_alert"("workspaceId", "type", "kind", "periodStart", "periodEnd"); -- CreateIndex CREATE INDEX "workspace_event_workspaceId_createdAt_idx" ON "workspace_event"("workspaceId", "createdAt"); -- CreateIndex CREATE INDEX "workspace_event_workspaceId_type_idx" ON "workspace_event"("workspaceId", "type"); -- CreateIndex CREATE INDEX "workspace_event_workspaceId_resourceType_resourceId_idx" ON "workspace_event"("workspaceId", "resourceType", "resourceId"); -- CreateIndex CREATE INDEX "workspace_event_workspaceId_processedAt_idx" ON "workspace_event"("workspaceId", "processedAt"); -- CreateIndex CREATE INDEX "webhook_delivery_eventId_idx" ON "webhook_delivery"("eventId"); -- CreateIndex CREATE INDEX "webhook_delivery_workspaceId_status_idx" ON "webhook_delivery"("workspaceId", "status"); -- CreateIndex CREATE INDEX "webhook_delivery_workspaceId_createdAt_idx" ON "webhook_delivery"("workspaceId", "createdAt"); -- CreateIndex CREATE INDEX "webhook_delivery_webhookEndpointId_idx" ON "webhook_delivery"("webhookEndpointId"); -- CreateIndex CREATE UNIQUE INDEX "webhook_delivery_eventId_webhookEndpointId_key" ON "webhook_delivery"("eventId", "webhookEndpointId"); -- CreateIndex CREATE INDEX "webhook_delivery_attempt_deliveryId_idx" ON "webhook_delivery_attempt"("deliveryId"); -- CreateIndex CREATE UNIQUE INDEX "webhook_delivery_attempt_deliveryId_attemptNumber_key" ON "webhook_delivery_attempt"("deliveryId", "attemptNumber"); -- RenameForeignKey ALTER TABLE "webhook_endpoint" RENAME CONSTRAINT "webhook_workspaceId_fkey" TO "webhook_endpoint_workspaceId_fkey"; -- AddForeignKey ALTER TABLE "usage_alert" ADD CONSTRAINT "usage_alert_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "workspace_event" ADD CONSTRAINT "workspace_event_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "workspace_event"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "webhook_delivery" ADD CONSTRAINT "webhook_delivery_webhookEndpointId_fkey" FOREIGN KEY ("webhookEndpointId") REFERENCES "webhook_endpoint"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "webhook_delivery_attempt" ADD CONSTRAINT "webhook_delivery_attempt_deliveryId_fkey" FOREIGN KEY ("deliveryId") REFERENCES "webhook_delivery"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- RenameIndex ALTER INDEX "webhook_workspaceId_enabled_idx" RENAME TO "webhook_endpoint_workspaceId_enabled_idx"; -- RenameIndex ALTER INDEX "webhook_workspaceId_idx" RENAME TO "webhook_endpoint_workspaceId_idx"; ================================================ FILE: packages/db/prisma/migrations/20260515000100_add_subscription_polar_event_ordering/migration.sql ================================================ -- Store the latest Polar webhook timestamp applied to a subscription so stale -- webhook deliveries cannot overwrite newer subscription state. ALTER TABLE "subscription" ADD COLUMN "lastPolarEventAt" TIMESTAMP(3); ================================================ FILE: packages/db/prisma/migrations/migration_lock.toml ================================================ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) provider = "postgresql" ================================================ FILE: packages/db/prisma/schema.prisma ================================================ generator client { provider = "prisma-client" output = "../src/generated/node" moduleFormat = "esm" generatedFileExtension = "ts" importFileExtension = "ts" } generator client_workers { provider = "prisma-client" runtime = "workerd" output = "../src/generated/workerd" moduleFormat = "esm" generatedFileExtension = "ts" importFileExtension = "ts" } datasource db { provider = "postgresql" } model Subscription { id String @id @default(cuid()) userId String plan PlanType status SubscriptionStatus updatedAt DateTime @updatedAt cancelAtPeriodEnd Boolean canceledAt DateTime? createdAt DateTime @default(now()) currentPeriodEnd DateTime currentPeriodStart DateTime endedAt DateTime? endsAt DateTime? polarId String @unique workspaceId String startedAt DateTime? productId String? amount Int @default(20) currency String @default("USD") discountId String? lastPolarEventAt DateTime? recurringInterval SubscriptionRecurringInterval @default(month) user User @relation(fields: [userId], references: [id], onDelete: Cascade) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@index([userId]) @@index([status]) @@index([workspaceId, status]) @@map("subscription") } model Organization { id String @id @default(cuid()) name String slug String @unique logo String? metadata String? description String? subdomain String? @unique updatedAt DateTime @updatedAt createdAt DateTime @default(now()) timezone String @default("Europe/London") authors Author[] categories Category[] fields Field[] invitations Invitation[] media Media[] members Member[] posts Post[] subscriptions Subscription[] tags Tag[] fieldOptions FieldOption[] webhookEndpoints WebhookEndpoint[] ShareLink ShareLink[] usageEvents UsageEvent[] ApiToken ApiKey[] fieldValues FieldValue[] workspaceEvents WorkspaceEvent[] webhookDeliveries WebhookDelivery[] usageAlerts UsageAlert[] @@map("workspace") } model Post { id String @id @default(cuid()) title String content String coverImage String? contentJson Json description String views Int @default(0) workspaceId String slug String categoryId String status PostStatus @default(draft) featured Boolean @default(false) updatedAt DateTime @updatedAt createdAt DateTime @default(now()) publishedAt DateTime attribution Json? primaryAuthorId String? primaryAuthor Author? @relation("PrimaryAuthor", fields: [primaryAuthorId], references: [id], onDelete: SetNull) authors Author[] @relation("PostToAuthor") category Category @relation(fields: [categoryId], references: [id]) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) tags Tag[] @relation("PostToTag") shareLinks ShareLink[] fieldValues FieldValue[] @@unique([workspaceId, slug]) @@unique([id, workspaceId]) @@index([workspaceId, status]) @@index([workspaceId, createdAt]) @@index([workspaceId, status, publishedAt]) @@index([categoryId]) @@map("post") } model ShareLink { id String @id @default(cuid()) token String @unique postId String workspaceId String password String? expiresAt DateTime isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt post Post @relation(fields: [postId], references: [id], onDelete: Cascade) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@index([postId]) @@index([workspaceId]) @@index([expiresAt]) @@index([isActive]) } model Tag { id String @id @default(cuid()) name String description String? slug String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspaceId String workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) posts Post[] @relation("PostToTag") @@unique([workspaceId, slug]) @@index([workspaceId]) @@map("tag") } model Media { id String @id @default(cuid()) name String url String size Int alt String? mimeType String? width Int? height Int? duration Int? // video length in milliseconds blurHash String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspaceId String type MediaType @default(image) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@index([workspaceId, createdAt]) @@index([workspaceId, type]) @@map("media") } model Category { id String @id @default(cuid()) name String description String? slug String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspaceId String workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) posts Post[] @@unique([workspaceId, slug]) @@index([workspaceId]) @@map("category") } model WebhookEndpoint { id String @id @default(cuid()) name String url String secret String enabled Boolean @default(true) workspaceId String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt events WorkspaceEventType[] format PayloadFormat @default(json) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) deliveries WebhookDelivery[] @@index([workspaceId]) @@index([workspaceId, enabled]) @@map("webhook_endpoint") } model User { id String @id @default(cuid()) name String email String @unique emailVerified Boolean image String? createdAt DateTime updatedAt DateTime accounts Account[] authors Author[] invitations Invitation[] members Member[] sessions Session[] subscriptions Subscription[] ApiKey ApiKey[] notificationPreferences UserNotificationPreferences? @@map("user") } model UserNotificationPreferences { id String @id @default(cuid()) userId String @unique marketing Boolean @default(false) product Boolean @default(true) marketingConsentedAt DateTime? marketingConsentSource String? marketingUnsubscribedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("user_notification_preferences") } model Author { id String @id @default(cuid()) name String email String? bio String? image String? role String? slug String socials AuthorSocial[] workspaceId String userId String? isActive Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) primaryPosts Post[] @relation("PrimaryAuthor") coAuthoredPosts Post[] @relation("PostToAuthor") @@unique([workspaceId, userId]) @@unique([workspaceId, slug]) @@index([workspaceId, isActive]) @@index([userId]) @@map("author") } model AuthorSocial { id String @id @default(cuid()) authorId String platform String url String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt author Author @relation(fields: [authorId], references: [id], onDelete: Cascade) @@index([authorId]) @@map("author_social") } model Session { id String @id @default(cuid()) expiresAt DateTime token String @unique createdAt DateTime updatedAt DateTime ipAddress String? userAgent String? userId String activeOrganizationId String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([token]) @@index([activeOrganizationId]) @@map("session") } model Account { id String @id @default(cuid()) accountId String providerId String userId String accessToken String? refreshToken String? idToken String? accessTokenExpiresAt DateTime? refreshTokenExpiresAt DateTime? scope String? password String? createdAt DateTime updatedAt DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([providerId, accountId]) @@map("account") } model Verification { id String @id @default(cuid()) identifier String value String expiresAt DateTime createdAt DateTime? updatedAt DateTime? @@index([identifier]) @@map("verification") } model Member { id String @id @default(cuid()) organizationId String userId String role String? createdAt DateTime notificationPreferences WorkspaceNotificationPreferences? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([organizationId]) @@index([organizationId, userId]) @@map("member") } model WorkspaceNotificationPreferences { id String @id @default(cuid()) memberId String @unique usageAlerts Boolean @default(true) subscriptions Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) @@map("workspace_notification_preferences") } model Invitation { id String @id @default(cuid()) organizationId String email String role String? status String expiresAt DateTime inviterId String user User @relation(fields: [inviterId], references: [id], onDelete: Cascade) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@index([organizationId]) @@index([email]) @@index([inviterId]) @@map("invitation") } model UsageEvent { id String @id @default(cuid()) type UsageEventType workspaceId String endpoint String? size Int? createdAt DateTime @default(now()) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@index([workspaceId, type, createdAt]) @@index([workspaceId, createdAt]) @@map("usage_event") } model UsageAlert { id String @id @default(cuid()) workspaceId String type UsageEventType kind UsageAlertKind periodStart DateTime periodEnd DateTime emailSentTo String sentAt DateTime @default(now()) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@unique([workspaceId, type, kind, periodStart, periodEnd]) @@index([workspaceId, type, periodStart, periodEnd]) @@map("usage_alert") } enum UsageAlertKind { warning critical exhausted } model ApiKey { id String @id @default(cuid()) workspaceId String userId String? name String prefix String? key String @unique // SHA-256 hash of the API key preview String type ApiKeyType @default(public) scopes ApiScope[] @default([]) requestCount Int @default(0) enabled Boolean @default(true) // Rate limiting fields rateLimitTimeWindow Int? // milliseconds (e.g., 86400000 for 24 hours) rateLimitMax Int? // max requests per window lastRequest DateTime? // for rate limit window tracking lastUsed DateTime? expiresAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([workspaceId]) @@index([workspaceId, createdAt]) @@index([workspaceId, enabled]) @@index([workspaceId, type]) @@index([key]) @@map("api_key") } enum PostStatus { published draft } enum PlanType { hobby pro } enum SubscriptionRecurringInterval { day week month year } enum SubscriptionStatus { active // Active subscription expired // End of period trialing // Still in a trial period past_due // Payment failed, unpaid, or incomplete incomplete // Created, but with unpaid invoice incomplete_expired // Created, but never made a payment and now expired unpaid // Payment failed and is unpaid canceled // Canceled (Polar uses American spelling) } enum WorkspaceEventType { post_created post_published post_unpublished post_updated post_deleted category_created category_updated category_deleted tag_created tag_updated tag_deleted media_uploaded media_updated media_deleted author_created author_updated author_deleted } enum WorkspaceEventSource { dashboard api mcp workflow system } enum WorkspaceEventActorType { user api_key mcp system } enum WorkspaceEventResourceType { post category tag media author workspace } enum PayloadFormat { json discord slack } enum MediaType { image video audio document } enum UsageEventType { api_request media_upload webhook_delivery } enum ApiKeyType { public private } enum ApiScope { posts_read posts_write authors_read authors_write categories_read categories_write tags_read tags_write media_read media_write } enum FieldType { text number boolean date richtext select multiselect } model Field { id String @id @default(cuid()) workspaceId String key String name String description String? type FieldType required Boolean @default(false) position Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) values FieldValue[] options FieldOption[] @@unique([workspaceId, key]) @@unique([id, workspaceId]) @@index([workspaceId]) @@map("field") } model FieldOption { id String @id @default(cuid()) fieldId String workspaceId String value String label String position Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt field Field @relation(fields: [fieldId, workspaceId], references: [id, workspaceId], onDelete: Cascade) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@unique([fieldId, value]) @@unique([id, workspaceId]) @@index([fieldId]) @@index([workspaceId]) @@index([fieldId, position]) @@map("field_option") } model FieldValue { id String @id @default(cuid()) postId String fieldId String workspaceId String value String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt post Post @relation(fields: [postId, workspaceId], references: [id, workspaceId], onDelete: Cascade) field Field @relation(fields: [fieldId, workspaceId], references: [id, workspaceId], onDelete: Cascade) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) @@unique([postId, fieldId]) @@index([postId]) @@index([fieldId]) @@index([workspaceId]) @@map("field_value") } model WorkspaceEvent { id String @id @default(cuid()) workspaceId String type WorkspaceEventType source WorkspaceEventSource @default(dashboard) resourceType WorkspaceEventResourceType? resourceId String? actorType WorkspaceEventActorType? actorId String? payload Json @default("{}") processedAt DateTime? createdAt DateTime @default(now()) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) deliveries WebhookDelivery[] @@index([workspaceId, createdAt]) @@index([workspaceId, type]) @@index([workspaceId, resourceType, resourceId]) @@index([workspaceId, processedAt]) @@map("workspace_event") } model WebhookDelivery { id String @id @default(cuid()) eventId String workspaceId String webhookEndpointId String url String status WebhookDeliveryStatus @default(pending) isTest Boolean @default(false) attemptCount Int @default(0) maxAttempts Int @default(3) nextRetryAt DateTime? lastAttemptAt DateTime? deliveredAt DateTime? failedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt event WorkspaceEvent @relation(fields: [eventId], references: [id], onDelete: Cascade) workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) webhookEndpoint WebhookEndpoint @relation(fields: [webhookEndpointId], references: [id], onDelete: Cascade) attempts WebhookDeliveryAttempt[] @@index([eventId]) @@index([workspaceId, status]) @@index([workspaceId, createdAt]) @@index([webhookEndpointId]) @@unique([eventId, webhookEndpointId]) @@map("webhook_delivery") } model WebhookDeliveryAttempt { id String @id @default(cuid()) deliveryId String attemptNumber Int success Boolean @default(false) statusCode Int? responseBody String? errorMessage String? durationMs Int? createdAt DateTime @default(now()) delivery WebhookDelivery @relation(fields: [deliveryId], references: [id], onDelete: Cascade) @@unique([deliveryId, attemptNumber]) @@index([deliveryId]) @@map("webhook_delivery_attempt") } enum WebhookDeliveryStatus { pending sending success retrying failed } ================================================ FILE: packages/db/prisma.config.ts ================================================ import "dotenv/config"; import { defineConfig } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", }, datasource: { // Prefer a direct URL for CLI operations when available, but keep // DATABASE_URL as a fallback so local generate/build flows stay simple. url: process.env.DIRECT_URL ?? process.env.DATABASE_URL ?? "", }, }); ================================================ FILE: packages/db/src/browser.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: "required" */ export * from "./generated/node/browser"; ================================================ FILE: packages/db/src/client.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: "required" */ export * from "./generated/node/client"; ================================================ FILE: packages/db/src/hyperdrive.ts ================================================ import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "./generated/workerd/client"; /** * Create a Prisma client for Hyperdrive. * * Uses the pg-worker adapter (standard PostgreSQL protocol) instead of the Neon * serverless driver. Compatible with Cloudflare Hyperdrive, which requires * direct TCP Postgres connections per CF docs. * * Pass env.HYPERDRIVE.connectionString from your Worker. Same Prisma Client * API for all queries — no schema changes needed. */ const createClient = (connectionString: string) => { const url = typeof connectionString === "string" ? connectionString.trim() : String(connectionString || "").trim(); if (!url) { throw new Error("Connection string is required and must be non-empty"); } const adapter = new PrismaPg({ connectionString: url }); return new PrismaClient({ adapter }); }; export { createClient }; ================================================ FILE: packages/db/src/index.ts ================================================ import { neonConfig } from "@neondatabase/serverless"; import { PrismaNeon } from "@prisma/adapter-neon"; import ws from "ws"; import { PrismaClient } from "./generated/node/client"; neonConfig.webSocketConstructor = ws; const createClient = () => { const connectionString = process.env.DATABASE_URL; if (!connectionString || typeof connectionString !== "string") { throw new Error("DATABASE_URL is not set"); } const adapter = new PrismaNeon({ connectionString }); return new PrismaClient({ adapter }); }; declare global { var prisma: PrismaClient | undefined; } let db: PrismaClient; if (process.env.NODE_ENV === "production") { db = createClient(); } else { if (!global.prisma) { global.prisma = createClient(); } db = global.prisma; } export { db }; ================================================ FILE: packages/db/src/workers.ts ================================================ import { PrismaNeon } from "@prisma/adapter-neon"; import { PrismaClient } from "./generated/workerd/client"; const createClient = (url: string) => { const connectionString = typeof url === "string" ? url.trim() : String(url || "").trim(); if (!connectionString) { throw new Error("DATABASE_URL is required and must be a non-empty string"); } const adapter = new PrismaNeon({ connectionString }); return new PrismaClient({ adapter }); }; export { createClient }; ================================================ FILE: packages/db/tsconfig.json ================================================ { "extends": "@marble/tsconfig/base.json", "compilerOptions": { "baseUrl": ".", "module": "ESNext", "moduleResolution": "Bundler", "target": "ES2023" }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } ================================================ FILE: packages/demo-markdown.md ================================================ # Markdown Demo File This is a demo markdown file to test markdown paste functionality in the editor. ## Images with Alt Text Here's a simple image with alt text: ![A beautiful landscape with mountains and a lake](https://images.unsplash.com/photo-1764377975933-2ffbd913f3c2?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) ## Linked Image Here's an image that's also a link: [![Clickable image with alt text](https://images.unsplash.com/photo-1761839271800-f44070ff0eb9?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDF8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D)](https://taqib.dev) ## Multiple Images ![First image](https://images.unsplash.com/photo-1764312349609-41297ede0e57?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) ![Second image with descriptive alt text](https://images.unsplash.com/photo-1764489307253-da5c9c311fa2?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) ![Third image](https://images.unsplash.com/photo-1764813824530-eb9e431ea89d?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) ## Text Formatting This paragraph has **bold text**, _italic text_, and `inline code`. ## Lists ### Unordered List - Item one - Item two - Item three ### Ordered List 1. First item 2. Second item 3. Third item ## Blockquote > This is a blockquote with an image below it. ## Code Block ```javascript function hello() { console.log("Hello, World!"); } ``` ## Links Here's a [regular link](https://images.unsplash.com/photo-1764760764956-fcb78be107a5?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) and an image link: ![Image link](https://images.unsplash.com/photo-1764377725021-33bba9d00944?q=80&w=1980&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) ## Table | Column 1 | Column 2 | Column 3 | | -------- | -------- | -------- | | Data 1 | Data 2 | Data 3 | | Data 4 | Data 5 | Data 6 | ## Horizontal Rule --- ## Final Image ![Final test image with detailed alt text describing the content](https://images.unsplash.com/photo-1764526612515-6b4ab2868a6e?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D) End of demo file. ================================================ FILE: packages/editor/package.json ================================================ { "name": "@marble/editor", "version": "0.1.0", "private": true, "exports": { ".": "./src/index.ts", "./components/*": "./src/components/*.tsx", "./extensions/*": "./src/extensions/*" }, "scripts": { "lint": "biome check .", "format": "biome --write ." }, "dependencies": { "@floating-ui/dom": "^1.7.6", "@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/react": "^1.1.6", "@marble/ui": "workspace:*", "@phosphor-icons/react": "^2.1.10", "@tiptap/core": "3.22.3", "@tiptap/extension-code-block-lowlight": "3.22.3", "@tiptap/extension-document": "3.22.3", "@tiptap/extension-drag-handle": "3.22.3", "@tiptap/extension-drag-handle-react": "3.22.3", "@tiptap/extension-file-handler": "3.22.3", "@tiptap/extension-highlight": "3.22.3", "@tiptap/extension-image": "3.22.3", "@tiptap/extension-list": "3.22.3", "@tiptap/extension-subscript": "3.22.3", "@tiptap/extension-superscript": "3.22.3", "@tiptap/extension-table": "3.22.3", "@tiptap/extension-text-align": "3.22.3", "@tiptap/extension-text-style": "3.22.3", "@tiptap/extension-twitch": "3.22.3", "@tiptap/extension-typography": "3.22.3", "@tiptap/extension-youtube": "3.22.3", "@tiptap/extensions": "3.22.3", "@tiptap/markdown": "3.22.3", "@tiptap/pm": "3.22.3", "@tiptap/react": "3.22.3", "@tiptap/starter-kit": "3.22.3", "@tiptap/suggestion": "3.22.3", "fuse.js": "^7.1.0", "lowlight": "^3.3.0", "react-colorful": "^5.6.1", "react-tweet": "^3.3.0", "tippy.js": "^6.3.7" }, "devDependencies": { "@marble/tsconfig": "workspace:*", "@types/node": "^22.9.0", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.1", "typescript": "^5.9.3" } } ================================================ FILE: packages/editor/src/components/color-picker.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Input } from "@marble/ui/components/input"; import { ArrowUUpLeft } from "@phosphor-icons/react"; import { useCallback, useState } from "react"; import { HexColorPicker } from "react-colorful"; import "../styles/color-picker.css"; const PRESET_COLORS = [ "#fb7185", // Rose "#fdba74", // Orange "#d9f99d", // Lime "#a7f3d0", // Emerald "#a5f3fc", // Cyan "#a5b4fc", // Indigo ]; export const ColorPicker = ({ color, onChange, onClear, }: { color?: string; onChange: (color: string) => void; onClear: () => void; }) => { const [hexInput, setHexInput] = useState(color || ""); const [prevColor, setPrevColor] = useState(color); if (color !== prevColor) { setPrevColor(color); setHexInput(color || ""); } const handleHexInputChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setHexInput(value); // Validate hex color format if (/^#[0-9A-Fa-f]{6}$/.test(value)) { onChange(value); } }, [onChange] ); const handleColorChange = useCallback( (newColor: string) => { setHexInput(newColor); onChange(newColor); }, [onChange] ); return (
    {PRESET_COLORS.map((presetColor) => (
    ); }; ================================================ FILE: packages/editor/src/components/editor-character-count.tsx ================================================ import { cn } from "@marble/ui/lib/utils"; import { useCurrentEditor } from "@tiptap/react"; import type { ReactNode } from "react"; export interface EditorCharacterCountProps { children: ReactNode; className?: string; } /** * Character Count Component * * Displays character or word count statistics for the editor content. * Provides two variants: Characters and Words. * * @example * ```tsx * Words: * Characters: * ``` */ export const EditorCharacterCount = { Characters({ children, className }: EditorCharacterCountProps) { const { editor } = useCurrentEditor(); if (!editor) { return null; } return (
    {children} {editor.storage.characterCount.characters()}
    ); }, Words({ children, className }: EditorCharacterCountProps) { const { editor } = useCurrentEditor(); if (!editor) { return null; } return (
    {children} {editor.storage.characterCount.words()}
    ); }, }; ================================================ FILE: packages/editor/src/components/editor-content.tsx ================================================ import { EditorContent as TiptapEditorContent, useCurrentEditor, } from "@tiptap/react"; /** * EditorContent Component * * Component that renders the actual editor content area. * This is the EditorContent component from @tiptap/react - the main editable area * where users type and edit content. * */ export function EditorContent() { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ; } ================================================ FILE: packages/editor/src/components/editor-provider.tsx ================================================ /** biome-ignore-all lint/suspicious/noExplicitAny: <> */ import type { AnyExtension } from "@tiptap/core"; import { EditorProvider as TiptapEditorProvider, type EditorProviderProps as TiptapEditorProviderProps, type UseEditorOptions, useEditor, } from "@tiptap/react"; import { ExtensionKit } from "../extensions/extension-kit"; import { handleCommandNavigation } from "../extensions/slash-command"; function deduplicateExtensions( defaults: AnyExtension[], overrides: AnyExtension[] ): AnyExtension[] { const overrideNames = new Set(overrides.map((ext) => ext.name)); return [ ...defaults.filter((ext) => !overrideNames.has(ext.name)), ...overrides, ]; } export type EditorProviderProps = Omit< TiptapEditorProviderProps, "extensions" > & { limit?: number; placeholder?: string; extensions?: any[]; }; /** * Editor Provider Component * * The root component that wraps the Tiptap editor with default extensions and configuration. * Provides the editor context to all child components. Use this as the wrapper for your * editor content and menus. * * * @example * ```tsx * * ... * * * ``` */ export const EditorProvider = ({ extensions, limit, placeholder, onUpdate, ...props }: EditorProviderProps) => { const defaultExtensions = ExtensionKit({ limit, placeholder }); return ( { handleCommandNavigation(event); }, }} extensions={deduplicateExtensions(defaultExtensions, extensions ?? [])} immediatelyRender={false} onUpdate={onUpdate} {...props} /> ); }; // biome-ignore lint/performance/noBarrelFile: Re-exporting TipTap hooks for convenience export { EditorContext, useCurrentEditor, useEditor } from "@tiptap/react"; /** * Hook to create a Marble editor instance with default extensions and configuration. * This is a convenience hook that sets up the editor with ExtensionKit and handleCommandNavigation. * * Use this with EditorContext.Provider to avoid layout issues: * * @example * ```tsx * const editor = useMarbleEditor({ * content: "

    Hello

    ", * placeholder: "Start typing...", * onUpdate: ({ editor }) => { * console.log(editor.getHTML()); * }, * }); * * return ( * * * * * ); * ``` */ export function useMarbleEditor(options: UseMarbleEditorOptions) { const { limit, placeholder, extensions = [], ...restOptions } = options; const defaultExtensions = ExtensionKit({ limit, placeholder }); return useEditor({ immediatelyRender: false, editorProps: { handleKeyDown: (_view, event) => { handleCommandNavigation(event); }, ...restOptions.editorProps, }, extensions: deduplicateExtensions(defaultExtensions, extensions), ...restOptions, }); } export type UseMarbleEditorOptions = Omit & { limit?: number; placeholder?: string; extensions?: any[]; }; ================================================ FILE: packages/editor/src/components/editor-table-menus.tsx ================================================ import { useCurrentEditor } from "@tiptap/react"; import { TableColumnMenu, TableRowMenu } from "../extensions/table"; /** * EditorTableMenus Component * * Component that renders table row and column menus. * These menus appear when clicking on table grip handles (row grips on the left, * column grips on the top) and allow users to add/remove rows and columns. * * The menus are automatically shown/hidden based on which grip handle is selected. * This component handles the editor instance check internally. * * @example * ```tsx * * * * * ``` */ export function EditorTableMenus() { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( <> ); } ================================================ FILE: packages/editor/src/components/icons/twitter.tsx ================================================ import type { SVGProps } from "react"; const Twitter = (props: SVGProps) => ( Twitter Icon ); export { Twitter }; ================================================ FILE: packages/editor/src/components/icons/youtube.tsx ================================================ import type { SVGProps } from "react"; const YouTubeIcon = (props: SVGProps) => ( YouTube Icon ); export { YouTubeIcon }; ================================================ FILE: packages/editor/src/components/index.ts ================================================ // Components /** biome-ignore-all lint/performance/noBarrelFile: <> */ // Utility Components export { EditorCharacterCount, type EditorCharacterCountProps, } from "./editor-character-count"; export { EditorContent } from "./editor-content"; export { EditorContext, EditorProvider, type EditorProviderProps, type UseMarbleEditorOptions, useCurrentEditor, useEditor, useMarbleEditor, } from "./editor-provider"; export { EditorTableMenus } from "./editor-table-menus"; // Mark Components export * from "./marks"; export { EditorBlockHandleMenu, type EditorBlockHandleMenuProps, EditorBubbleMenu, type EditorBubbleMenuProps, EditorFloatingMenu, type EditorFloatingMenuProps, } from "./menus"; // Node Components export * from "./nodes"; export { FieldRichTextEditor, type FieldRichTextEditorProps, } from "./rich-text-field"; export * from "./ui"; ================================================ FILE: packages/editor/src/components/marks/editor-clear-formatting.tsx ================================================ import { TextTSlashIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui"; /** * Clear Formatting Button * * Button that removes all formatting (marks and node styles) from the selected text. * Resets the selection to plain text/paragraph format. * * @example * ```tsx * * * ``` */ export type EditorClearFormattingProps = Pick; export const EditorClearFormatting = ({ hideName = true, }: EditorClearFormattingProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().clearNodes().unsetAllMarks().run()} hideName={hideName} icon={TextTSlashIcon} isActive={() => false} name="Clear Formatting" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-link-selector.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@marble/ui/components/popover"; import { Separator } from "@marble/ui/components/separator"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@marble/ui/components/tooltip"; import { cn } from "@marble/ui/lib/utils"; import { ArrowSquareOutIcon, ArrowsInSimpleIcon, ArrowsOutSimpleIcon, CheckIcon, LinkIcon, TrashIcon, } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { FormEventHandler } from "react"; import { useEffect, useRef, useState } from "react"; export interface EditorLinkSelectorProps { open?: boolean; onOpenChange?: (open: boolean) => void; } /** * Link Selector Component * * A popover component for adding, editing, or removing links from selected text. * Opens a popover with an input field to enter a URL. If text is already linked, * shows a delete button to remove the link. * * @example * ```tsx * * * ``` */ export const EditorLinkSelector = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, }: EditorLinkSelectorProps) => { const { editor } = useCurrentEditor(); const [internalOpen, setInternalOpen] = useState(false); const [url, setUrl] = useState(""); const [openInNewTab, setOpenInNewTab] = useState(true); const inputReference = useRef(null); const isOpen = controlledOpen ?? internalOpen; const setIsOpen = controlledOnOpenChange ?? setInternalOpen; const isValidUrl = (text: string): boolean => { try { new URL(text); return true; } catch { return false; } }; const getUrlFromString = (text: string): string | null => { if (isValidUrl(text)) { return text; } try { if (text.includes(".") && !text.includes(" ")) { return new URL(`https://${text}`).toString(); } return null; } catch { return null; } }; useEffect(() => { if (isOpen) { const linkAttributes = editor?.getAttributes("link") ?? {}; const href = linkAttributes.href ?? ""; const target = linkAttributes.target ?? "_blank"; setUrl(href); setOpenInNewTab(target === "_blank"); setTimeout(() => inputReference.current?.focus(), 0); } else { setUrl(""); } }, [isOpen, editor]); if (!editor) { return null; } const applyLink = () => { const href = getUrlFromString(url); if (href) { editor .chain() .focus() .setLink({ href, target: openInNewTab ? "_blank" : "_self", }) .run(); setUrl(""); setIsOpen(false); } }; const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); event.stopPropagation(); applyLink(); }; return ( } />
    setUrl(event.target.value)} placeholder="Paste or type link" ref={inputReference} type="text" value={url} /> setOpenInNewTab(!openInNewTab)} size="icon" type="button" variant="ghost" > {openInNewTab ? ( ) : ( )} } />

    {openInNewTab ? "Opens in new tab" : "Opens in same tab"}

    { editor.chain().focus().unsetLink().run(); setUrl(""); }} size="icon" type="button" variant="ghost" > } />

    Remove link

    { const href = getUrlFromString(url) || editor.getAttributes("link").href; if (href) { window.open(href, "_blank", "noopener,noreferrer"); } }} size="icon" type="button" variant="ghost" > } />

    Open link

    ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-bold.tsx ================================================ import { TextBIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkBoldProps = Pick; /** * Bold Mark Button * * Button to toggle bold formatting on the selected text. * Active when the selection has bold formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkBold = ({ hideName = false }: EditorMarkBoldProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleBold().run()} hideName={hideName} icon={TextBIcon} isActive={() => editor.isActive("bold") ?? false} name="Bold" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-code.tsx ================================================ import { CodeIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkCodeProps = Pick; /** * Inline Code Mark Button * * Button to toggle inline code formatting on the selected text (monospace font). * Active when the selection has inline code formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkCode = ({ hideName = false }: EditorMarkCodeProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleCode().run()} hideName={hideName} icon={CodeIcon} isActive={() => editor.isActive("code") ?? false} name="Code" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-highlight.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@marble/ui/components/popover"; import { cn } from "@marble/ui/lib/utils"; import { HighlighterIcon } from "@phosphor-icons/react"; import { useCurrentEditor, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; import type { EditorButtonProps } from "../../types"; import { ColorPicker } from "../color-picker"; export type EditorMarkHighlightProps = Pick; /** * Highlight Mark Button * * Button that opens a color picker to highlight the selected text. * Uses a Popover to display the ColorPicker component. * Active when the selection has a highlight color applied. * * @example * ```tsx * * * ``` */ export const EditorMarkHighlight = ({ hideName = true, }: EditorMarkHighlightProps) => { const { editor } = useCurrentEditor(); const currentHighlight = useEditorState({ editor, selector: (ctx) => ctx.editor?.getAttributes("highlight")?.color || undefined, }); const isActive = Boolean(currentHighlight); const handleColorChange = useCallback( (color: string) => { if (!editor) { return; } editor.chain().focus().setHighlight({ color }).run(); }, [editor] ); const handleClearHighlight = useCallback(() => { if (!editor) { return; } editor.chain().focus().unsetHighlight().run(); }, [editor]); if (!editor) { return null; } // Check if Highlight extension is available const hasHighlightExtension = editor.can().setHighlight({ color: "#000000" }); if (!hasHighlightExtension) { return null; } return ( {!hideName && Highlight} } /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-italic.tsx ================================================ import { TextItalicIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkItalicProps = Pick; /** * Italic Mark Button * * Button to toggle italic formatting on the selected text. * Active when the selection has italic formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkItalic = ({ hideName = false, }: EditorMarkItalicProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleItalic().run()} hideName={hideName} icon={TextItalicIcon} isActive={() => editor.isActive("italic") ?? false} name="Italic" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-strike.tsx ================================================ import { TextStrikethroughIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkStrikeProps = Pick; /** * Strikethrough Mark Button * * Button to toggle strikethrough formatting on the selected text. * Active when the selection has strikethrough formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkStrike = ({ hideName = false, }: EditorMarkStrikeProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleStrike().run()} hideName={hideName} icon={TextStrikethroughIcon} isActive={() => editor.isActive("strike") ?? false} name="Strikethrough" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-subscript.tsx ================================================ import { TextSubscriptIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkSubscriptProps = Pick; /** * Subscript Mark Button * * Button to toggle subscript formatting on the selected text. * Active when the selection has subscript formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkSubscript = ({ hideName = false, }: EditorMarkSubscriptProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleSubscript().run()} hideName={hideName} icon={TextSubscriptIcon} isActive={() => editor.isActive("subscript") ?? false} name="Subscript" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-superscript.tsx ================================================ import { TextSuperscriptIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkSuperscriptProps = Pick; /** * Superscript Mark Button * * Button to toggle superscript formatting on the selected text. * Active when the selection has superscript formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkSuperscript = ({ hideName = false, }: EditorMarkSuperscriptProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleSuperscript().run()} hideName={hideName} icon={TextSuperscriptIcon} isActive={() => editor.isActive("superscript") ?? false} name="Superscript" /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-text-color.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@marble/ui/components/popover"; import { cn } from "@marble/ui/lib/utils"; import { PaletteIcon } from "@phosphor-icons/react"; import { useCurrentEditor, useEditorState } from "@tiptap/react"; import { useCallback } from "react"; import type { EditorButtonProps } from "../../types"; import { ColorPicker } from "../color-picker"; export type EditorMarkTextColorProps = Pick; /** * Text Color Mark Button * * Button that opens a color picker to change the text color of the selected text. * Uses a Popover to display the ColorPicker component. * Active when the selection has a text color applied. * * @example * ```tsx * * * ``` */ export const EditorMarkTextColor = ({ hideName = true, }: EditorMarkTextColorProps) => { const { editor } = useCurrentEditor(); const currentColor = useEditorState({ editor, selector: (ctx) => ctx.editor?.getAttributes("textStyle")?.color || undefined, }); const isActive = Boolean(currentColor); const handleColorChange = useCallback( (color: string) => { if (!editor) { return; } editor.chain().focus().setColor(color).run(); }, [editor] ); const handleClearColor = useCallback(() => { if (!editor) { return; } editor.chain().focus().unsetColor().run(); }, [editor]); if (!editor) { return null; } // Check if Color extension is available const hasColorExtension = editor.can().setColor("#000000"); if (!hasColorExtension) { return null; } return ( {!hideName && Text Color} } /> ); }; ================================================ FILE: packages/editor/src/components/marks/editor-mark-underline.tsx ================================================ import { TextUnderlineIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorMarkUnderlineProps = Pick; /** * Underline Mark Button * * Button to toggle underline formatting on the selected text. * Active when the selection has underline formatting applied. * * @example * ```tsx * * * ``` */ export const EditorMarkUnderline = ({ hideName = false, }: EditorMarkUnderlineProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleUnderline().run()} hideName={hideName} icon={TextUnderlineIcon} isActive={() => editor.isActive("underline") ?? false} name="Underline" /> ); }; ================================================ FILE: packages/editor/src/components/marks/index.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: <> */ export { EditorClearFormatting, type EditorClearFormattingProps, } from "./editor-clear-formatting"; export { EditorLinkSelector, type EditorLinkSelectorProps, } from "./editor-link-selector"; export { EditorMarkBold, type EditorMarkBoldProps } from "./editor-mark-bold"; export { EditorMarkCode, type EditorMarkCodeProps } from "./editor-mark-code"; export { EditorMarkHighlight, type EditorMarkHighlightProps, } from "./editor-mark-highlight"; export { EditorMarkItalic, type EditorMarkItalicProps, } from "./editor-mark-italic"; export { EditorMarkStrike, type EditorMarkStrikeProps, } from "./editor-mark-strike"; export { EditorMarkSubscript, type EditorMarkSubscriptProps, } from "./editor-mark-subscript"; export { EditorMarkSuperscript, type EditorMarkSuperscriptProps, } from "./editor-mark-superscript"; export { EditorMarkTextColor, type EditorMarkTextColorProps, } from "./editor-mark-text-color"; export { EditorMarkUnderline, type EditorMarkUnderlineProps, } from "./editor-mark-underline"; ================================================ FILE: packages/editor/src/components/menus/block-handle-menu.tsx ================================================ "use client"; import { offset } from "@floating-ui/dom"; import { PlusSignIcon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Button } from "@marble/ui/components/button"; import { createDropdownMenuHandle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@marble/ui/components/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@marble/ui/components/tooltip"; import { cn } from "@marble/ui/lib/utils"; import { CheckSquareIcon, CodeIcon, CopyIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextAlignLeftIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTSlashIcon, TrashIcon, } from "@phosphor-icons/react"; import DragHandle from "@tiptap/extension-drag-handle-react"; import { DOMSerializer, Fragment, type Node as ProseMirrorNode, } from "@tiptap/pm/model"; import { NodeSelection } from "@tiptap/pm/state"; import { useCurrentEditor } from "@tiptap/react"; import { type ComponentType, type SVGProps, useCallback, useEffect, useMemo, useRef, useState, } from "react"; interface TargetBlock { node: ProseMirrorNode; pos: number; } interface TransformOption { icon: ComponentType>; isActive: (node: ProseMirrorNode) => boolean; label: string; run: (focusPos: number) => void; } export interface EditorBlockHandleMenuProps { className?: string; } const HANDLE_PLUGIN_KEY = "marble-block-handle"; const SUPPORTED_NODE_TYPES = new Set([ "paragraph", "heading", "blockquote", "codeBlock", "bulletList", "orderedList", "taskList", "figure", "image", "imageUpload", "video", "videoUpload", "twitter", "twitterUpload", "youtube", "youtubeUpload", "horizontalRule", ]); const TURN_INTO_SOURCE_TYPES = new Set([ "paragraph", "heading", "blockquote", "codeBlock", ]); const CLEAR_FORMATTING_TYPES = new Set([ "paragraph", "heading", "blockquote", "codeBlock", ]); const HANDLE_CONTROL_CLASSNAME = "flex size-6.5 items-center justify-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"; function getFocusPos(target: TargetBlock) { return target.node.isTextblock ? target.pos + 1 : target.pos; } function isSupportedNode( node: ProseMirrorNode | null ): node is ProseMirrorNode { return node !== null && SUPPORTED_NODE_TYPES.has(node.type.name); } function canTurnInto(node: ProseMirrorNode) { return TURN_INTO_SOURCE_TYPES.has(node.type.name); } function canClearFormatting(node: ProseMirrorNode) { return CLEAR_FORMATTING_TYPES.has(node.type.name); } function getScrollParent(node: HTMLElement | null) { if (!node) { return null; } let current: HTMLElement | null = node.parentElement; while (current) { const { overflowY } = window.getComputedStyle(current); if (overflowY === "auto" || overflowY === "scroll") { return current; } current = current.parentElement; } return null; } function serializeNodeToClipboardData( node: ProseMirrorNode, schema: Parameters[0], ownerDocument: Document ) { const serializer = DOMSerializer.fromSchema(schema); const fragment = serializer.serializeFragment(Fragment.from(node), { document: ownerDocument, }); const container = ownerDocument.createElement("div"); container.appendChild(fragment); return { html: container.innerHTML, text: node.textContent || container.textContent || "", }; } export function EditorBlockHandleMenu({ className, }: EditorBlockHandleMenuProps = {}) { const { editor } = useCurrentEditor(); const [menuOpen, setMenuOpen] = useState(false); const [target, setTarget] = useState(null); const menuHandle = useMemo(() => createDropdownMenuHandle(), []); const menuTriggerRef = useRef(null); useEffect(() => { if (!editor) { return; } const transaction = editor.state.tr.setMeta("lockDragHandle", menuOpen); editor.view.dispatch(transaction); }, [editor, menuOpen]); useEffect(() => { if (!editor) { return; } const hideHandle = () => { setMenuOpen(false); setTarget(null); editor.view.dispatch(editor.state.tr.setMeta("hideDragHandle", true)); }; const scrollParent = getScrollParent(editor.view.dom as HTMLElement); scrollParent?.addEventListener("scroll", hideHandle, { passive: true }); window.addEventListener("scroll", hideHandle, { passive: true }); return () => { scrollParent?.removeEventListener("scroll", hideHandle); window.removeEventListener("scroll", hideHandle); }; }, [editor]); const handleNodeChange = useCallback( ({ node, pos }: { node: ProseMirrorNode | null; pos: number }) => { if (!editor || !editor.isEditable || !isSupportedNode(node)) { if (!menuOpen) { setTarget(null); } return; } setTarget({ node, pos }); }, [editor, menuOpen] ); const selectTargetNode = useCallback(() => { if (!editor || !target) { return null; } const nextSelection = NodeSelection.create(editor.state.doc, target.pos); editor.view.dispatch(editor.state.tr.setSelection(nextSelection)); return editor.state.doc.nodeAt(target.pos); }, [editor, target]); const handleAdd = useCallback(() => { if (!editor || !target) { return; } const currentNode = editor.state.doc.nodeAt(target.pos); if (!currentNode) { return; } const currentNodeIsEmptyParagraph = currentNode.type.name === "paragraph" && currentNode.content.size === 0; const insertPos = target.pos + currentNode.nodeSize; const focusPos = currentNodeIsEmptyParagraph ? target.pos + 2 : insertPos + 2; editor .chain() .command(({ dispatch, state, tr }) => { if (!dispatch) { return true; } if (currentNodeIsEmptyParagraph) { tr.insertText("/", target.pos + 1); dispatch(tr); return true; } const paragraphNodeType = state.schema.nodes.paragraph; if (!paragraphNodeType) { return false; } const slashParagraph = paragraphNodeType.create( null, state.schema.text("/") ); tr.insert(insertPos, slashParagraph); dispatch(tr); return true; }) .focus(focusPos) .run(); }, [editor, target]); const handleDuplicate = useCallback(() => { if (!editor || !target) { return; } const currentNode = editor.state.doc.nodeAt(target.pos); if (!currentNode) { return; } editor .chain() .focus() .insertContentAt(target.pos + currentNode.nodeSize, currentNode.toJSON()) .run(); }, [editor, target]); const handleDelete = useCallback(() => { if (!editor || !target) { return; } editor.chain().focus().setNodeSelection(target.pos).deleteSelection().run(); }, [editor, target]); const handleCopy = useCallback(async () => { if (!editor || !target) { return; } const currentNode = editor.state.doc.nodeAt(target.pos) ?? selectTargetNode(); if (!currentNode) { return; } const ownerDocument = editor.view.dom.ownerDocument; const { html, text } = serializeNodeToClipboardData( currentNode, editor.schema, ownerDocument ); try { if ( typeof window !== "undefined" && "ClipboardItem" in window && html.trim().length > 0 ) { const clipboardItem = new ClipboardItem({ "text/html": new Blob([html], { type: "text/html" }), "text/plain": new Blob([text || html], { type: "text/plain" }), }); await navigator.clipboard.write([clipboardItem]); return; } await navigator.clipboard.writeText(text || html); } catch (error) { console.error("Failed to copy block content:", error); } }, [editor, selectTargetNode, target]); const handleClearFormatting = useCallback(() => { if (!editor || !target || !canClearFormatting(target.node)) { return; } const focusPos = getFocusPos(target); const chain = editor.chain().focus(focusPos).unsetAllMarks(); if (target.node.type.name !== "paragraph") { chain.clearNodes(); } chain.run(); }, [editor, target]); const transformOptions = useMemo(() => { if (!editor) { return []; } return [ { icon: TextAlignLeftIcon, isActive: (node) => node.type.name === "paragraph", label: "Text", run: (focusPos) => { editor.chain().focus(focusPos).clearNodes().run(); }, }, { icon: TextHOneIcon, isActive: (node) => node.type.name === "heading" && node.attrs.level === 1, label: "Heading 1", run: (focusPos) => { editor .chain() .focus(focusPos) .clearNodes() .setNode("heading", { level: 1 }) .run(); }, }, { icon: TextHTwoIcon, isActive: (node) => node.type.name === "heading" && node.attrs.level === 2, label: "Heading 2", run: (focusPos) => { editor .chain() .focus(focusPos) .clearNodes() .setNode("heading", { level: 2 }) .run(); }, }, { icon: TextHThreeIcon, isActive: (node) => node.type.name === "heading" && node.attrs.level === 3, label: "Heading 3", run: (focusPos) => { editor .chain() .focus(focusPos) .clearNodes() .setNode("heading", { level: 3 }) .run(); }, }, { icon: ListBulletsIcon, isActive: (node) => node.type.name === "bulletList", label: "Bullet List", run: (focusPos) => { editor.chain().focus(focusPos).clearNodes().toggleBulletList().run(); }, }, { icon: ListNumbersIcon, isActive: (node) => node.type.name === "orderedList", label: "Numbered List", run: (focusPos) => { editor.chain().focus(focusPos).clearNodes().toggleOrderedList().run(); }, }, { icon: CheckSquareIcon, isActive: (node) => node.type.name === "taskList", label: "Task List", run: (focusPos) => { editor .chain() .focus(focusPos) .clearNodes() .toggleList("taskList", "taskItem") .run(); }, }, { icon: QuotesIcon, isActive: (node) => node.type.name === "blockquote", label: "Quote", run: (focusPos) => { editor.chain().focus(focusPos).clearNodes().toggleBlockquote().run(); }, }, { icon: CodeIcon, isActive: (node) => node.type.name === "codeBlock", label: "Code", run: (focusPos) => { editor.chain().focus(focusPos).clearNodes().toggleCodeBlock().run(); }, }, ]; }, [editor]); if (!editor) { return null; } const canShowMenu = !!target && editor.isEditable; const canTransformTarget = !!target && canTurnInto(target.node); const canClearTarget = !!target && canClearFormatting(target.node); return ( { setMenuOpen(false); }} onNodeChange={handleNodeChange} pluginKey={HANDLE_PLUGIN_KEY} >
    Insert block below } />

    Click to insert block below

    { if (menuOpen) { menuHandle.close(); return; } menuTriggerRef.current?.click(); }} type="button" > Open block actions Open block actions } />

    Drag to move, click to open menu

    {canTransformTarget ? ( Turn into {transformOptions.map((option) => { const Icon = option.icon; const isActive = target ? option.isActive(target.node) : false; return ( { if (!target) { return; } option.run(getFocusPos(target)); }} > {option.label} ); })} ) : null} {canTransformTarget && canClearTarget ? ( ) : null} {canClearTarget ? ( Clear formatting ) : null} {canTransformTarget || canClearTarget ? ( ) : null} Duplicate Copy Delete
    ); } ================================================ FILE: packages/editor/src/components/menus/bubble-menu.tsx ================================================ /** biome-ignore-all lint/suspicious/noArrayIndexKey: <> */ import { cn } from "@marble/ui/lib/utils"; import { useCurrentEditor } from "@tiptap/react"; import { BubbleMenu as TiptapBubbleMenu, type BubbleMenuProps as TiptapBubbleMenuProps, } from "@tiptap/react/menus"; import { useCallback } from "react"; import { isCustomNodeSelected, isTextSelected } from "../../lib"; export type EditorBubbleMenuProps = Omit; /** * Bubble Menu Component * * A floating menu that appears when text is selected in the editor. * Displays formatting options like text styles, marks, and other tools. * Automatically positions itself above the selected text using Floating UI. * * The menu will not appear when custom nodes (like YouTube embeds, code blocks, etc.) are selected. * * @example * ```tsx * * * * * * * ``` */ export const EditorBubbleMenu = ({ className, children, shouldShow: customShouldShow, ...props }: EditorBubbleMenuProps) => { const { editor } = useCurrentEditor(); const shouldShow = useCallback( ( props: Parameters>[0] ) => { if (!editor || !props.view || editor.view.dragging) { return false; } // If a custom shouldShow is provided, check it first if (customShouldShow) { const customResult = customShouldShow(props); if (!customResult) { return false; } } const fromPos = props.from ?? 0; const domAtPos = props.view.domAtPos(fromPos).node as HTMLElement | null; const nodeDOM = props.view.nodeDOM(fromPos) as HTMLElement | null; const node = nodeDOM ?? domAtPos; // Don't show bubble menu if a custom node is selected if (isCustomNodeSelected(editor, node)) { return false; } // Only show if text is actually selected return isTextSelected({ editor }); }, [editor, customShouldShow] ); if (!editor) { return null; } return ( *:first-child]:rounded-l-[9px]", "[&>*:last-child]:rounded-r-[9px]", className )} editor={editor} shouldShow={shouldShow} {...props} > {children} ); }; ================================================ FILE: packages/editor/src/components/menus/floating-menu.tsx ================================================ import { cn } from "@marble/ui/lib/utils"; import { useCurrentEditor } from "@tiptap/react"; import { FloatingMenu as TiptapFloatingMenu, type FloatingMenuProps as TiptapFloatingMenuProps, } from "@tiptap/react/menus"; export type EditorFloatingMenuProps = Omit; /** * Floating Menu Component * Shows formatting options on empty lines * Updated for Tiptap v3 with Floating UI */ export const EditorFloatingMenu = ({ className, ...props }: EditorFloatingMenuProps) => { const { editor } = useCurrentEditor(); return ( ); }; ================================================ FILE: packages/editor/src/components/menus/index.ts ================================================ /* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */ export { EditorBlockHandleMenu, type EditorBlockHandleMenuProps, } from "./block-handle-menu"; export { EditorBubbleMenu, type EditorBubbleMenuProps } from "./bubble-menu"; export { EditorFloatingMenu, type EditorFloatingMenuProps, } from "./floating-menu"; ================================================ FILE: packages/editor/src/components/nodes/editor-align-selector.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@marble/ui/components/popover"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@marble/ui/components/tooltip"; import { cn } from "@marble/ui/lib/utils"; import { TextAlignCenter, TextAlignJustify, TextAlignLeft, TextAlignRight, } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import { useState } from "react"; export interface EditorAlignSelectorProps { open?: boolean; onOpenChange?: (open: boolean) => void; } type Alignment = "left" | "center" | "right" | "justify"; const alignments: { value: Alignment; icon: typeof TextAlignLeft; label: string; }[] = [ { value: "left", icon: TextAlignLeft, label: "Align Left" }, { value: "center", icon: TextAlignCenter, label: "Align Center" }, { value: "right", icon: TextAlignRight, label: "Align Right" }, { value: "justify", icon: TextAlignJustify, label: "Justify" }, ]; /** * Align Selector Component * * A popover component for setting text alignment. * Shows alignment options when clicked. * * @example * ```tsx * * * ``` */ export const EditorAlignSelector = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, }: EditorAlignSelectorProps) => { const { editor } = useCurrentEditor(); const [internalOpen, setInternalOpen] = useState(false); const isOpen = controlledOpen ?? internalOpen; const setIsOpen = controlledOnOpenChange ?? setInternalOpen; if (!editor) { return null; } const getCurrentAlignment = (): Alignment => { for (const alignment of alignments) { if (editor.isActive({ textAlign: alignment.value })) { return alignment.value; } } return "left"; }; const currentAlignment = getCurrentAlignment(); const CurrentIcon = alignments.find((a) => a.value === currentAlignment)?.icon ?? TextAlignLeft; const handleAlignmentChange = (alignment: Alignment) => { editor.chain().focus().setTextAlign(alignment).run(); setIsOpen(false); }; return ( } />
    {alignments.map(({ value, icon: Icon, label }) => ( handleAlignmentChange(value)} size="icon" type="button" variant="ghost" > } />

    {label}

    ))}
    ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-align.tsx ================================================ import { TextAlignCenter, TextAlignJustify, TextAlignLeft, TextAlignRight, } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; /** * Align Left Button * * Button that aligns text to the left. * * @example * ```tsx * * * ``` */ export type EditorAlignProps = Pick; export const EditorAlignLeft = ({ hideName = true }: EditorAlignProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().setTextAlign("left").run()} hideName={hideName} icon={TextAlignLeft} isActive={() => editor.isActive({ textAlign: "left" }) ?? false} name="Align Left" /> ); }; /** * Align Center Button * * Button that centers text. * * @example * ```tsx * * * ``` */ export const EditorAlignCenter = ({ hideName = true }: EditorAlignProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().setTextAlign("center").run()} hideName={hideName} icon={TextAlignCenter} isActive={() => editor.isActive({ textAlign: "center" }) ?? false} name="Align Center" /> ); }; /** * Align Right Button * * Button that aligns text to the right. * * @example * ```tsx * * * ``` */ export const EditorAlignRight = ({ hideName = true }: EditorAlignProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().setTextAlign("right").run()} hideName={hideName} icon={TextAlignRight} isActive={() => editor.isActive({ textAlign: "right" }) ?? false} name="Align Right" /> ); }; /** * Justify Button * * Button that justifies text. * * @example * ```tsx * * * ``` */ export const EditorAlignJustify = ({ hideName = true }: EditorAlignProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().setTextAlign("justify").run()} hideName={hideName} icon={TextAlignJustify} isActive={() => editor.isActive({ textAlign: "justify" }) ?? false} name="Justify" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-bullet-list.tsx ================================================ import { ListBulletsIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeBulletListProps = Pick; /** * Bullet List Node Button * * Button to toggle the current selection to a bullet list (unordered list). * Active when the selection is within a bullet list. * * @example * ```tsx * * * ``` */ export const EditorNodeBulletList = ({ hideName = false, }: EditorNodeBulletListProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleBulletList().run()} hideName={hideName} icon={ListBulletsIcon} isActive={() => editor.isActive("bulletList") ?? false} name="Bullet List" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-code.tsx ================================================ import { CodeIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeCodeProps = Pick; /** * Code Block Node Button * * Button to toggle the current selection to a code block (syntax-highlighted code). * Active when the selection is within a code block. * * @example * ```tsx * * * ``` */ export const EditorNodeCode = ({ hideName = false }: EditorNodeCodeProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleCodeBlock().run()} hideName={hideName} icon={CodeIcon} isActive={() => editor.isActive("codeBlock") ?? false} name="Code" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-heading1.tsx ================================================ import { TextHOneIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeHeading1Props = Pick; /** * Heading 1 Node Button * * Button to toggle the current selection to Heading 1 (largest heading). * Active when the selection is a heading with level 1. * * @example * ```tsx * * * ``` */ export const EditorNodeHeading1 = ({ hideName = false, }: EditorNodeHeading1Props) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleHeading({ level: 1 }).run()} hideName={hideName} icon={TextHOneIcon} isActive={() => editor.isActive("heading", { level: 1 }) ?? false} name="Heading 1" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-heading2.tsx ================================================ import { TextHTwoIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeHeading2Props = Pick; /** * Heading 2 Node Button * * Button to toggle the current selection to Heading 2 (medium heading). * Active when the selection is a heading with level 2. * * @example * ```tsx * * * ``` */ export const EditorNodeHeading2 = ({ hideName = false, }: EditorNodeHeading2Props) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleHeading({ level: 2 }).run()} hideName={hideName} icon={TextHTwoIcon} isActive={() => editor.isActive("heading", { level: 2 }) ?? false} name="Heading 2" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-heading3.tsx ================================================ import { TextHThreeIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeHeading3Props = Pick; /** * Heading 3 Node Button * * Button to toggle the current selection to Heading 3 (small heading). * Active when the selection is a heading with level 3. * * @example * ```tsx * * * ``` */ export const EditorNodeHeading3 = ({ hideName = false, }: EditorNodeHeading3Props) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleHeading({ level: 3 }).run()} hideName={hideName} icon={TextHThreeIcon} isActive={() => editor.isActive("heading", { level: 3 }) ?? false} name="Heading 3" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-ordered-list.tsx ================================================ import { ListNumbersIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeOrderedListProps = Pick; /** * Ordered List Node Button * * Button to toggle the current selection to an ordered list (numbered list). * Active when the selection is within an ordered list. * * @example * ```tsx * * * ``` */ export const EditorNodeOrderedList = ({ hideName = false, }: EditorNodeOrderedListProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleOrderedList().run()} hideName={hideName} icon={ListNumbersIcon} isActive={() => editor.isActive("orderedList") ?? false} name="Numbered List" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-quote.tsx ================================================ import { QuotesIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeQuoteProps = Pick; /** * Quote Node Button * * Button to toggle the current selection to a blockquote (quote block). * Active when the selection is within a blockquote. * * @example * ```tsx * * * ``` */ export const EditorNodeQuote = ({ hideName = false }: EditorNodeQuoteProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor .chain() .focus() .toggleNode("paragraph", "paragraph") .toggleBlockquote() .run() } hideName={hideName} icon={QuotesIcon} isActive={() => editor.isActive("blockquote") ?? false} name="Quote" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-table.tsx ================================================ import { TableIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeTableProps = Pick; /** * Table Node Button * * Button to insert a new table (3x3 with header row) at the current position. * Active when the cursor is inside a table. * * @example * ```tsx * * * ``` */ export const EditorNodeTable = ({ hideName = false }: EditorNodeTableProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor .chain() .focus() .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run() } hideName={hideName} icon={TableIcon} isActive={() => editor.isActive("table") ?? false} name="Table" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-task-list.tsx ================================================ import { CheckSquareIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeTaskListProps = Pick; /** * Task List Node Button * * Button to toggle the current selection to a task list (to-do list with checkboxes). * Active when the selection is within a task list item. * * @example * ```tsx * * * ``` */ export const EditorNodeTaskList = ({ hideName = false, }: EditorNodeTaskListProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleList("taskList", "taskItem").run() } hideName={hideName} icon={CheckSquareIcon} isActive={() => editor.isActive("taskItem") ?? false} name="To-do List" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/editor-node-text.tsx ================================================ import { TextAlignLeftIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import type { EditorButtonProps } from "../../types"; import { BubbleMenuButton } from "../ui/editor-button"; export type EditorNodeTextProps = Pick; /** * Text Node Button * * Button to toggle the current selection to plain text (paragraph) format. * Active when the selection is not a heading, list, or other block node. * * @example * ```tsx * * * ``` */ export const EditorNodeText = ({ hideName = false }: EditorNodeTextProps) => { const { editor } = useCurrentEditor(); if (!editor) { return null; } return ( editor.chain().focus().toggleNode("paragraph", "paragraph").run() } hideName={hideName} icon={TextAlignLeftIcon} isActive={() => (editor && !editor.isActive("paragraph") && !editor.isActive("bulletList") && !editor.isActive("orderedList")) ?? false } name="Text" /> ); }; ================================================ FILE: packages/editor/src/components/nodes/index.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: <> */ export { EditorAlignCenter, EditorAlignJustify, EditorAlignLeft, type EditorAlignProps, EditorAlignRight, } from "./editor-align"; export { EditorAlignSelector, type EditorAlignSelectorProps, } from "./editor-align-selector"; export { EditorNodeBulletList, type EditorNodeBulletListProps, } from "./editor-node-bullet-list"; export { EditorNodeCode, type EditorNodeCodeProps } from "./editor-node-code"; export { EditorNodeHeading1, type EditorNodeHeading1Props, } from "./editor-node-heading1"; export { EditorNodeHeading2, type EditorNodeHeading2Props, } from "./editor-node-heading2"; export { EditorNodeHeading3, type EditorNodeHeading3Props, } from "./editor-node-heading3"; export { EditorNodeOrderedList, type EditorNodeOrderedListProps, } from "./editor-node-ordered-list"; export { EditorNodeQuote, type EditorNodeQuoteProps, } from "./editor-node-quote"; export { EditorNodeTable, type EditorNodeTableProps, } from "./editor-node-table"; export { EditorNodeTaskList, type EditorNodeTaskListProps, } from "./editor-node-task-list"; export { EditorNodeText, type EditorNodeTextProps } from "./editor-node-text"; ================================================ FILE: packages/editor/src/components/rich-text-field.tsx ================================================ "use client"; import { Button } from "@marble/ui/components/button"; import { cn } from "@marble/ui/lib/utils"; import { ListBulletsIcon, ListNumbersIcon, TextBIcon, TextItalicIcon, TextUnderlineIcon, } from "@phosphor-icons/react"; import { TextStyleKit } from "@tiptap/extension-text-style"; import { Placeholder } from "@tiptap/extensions"; import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { useEffect } from "react"; export interface FieldRichTextEditorProps { disabled?: boolean; id?: string; labelId?: string; onBlur?: () => void; onChange: (value: string) => void; placeholder?: string; value: string; } function ToolbarButton({ active, disabled, icon: Icon, onClick, }: { active?: boolean; disabled?: boolean; icon: React.ComponentType<{ className?: string }>; onClick: () => void; }) { return ( ); } export function FieldRichTextEditor({ disabled, id, labelId, onBlur, onChange, placeholder = "Write something...", value, }: FieldRichTextEditorProps) { const editor = useEditor({ immediatelyRender: false, editable: !disabled, content: value || "

    ", extensions: [ StarterKit.configure({ blockquote: false, codeBlock: false, dropcursor: false, gapcursor: false, heading: false, horizontalRule: false, }), TextStyleKit, Placeholder.configure({ placeholder: ({ node }) => { if ( node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "listItem" ) { return ""; } return placeholder; }, emptyEditorClass: "field-rich-text-placeholder before:content-[attr(data-placeholder)]", emptyNodeClass: "field-rich-text-placeholder before:content-[attr(data-placeholder)]", }), ], editorProps: { attributes: { class: "min-h-[120px] px-3 py-3 text-sm leading-6 text-foreground caret-foreground focus:outline-hidden [&_.field-rich-text-placeholder::before]:pointer-events-none [&_.field-rich-text-placeholder::before]:float-left [&_.field-rich-text-placeholder::before]:h-0 [&_.field-rich-text-placeholder::before]:text-muted-foreground [&_.field-rich-text-placeholder::before]:leading-6 [&_li_.field-rich-text-placeholder::before]:content-none [&_ol]:my-0 [&_ol]:list-decimal [&_ol]:pl-6 [&_p]:m-0 [&_strong]:text-foreground [&_u]:text-foreground [&_ul]:my-0 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:my-1 [&_li_p]:m-0", ...(id ? { id } : {}), ...(labelId ? { "aria-labelledby": labelId } : {}), }, }, onBlur: () => { onBlur?.(); }, onUpdate: ({ editor: nextEditor }) => { onChange(nextEditor.getHTML()); }, }); useEffect(() => { if (!editor) { return; } const nextValue = value || "

    "; if (editor.getHTML() === nextValue) { return; } editor.commands.setContent(nextValue, { emitUpdate: false, }); }, [editor, value]); if (!editor) { return null; } return (
    editor.chain().focus().toggleBold().run()} /> editor.chain().focus().toggleItalic().run()} /> editor.chain().focus().toggleUnderline().run()} />
    editor.chain().focus().toggleBulletList().run()} /> editor.chain().focus().toggleOrderedList().run()} />
    ); } ================================================ FILE: packages/editor/src/components/ui/editor-button.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { cn } from "@marble/ui/lib/utils"; import { CheckIcon } from "@phosphor-icons/react"; import type { EditorButtonProps } from "../../types"; /** * Base Button Component for Editor Toolbar * Used in BubbleMenu and other UI components */ export const BubbleMenuButton = ({ name, isActive, command, icon: Icon, hideName, }: EditorButtonProps) => ( ); ================================================ FILE: packages/editor/src/components/ui/editor-selector.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Popover, PopoverContent, PopoverTrigger, } from "@marble/ui/components/popover"; import { cn } from "@marble/ui/lib/utils"; import { CaretDownIcon } from "@phosphor-icons/react"; import { useCurrentEditor } from "@tiptap/react"; import { type HTMLAttributes, type ReactNode, useState } from "react"; export type EditorSelectorProps = HTMLAttributes & { open?: boolean; onOpenChange?: (open: boolean) => void; title: string; children?: ReactNode; }; /** * Editor Selector Component * * A popover-based selector that groups related editor buttons together. * Displays a button with a title and dropdown arrow that opens a popover * containing child components (typically editor node or mark buttons). * * @example * ```tsx * * * * * * ``` */ export const EditorSelector = ({ open, onOpenChange, title, className, children, ...props }: EditorSelectorProps) => { const { editor } = useCurrentEditor(); const [internalOpen, setInternalOpen] = useState(false); if (!editor) { return null; } const isControlled = open !== undefined; const currentOpen = isControlled ? open : internalOpen; const handleOpenChange = (newOpen: boolean) => { if (!isControlled) { setInternalOpen(newOpen); } onOpenChange?.(newOpen); }; return ( {title} } /> handleOpenChange(false)} sideOffset={5} {...props} > {children} ); }; ================================================ FILE: packages/editor/src/components/ui/index.ts ================================================ /* biome-ignore lint/performance/noBarrelFile: Barrel file for organized exports */ export { BubbleMenuButton } from "./editor-button"; export { EditorSelector, type EditorSelectorProps } from "./editor-selector"; ================================================ FILE: packages/editor/src/extensions/code-block/code-block-comp.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Card, CardContent } from "@marble/ui/components/card"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@marble/ui/components/command"; import { Popover, PopoverContent } from "@marble/ui/components/popover"; import { cn } from "@marble/ui/lib/utils"; import { CaretUpDownIcon, CheckIcon, CopySimpleIcon, } from "@phosphor-icons/react"; import type { ReactNode } from "react"; import { useCallback, useRef, useState } from "react"; /** * Supported languages for the code block language selector. */ const LANGUAGES = [ { value: "text", label: "Text" }, { value: "javascript", label: "JavaScript" }, { value: "typescript", label: "TypeScript" }, { value: "python", label: "Python" }, { value: "html", label: "HTML" }, { value: "css", label: "CSS" }, { value: "json", label: "JSON" }, { value: "bash", label: "Bash" }, { value: "sql", label: "SQL" }, { value: "go", label: "Go" }, { value: "rust", label: "Rust" }, ] as const; /** Common aliases that map to a supported language value. */ const LANGUAGE_ALIASES: Record = { js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", sh: "bash", shell: "bash", zsh: "bash", htm: "html", golang: "go", rs: "rust", plaintext: "text", plain: "text", txt: "text", }; const LANGUAGE_VALUES: Set = new Set(LANGUAGES.map((l) => l.value)); /** * Resolve a raw language string (from markdown fences, pasted content, etc.) * to a known language value. Unrecognised strings fall back to "text". */ export const resolveLanguage = (raw: string): string => { const lower = raw.toLowerCase().trim(); if (LANGUAGE_VALUES.has(lower)) { return lower; } return LANGUAGE_ALIASES[lower] ?? "text"; }; interface CodeBlockCompProps { /** The currently selected language */ language: string; /** Callback when the language changes */ onLanguageChange: (language: string) => void; /** Callback to copy the code block content */ onCopy: () => void; /** Whether the content was recently copied */ copied: boolean; /** The editable code content (NodeViewContent) */ children: ReactNode; } /** * Code Block UI Component * * Card-based layout with a searchable language selector and copy button * in the header, and the editable code content in the body. */ export const CodeBlockComp = ({ language, onLanguageChange, onCopy, copied, children, }: CodeBlockCompProps) => { const [open, setOpen] = useState(false); const triggerRef = useRef(null); const selectedLabel = LANGUAGES.find((lang) => lang.value === language)?.label ?? language; const handleSelect = useCallback( (value: string) => { onLanguageChange(value); setOpen(false); }, [onLanguageChange] ); return ( {/* Header with language selector and copy button */} {/* biome-ignore lint/a11y/useKeyWithClickEvents: ProseMirror event isolation */} {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: ProseMirror event isolation */} {/* biome-ignore lint/a11y/noStaticElementInteractions: ProseMirror event isolation */}
    e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > {/* * we are using a regular Button instead of PopoverTrigger to avoid * Base UI's internal pointer-event handling conflicting with * ProseMirror's contentEditable. The popover is fully controlled * via open/onOpenChange and anchored to this button via ref. */} No language found. {LANGUAGES.map((lang) => ( handleSelect(lang.value)} value={lang.value} > {lang.label} ))}
    {/* Code content area with syntax highlighting */} {children}
    ); }; ================================================ FILE: packages/editor/src/extensions/code-block/code-block-view.tsx ================================================ import type { NodeViewProps } from "@tiptap/core"; import { NodeViewContent, NodeViewWrapper } from "@tiptap/react"; import { useCallback, useState } from "react"; import { CodeBlockComp, resolveLanguage } from "./code-block-comp"; export const CodeBlockView = ({ node, updateAttributes }: NodeViewProps) => { const rawLanguage = (node.attrs.language as string) || "text"; const language = resolveLanguage(rawLanguage); const [copied, setCopied] = useState(false); const onLanguageChange = useCallback( (lang: string) => { updateAttributes({ language: lang }); }, [updateAttributes] ); const onCopy = useCallback(() => { const text = node.textContent; navigator.clipboard .writeText(text) .then(() => { setCopied(true); setTimeout(() => { setCopied(false); }, 2000); }) .catch((error: unknown) => { console.error("Failed to copy code block content:", error); }); }, [node]); return (
              
            
    ); }; ================================================ FILE: packages/editor/src/extensions/code-block/code-block.ts ================================================ import { textblockTypeInputRule } from "@tiptap/core"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { lowlight } from "../../lib/lowlight"; import { CodeBlockView } from "./code-block-view"; /** * Code Block extension with syntax highlighting and custom UI. * * Extends CodeBlockLowlight with a React NodeView that renders a card-style * wrapper with a searchable language selector and copy button. * Lowlight decorations (syntax highlighting) still apply through ProseMirror. * * Input rules are overridden so that triple backticks (or tildes) followed by * space/enter immediately insert a code block with language "text", without * allowing a language string after the backticks (Notion-style behaviour). * Language selection happens exclusively via the dropdown in the UI. */ export const CodeBlock = CodeBlockLowlight.extend({ addNodeView() { return ReactNodeViewRenderer(CodeBlockView); }, addInputRules() { return [ textblockTypeInputRule({ find: /^```[\s\n]$/, type: this.type, getAttributes: () => ({ language: "text" }), }), textblockTypeInputRule({ find: /^~~~[\s\n]$/, type: this.type, getAttributes: () => ({ language: "text" }), }), ]; }, }).configure({ lowlight, defaultLanguage: "text", }); ================================================ FILE: packages/editor/src/extensions/code-block/index.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: <> */ export { CodeBlock } from "./code-block"; ================================================ FILE: packages/editor/src/extensions/extension-kit.ts ================================================ import { cn } from "@marble/ui/lib/utils"; import { FileHandler } from "@tiptap/extension-file-handler"; import { Highlight } from "@tiptap/extension-highlight"; import { Image } from "@tiptap/extension-image"; import { TaskItem, TaskList } from "@tiptap/extension-list"; import { Subscript } from "@tiptap/extension-subscript"; import { Superscript } from "@tiptap/extension-superscript"; import { TextAlign } from "@tiptap/extension-text-align"; import { TextStyleKit } from "@tiptap/extension-text-style"; import Typography from "@tiptap/extension-typography"; import { Youtube } from "@tiptap/extension-youtube"; import { CharacterCount, Placeholder } from "@tiptap/extensions"; import { Markdown } from "@tiptap/markdown"; import StarterKit from "@tiptap/starter-kit"; import { CodeBlock } from "./code-block"; import { Figure } from "./figure"; import { ImageUpload } from "./image-upload"; import { MarkdownInput } from "./markdown-input"; import { configureSlashCommand } from "./slash-command"; import { Table, TableCell, TableHeader, TableRow } from "./table"; import { Twitter } from "./twitter/index"; import { TwitterUpload } from "./twitter/twitter-upload"; import { Video } from "./video"; import { VideoUpload } from "./video-upload"; import { YouTubeUpload } from "./youtube/youtube-upload"; import "../styles/task-list.css"; /** * Extension kit configuration options */ export interface ExtensionKitOptions { /** Character limit for the editor */ limit?: number; /** Placeholder text for empty editor */ placeholder?: string; } /** * Extension Kit * Bundles all editor extensions with default configurations */ export const ExtensionKit = ({ limit, placeholder, }: ExtensionKitOptions = {}) => [ Markdown, StarterKit.configure({ codeBlock: false, // Using custom CodeBlock with syntax highlighting bulletList: { HTMLAttributes: { class: cn("list-outside list-disc pl-4"), }, }, link: { openOnClick: false, }, orderedList: { HTMLAttributes: { class: cn("list-outside list-decimal pl-4"), }, }, listItem: { HTMLAttributes: { class: cn("leading-normal"), }, }, blockquote: { HTMLAttributes: { class: cn("border-l border-l-2 pl-2"), }, }, code: { HTMLAttributes: { class: cn("rounded-md bg-muted px-1.5 py-1 font-medium font-mono"), spellcheck: "false", }, }, horizontalRule: { HTMLAttributes: { class: cn("mt-4 mb-6 border-muted-foreground border-t"), }, }, dropcursor: { color: "var(--border)", width: 4, }, }), // Typography for smart quotes, dashes, etc. Typography, Placeholder.configure({ placeholder: ({ editor }) => { if (!editor) { return placeholder ?? ""; } // Hide placeholder inside tables, blockquotes, code blocks, and lists if ( editor.isActive("table") || editor.isActive("tableCell") || editor.isActive("tableHeader") || editor.isActive("blockquote") || editor.isActive("codeBlock") || editor.isActive("bulletList") || editor.isActive("orderedList") || editor.isActive("taskList") || editor.isActive("listItem") || editor.isActive("taskItem") ) { return ""; } return placeholder ?? ""; }, emptyEditorClass: "before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none", emptyNodeClass: "before:text-muted-foreground before:content-[attr(data-placeholder)] before:float-left before:h-0 before:pointer-events-none", }), // Character count CharacterCount.configure({ limit, }), // Code block with syntax highlighting CodeBlock, // Subscript and superscript Superscript, Subscript, // Slash command configureSlashCommand(), // Table extensions Table, TableRow, TableCell, TableHeader, // YouTube Youtube.configure({ controls: true, nocookie: false, }), // YouTube Upload (placeholder node for YouTube upload component) YouTubeUpload, // Twitter Twitter.configure({ addPasteHandler: true, inline: false, }), // Twitter Upload (placeholder node for Twitter upload component) TwitterUpload, // Image extension for backward compatibility with older posts Image.configure({ inline: false, allowBase64: false, }), // Figure (image with caption support) Figure, // Image Upload (placeholder node for image upload component) // Note: Will be unconfigured by default, CMS app should pass configured version ImageUpload, // Video (self-hosted video with caption support) Video, // Video Upload (placeholder node for video upload component) // Note: Will be unconfigured by default, CMS app should pass configured version VideoUpload, // File Handler for drag-and-drop and paste image/video uploads FileHandler.configure({ allowedMimeTypes: [ "image/png", "image/jpeg", "image/gif", "image/webp", "video/mp4", "video/webm", "video/ogg", "video/quicktime", ], onDrop: (currentEditor, files, _pos) => { for (const file of files) { if (file.type.startsWith("video/")) { currentEditor.chain().focus().setVideoUpload({ file }).run(); } else { currentEditor.chain().focus().setImageUpload({ file }).run(); } } }, onPaste: (currentEditor, files) => { for (const file of files) { if (file.type.startsWith("video/")) { currentEditor.chain().focus().setVideoUpload({ file }).run(); } else { currentEditor.chain().focus().setImageUpload({ file }).run(); } } }, }), // Task list TaskList.configure({ HTMLAttributes: { class: "list-none p-0", }, }), TaskItem.configure({ nested: true, HTMLAttributes: { class: "flex", }, }), // Text styling kit (includes Color, BackgroundColor, FontFamily, FontSize, LineHeight, TextStyle) TextStyleKit, // Highlight extension for text highlighting Highlight.configure({ multicolor: true }), // Text alignment TextAlign.configure({ types: ["heading", "paragraph"], alignments: ["left", "center", "right", "justify"], }), // Markdown input handling (paste and file drop) MarkdownInput, ]; export default ExtensionKit; ================================================ FILE: packages/editor/src/extensions/figure/figure-view.tsx ================================================ /** biome-ignore-all lint/a11y/noNoninteractiveElementInteractions: <> */ /** biome-ignore-all lint/a11y/useKeyWithClickEvents: <> */ import { Button } from "@marble/ui/components/button"; import { Input } from "@marble/ui/components/input"; import { Label } from "@marble/ui/components/label"; import { cn } from "@marble/ui/lib/utils"; import { FadersHorizontalIcon, TextAlignCenterIcon, TextAlignLeftIcon, TextAlignRightIcon, XIcon, } from "@phosphor-icons/react"; import type { NodeViewProps } from "@tiptap/core"; import { NodeViewWrapper } from "@tiptap/react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; export const FigureView = ({ node, updateAttributes, selected, }: NodeViewProps) => { const { src, alt, caption, width, align } = node.attrs as { src: string; alt: string; caption: string; width: string; align: "left" | "center" | "right"; }; const [altValue, setAltValue] = useState(alt || ""); const [captionValue, setCaptionValue] = useState(caption || ""); const [widthValue, setWidthValue] = useState(width || "100"); const [alignValue, setAlignValue] = useState<"left" | "center" | "right">( align || "center" ); const [isResizing, setIsResizing] = useState(false); const [isHovered, setIsHovered] = useState(false); const [showSettings, setShowSettings] = useState(false); const figureRef = useRef(null); const settingsPanelRef = useRef(null); const startXRef = useRef(0); const startWidthRef = useRef(0); const resizeSideRef = useRef<"left" | "right">("right"); const altId = useId(); const captionId = useId(); const [prevAttrs, setPrevAttrs] = useState({ alt, caption, width, align }); if ( alt !== prevAttrs.alt || caption !== prevAttrs.caption || width !== prevAttrs.width || align !== prevAttrs.align ) { setPrevAttrs({ alt, caption, width, align }); setAltValue(alt || ""); setCaptionValue(caption || ""); setWidthValue(width || "100"); setAlignValue(align || "center"); } // Handle click outside settings panel useEffect(() => { if (!showSettings) { return; } const handleClickOutside = (e: MouseEvent) => { if ( settingsPanelRef.current && !settingsPanelRef.current.contains(e.target as Node) ) { // Check if click was on the settings button const target = e.target as HTMLElement; if (!target.closest("[data-settings-trigger]")) { setShowSettings(false); } } }; // Use timeout to avoid the click that opened the panel from closing it const timeoutId = setTimeout(() => { document.addEventListener("mousedown", handleClickOutside); }, 0); return () => { clearTimeout(timeoutId); document.removeEventListener("mousedown", handleClickOutside); }; }, [showSettings]); const handleAltChange = useCallback( (e: React.ChangeEvent) => { const newAlt = e.target.value; setAltValue(newAlt); updateAttributes({ alt: newAlt }); }, [updateAttributes] ); const handleCaptionChange = useCallback( (e: React.ChangeEvent) => { const newCaption = e.target.value; setCaptionValue(newCaption); updateAttributes({ caption: newCaption }); }, [updateAttributes] ); const handleAlignChange = useCallback( (newAlign: "left" | "center" | "right") => { setAlignValue(newAlign); setTimeout(() => { updateAttributes({ align: newAlign }); }, 0); }, [updateAttributes] ); const handleResizeStart = useCallback( (side: "left" | "right") => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setIsResizing(true); startXRef.current = e.clientX; resizeSideRef.current = side; const currentWidth = Number.parseInt(widthValue, 10) || 100; startWidthRef.current = currentWidth; }, [widthValue] ); useEffect(() => { if (!isResizing) { return; } const handleMouseMove = (e: MouseEvent) => { const deltaX = e.clientX - startXRef.current; const containerWidth = figureRef.current?.parentElement?.clientWidth || 800; const effectiveDelta = resizeSideRef.current === "left" ? -deltaX : deltaX; const deltaPercent = (effectiveDelta / containerWidth) * 100; const newWidth = Math.max( 10, Math.min(100, startWidthRef.current + deltaPercent) ); const roundedWidth = Math.round(newWidth); setWidthValue(String(roundedWidth)); updateAttributes({ width: String(roundedWidth) }); }; const handleMouseUp = () => { setIsResizing(false); }; document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); return () => { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; }, [isResizing, updateAttributes]); const alignmentStyles: React.CSSProperties = { width: `${widthValue}%`, marginLeft: alignValue === "left" ? 0 : "auto", marginRight: alignValue === "right" ? 0 : "auto", }; const showToolbar = selected || isHovered || showSettings; const handleSettingsClick = useCallback( (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowSettings(!showSettings); }, [showSettings] ); return (
    setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} ref={figureRef} style={alignmentStyles} > {/* biome-ignore lint: Tiptap NodeView requires standard img element */} {altValue} {showToolbar && (
    {/* Divider */}
    )} {showSettings && (
    {/* Alt Text */}
    {/* Caption */}
    )} {showToolbar && ( <>
    ); }; ================================================ FILE: packages/editor/src/extensions/figure/index.ts ================================================ import type { CommandProps } from "@tiptap/core"; import { mergeAttributes, Node } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import { FigureView } from "./figure-view"; declare module "@tiptap/core" { interface Commands { figure: { setFigure: (options: { src: string; alt?: string; caption?: string; href?: string; width?: string; align?: "left" | "center" | "right"; }) => ReturnType; updateFigure: (attrs: { alt?: string; caption?: string; href?: string; width?: string; align?: "left" | "center" | "right"; }) => ReturnType; }; } } export const Figure = Node.create({ name: "figure", group: "block", content: "", draggable: true, selectable: true, isolating: true, addAttributes() { return { src: { default: null, parseHTML: (element) => element.querySelector("img")?.getAttribute("src") || element.querySelector("a img")?.getAttribute("src"), renderHTML: (attributes) => { // Return attribute to make it available in HTMLAttributes // Main renderHTML will apply it to img element return { src: attributes.src }; }, }, alt: { default: "", parseHTML: (element) => element.querySelector("img")?.getAttribute("alt") || element.querySelector("a img")?.getAttribute("alt") || "", renderHTML: (attributes) => { // Return attribute to make it available in HTMLAttributes // Main renderHTML will apply it to img element return { alt: attributes.alt }; }, }, caption: { default: "", parseHTML: (element) => element.querySelector("figcaption")?.textContent || "", renderHTML: (attributes) => { // Return attribute to make it available in HTMLAttributes // Main renderHTML will use it for figcaption content return { caption: attributes.caption }; }, }, href: { default: null, parseHTML: (element) => element.querySelector("a")?.getAttribute("href") || null, renderHTML: (attributes) => { // Return attribute to make it available in HTMLAttributes // Main renderHTML will apply it to anchor element return { href: attributes.href }; }, }, width: { default: "100", parseHTML: (element) => element.getAttribute("data-width") || "100", renderHTML: (attributes) => ({ "data-width": attributes.width, }), }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align") || "center", renderHTML: (attributes) => ({ "data-align": attributes.align, }), }, }; }, parseHTML() { return [ { tag: "figure", getAttrs: (element) => { if (typeof element === "string") { return false; } const img = element.querySelector("img"); return img ? {} : false; }, }, ]; }, renderHTML({ HTMLAttributes }) { const { src, alt, href, caption, ...figureAttrs } = HTMLAttributes; // Prepare img attributes const imgAttrs: Record = {}; if (src) { imgAttrs.src = src; } if (alt) { imgAttrs.alt = alt; } // Prepare figcaption content const figcaptionContent = caption || ""; // If href exists, wrap img in anchor tag if (href) { return [ "figure", mergeAttributes(figureAttrs), ["a", { href }, ["img", imgAttrs]], ["figcaption", {}, figcaptionContent], ]; } // Otherwise, render img directly return [ "figure", mergeAttributes(figureAttrs), ["img", imgAttrs], ["figcaption", {}, figcaptionContent], ]; }, addCommands() { return { setFigure: (options) => ({ commands }: CommandProps) => commands.insertContent({ type: this.name, attrs: options, }), updateFigure: (attrs) => ({ commands, tr, state }: CommandProps) => { const { selection } = state; const node = tr.doc.nodeAt(selection.from); if (node?.type.name === this.name) { return commands.updateAttributes(this.name, attrs); } return false; }, }; }, addNodeView() { return ReactNodeViewRenderer(FigureView); }, }); ================================================ FILE: packages/editor/src/extensions/image-upload/hooks.ts ================================================ import { toast } from "@marble/ui/components/sonner"; import type { DragEvent } from "react"; import { useCallback, useRef, useState } from "react"; export const useFileUpload = () => { const fileInput = useRef(null); const handleUploadClick = useCallback(() => { fileInput.current?.click(); }, []); return { ref: fileInput, handleUploadClick }; }; export const useUploader = ({ onUpload, upload, onError, }: { onUpload: (url: string) => void; upload: (file: File) => Promise; onError?: (error: Error) => void; }) => { const [loading, setLoading] = useState(false); const uploadImage = useCallback( async (file: File) => { setLoading(true); try { const url = await upload(file); if (url) { onUpload(url); } else { const error = new Error( "Upload failed: Invalid response from server." ); if (onError) { onError(error); } else { toast.error(error.message); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Failed to upload image"; const uploadError = new Error(errorMessage); if (onError) { onError(uploadError); } else { toast.error(errorMessage); } } setLoading(false); }, [onUpload, upload, onError] ); return { loading, uploadImage }; }; export const useDropZone = ({ uploader, }: { uploader: (file: File) => void; }) => { const [draggedInside, setDraggedInside] = useState(false); const onDrop = useCallback( (e: DragEvent) => { setDraggedInside(false); e.preventDefault(); e.stopPropagation(); const fileList = e.dataTransfer.files; const files: File[] = []; for (let i = 0; i < fileList.length; i += 1) { const item = fileList.item(i); if (item) { files.push(item); } } // Validate only image files if (files.some((file) => !file.type.startsWith("image/"))) { toast.error("Only image files are allowed"); return; } const filteredFiles = files.filter((f) => f.type.startsWith("image/")); const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined; if (file) { uploader(file); } }, [uploader] ); const onDragEnter = useCallback(() => { setDraggedInside(true); }, []); const onDragLeave = useCallback(() => { setDraggedInside(false); }, []); const onDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); return { draggedInside, onDragEnter, onDragLeave, onDrop, onDragOver }; }; ================================================ FILE: packages/editor/src/extensions/image-upload/image-upload-comp.tsx ================================================ import { Album02Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Button } from "@marble/ui/components/button"; import { Card, CardContent, CardFooter } from "@marble/ui/components/card"; import { Dialog, DialogBody, DialogContent, DialogHeader, DialogTitle, DialogX, } from "@marble/ui/components/dialog"; import { Input } from "@marble/ui/components/input"; import { cn } from "@marble/ui/lib/utils"; import { CheckIcon, ImagesIcon, SpinnerIcon, XIcon, } from "@phosphor-icons/react"; import type { ChangeEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import type { MediaItem, MediaPage } from "../../types"; import { useDropZone, useFileUpload, useUploader } from "./hooks"; // Simple URL validation const isValidUrl = (url: string): boolean => { try { new URL(url); return true; } catch { return false; } }; export interface ImageUploadCompProps { initialFile?: File; onUpload: (url: string) => void; onCancel: () => void; upload: (file: File) => Promise; media?: MediaItem[]; fetchMediaPage?: (cursor?: string) => Promise; onError?: (error: Error) => void; } export const ImageUploadComp = ({ initialFile, onUpload, onCancel, upload, media: providedMedia, fetchMediaPage, onError, }: ImageUploadCompProps) => { const [showEmbedInput, setShowEmbedInput] = useState(false); const [embedUrl, setEmbedUrl] = useState(""); const [urlError, setUrlError] = useState(null); const [isValidatingUrl, setIsValidatingUrl] = useState(false); const [isGalleryOpen, setIsGalleryOpen] = useState(false); const [media, setMedia] = useState(providedMedia); const [isLoadingMedia, setIsLoadingMedia] = useState(false); const [nextCursor, setNextCursor] = useState(undefined); const [isLoadingMore, setIsLoadingMore] = useState(false); const { loading, uploadImage } = useUploader({ onUpload, upload, onError }); const { handleUploadClick, ref } = useFileUpload(); const { draggedInside, onDrop, onDragEnter, onDragLeave, onDragOver } = useDropZone({ uploader: uploadImage, }); // Fetch initial media page if fetchMediaPage function is provided. // Uses an `active` flag so stale responses from a previous render are ignored. useEffect(() => { if (!fetchMediaPage || providedMedia) { return; } let active = true; setIsLoadingMedia(true); fetchMediaPage() .then((page) => { if (active) { setMedia(page.media); setNextCursor(page.nextCursor); } }) .catch(() => { if (active) { setMedia([]); } }) .finally(() => { if (active) { setIsLoadingMedia(false); } }); return () => { active = false; }; }, [fetchMediaPage, providedMedia]); // Load more media handler const handleLoadMore = useCallback(async () => { if (!fetchMediaPage || !nextCursor || isLoadingMore) { return; } setIsLoadingMore(true); try { const page = await fetchMediaPage(nextCursor); setMedia((prev) => [...(prev || []), ...page.media]); setNextCursor(page.nextCursor); } catch { // Ignore errors on load more } finally { setIsLoadingMore(false); } }, [fetchMediaPage, nextCursor, isLoadingMore]); // Update media when providedMedia changes useEffect(() => { if (providedMedia) { setMedia(providedMedia); } }, [providedMedia]); const initialUploadedRef = useRef(false); useEffect(() => { if (initialFile && !initialUploadedRef.current) { initialUploadedRef.current = true; uploadImage(initialFile); } }, [initialFile, uploadImage]); const onFileChange = useCallback( (e: ChangeEvent) => { const file = e.target.files?.[0]; if (file) { uploadImage(file); } }, [uploadImage] ); const handleDrop = useCallback( (e: React.DragEvent) => { onDrop(e); }, [onDrop] ); const handleEmbedUrl = useCallback( async (url: string) => { if (!url) { return; } setIsValidatingUrl(true); setUrlError(null); if (!isValidUrl(url)) { setUrlError("Please enter a valid URL"); setIsValidatingUrl(false); return; } const img = new Image(); img.onload = () => { onUpload(url); setEmbedUrl(""); setShowEmbedInput(false); setIsValidatingUrl(false); }; img.onerror = () => { setUrlError("Invalid image URL"); setIsValidatingUrl(false); }; img.src = url; }, [onUpload] ); const handleMediaSelect = useCallback( (url: string) => { onUpload(url); setIsGalleryOpen(false); }, [onUpload] ); const handleDropzoneClick = useCallback(() => { handleUploadClick(); }, [handleUploadClick]); const handleDropzoneKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleUploadClick(); } }, [handleUploadClick] ); // Get dropzone text based on drag state const getDropzoneText = () => { if (draggedInside) { return "Drop image here"; } return "Drag and drop or click to upload"; }; return ( <>
    Upload or embed an image
    {/* Dropzone or Uploading state */} {loading ? (

    Uploading image...

    ) : ( // biome-ignore lint/a11y/useSemanticElements: Dropzone requires div for drag-and-drop functionality

    {getDropzoneText()}

    )}
    {showEmbedInput ? (
    { setEmbedUrl(target.value); setUrlError(null); }} onKeyDown={(e) => { if ( e.key === "Enter" && embedUrl && !isValidatingUrl && !loading ) { handleEmbedUrl(embedUrl); } }} placeholder="Paste image URL" value={embedUrl} />
    {urlError && (

    {urlError}

    )}
    ) : ( // Media and Embed URL buttons - shown by default
    {(media !== undefined || fetchMediaPage) && ( )}
    )}
    {/* Media Gallery Dialog */} {(media !== undefined || fetchMediaPage) && (
    Media Gallery
    {isLoadingMedia ? (

    Loading media...

    ) : media && media.length > 0 ? (
      {media ?.filter((item) => item.type === "image") .map((item) => (
    • ))}
    {nextCursor && (
    )}
    ) : (

    Your gallery is empty. Upload some media to get started.

    )}
    )} ); }; ================================================ FILE: packages/editor/src/extensions/image-upload/image-upload-view.tsx ================================================ import type { NodeViewProps } from "@tiptap/core"; import { NodeViewWrapper } from "@tiptap/react"; import { useCallback, useEffect, useRef } from "react"; import { ImageUploadComp } from "./image-upload-comp"; import type { ImageUploadStorage } from "./index"; export const ImageUploadView = ({ getPos, editor, node, extension, }: NodeViewProps) => { const storage = extension.storage as ImageUploadStorage; const pendingUploads = storage.pendingUploads; // Get fileId from node attributes const fileId = node.attrs.fileId as string | null; const initialFile = fileId ? pendingUploads.get(fileId) : undefined; // Get extension options from storage const { options } = storage; // Track whether the upload was consumed (success or cancel) so the // unmount cleanup knows whether it still needs to release the entry. const consumedRef = useRef(false); // Clean up the pending upload entry when this view unmounts (e.g. the // node is deleted while an upload is still in progress). useEffect(() => { return () => { if (fileId && !consumedRef.current) { pendingUploads.delete(fileId); } }; }, [fileId, pendingUploads]); const onUpload = useCallback( (url: string) => { if (url && typeof getPos === "function") { const pos = getPos(); if (typeof pos === "number") { consumedRef.current = true; if (fileId) { pendingUploads.delete(fileId); } editor .chain() .focus() .deleteRange({ from: pos, to: pos + 1 }) .setFigure({ src: url, alt: "", caption: "" }) .run(); } } }, [getPos, editor, fileId, pendingUploads] ); const onCancel = useCallback(() => { if (typeof getPos === "function") { const pos = getPos(); if (typeof pos === "number") { consumedRef.current = true; if (fileId) { pendingUploads.delete(fileId); } editor .chain() .focus() .deleteRange({ from: pos, to: pos + 1 }) .run(); } } }, [getPos, editor, fileId, pendingUploads]); // Only render if upload handler is configured if (!options.upload) { return (

    Image upload is not configured. Please configure the ImageUpload extension with an upload handler.

    ); } return (
    ); }; ================================================ FILE: packages/editor/src/extensions/image-upload/index.ts ================================================ /** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */ import type { CommandProps } from "@tiptap/core"; import { Node } from "@tiptap/core"; import { ReactNodeViewRenderer } from "@tiptap/react"; import type { ImageUploadOptions } from "../../types"; import { ImageUploadView } from "./image-upload-view"; declare module "@tiptap/core" { interface Commands { imageUpload: { setImageUpload: (options?: { file?: File }) => ReturnType; }; } } export const ImageUpload = Node.create({ name: "imageUpload", isolating: true, defining: true, group: "block", draggable: true, selectable: true, inline: false, addOptions() { return { upload: undefined, accept: "image/*", maxSize: undefined, limit: undefined, onError: undefined, media: undefined, fetchMediaPage: undefined, }; }, addAttributes() { return { fileId: { default: null, parseHTML: (element) => element.getAttribute("data-file-id"), renderHTML: (attributes) => { if (!attributes.fileId) { return {}; } return { "data-file-id": attributes.fileId, }; }, }, }; }, parseHTML() { return [ { tag: `div[data-type="${this.name}"]`, }, ]; }, renderHTML({ HTMLAttributes }) { return ["div", { "data-type": this.name, ...HTMLAttributes }]; }, addCommands() { const extensionStorage = this.storage as ImageUploadStorage; return { setImageUpload: (options) => ({ commands }: CommandProps) => { const { file } = options || {}; if (file) { const fileId = `upload-${Date.now()}-${Math.random()}`; extensionStorage.pendingUploads.set(fileId, file); return commands.insertContent({ type: this.name, attrs: { fileId }, }); } return commands.insertContent({ type: this.name, }); }, }; }, addNodeView() { return ReactNodeViewRenderer(ImageUploadView, { as: "div", }); }, addStorage() { return { pendingUploads: new Map(), options: this.options, }; }, onDestroy() { const storage = this.storage as ImageUploadStorage; storage.pendingUploads.clear(); }, }); export interface ImageUploadStorage { pendingUploads: Map; options: ImageUploadOptions; } ================================================ FILE: packages/editor/src/extensions/index.ts ================================================ // Extensions /** biome-ignore-all lint/performance/noBarrelFile: <> */ export type { ImageUploadOptions, MediaItem, VideoUploadOptions, } from "../types"; export { CodeBlock } from "./code-block"; // Extension Kit export { default, ExtensionKit, type ExtensionKitOptions, } from "./extension-kit"; export { Figure } from "./figure"; export { ImageUpload } from "./image-upload"; export { MarkdownInput } from "./markdown-input"; export { configureSlashCommand, handleCommandNavigation, SlashCommand, } from "./slash-command"; export { Table, TableCell, TableColumnMenu, TableHeader, TableRow, TableRowMenu, } from "./table"; export { TwitterUpload } from "./twitter/twitter-upload"; export { Video } from "./video"; export { VideoUpload } from "./video-upload"; export { YouTubeUpload } from "./youtube/youtube-upload"; ================================================ FILE: packages/editor/src/extensions/markdown-input/index.ts ================================================ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import { looksLikeMarkdown, transformContent } from "./utils"; /** * Unified extension for handling markdown input via paste and file drop * Handles three scenarios: * 1. Text paste: Detects and parses markdown text from clipboard * 2. File drop: Handles dropped markdown files * 3. File paste: Handles pasted markdown files from clipboard */ export const MarkdownInput = Extension.create({ name: "markdownInput", addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey("markdownInput"), props: { handlePaste: (_view: EditorView, event: ClipboardEvent) => { const { editor } = this; // First, check for markdown files in clipboard const files = Array.from(event.clipboardData?.files || []); const markdownFiles = files.filter( (file) => file.name.endsWith(".md") || file.name.endsWith(".markdown") || file.type === "text/markdown" ); if (markdownFiles.length > 0) { // Handle pasted markdown files event.preventDefault(); for (const file of markdownFiles) { const reader = new FileReader(); reader.onload = (e) => { const text = e.target?.result as string; if (text) { try { const json = editor?.markdown?.parse(text); if (json) { const transformedContent = transformContent(json); editor.commands.insertContent(transformedContent); } } catch (error) { console.error("Failed to parse markdown file:", error); } } }; reader.readAsText(file); } return true; } // If HTML is available, let the normal HTML paste pipeline handle it const html = event.clipboardData?.getData("text/html"); if (html) { return false; } // If no HTML, check if clipboard text looks like markdown const text = event.clipboardData?.getData("text/plain"); if (!text) { return false; } if (!looksLikeMarkdown(text)) { return false; } // Prevent default paste behavior event.preventDefault(); try { // Parse markdown to JSON using Tiptap's markdown extension const json = editor?.markdown?.parse(text) ?? { type: "doc", content: [], }; // Transform Image nodes to Figure nodes const transformedContent = transformContent(json); // Insert the parsed and transformed content editor.commands.insertContent(transformedContent); return true; } catch (error) { console.error("Failed to parse markdown:", error); // Fall back to default paste behavior return false; } }, handleDrop: (_view: EditorView, event: DragEvent, _slice, moved) => { // Don't handle if this is a move within the editor if (moved) { return false; } const { editor } = this; const files = Array.from(event.dataTransfer?.files || []); // Check if any files are markdown files const markdownFiles = files.filter( (file) => file.name.endsWith(".md") || file.name.endsWith(".markdown") || file.type === "text/markdown" ); if (markdownFiles.length === 0) { // Let other plugins handle this return false; } // Prevent default browser behavior event.preventDefault(); // Process all markdown files for (const file of markdownFiles) { const reader = new FileReader(); reader.onload = (e) => { const text = e.target?.result as string; if (text) { try { // Parse markdown to JSON const json = editor?.markdown?.parse(text); if (json) { // Transform Image nodes to Figure nodes const transformedContent = transformContent(json); // Insert at drop position editor.commands.insertContent(transformedContent); } } catch (error) { console.error("Failed to parse markdown file:", error); } } }; reader.readAsText(file); } // Return true to indicate we handled this event return true; }, }, }), ]; }, }); ================================================ FILE: packages/editor/src/extensions/markdown-input/utils.ts ================================================ import type { JSONContent } from "@tiptap/core"; /** * Checks if text looks like markdown by detecting common markdown patterns */ export function looksLikeMarkdown(text: string): boolean { if (!text || text.trim().length === 0) { return false; } const markdownPatterns = [ /^#{1,6}\s+.+/m, // Headings /\*\*[^*]+\*\*/m, // Bold with ** /__[^_]+__/m, // Bold with __ /\*[^*]+\*/m, // Italic with * /_[^_]+_/m, // Italic with _ /\[.+\]\(.+\)/m, // Links [text](url) /^\s*[-*+]\s+/m, // Unordered lists /^\s*\d+\.\s+/m, // Ordered lists /```[\s\S]*?```/m, // Code blocks /`[^`]+`/m, // Inline code /^\s*>\s+/m, // Blockquotes /!\[.*\]\(.*\)/m, // Images /^\s*[-*_]{3,}\s*$/m, // Horizontal rules /^\|.+\|$/m, // Tables ]; // Check if at least 2 patterns match for better accuracy const matchCount = markdownPatterns.filter((pattern) => pattern.test(text) ).length; return matchCount >= 2 || /^#{1,6}\s+.+/m.test(text); // Or single heading pattern } /** * Check if a node type is an inline context where block-level figures can't exist */ function isInlineContext(nodeType?: string): boolean { const inlineTypes = ["text", "strong", "em", "code", "strike", "underline"]; return nodeType ? inlineTypes.includes(nodeType) : false; } /** * Recursively transforms Image nodes to Figure nodes in parsed JSON * Converts markdown image syntax ![caption](url) to Figure nodes with captions * Handles linked images [![alt](img)](href) by extracting the href */ export function transformImageToFigure( content: JSONContent, parentType?: string ): JSONContent { if (!content) { return content; } // Handle link nodes that contain a single image (linked images) // Transform: link > image -> figure with href if (content.type === "link") { const hasOnlyImage = content.content && content.content.length === 1 && content.content[0]?.type === "image"; if (hasOnlyImage && content.content) { const image = content.content[0]; const href = content.attrs?.href; // Transform to figure with href return { type: "figure", attrs: { src: image?.attrs?.src || "", alt: image?.attrs?.alt || "", caption: image?.attrs?.alt || "", href: href || null, }, }; } } // Transform the current node if it's an image if (content.type === "image") { // Don't transform images in inline contexts (e.g., inside text, strong, etc.) if (isInlineContext(parentType)) { return content; // Keep as image } // Transform to figure (without href) return { type: "figure", attrs: { src: content.attrs?.src || "", alt: content.attrs?.alt || "", caption: content.attrs?.alt || "", // Use alt text as caption href: null, }, }; } // Recursively transform children, passing current node type as parent if (content.content && Array.isArray(content.content)) { return { ...content, content: content.content.map((child) => transformImageToFigure(child, content.type) ), }; } return content; } /** * Lifts figures out of paragraphs where they're the only child * Markdown parsers often wrap standalone images in paragraphs, which becomes * invalid when the image is transformed to a figure (block-level element) */ function liftFiguresFromParagraphs(content: JSONContent): JSONContent { if (!content) { return content; } // If this is a paragraph with a single figure child, replace the paragraph with the figure if ( content.type === "paragraph" && content.content && content.content.length === 1 && content.content[0]?.type === "figure" ) { return content.content[0]; // Replace paragraph with the figure } // Recursively process children if (content.content && Array.isArray(content.content)) { return { ...content, content: content.content.map((child) => liftFiguresFromParagraphs(child)), }; } return content; } /** * Transforms an array of JSON content, converting images to figures * and lifting figures out of invalid contexts */ export function transformContent( json: JSONContent | JSONContent[] ): JSONContent | JSONContent[] { if (Array.isArray(json)) { // First transform images to figures const transformed = json.map((item) => transformImageToFigure(item)); // Then lift figures out of paragraphs return transformed.map((item) => liftFiguresFromParagraphs(item)); } // First transform images to figures const transformed = transformImageToFigure(json); // Then lift figures out of paragraphs return liftFiguresFromParagraphs(transformed); } ================================================ FILE: packages/editor/src/extensions/slash-command/groups.ts ================================================ import { CheckSquareIcon, CodeIcon, ImageIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TableIcon, TextAlignLeftIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, VideoCameraIcon, } from "@phosphor-icons/react"; import type { SuggestionOptions } from "@tiptap/suggestion"; import { Twitter } from "../../components/icons/twitter"; import { YouTubeIcon } from "../../components/icons/youtube"; import type { SuggestionItem } from "../../types"; /** * Default slash command suggestions * These are the commands that appear when typing "/" in the editor */ export const defaultSlashSuggestions: SuggestionOptions["items"] = () => [ { title: "Text", description: "Just start typing with plain text.", searchTerms: ["p", "paragraph"], icon: TextAlignLeftIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .toggleNode("paragraph", "paragraph") .run(); }, }, { title: "Heading 1", description: "Use for main page title.", searchTerms: ["title", "big", "large"], icon: TextHOneIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .setNode("heading", { level: 1 }) .run(); }, }, { title: "Heading 2", description: "Use for section headings.", searchTerms: ["subtitle", "medium"], icon: TextHTwoIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .setNode("heading", { level: 2 }) .run(); }, }, { title: "Heading 3", description: "Use for sub-section headings.", searchTerms: ["subtitle", "small"], icon: TextHThreeIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .setNode("heading", { level: 3 }) .run(); }, }, { title: "Bullet List", description: "Create a simple bullet list.", searchTerms: ["unordered", "point"], icon: ListBulletsIcon, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, { title: "Numbered List", description: "Create a list with numbering.", searchTerms: ["ordered"], icon: ListNumbersIcon, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, { title: "To-do List", description: "Track tasks with a to-do list.", searchTerms: ["todo", "task", "list", "check", "checkbox"], icon: CheckSquareIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .toggleList("taskList", "taskItem") .run(); }, }, { title: "Quote", description: "Capture a quote.", searchTerms: ["blockquote"], icon: QuotesIcon, command: ({ editor, range }) => editor .chain() .focus() .deleteRange(range) .toggleNode("paragraph", "paragraph") .toggleBlockquote() .run(), }, { title: "Code", description: "Capture a code snippet.", searchTerms: ["codeblock"], icon: CodeIcon, command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, { title: "Table", description: "Add a table view to organize data.", searchTerms: ["table"], icon: TableIcon, command: ({ editor, range }) => editor .chain() .focus() .deleteRange(range) .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(), }, { title: "YouTube", description: "Embed a YouTube video.", searchTerms: ["youtube", "video", "embed"], icon: YouTubeIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertContent({ type: "youtubeUpload", }) .run(); }, }, { title: "Twitter", description: "Embed a Tweet.", searchTerms: ["twitter", "tweet", "x"], icon: Twitter, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertContent({ type: "twitterUpload", }) .run(); }, }, { title: "Image", description: "Upload or embed an image.", searchTerms: ["image", "picture", "photo", "img"], icon: ImageIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertContent({ type: "imageUpload", }) .run(); }, }, { title: "Video", description: "Upload or embed a video.", searchTerms: ["video", "mp4", "clip", "media"], icon: VideoCameraIcon, command: ({ editor, range }) => { editor .chain() .focus() .deleteRange(range) .insertContent({ type: "videoUpload", }) .run(); }, }, ]; ================================================ FILE: packages/editor/src/extensions/slash-command/index.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: <> */ export type { EditorSlashMenuProps, SlashNodeAttrs } from "../../types"; export { defaultSlashSuggestions } from "./groups"; export { EditorSlashMenu, handleCommandNavigation } from "./menu-list"; export type { SlashOptions } from "./slash-command"; export { configureSlashCommand, SlashCommand } from "./slash-command"; ================================================ FILE: packages/editor/src/extensions/slash-command/menu-list.tsx ================================================ import { Command, CommandEmpty, CommandItem, CommandList, } from "@marble/ui/components/command"; import { useRef } from "react"; import type { EditorSlashMenuProps } from "../../types"; /** * Menu list component for slash commands * Displays available commands in a dropdown menu * Uses cmdk's built-in keyboard navigation (ArrowUp, ArrowDown, Enter) */ export const EditorSlashMenu = ({ items, editor, range, }: EditorSlashMenuProps) => { const commandRef = useRef(null); const selectItem = (index: number) => { const item = items.at(index); if (item) { item.command({ editor, range }); } }; return (

    No results

    {items.map((item, index) => ( selectItem(index)} value={item.title} >
    {item.title} {item.description}
    ))}
    ); }; /** * Handle keyboard navigation for slash command menu */ export const handleCommandNavigation = (event: KeyboardEvent) => { if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) { const slashCommand = document.querySelector("#slash-command"); if (slashCommand) { // For Enter key, find and trigger the selected item directly if (event.key === "Enter") { const selectedItem = slashCommand.querySelector( '[data-selected="true"], [cmdk-item][aria-selected="true"], [cmdk-item][data-state="selected"]' ); if (selectedItem) { event.preventDefault(); event.stopPropagation(); selectedItem.click(); return true; } // If no item is selected, select the first item const firstItem = slashCommand.querySelector("[cmdk-item]"); if (firstItem) { event.preventDefault(); event.stopPropagation(); firstItem.click(); return true; } } // For ArrowUp/ArrowDown, dispatch the event to cmdk const keyboardEvent = new KeyboardEvent("keydown", { key: event.key, cancelable: true, bubbles: true, }); slashCommand.dispatchEvent(keyboardEvent); event.preventDefault(); event.stopPropagation(); return true; } } return false; }; ================================================ FILE: packages/editor/src/extensions/slash-command/slash-command.ts ================================================ /** biome-ignore-all lint/suspicious/noExplicitAny: <> */ import { autoUpdate, computePosition, flip, offset, shift, } from "@floating-ui/dom"; import { mergeAttributes, Node } from "@tiptap/core"; import type { DOMOutputSpec, Node as ProseMirrorNode } from "@tiptap/pm/model"; import { PluginKey } from "@tiptap/pm/state"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion"; import Fuse from "fuse.js"; import type { EditorSlashMenuProps, SlashNodeAttrs } from "../../types"; import { defaultSlashSuggestions } from "./groups"; import { EditorSlashMenu, handleCommandNavigation } from "./menu-list"; const SlashPluginKey = new PluginKey("slash"); /** * Slash command options type */ export interface SlashOptions< SlashOptionSuggestionItem = unknown, Attrs = SlashNodeAttrs, > { HTMLAttributes: Record; renderText: (props: { options: SlashOptions; node: ProseMirrorNode; }) => string; renderHTML: (props: { options: SlashOptions; node: ProseMirrorNode; }) => DOMOutputSpec; deleteTriggerWithBackspace: boolean; suggestion: Omit< SuggestionOptions, "editor" >; } /** * Slash Command Extension * Allows users to type "/" to open a command menu with formatting options */ export const SlashCommand = Node.create({ name: "slash", priority: 101, addOptions() { return { HTMLAttributes: {}, renderText({ options, node }) { return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`; }, deleteTriggerWithBackspace: false, renderHTML({ options, node }) { return [ "span", mergeAttributes(this.HTMLAttributes, options.HTMLAttributes), `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`, ]; }, suggestion: { char: "/", pluginKey: SlashPluginKey, command: ({ editor, range, props }) => { // increase range.to by one when the next node is of type "text" // and starts with a space character const nodeAfter = editor.view.state.selection.$to.nodeAfter; const overrideSpace = nodeAfter?.text?.startsWith(" "); if (overrideSpace) { range.to += 1; } editor .chain() .focus() .insertContentAt(range, [ { type: this.name, attrs: props, }, { type: "text", text: " ", }, ]) .run(); // get reference to `window` object from editor element, to support cross-frame JS usage editor.view.dom.ownerDocument.defaultView ?.getSelection() ?.collapseToEnd(); }, allow: ({ state, range }) => { const $from = state.doc.resolve(range.from); // Check if we're inside a table by looking at ancestor nodes let isInTable = false; for (let depth = $from.depth; depth > 0; depth -= 1) { const node = $from.node(depth); if ( node.type.name === "table" || node.type.name === "tableRow" || node.type.name === "tableCell" || node.type.name === "tableHeader" ) { isInTable = true; break; } } // Don't allow slash commands inside tables if (isInTable) { return false; } const isRootDepth = $from.depth === 1; const isParagraph = $from.parent.type.name === "paragraph"; const isStartOfNode = $from.parent.textContent?.charAt(0) === "/"; // Check if we're in a column (for column layouts) by checking ancestor nodes let isInColumn = false; for (let depth = $from.depth; depth > 0; depth -= 1) { const node = $from.node(depth); if (node.type.name === "column") { isInColumn = true; break; } } // Check if content after '/' is valid (not ending with double space) const afterContent = $from.parent.textContent?.substring( $from.parent.textContent?.indexOf("/") ?? 0 ); const isValidAfterContent = !afterContent?.endsWith(" "); // Only allow slash commands at root depth or in columns, and only in paragraphs at the start return ( ((isRootDepth && isParagraph && isStartOfNode) || (isInColumn && isParagraph && isStartOfNode)) && isValidAfterContent ); }, }, }; }, group: "inline", inline: true, selectable: false, atom: true, addAttributes() { return { id: { default: null, parseHTML: (element) => element.getAttribute("data-id"), renderHTML: (attributes) => { if (!attributes.id) { return {}; } return { "data-id": attributes.id, }; }, }, label: { default: null, parseHTML: (element) => element.getAttribute("data-label"), renderHTML: (attributes) => { if (!attributes.label) { return {}; } return { "data-label": attributes.label, }; }, }, }; }, parseHTML() { return [ { tag: `span[data-type="${this.name}"]`, }, ]; }, renderHTML({ node, HTMLAttributes }) { const mergedOptions = { ...this.options }; mergedOptions.HTMLAttributes = mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes ); const html = this.options.renderHTML({ options: mergedOptions, node, }); if (typeof html === "string") { return [ "span", mergeAttributes( { "data-type": this.name }, this.options.HTMLAttributes, HTMLAttributes ), html, ]; } return html; }, renderText({ node }) { return this.options.renderText({ options: this.options, node, }); }, addKeyboardShortcuts() { return { Backspace: () => this.editor.commands.command(({ tr, state }) => { let isMention = false; const { selection } = state; const { empty, anchor } = selection; if (!empty) { return false; } state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => { if (node.type.name === this.name) { isMention = true; tr.insertText( this.options.deleteTriggerWithBackspace ? "" : this.options.suggestion.char || "", pos, pos + node.nodeSize ); return false; } }); return isMention; }), }; }, addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, ...this.options.suggestion, }), ]; }, }); /** * Configure slash command with default suggestions and Floating UI renderer */ export const configureSlashCommand = () => SlashCommand.configure({ suggestion: { items: async ({ editor, query }) => { if (!defaultSlashSuggestions) { return []; } const items = await defaultSlashSuggestions({ editor, query }); if (!query) { return items; } const slashFuse = new Fuse(items, { keys: ["title", "description", "searchTerms"], threshold: 0.2, minMatchCharLength: 1, }); const results = slashFuse.search(query); return results.map((result) => result.item); }, char: "/", render: () => { let component: ReactRenderer; let cleanup: (() => void) | undefined; return { onStart: (onStartProps) => { // Clean up any existing component first (prevents double rendering in Strict Mode) if (component) { if (cleanup) { cleanup(); } if (component.element.parentNode) { component.element.parentNode.removeChild(component.element); } component.destroy(); } component = new ReactRenderer(EditorSlashMenu, { props: onStartProps, editor: onStartProps.editor, }); const referenceElement = { getBoundingClientRect: () => onStartProps.clientRect?.() || new DOMRect(), }; // Use Floating UI for positioning (Tiptap v3) cleanup = autoUpdate( referenceElement as any, component.element, () => { computePosition(referenceElement as any, component.element, { placement: "bottom-start", middleware: [offset(6), flip(), shift({ padding: 8 })], }).then(({ x, y }) => { Object.assign(component.element.style, { left: `${x}px`, top: `${y}px`, position: "absolute", }); }); } ); // Only append if not already in DOM (prevents duplicates) if (!component.element.parentNode) { document.body.appendChild(component.element); } }, onUpdate(onUpdateProps) { component.updateProps(onUpdateProps); }, onKeyDown(onKeyDownProps) { if (onKeyDownProps.event.key === "Escape") { if (cleanup) { cleanup(); } if (component.element.parentNode) { component.element.parentNode.removeChild(component.element); } component.destroy(); return true; } return handleCommandNavigation(onKeyDownProps.event) ?? false; }, onExit() { if (cleanup) { cleanup(); } if (component.element.parentNode) { component.element.parentNode.removeChild(component.element); } component.destroy(); }, }; }, }, }); ================================================ FILE: packages/editor/src/extensions/table/index.ts ================================================ /** biome-ignore-all lint/performance/noBarrelFile: <> */ export { TableColumnMenu } from "./menus/table-column"; export { TableRowMenu } from "./menus/table-row"; export { Table } from "./table"; export { TableCell } from "./table-cell"; export { TableHeader } from "./table-header"; export { TableRow } from "./table-row"; ================================================ FILE: packages/editor/src/extensions/table/menus/table-column/index.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { ArrowLeftIcon, ArrowRightIcon, TrashIcon, } from "@phosphor-icons/react"; import type { Editor } from "@tiptap/react"; import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; import { type JSX, memo, useCallback } from "react"; import { isColumnGripSelected } from "./utils"; interface MenuProps { editor: Editor; appendTo?: React.RefObject; } interface ShouldShowProps { view: unknown; state: unknown; from: number; } function TableColumnMenuComponent({ editor, appendTo, }: MenuProps): JSX.Element { const shouldShow = useCallback( ({ view, state, from }: ShouldShowProps) => { if (!state) { return false; } return isColumnGripSelected({ editor, view, state, from: from || 0, } as Parameters[0]); }, [editor] ); const onAddColumnBefore = useCallback(() => { editor.chain().focus().addColumnBefore().run(); }, [editor]); const onAddColumnAfter = useCallback(() => { editor.chain().focus().addColumnAfter().run(); }, [editor]); const onDeleteColumn = useCallback(() => { editor.chain().focus().deleteColumn().run(); }, [editor]); return ( appendTo?.current ?? document.body} className="flex flex-col items-center gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm" editor={editor} options={{ placement: "top", offset: { mainAxis: 24, crossAxis: 0 }, }} pluginKey="tableColumnMenu" shouldShow={shouldShow} updateDelay={0} > ); } export const TableColumnMenu = memo(TableColumnMenuComponent); TableColumnMenu.displayName = "TableColumnMenu"; export default TableColumnMenu; ================================================ FILE: packages/editor/src/extensions/table/menus/table-column/utils.ts ================================================ import type { EditorState } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import type { Editor } from "@tiptap/react"; import { Table } from "../.."; import { isTableSelected } from "../../utils"; export const isColumnGripSelected = ({ editor, view, state, from, }: { editor: Editor; view: EditorView; state: EditorState; from: number; }) => { const domAtPos = view.domAtPos(from).node as HTMLElement; const nodeDOM = view.nodeDOM(from) as HTMLElement; const node = nodeDOM || domAtPos; if ( !editor.isActive(Table.name) || !node || isTableSelected(state.selection) ) { return false; } // Find the owning table cell (TD/TH) const element: Element | null = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; const cell = element?.closest?.("td, th") ?? null; const gripColumn = cell?.querySelector?.("a.grip-column.selected"); return !!gripColumn; }; export default isColumnGripSelected; ================================================ FILE: packages/editor/src/extensions/table/menus/table-row/index.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from "@phosphor-icons/react"; import type { EditorState } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import type { Editor } from "@tiptap/react"; import { BubbleMenu as TiptapBubbleMenu } from "@tiptap/react/menus"; import { type JSX, memo, useCallback } from "react"; import { isRowGripSelected } from "./utils"; interface MenuProps { editor: Editor; appendTo?: React.RefObject; } interface ShouldShowProps { view: EditorView; state: EditorState; from: number; } function TableRowMenuComponent({ editor, appendTo }: MenuProps): JSX.Element { const shouldShow = useCallback( ({ view, state, from }: ShouldShowProps) => { if (!state || !from) { return false; } return isRowGripSelected({ editor, view, state, from } as Parameters< typeof isRowGripSelected >[0]); }, [editor] ); const onAddRowBefore = useCallback(() => { editor.chain().focus().addRowBefore().run(); }, [editor]); const onAddRowAfter = useCallback(() => { editor.chain().focus().addRowAfter().run(); }, [editor]); const onDeleteRow = useCallback(() => { editor.chain().focus().deleteRow().run(); }, [editor]); return ( appendTo?.current ?? document.body} className="flex flex-col gap-0.5 overflow-hidden rounded-lg border bg-background p-1 shadow-sm" editor={editor} options={{ placement: "left", offset: { mainAxis: 24, crossAxis: 0 }, }} pluginKey="tableRowMenu" shouldShow={shouldShow} updateDelay={0} > ); } export const TableRowMenu = memo(TableRowMenuComponent); TableRowMenu.displayName = "TableRowMenu"; export default TableRowMenu; ================================================ FILE: packages/editor/src/extensions/table/menus/table-row/utils.ts ================================================ import type { EditorState } from "@tiptap/pm/state"; import type { EditorView } from "@tiptap/pm/view"; import type { Editor } from "@tiptap/react"; import { Table } from "../.."; import { isTableSelected } from "../../utils"; export const isRowGripSelected = ({ editor, view, state, from, }: { editor: Editor; view: EditorView; state: EditorState; from: number; }) => { const domAtPos = view.domAtPos(from).node as HTMLElement; const nodeDOM = view.nodeDOM(from) as HTMLElement; const node = nodeDOM || domAtPos; if ( !editor.isActive(Table.name) || !node || isTableSelected(state.selection) ) { return false; } const element: Element | null = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; const cell = element?.closest?.("td, th") ?? null; const gripRow = cell?.querySelector?.("a.grip-row.selected"); return !!gripRow; }; export default isRowGripSelected; ================================================ FILE: packages/editor/src/extensions/table/table-cell.ts ================================================ import { mergeAttributes, Node } from "@tiptap/core"; import { Plugin } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { getCellsInColumn, isRowSelected, selectRow } from "./utils"; export interface TableCellOptions { HTMLAttributes: Record; } export const TableCell = Node.create({ name: "tableCell", content: "block+", tableRole: "cell", isolating: true, addOptions() { return { HTMLAttributes: {}, }; }, parseHTML() { return [{ tag: "td" }]; }, renderHTML({ HTMLAttributes }) { return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0, ]; }, addAttributes() { return { colspan: { default: 1, parseHTML: (element) => { const colspan = element.getAttribute("colspan"); const value = colspan ? Number.parseInt(colspan, 10) : 1; return value; }, }, rowspan: { default: 1, parseHTML: (element) => { const rowspan = element.getAttribute("rowspan"); const value = rowspan ? Number.parseInt(rowspan, 10) : 1; return value; }, }, colwidth: { default: null, parseHTML: (element) => { const colwidth = element.getAttribute("colwidth"); const value = colwidth ? [Number.parseInt(colwidth, 10)] : null; return value; }, }, style: { default: null, }, }; }, addProseMirrorPlugins() { return [ new Plugin({ props: { decorations: (state) => { const { isEditable } = this.editor; if (!isEditable) { return DecorationSet.empty; } const { doc, selection } = state; const decorations: Decoration[] = []; const cells = getCellsInColumn(0)(selection); if (cells) { let index = 0; for (const { pos } of cells) { const currentIndex = index; decorations.push( Decoration.widget(pos + 1, () => { const rowSelected = isRowSelected(currentIndex)(selection); let className = "grip-row"; if (rowSelected) { className += " selected"; } if (currentIndex === 0) { className += " first"; } if (currentIndex === cells.length - 1) { className += " last"; } const grip = document.createElement("a"); grip.className = className; grip.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopImmediatePropagation(); this.editor.view.dispatch( selectRow(currentIndex)(this.editor.state.tr) ); }); return grip; }) ); index += 1; } } return DecorationSet.create(doc, decorations); }, }, }), ]; }, }); ================================================ FILE: packages/editor/src/extensions/table/table-header.ts ================================================ import { TableHeader as TiptapTableHeader } from "@tiptap/extension-table"; import { Plugin } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; import { getCellsInRow, isColumnSelected, selectColumn } from "./utils"; export const TableHeader = TiptapTableHeader.extend({ addAttributes() { return { colspan: { default: 1, }, rowspan: { default: 1, }, colwidth: { default: null, parseHTML: (element: HTMLElement) => { const colwidth = element.getAttribute("colwidth"); const value = colwidth ? colwidth .split(",") .map((item: string) => Number.parseInt(item, 10)) : null; return value; }, }, style: { default: null, }, }; }, addProseMirrorPlugins() { return [ new Plugin({ props: { decorations: (state) => { const { isEditable } = this.editor; if (!isEditable) { return DecorationSet.empty; } const { doc, selection } = state; const decorations: Decoration[] = []; const cells = getCellsInRow(0)(selection); if (cells) { let index = 0; for (const { pos } of cells) { const currentIndex = index; decorations.push( Decoration.widget(pos + 1, () => { const colSelected = isColumnSelected(currentIndex)(selection); let className = "grip-column"; if (colSelected) { className += " selected"; } if (currentIndex === 0) { className += " first"; } if (currentIndex === cells.length - 1) { className += " last"; } const grip = document.createElement("a"); grip.className = className; grip.addEventListener("mousedown", (event) => { event.preventDefault(); event.stopImmediatePropagation(); this.editor.view.dispatch( selectColumn(currentIndex)(this.editor.state.tr) ); }); return grip; }) ); index += 1; } } return DecorationSet.create(doc, decorations); }, }, }), ]; }, }); export default TableHeader; ================================================ FILE: packages/editor/src/extensions/table/table-row.ts ================================================ import { TableRow as TiptapTableRow } from "@tiptap/extension-table"; export const TableRow = TiptapTableRow.extend({ allowGapCursor: false, content: "(tableCell | tableHeader)*", }); export default TableRow; ================================================ FILE: packages/editor/src/extensions/table/table.ts ================================================ import { Table as TiptapTable } from "@tiptap/extension-table"; import "../../styles/table.css"; export const Table = TiptapTable.configure({ resizable: true, lastColumnResizable: false, }); export default Table; ================================================ FILE: packages/editor/src/extensions/table/utils.ts ================================================ import { findParentNode } from "@tiptap/core"; import type { Node, ResolvedPos } from "@tiptap/pm/model"; import type { Selection, Transaction } from "@tiptap/pm/state"; import { CellSelection, type Rect, TableMap } from "@tiptap/pm/tables"; export const isRectSelected = (rect: Rect) => (selection: CellSelection) => { const map = TableMap.get(selection.$anchorCell.node(-1)); const start = selection.$anchorCell.start(-1); const cells = map.cellsInRect(rect); const selectedCells = map.cellsInRect( map.rectBetween( selection.$anchorCell.pos - start, selection.$headCell.pos - start ) ); for (let i = 0, count = cells.length; i < count; i += 1) { const cell = cells[i]; if (cell !== undefined && selectedCells.indexOf(cell) === -1) { return false; } } return true; }; export const findTable = (selection: Selection) => findParentNode( (node) => node.type.spec.tableRole && node.type.spec.tableRole === "table" )(selection); export const isCellSelection = ( selection: Selection ): selection is CellSelection => selection instanceof CellSelection; export const isColumnSelected = (columnIndex: number) => (selection: Selection) => { if (isCellSelection(selection)) { const map = TableMap.get(selection.$anchorCell.node(-1)); return isRectSelected({ left: columnIndex, right: columnIndex + 1, top: 0, bottom: map.height, })(selection); } return false; }; export const isRowSelected = (rowIndex: number) => (selection: Selection) => { if (isCellSelection(selection)) { const map = TableMap.get(selection.$anchorCell.node(-1)); return isRectSelected({ left: 0, right: map.width, top: rowIndex, bottom: rowIndex + 1, })(selection); } return false; }; export const isTableSelected = (selection: Selection) => { if (isCellSelection(selection)) { const map = TableMap.get(selection.$anchorCell.node(-1)); return isRectSelected({ left: 0, right: map.width, top: 0, bottom: map.height, })(selection); } return false; }; export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => { const table = findTable(selection); if (table) { const map = TableMap.get(table.node); const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]); return indexes.reduce( (acc, index) => { if (index >= 0 && index <= map.width - 1) { const cells = map.cellsInRect({ left: index, right: index + 1, top: 0, bottom: map.height, }); return acc.concat( cells.map((nodePos) => { const node = table.node.nodeAt(nodePos); const pos = nodePos + table.start; return { pos, start: pos + 1, node }; }) ); } return acc; }, [] as { pos: number; start: number; node: Node | null | undefined }[] ); } return null; }; export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => { const table = findTable(selection); if (table) { const map = TableMap.get(table.node); const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]); return indexes.reduce( (acc, index) => { if (index >= 0 && index <= map.height - 1) { const cells = map.cellsInRect({ left: 0, right: map.width, top: index, bottom: index + 1, }); return acc.concat( cells.map((nodePos) => { const node = table.node.nodeAt(nodePos); const pos = nodePos + table.start; return { pos, start: pos + 1, node }; }) ); } return acc; }, [] as { pos: number; start: number; node: Node | null | undefined }[] ); } return null; }; export const getCellsInTable = (selection: Selection) => { const table = findTable(selection); if (table) { const map = TableMap.get(table.node); const cells = map.cellsInRect({ left: 0, right: map.width, top: 0, bottom: map.height, }); return cells.map((nodePos) => { const node = table.node.nodeAt(nodePos); const pos = nodePos + table.start; return { pos, start: pos + 1, node }; }); } return null; }; export const findParentNodeClosestToPos = ( $pos: ResolvedPos, predicate: (node: Node) => boolean ) => { for (let i = $pos.depth; i > 0; i -= 1) { const node = $pos.node(i); if (predicate(node)) { return { pos: i > 0 ? $pos.before(i) : 0, start: $pos.start(i), depth: i, node, }; } } return null; }; export const findCellClosestToPos = ($pos: ResolvedPos) => { const predicate = (node: Node) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole); return findParentNodeClosestToPos($pos, predicate); }; const select = (type: "row" | "column") => (index: number) => (tr: Transaction) => { const table = findTable(tr.selection); const isRowSelection = type === "row"; if (table) { const map = TableMap.get(table.node); // Check if the index is valid if (index >= 0 && index < (isRowSelection ? map.height : map.width)) { const left = isRowSelection ? 0 : index; const top = isRowSelection ? index : 0; const right = isRowSelection ? map.width : index + 1; const bottom = isRowSelection ? index + 1 : map.height; const cellsInFirstRow = map.cellsInRect({ left, top, right: isRowSelection ? right : left + 1, bottom: isRowSelection ? top + 1 : bottom, }); const cellsInLastRow = bottom - top === 1 ? cellsInFirstRow : map.cellsInRect({ left: isRowSelection ? left : right - 1, top: isRowSelection ? bottom - 1 : top, right, bottom, }); const head = table.start + (cellsInFirstRow[0] ?? 0); const anchor = table.start + (cellsInLastRow.at(-1) ?? 0); const $head = tr.doc.resolve(head); const $anchor = tr.doc.resolve(anchor); return tr.setSelection(new CellSelection($anchor, $head)); } } return tr; }; export const selectColumn = select("column"); export const selectRow = select("row"); export const selectTable = (tr: Transaction) => { const table = findTable(tr.selection); if (table) { const { map } = TableMap.get(table.node); if (map?.length) { const head = table.start + (map[0] ?? 0); const anchor = table.start + (map.at(-1) ?? 0); const $head = tr.doc.resolve(head); const $anchor = tr.doc.resolve(anchor); return tr.setSelection(new CellSelection($anchor, $head)); } } return tr; }; ================================================ FILE: packages/editor/src/extensions/twitter/index.tsx ================================================ /** biome-ignore-all lint/style/useConsistentTypeDefinitions: <> */ import { mergeAttributes, Node, nodePasteRule } from "@tiptap/core"; import { NodeViewWrapper, ReactNodeViewRenderer, type ReactNodeViewRendererOptions, } from "@tiptap/react"; import { Tweet } from "react-tweet"; export const TWITTER_REGEX_GLOBAL = /(https?:\/\/)?(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?/g; export const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/; export const isValidTwitterUrl = (url: string) => url.match(TWITTER_REGEX); const TweetComponent = ({ node, }: { node: Partial; }) => { const url = (node?.attrs as Record)?.src; const tweetId = url?.split("/").pop(); if (!tweetId) { return null; } return (
    ); }; export interface TwitterOptions { /** * Controls if the paste handler for tweets should be added. * @default true * @example false */ addPasteHandler: boolean; // biome-ignore lint/suspicious/noExplicitAny: <> HTMLAttributes: Record; /** * Controls if the twitter node should be inline or not. * @default false * @example true */ inline: boolean; /** * The origin of the tweet. * @default '' * @example 'https://tiptap.dev' */ origin: string; } /** * The options for setting a tweet. */ type SetTweetOptions = { src: string }; declare module "@tiptap/core" { interface Commands { twitter: { /** * Insert a tweet * @param options The tweet attributes * @example editor.commands.setTweet({ src: 'https://x.com/seanpk/status/1800145949580517852' }) */ setTweet: (options: SetTweetOptions) => ReturnType; }; } } /** * This extension adds support for tweets. */ export const Twitter = Node.create({ name: "twitter", addOptions() { return { addPasteHandler: true, HTMLAttributes: {}, inline: false, origin: "", }; }, addNodeView() { return ReactNodeViewRenderer(TweetComponent, { attrs: this.options.HTMLAttributes, }); }, inline() { return this.options.inline; }, group() { return this.options.inline ? "inline" : "block"; }, draggable: true, addAttributes() { return { src: { default: null, parseHTML: (element) => element.getAttribute("data-src"), renderHTML: (attributes) => { if (!attributes.src) { return {}; } return { "data-src": attributes.src, }; }, }, }; }, parseHTML() { return [ { tag: "div[data-twitter]", }, ]; }, addCommands() { return { setTweet: (options: SetTweetOptions) => ({ commands }) => { if (!isValidTwitterUrl(options.src)) { return false; } return commands.insertContent({ type: this.name, attrs: options, }); }, }; }, addPasteRules() { if (!this.options.addPasteHandler) { return []; } return [ nodePasteRule({ find: TWITTER_REGEX_GLOBAL, type: this.type, getAttributes: (match) => ({ src: match.input }), }), ]; }, renderHTML({ HTMLAttributes }) { return ["div", mergeAttributes({ "data-twitter": "" }, HTMLAttributes)]; }, }); ================================================ FILE: packages/editor/src/extensions/twitter/twitter-comp.tsx ================================================ import { Button } from "@marble/ui/components/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle, } from "@marble/ui/components/card"; import { Textarea } from "@marble/ui/components/textarea"; import { cn } from "@marble/ui/lib/utils"; import type { ChangeEvent, KeyboardEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { Twitter } from "../../components/icons/twitter"; // Validate Twitter/X.com URL const TWITTER_REGEX = /^https?:\/\/(www\.)?x\.com\/([a-zA-Z0-9_]{1,15})(\/status\/(\d+))?(\/\S*)?$/; function isValidTwitterUrl(url: string): boolean { if (!url) { return false; } return TWITTER_REGEX.test(url); } export const TwitterComp = ({ onSubmit, onCancel, }: { onSubmit: (url: string) => void; onCancel: () => void; }) => { const [url, setUrl] = useState(""); const [error, setError] = useState(null); const inputRef = useRef(null); useEffect(() => { // Use requestAnimationFrame to ensure the element is rendered const frame = requestAnimationFrame(() => { inputRef.current?.focus(); }); return () => cancelAnimationFrame(frame); }, []); const validateAndSubmit = useCallback(() => { if (!isValidTwitterUrl(url)) { setError("Invalid Tweet link"); return; } onSubmit(url); }, [url, onSubmit]); const handleInputChange = useCallback( (e: ChangeEvent) => { setUrl(e.target.value); setError(null); }, [] ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); validateAndSubmit(); } else if (e.key === "Escape") { e.preventDefault(); onCancel(); } }, [validateAndSubmit, onCancel] ); const isValidUrl = isValidTwitterUrl(url); return (
    Paste a Tweet link