├── src ├── app │ ├── mock │ │ └── mdc-import.ts │ ├── src │ │ ├── public │ │ │ ├── card-placeholder-dark.png │ │ │ └── card-placeholder-light.png │ │ ├── types │ │ │ ├── media.ts │ │ │ ├── item.ts │ │ │ ├── user.ts │ │ │ ├── content.ts │ │ │ ├── component.ts │ │ │ ├── config.ts │ │ │ ├── tree.ts │ │ │ ├── database.ts │ │ │ ├── form.ts │ │ │ ├── file.ts │ │ │ ├── draft.ts │ │ │ ├── context.ts │ │ │ └── git.ts │ │ ├── utils │ │ │ ├── providers │ │ │ │ ├── index.ts │ │ │ │ └── null.ts │ │ │ ├── media.ts │ │ │ ├── content.ts │ │ │ ├── string.ts │ │ │ ├── tiptap │ │ │ │ ├── extensions │ │ │ │ │ ├── code-block.ts │ │ │ │ │ ├── image-picker.ts │ │ │ │ │ ├── video-picker.ts │ │ │ │ │ ├── frontmatter.ts │ │ │ │ │ ├── slot.ts │ │ │ │ │ ├── video.ts │ │ │ │ │ └── inline-element.ts │ │ │ │ └── input-rules.ts │ │ │ ├── storage.ts │ │ │ ├── data.ts │ │ │ ├── styles.ts │ │ │ └── draft.ts │ │ ├── shared.ts │ │ ├── composables │ │ │ ├── useHooks.ts │ │ │ ├── useUI.ts │ │ │ ├── useGitProvider.ts │ │ │ ├── useStudioState.ts │ │ │ └── useMonacoDiff.ts │ │ ├── components │ │ │ ├── shared │ │ │ │ ├── item │ │ │ │ │ ├── ItemBadge.vue │ │ │ │ │ ├── ItemTree.vue │ │ │ │ │ ├── ItemCard.vue │ │ │ │ │ ├── ItemCardReview.vue │ │ │ │ │ └── ItemBreadcrumb.vue │ │ │ │ ├── CopyButton.vue │ │ │ │ ├── MDCFormattingBanner.vue │ │ │ │ ├── Collapsible.vue │ │ │ │ └── ResizableElement.vue │ │ │ ├── media │ │ │ │ ├── MediaCardReview.vue │ │ │ │ ├── MediaEditor.vue │ │ │ │ ├── MediaEditorAudio.vue │ │ │ │ ├── MediaCardForm.vue │ │ │ │ └── MediaCard.vue │ │ │ ├── header │ │ │ │ ├── HeaderSuccess.vue │ │ │ │ └── HeaderMain.vue │ │ │ ├── AppResizeHandle.vue │ │ │ ├── tiptap │ │ │ │ └── extension │ │ │ │ │ ├── TiptapExtensionVideo.vue │ │ │ │ │ ├── TiptapExtensionImagePicker.vue │ │ │ │ │ ├── TiptapExtensionVideoPicker.vue │ │ │ │ │ ├── TiptapExtensionCodeBlock.vue │ │ │ │ │ └── TiptapExtensionFrontmatter.vue │ │ │ ├── AppHeader.vue │ │ │ ├── content │ │ │ │ ├── ContentEditorDiff.vue │ │ │ │ ├── ContentCardForm.vue │ │ │ │ ├── ContentCard.vue │ │ │ │ ├── ContentEditorTipTapDebug.vue │ │ │ │ └── ContentEditorForm.vue │ │ │ ├── form │ │ │ │ ├── FormPanelSection.vue │ │ │ │ ├── FormSchemaBased.vue │ │ │ │ ├── input │ │ │ │ │ └── FormInputObject.vue │ │ │ │ └── FormPanelInput.vue │ │ │ ├── AppLayout.vue │ │ │ └── AppBanner.vue │ │ ├── assets │ │ │ └── css │ │ │ │ └── main.css │ │ ├── main.ts │ │ └── pages │ │ │ └── error.vue │ ├── tsconfig.json │ ├── test │ │ ├── mocks │ │ │ ├── composables.ts │ │ │ ├── document.ts │ │ │ ├── tree.ts │ │ │ ├── git.ts │ │ │ └── form.ts │ │ ├── utils │ │ │ └── index.ts │ │ └── unit │ │ │ ├── utils │ │ │ ├── file.test.ts │ │ │ ├── draft.test.ts │ │ │ └── object.test.ts │ │ │ └── composables │ │ │ └── draft-base.test.ts │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ └── vite.config.ts └── module │ ├── src │ ├── runtime │ │ ├── types │ │ │ └── content.ts │ │ ├── utils │ │ │ ├── media.ts │ │ │ ├── url.ts │ │ │ ├── ensure.ts │ │ │ ├── document │ │ │ │ ├── index.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── tree.ts │ │ │ │ └── schema.ts │ │ │ ├── sidebar.ts │ │ │ ├── object.ts │ │ │ ├── activation.ts │ │ │ └── source.ts │ │ ├── server │ │ │ ├── routes │ │ │ │ ├── auth │ │ │ │ │ ├── session.delete.ts │ │ │ │ │ └── session.get.ts │ │ │ │ ├── sw.ts │ │ │ │ ├── meta.ts │ │ │ │ └── dev │ │ │ │ │ ├── content │ │ │ │ │ └── [...path].ts │ │ │ │ │ └── public │ │ │ │ │ └── [...path].ts │ │ │ └── utils │ │ │ │ └── session.ts │ │ ├── plugins │ │ │ ├── studio.client.ts │ │ │ └── studio.client.dev.ts │ │ ├── composables │ │ │ └── useMeta.ts │ │ └── host.dev.ts │ ├── types │ │ ├── global.d.ts │ │ └── content.ts │ ├── templates.ts │ ├── dev.ts │ └── auth.ts │ ├── build.config.ts │ └── test │ └── mocks │ └── collection.ts ├── .npmrc ├── playground ├── minimal │ ├── content │ │ └── index.md │ ├── public │ │ ├── favicon.ico │ │ └── mountains.webp │ ├── package.json │ ├── tsconfig.json │ ├── nuxt.config.ts │ ├── content.config.ts │ └── app │ │ └── app.vue └── docus │ ├── content │ ├── 1.getting-started │ │ ├── 7.essentials │ │ │ ├── .navigation.yml │ │ │ └── 2.images-embeds.md │ │ ├── .navigation.yml │ │ ├── 6.migration.md │ │ ├── 4.project-structure.md │ │ ├── 2.introduction.md │ │ └── 3.installation.md │ ├── 9.studio │ │ ├── .navigation.yml │ │ ├── 6.medias.md │ │ └── 1.introduction.md │ ├── authors │ │ ├── atinux.yml │ │ ├── farnabaz.md │ │ └── larbish.json │ └── 3.pages │ │ └── authors.md │ ├── public │ ├── favicon.ico │ └── mountains.webp │ ├── app │ ├── app.config.ts │ └── pages │ │ └── authors.vue │ ├── tsconfig.json │ ├── package.json │ ├── nuxt.config.ts │ └── content.config.ts ├── .vscode └── settings.json ├── vitest.config.ts ├── tsconfig.json ├── .editorconfig ├── pnpm-workspace.yaml ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature-request.md │ ├── stale.yml │ └── bug-report.yml └── workflows │ ├── module.yml │ └── release.yml ├── .env.example ├── eslint.config.mjs ├── .release-it.json ├── .gitignore └── license.md /src/app/mock/mdc-import.ts: -------------------------------------------------------------------------------- 1 | export default {} 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /playground/minimal/content/index.md: -------------------------------------------------------------------------------- 1 | Minimal Nuxt + Nuxt Content application -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.experimental.useFlatConfig": true 3 | } 4 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/7.essentials/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: MDC 2 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/.navigation.yml: -------------------------------------------------------------------------------- 1 | title: Docus 2 | icon: false 3 | -------------------------------------------------------------------------------- /playground/docus/content/9.studio/.navigation.yml: -------------------------------------------------------------------------------- 1 | icon: i-lucide-monitor 2 | title: Studio 3 | -------------------------------------------------------------------------------- /playground/docus/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-content/studio/HEAD/playground/docus/public/favicon.ico -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: {}, 5 | }) 6 | -------------------------------------------------------------------------------- /playground/docus/public/mountains.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-content/studio/HEAD/playground/docus/public/mountains.webp -------------------------------------------------------------------------------- /playground/minimal/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-content/studio/HEAD/playground/minimal/public/favicon.ico -------------------------------------------------------------------------------- /playground/minimal/public/mountains.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-content/studio/HEAD/playground/minimal/public/mountains.webp -------------------------------------------------------------------------------- /playground/docus/app/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | header: { 3 | title: 'Studio Playground', 4 | }, 5 | }) 6 | -------------------------------------------------------------------------------- /src/app/src/public/card-placeholder-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-content/studio/HEAD/src/app/src/public/card-placeholder-dark.png -------------------------------------------------------------------------------- /src/app/src/public/card-placeholder-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxt-content/studio/HEAD/src/app/src/public/card-placeholder-light.png -------------------------------------------------------------------------------- /src/app/src/types/media.ts: -------------------------------------------------------------------------------- 1 | import type { BaseItem } from './item' 2 | 3 | export interface MediaItem extends BaseItem { 4 | [key: string]: unknown 5 | } 6 | -------------------------------------------------------------------------------- /src/app/src/types/item.ts: -------------------------------------------------------------------------------- 1 | export interface BaseItem { 2 | id: string 3 | fsPath?: string 4 | extension: string 5 | stem: string 6 | path?: string 7 | } 8 | -------------------------------------------------------------------------------- /src/app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/module/src/runtime/types/content.ts: -------------------------------------------------------------------------------- 1 | export enum ContentFileExtension { 2 | Markdown = 'md', 3 | YAML = 'yaml', 4 | YML = 'yml', 5 | JSON = 'json', 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./src/module/.nuxt/tsconfig.json", 3 | "exclude": [ 4 | "**/dist", 5 | "node_modules", 6 | "playground/*", 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/app/src/utils/providers/index.ts: -------------------------------------------------------------------------------- 1 | export { createGitHubProvider } from './github' 2 | export { createGitLabProvider } from './gitlab' 3 | export { createNullProvider } from './null' 4 | -------------------------------------------------------------------------------- /src/app/src/utils/media.ts: -------------------------------------------------------------------------------- 1 | export function generateStemFromFsPath(fsPath: string) { 2 | return fsPath.split('.').slice(0, -1).join('.') 3 | } 4 | 5 | export const VirtualMediaCollectionName = 'public-assets' as const 6 | -------------------------------------------------------------------------------- /src/app/src/utils/content.ts: -------------------------------------------------------------------------------- 1 | export function areContentEqual(content1: string | null, content2: string | null): boolean { 2 | if (content1 && content2) { 3 | return content1.trim() === content2.trim() 4 | } 5 | 6 | return false 7 | } 8 | -------------------------------------------------------------------------------- /playground/docus/content/authors/atinux.yml: -------------------------------------------------------------------------------- 1 | name: Sébastien Chopin 2 | avatar: 3 | src: https://avatars.githubusercontent.com/u/904724?v=4 4 | to: https://x.com/atinux 5 | username: atinux 6 | modules: 7 | - hub 8 | - ui 9 | - auth 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/media.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'pathe' 2 | import { VirtualMediaCollectionName } from 'nuxt-studio/app/utils' 3 | 4 | export function generateIdFromFsPath(fsPath: string) { 5 | return join(VirtualMediaCollectionName, fsPath) 6 | } 7 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/routes/auth/session.delete.ts: -------------------------------------------------------------------------------- 1 | import { eventHandler } from 'h3' 2 | import { clearStudioUserSession } from '../../utils/session' 3 | 4 | export default eventHandler(async (event) => { 5 | return await clearStudioUserSession(event) 6 | }) 7 | -------------------------------------------------------------------------------- /src/app/src/types/user.ts: -------------------------------------------------------------------------------- 1 | import type { GitProviderType } from './git' 2 | 3 | export interface StudioUser { 4 | providerId?: string 5 | accessToken: string 6 | name: string 7 | avatar?: string 8 | email: string 9 | provider: GitProviderType | 'google' 10 | } 11 | -------------------------------------------------------------------------------- /playground/docus/content/authors/farnabaz.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Ahad Birang 3 | avatar: 4 | src: https://avatars.githubusercontent.com/u/2047945?v=4 5 | to: https://x.com/farnabaz 6 | username: farnabaz 7 | modules: 8 | - studio 9 | - content 10 | - mdc 11 | - hub 12 | --- 13 | -------------------------------------------------------------------------------- /src/app/src/shared.ts: -------------------------------------------------------------------------------- 1 | export { VirtualMediaCollectionName } from './utils/media' 2 | export type { MarkdownParsingOptions } from './types/content' 3 | export type { GitProviderType } from './types/git' 4 | 5 | // Temporary export for remark emoji plugin 6 | export * from './utils/emoji' 7 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - ./ 3 | - ./playground/* 4 | 5 | ignoredBuiltDependencies: 6 | - '@parcel/watcher' 7 | - '@tailwindcss/oxide' 8 | - esbuild 9 | - leveldown 10 | - unrs-resolver 11 | - vue-demi 12 | 13 | onlyBuiltDependencies: 14 | - better-sqlite3 15 | - sharp 16 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/routes/sw.ts: -------------------------------------------------------------------------------- 1 | import { serviceWorker } from 'nuxt-studio/app/service-worker' 2 | import { eventHandler, setHeader } from 'h3' 3 | 4 | export default eventHandler(async (event) => { 5 | setHeader(event, 'Content-Type', 'application/javascript') 6 | return serviceWorker() 7 | }) 8 | -------------------------------------------------------------------------------- /src/app/src/types/content.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionType } from '@nuxt/content' 2 | 3 | export interface MarkdownParsingOptions { 4 | compress?: boolean 5 | collectionType?: CollectionType 6 | } 7 | 8 | export interface SyntaxHighlightTheme { 9 | default: string 10 | dark?: string 11 | light?: string 12 | } 13 | -------------------------------------------------------------------------------- /src/app/src/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function fromBase64ToUTF8(base64: string) { 2 | const binary = atob(base64) 3 | const len = binary.length 4 | const bytes = new Uint8Array(len) 5 | for (let i = 0; i < len; i++) { 6 | bytes[i] = binary.charCodeAt(i) 7 | } 8 | return new TextDecoder('utf-8').decode(bytes) 9 | } 10 | -------------------------------------------------------------------------------- /playground/docus/content/authors/larbish.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Baptiste Leproux", 3 | "avatar": { 4 | "src": "https://avatars.githubusercontent.com/u/7290030?v=4" 5 | }, 6 | "to": "https://x.com/_larbish", 7 | "username": "larbish", 8 | "modules": [ 9 | "studio", 10 | "content", 11 | "supabase" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /playground/minimal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxt-studio-playground-minimal", 4 | "scripts": { 5 | "dev": "nuxt dev" 6 | }, 7 | "dependencies": { 8 | "better-sqlite3": "^12.5.0", 9 | "nuxt": "latest", 10 | "@nuxt/content": "latest", 11 | "nuxt-studio": "workspace:*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/src/types/component.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentData } from 'nuxt-component-meta' 2 | 3 | export interface ComponentMeta { 4 | name: string 5 | path: string 6 | meta: { 7 | props: ComponentData['meta']['props'] 8 | slots: ComponentData['meta']['slots'] 9 | events: ComponentData['meta']['events'] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: 📚 Documentation 3 | url: https://content.nuxt.com/docs/studio/setup 4 | about: Check documentation for usage 5 | - name: 💬 Discussions 6 | url: https://github.com/nuxt-content/studio/discussions 7 | about: Use discussions if you have an idea for improvement and asking questions 8 | -------------------------------------------------------------------------------- /src/app/src/types/config.ts: -------------------------------------------------------------------------------- 1 | import type { StudioFeature } from './context' 2 | 3 | export type EditorMode = 'code' | 'tiptap' 4 | 5 | export interface StudioConfig { 6 | syncEditorAndRoute: boolean 7 | showTechnicalMode: boolean 8 | editorMode: EditorMode 9 | debug: boolean 10 | } 11 | 12 | export interface StudioLocation { 13 | active: boolean 14 | feature: StudioFeature 15 | fsPath: string 16 | } 17 | -------------------------------------------------------------------------------- /playground/docus/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "files": [], 4 | "references": [ 5 | { 6 | "path": "./.nuxt/tsconfig.app.json" 7 | }, 8 | { 9 | "path": "./.nuxt/tsconfig.server.json" 10 | }, 11 | { 12 | "path": "./.nuxt/tsconfig.shared.json" 13 | }, 14 | { 15 | "path": "./.nuxt/tsconfig.node.json" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /playground/minimal/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "files": [], 4 | "references": [ 5 | { 6 | "path": "./.nuxt/tsconfig.app.json" 7 | }, 8 | { 9 | "path": "./.nuxt/tsconfig.server.json" 10 | }, 11 | { 12 | "path": "./.nuxt/tsconfig.shared.json" 13 | }, 14 | { 15 | "path": "./.nuxt/tsconfig.node.json" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /playground/docus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxt-studio-playground-docus", 4 | "scripts": { 5 | "dev": "nuxt dev", 6 | "build": "nuxt build --extends docus" 7 | }, 8 | "dependencies": { 9 | "docus": "^5.4.0", 10 | "better-sqlite3": "^12.5.0", 11 | "nuxt": "^4.2.2", 12 | "@nuxt/content": "latest", 13 | "@nuxt/ui": "^4.2.1", 14 | "nuxt-studio": "workspace:*" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/module/src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module '#content/preview' { 2 | import type { CollectionInfo } from './collection' 3 | 4 | export const collections: Record 5 | export const gitInfo: GitInfo 6 | export const appConfigSchema: Record 7 | } 8 | 9 | declare module '#build/studio-public-assets' { 10 | import type { Storage } from 'unstorage' 11 | 12 | export const publicAssetsStorage: Storage 13 | } 14 | -------------------------------------------------------------------------------- /src/app/src/types/tree.ts: -------------------------------------------------------------------------------- 1 | export enum TreeStatus { 2 | Deleted = 'deleted', 3 | Created = 'created', 4 | Updated = 'updated', 5 | Renamed = 'renamed', 6 | Opened = 'opened', 7 | } 8 | 9 | export interface TreeItem { 10 | name: string 11 | fsPath: string // unique identifier 12 | type: 'file' | 'directory' | 'root' 13 | prefix: string | null 14 | status?: TreeStatus 15 | routePath?: string 16 | children?: TreeItem[] 17 | hide?: boolean 18 | } 19 | -------------------------------------------------------------------------------- /src/app/src/composables/useHooks.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable } from '@vueuse/core' 2 | import { createHooks } from 'hookable' 3 | 4 | export const useHooks = createSharedComposable(() => { 5 | return createHooks<{ 6 | 'studio:draft:document:updated': ( 7 | { caller, selectItem }: { caller: string, selectItem?: boolean }, 8 | ) => void 9 | 'studio:draft:media:updated': ( 10 | { caller, selectItem }: { caller: string, selectItem?: boolean }, 11 | ) => void 12 | }>() 13 | }) 14 | -------------------------------------------------------------------------------- /src/app/test/mocks/composables.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import { ref } from 'vue' 3 | import type { useUI } from '../../src/composables/useUI' 4 | 5 | export const createMockStorage = () => new Map() 6 | 7 | export const createMockUI = (): ReturnType => { 8 | return { 9 | colorMode: ref('light'), 10 | sidebar: {} as never, 11 | isOpen: ref(false), 12 | open: vi.fn(), 13 | toggle: vi.fn(() => true), 14 | close: vi.fn(() => false) as never, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/src/types/database.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionItemBase, PageCollectionItemBase, DataCollectionItemBase } from '@nuxt/content' 2 | import type { BaseItem } from './item' 3 | 4 | export interface DatabaseItem extends CollectionItemBase, BaseItem { 5 | [key: string]: unknown 6 | } 7 | 8 | export interface DatabasePageItem extends PageCollectionItemBase, BaseItem { 9 | path: string 10 | [key: string]: unknown 11 | } 12 | 13 | export interface DatabaseDataItem extends DataCollectionItemBase, BaseItem { 14 | [key: string]: unknown 15 | } 16 | -------------------------------------------------------------------------------- /src/app/test/mocks/document.ts: -------------------------------------------------------------------------------- 1 | import type { DatabasePageItem } from '../../src/types' 2 | import { idToFsPath } from './host' 3 | 4 | export const createMockDocument = (id: string, overrides?: Partial) => { 5 | const fsPath = idToFsPath(id) 6 | const path = fsPath.replace('.md', '') 7 | return { 8 | id, 9 | fsPath, 10 | path, 11 | stem: path, 12 | extension: 'md', 13 | body: { 14 | type: 'minimark', 15 | value: ['Test content'], 16 | }, 17 | meta: {}, 18 | ...overrides, 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # If using GitHub provider 2 | STUDIO_GITHUB_CLIENT_ID= 3 | STUDIO_GITHUB_CLIENT_SECRET= 4 | STUDIO_GITHUB_MODERATORS= 5 | 6 | # If using GitLab provider 7 | STUDIO_GITLAB_APPLICATION_ID= 8 | STUDIO_GITLAB_APPLICATION_SECRET= 9 | STUDIO_GITLAB_MODERATORS= 10 | 11 | STUDIO_GOOGLE_CLIENT_ID= 12 | STUDIO_GOOGLE_CLIENT_SECRET= 13 | STUDIO_GOOGLE_MODERATORS=user@domain.com,user2@domain.com 14 | 15 | # If using GitHub provider with Google Oauth 16 | STUDIO_GITHUB_TOKEN= 17 | 18 | # If using GitLab provider with Google Oauth 19 | STUDIO_GITLAB_TOKEN= 20 | -------------------------------------------------------------------------------- /playground/minimal/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: [ 3 | 'nuxt-studio', 4 | '@nuxt/content', 5 | ], 6 | devtools: { enabled: true }, 7 | content: { 8 | experimental: { 9 | sqliteConnector: 'native', 10 | }, 11 | }, 12 | compatibilityDate: '2025-08-26', 13 | studio: { 14 | repository: { 15 | provider: 'github', 16 | owner: 'nuxt-content', 17 | repo: 'studio', 18 | branch: 'main', 19 | rootDir: 'playground/minimal', 20 | private: false, 21 | }, 22 | }, 23 | }) 24 | -------------------------------------------------------------------------------- /src/app/src/components/shared/item/ItemBadge.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 22 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 3 | 4 | // Run `npx @eslint/config-inspector` to inspect the resolved config interactively 5 | export default createConfigForNuxt({ 6 | features: { 7 | // Rules for module authors 8 | tooling: true, 9 | // Rules for formatting 10 | stylistic: true, 11 | }, 12 | dirs: { 13 | src: [ 14 | './playground', 15 | ], 16 | }, 17 | }) 18 | .append( 19 | { 20 | rules: { 21 | 'vue/multi-word-component-names': 'off', 22 | }, 23 | }, 24 | ) 25 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore(release): v${version}", 4 | "tagName": "v${version}" 5 | }, 6 | "npm": { 7 | "publish": true 8 | }, 9 | "github": { 10 | "release": true, 11 | "releaseName": "v${version}", 12 | "web": true 13 | }, 14 | "hooks": { 15 | "before:init": ["pnpm run verify"] 16 | }, 17 | "plugins": { 18 | "@release-it/conventional-changelog": { 19 | "preset": { 20 | "name": "conventionalcommits" 21 | }, 22 | "infile": "CHANGELOG.md", 23 | "header": "# Changelog", 24 | "ignoreRecommendedBump": true 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/url.ts: -------------------------------------------------------------------------------- 1 | const SEMVER_REGEX = /^\d+(?:\.\d+)*(?:\.x)?$/ 2 | 3 | export function cleanUrlSegment(name: string): string { 4 | name = name.split(/[/:]/).pop()! 5 | // Match 1, 1.2, 1.x, 1.2.x, 1.2.3.x, 6 | if (SEMVER_REGEX.test(name)) { 7 | return name 8 | } 9 | 10 | return ( 11 | name 12 | /** 13 | * Remove numbering 14 | */ 15 | .replace(/(\d+\.)?(.*)/, '$2') 16 | /** 17 | * Remove index keyword 18 | */ 19 | .replace(/^index(\.draft)?$/, '') 20 | /** 21 | * Remove draft keyword 22 | */ 23 | .replace(/\.draft$/, '') 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /playground/docus/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | extends: ['docus'], 3 | modules: [ 4 | '@nuxt/ui', 5 | '@nuxt/content', 6 | 'nuxt-studio', 7 | ], 8 | devtools: { enabled: true }, 9 | content: { 10 | experimental: { 11 | sqliteConnector: 'native', 12 | }, 13 | }, 14 | compatibilityDate: '2025-08-26', 15 | studio: { 16 | dev: false, 17 | route: '/admin', 18 | repository: { 19 | provider: 'github', 20 | owner: 'nuxt-content', 21 | repo: 'studio', 22 | branch: 'main', 23 | rootDir: 'playground/docus', 24 | private: false, 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/routes/auth/session.get.ts: -------------------------------------------------------------------------------- 1 | import { eventHandler, useSession, deleteCookie } from 'h3' 2 | import { useRuntimeConfig } from '#imports' 3 | 4 | export default eventHandler(async (event) => { 5 | const session = await useSession(event, { 6 | name: 'studio-session', 7 | password: useRuntimeConfig(event).studio?.auth?.sessionSecret, 8 | }) 9 | 10 | if (!session.data || Object.keys(session.data).length === 0) { 11 | // Delete the cookie to indicate that the session is inactive 12 | deleteCookie(event, 'studio-session-check') 13 | } 14 | 15 | return { 16 | ...session.data, 17 | id: session.id!, 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /src/app/src/components/media/MediaCardReview.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /src/app/src/components/header/HeaderSuccess.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 28 | -------------------------------------------------------------------------------- /src/app/src/types/form.ts: -------------------------------------------------------------------------------- 1 | import type { JSType } from 'untyped' 2 | 3 | export type FormInputsTypes = JSType | 'icon' | 'media' | 'file' | 'date' 4 | 5 | export type FormTree = Record 6 | export type FormItem = { 7 | id: string 8 | type: FormInputsTypes 9 | key?: string 10 | value?: unknown 11 | default?: unknown 12 | options?: string[] 13 | title: string 14 | icon?: string 15 | children?: FormTree 16 | disabled?: boolean 17 | hidden?: boolean 18 | // If type is combined with boolean 19 | toggleable?: boolean 20 | // Not in schema, created manually by user 21 | custom?: boolean 22 | // Items for array type 23 | arrayItemForm?: FormItem 24 | } 25 | -------------------------------------------------------------------------------- /src/app/src/components/AppResizeHandle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 19 | -------------------------------------------------------------------------------- /src/app/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true, 20 | 21 | /* Nuxt UI */ 22 | "paths": { 23 | "#build/ui": [ 24 | "./node_modules/.nuxt-ui/ui" 25 | ] 26 | } 27 | }, 28 | "include": ["./vite.config.ts"] 29 | } 30 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/code-block.ts: -------------------------------------------------------------------------------- 1 | import TiptapCodeBlock from '@tiptap/extension-code-block' 2 | import type { Attributes } from '@tiptap/vue-3' 3 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 4 | import TiptapExtensionCodeBlock from '../../../components/tiptap/extension/TiptapExtensionCodeBlock.vue' 5 | 6 | export const CodeBlock = TiptapCodeBlock.extend({ 7 | addNodeView() { 8 | return VueNodeViewRenderer(TiptapExtensionCodeBlock) 9 | }, 10 | addAttributes() { 11 | const parentAttributes: Attributes = this.parent!() 12 | parentAttributes.language!.default = 'js' 13 | return { 14 | ...parentAttributes, 15 | filename: { 16 | default: null, 17 | }, 18 | } 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/app/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "auto-imports.d.ts", "components.d.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /src/app/src/components/tiptap/extension/TiptapExtensionVideo.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F4A1 Feature Request" 3 | about: Suggest an idea or enhancement for the module. 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /src/app/src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import type { Driver } from 'unstorage' 2 | import { createStorage } from 'unstorage' 3 | import indexedDbDriver from 'unstorage/drivers/indexedb' 4 | import nullDriver from 'unstorage/drivers/null' 5 | import type { DraftItem } from '../types/draft' 6 | import type { DatabaseItem, MediaItem } from '../types' 7 | 8 | export const nullStorageDriver: Driver = nullDriver() 9 | 10 | export const indexedDbStorageDriver = (name: string): Driver => indexedDbDriver({ 11 | dbName: `studio-${name}`, 12 | storeName: 'drafts', 13 | }) 14 | 15 | export const documentStorage = createStorage >({ driver: indexedDbStorageDriver('document') }) 16 | 17 | export const mediaStorage = createStorage>({ driver: indexedDbStorageDriver('media') }) 18 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/ensure.ts: -------------------------------------------------------------------------------- 1 | export function ensure(check: () => boolean, timeout: number = 50, maxTries: number = 20) { 2 | return new Promise((resolve, reject) => { 3 | _ensureWithCallback(check, (error) => { 4 | if (error) { 5 | reject(error) 6 | } 7 | else { 8 | resolve(true) 9 | } 10 | }, timeout, maxTries) 11 | }) 12 | } 13 | 14 | function _ensureWithCallback(check: () => boolean, callback: (error?: Error) => void, timeout: number = 50, maxTries: number = 20) { 15 | if (check()) { 16 | return callback(undefined) 17 | } 18 | setTimeout(() => { 19 | _ensureWithCallback(check, callback, timeout, maxTries - 1) 20 | }, timeout) 21 | 22 | if (maxTries === 0) { 23 | callback(new Error('Max tries reached')) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/src/utils/providers/null.ts: -------------------------------------------------------------------------------- 1 | import type { GitOptions, GitProviderAPI, GitFile, RawFile, CommitResult } from '../../types' 2 | 3 | /** 4 | * Null provider for development/local usage 5 | * Returns mock/empty implementations 6 | */ 7 | export function createNullProvider(_options: GitOptions): GitProviderAPI { 8 | return { 9 | fetchFile: (_path: string, _options: { cached?: boolean } = {}): Promise => Promise.resolve(null), 10 | commitFiles: (_files: RawFile[], _message: string): Promise => Promise.resolve(null), 11 | getRepositoryUrl: () => '', 12 | getBranchUrl: () => '', 13 | getCommitUrl: () => '', 14 | getFileUrl: (_feature, _fsPath) => '', 15 | getRepositoryInfo: () => ({ owner: '', repo: '', branch: '', provider: null }), 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/src/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /playground/docus/content/3.pages/authors.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Authors page 3 | description: Page file corresponding to a custom case collection with an empty prefix different from the include path. 4 | navigation: 5 | icon: i-lucide-test 6 | --- 7 | 8 | ## Authors page 9 | 10 | Page file corresponding to a custom case collection with an empty prefix different from the include path. 11 | 12 | ```[content.config.ts] 13 | pages: defineCollection({ 14 | type: 'page', 15 | source: { 16 | include: '3.pages/**/*.md', 17 | prefix: '/', 18 | }, 19 | }), 20 | ``` 21 | 22 | ## Authors list 23 | 24 | Fetch from data collection 25 | 26 | ```[content.config.ts] 27 | authors: defineCollection({ 28 | type: 'data', 29 | source: { 30 | include: 'authors/**/*', 31 | }, 32 | schema: createAuthorsSchema(), 33 | }), 34 | ``` 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues' 2 | 3 | on: 4 | schedule: 5 | - cron: '30 * * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write # only for delete-branch option 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | stale: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/stale@v10 18 | with: 19 | exempt-issue-labels: pending 20 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 30 days.' 21 | close-issue-message: 'This issue was closed because it has been stalled for 30 days with no activity.' 22 | days-before-stale: 60 23 | days-before-close: 30 24 | operations-per-run: 200 25 | days-before-pr-stale: -1 26 | -------------------------------------------------------------------------------- /src/module/src/runtime/plugins/studio.client.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin, useRuntimeConfig } from '#imports' 2 | import type { Repository, UseStudioHost } from 'nuxt-studio/app' 3 | import { defineStudioActivationPlugin } from '../utils/activation' 4 | 5 | export default defineNuxtPlugin(() => { 6 | // Don't await this to avoid blocking the main thread 7 | defineStudioActivationPlugin(async (user) => { 8 | const config = useRuntimeConfig() 9 | // Initialize host 10 | const host = await import(config.public.studio.dev ? '../host.dev' : '../host').then(m => m.useStudioHost); 11 | (window as unknown as { useStudioHost: UseStudioHost }).useStudioHost = () => host(user, config.public.studio.repository as unknown as Repository) 12 | 13 | await import('nuxt-studio/app') 14 | document.body.appendChild(document.createElement('nuxt-studio')) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | 58 | auto-imports.d.ts 59 | components.d.ts 60 | 61 | # VSC plugins 62 | .history 63 | .cursor 64 | -------------------------------------------------------------------------------- /playground/minimal/content.config.ts: -------------------------------------------------------------------------------- 1 | import type { DefinedCollection } from '@nuxt/content' 2 | import { defineContentConfig, defineCollection, z } from '@nuxt/content' 3 | 4 | const createDocsSchema = () => z.object({ 5 | layout: z.string().optional(), 6 | links: z.array(z.object({ 7 | label: z.string(), 8 | icon: z.string(), 9 | to: z.string(), 10 | target: z.string().optional(), 11 | })).optional(), 12 | }) 13 | 14 | const collections: Record = { 15 | // landing: defineCollection({ 16 | // type: 'page', 17 | // source: { 18 | // include: 'index.md', 19 | // }, 20 | // }), 21 | docs: defineCollection({ 22 | type: 'page', 23 | source: { 24 | include: '**', 25 | // exclude: ['index.md'], 26 | }, 27 | schema: createDocsSchema(), 28 | }), 29 | } 30 | 31 | export default defineContentConfig({ collections }) 32 | -------------------------------------------------------------------------------- /src/app/src/types/file.ts: -------------------------------------------------------------------------------- 1 | export enum ContentFileExtension { 2 | Markdown = 'md', 3 | YAML = 'yaml', 4 | YML = 'yml', 5 | JSON = 'json', 6 | } 7 | 8 | export type MediaFileExtension = ImageFileExtension | AudioFileExtension | VideoFileExtension 9 | 10 | export enum ImageFileExtension { 11 | PNG = 'png', 12 | JPG = 'jpg', 13 | JPEG = 'jpeg', 14 | SVG = 'svg', 15 | WEBP = 'webp', 16 | AVIF = 'avif', 17 | ICO = 'ico', 18 | GIF = 'gif', 19 | } 20 | 21 | export enum AudioFileExtension { 22 | MP3 = 'mp3', 23 | WAV = 'wav', 24 | OGG = 'ogg', 25 | M4A = 'm4a', 26 | AAC = 'aac', 27 | FLAC = 'flac', 28 | } 29 | 30 | export enum VideoFileExtension { 31 | MP4 = 'mp4', 32 | MOV = 'mov', 33 | AVI = 'avi', 34 | MKV = 'mkv', 35 | WEBM = 'webm', 36 | } 37 | 38 | export interface ExtensionConfig { 39 | allowed: string[] 40 | default?: string 41 | editable: boolean 42 | } 43 | -------------------------------------------------------------------------------- /src/module/build.config.ts: -------------------------------------------------------------------------------- 1 | import { defineBuildConfig } from 'unbuild' 2 | 3 | export default defineBuildConfig({ 4 | outDir: '../../dist/module', 5 | externals: [ 6 | 'ufo', 7 | 'defu', 8 | 'destr', 9 | 'unstorage', 10 | 'unstorage/drivers/fs', 11 | 'chokidar', 12 | 'anymatch', 13 | 'readdirp', 14 | 'picomatch', 15 | 'normalize-path', 16 | ], 17 | entries: [ 18 | './src/module', 19 | { 20 | input: './src/runtime/', 21 | outDir: `../../dist/module/runtime`, 22 | addRelativeDeclarationExtensions: true, 23 | ext: 'js', 24 | pattern: [ 25 | '**', 26 | '!**/*.stories.{js,cts,mts,ts,jsx,tsx}', // ignore storybook files 27 | '!**/*.{spec,test}.{js,cts,mts,ts,jsx,tsx}', // ignore tests 28 | ], 29 | esbuild: { 30 | jsxImportSource: 'vue', 31 | jsx: 'automatic', 32 | jsxFactory: 'h', 33 | }, 34 | }, 35 | ], 36 | }) 37 | -------------------------------------------------------------------------------- /src/app/src/components/content/ContentEditorDiff.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 39 | -------------------------------------------------------------------------------- /src/app/src/types/draft.ts: -------------------------------------------------------------------------------- 1 | import type { GitFile } from './git' 2 | import type { DatabaseItem } from './database' 3 | import type { MediaItem } from './media' 4 | 5 | export enum DraftStatus { 6 | Deleted = 'deleted', 7 | Created = 'created', 8 | Updated = 'updated', 9 | Pristine = 'pristine', 10 | } 11 | 12 | export interface ContentConflict { 13 | remoteContent: string 14 | localContent: string 15 | } 16 | 17 | export interface DraftItem { 18 | fsPath: string // file path in content directory 19 | status: DraftStatus // status 20 | 21 | remoteFile?: GitFile 22 | original?: T 23 | modified?: T 24 | /** 25 | * - Buffer media content 26 | */ 27 | raw?: string | Buffer 28 | /** 29 | * Version of the draft 30 | * Incremented when the draft is updated 31 | * Used to detect changes when the draft is saved 32 | */ 33 | version?: number 34 | /** 35 | * Content conflict detection 36 | */ 37 | conflict?: ContentConflict 38 | } 39 | -------------------------------------------------------------------------------- /playground/minimal/app/app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | 48 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/document/index.ts: -------------------------------------------------------------------------------- 1 | // Schema 2 | export { 3 | applyCollectionSchema, 4 | pickReservedKeysFromDocument, 5 | removeReservedKeysFromDocument, 6 | reservedKeys, 7 | } from './schema' 8 | 9 | // Compare 10 | export { 11 | isDocumentMatchingContent, 12 | areDocumentsEqual, 13 | } from './compare' 14 | 15 | // Generate 16 | export { 17 | generateDocumentFromContent, 18 | generateDocumentFromMarkdownContent, 19 | generateDocumentFromYAMLContent, 20 | generateDocumentFromJSONContent, 21 | generateContentFromDocument, 22 | generateContentFromMarkdownDocument, 23 | generateContentFromYAMLDocument, 24 | generateContentFromJSONDocument, 25 | } from './generate' 26 | 27 | // Utils 28 | export { 29 | addPageTypeFields, 30 | parseDocumentId, 31 | generatePathFromStem, 32 | generateStemFromId, 33 | generateTitleFromPath, 34 | getFileExtension, 35 | } from './utils' 36 | 37 | // Tree (AST manipulation) 38 | export { 39 | sanitizeDocumentTree, 40 | removeLastStylesFromTree, 41 | } from './tree' 42 | -------------------------------------------------------------------------------- /src/app/src/utils/data.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty, replaceNullWithEmptyString } from './object' 2 | import yaml from 'js-yaml' 3 | 4 | export const jsonToYaml = (data: Record): string => { 5 | try { 6 | if (isEmpty(data)) { 7 | return '' 8 | } 9 | 10 | return yaml.dump(data, { 11 | noCompatMode: true, 12 | lineWidth: -1, 13 | }) 14 | } 15 | catch { 16 | return '' 17 | } 18 | } 19 | 20 | export const yamlToJson = (data: string) => { 21 | const customSchema = yaml.DEFAULT_SCHEMA.extend({ 22 | implicit: [ 23 | new yaml.Type('tag:yaml.org,2002:timestamp', { 24 | kind: 'scalar', 25 | resolve: () => false, 26 | construct: data => data, 27 | }), 28 | ], 29 | }) 30 | 31 | try { 32 | const json = yaml.load(data, { schema: customSchema }) as Record 33 | // Check if json is an object 34 | return json && typeof json === 'object' ? replaceNullWithEmptyString(json) : null 35 | } 36 | catch { 37 | return null 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/src/utils/styles.ts: -------------------------------------------------------------------------------- 1 | export function refineTailwindStyles(styles: string) { 2 | styles = convertPropertyToVar(styles) 3 | 4 | return styles 5 | } 6 | 7 | export function convertPropertyToVar(cssText: string) { 8 | const propertyRegex = /@property\s+(--[\w-]+)\s*\{([^}]*)\}/g 9 | const cssVars: string[] = [] 10 | 11 | const result = cssText.replace(propertyRegex, (_, propertyName, propertyContent) => { 12 | const initialValueMatch = propertyContent.match(/initial-value\s*:([^;]+)(;|$)/) 13 | 14 | if (initialValueMatch) { 15 | let initialValue = initialValueMatch[1].trim() 16 | 17 | if (propertyContent.includes('') && !initialValue.endsWith('px')) { 18 | initialValue = `${initialValue}px` 19 | } 20 | 21 | cssVars.push(`${propertyName}: ${initialValue};`) 22 | } 23 | 24 | return '' 25 | }) 26 | 27 | const cssVarsBlock = cssVars.length > 0 ? `:host {\n ${cssVars.join('\n ')}\n}` : '' 28 | 29 | return cssVarsBlock + (cssVarsBlock && result.trim() ? '\n\n' : '') + result.trim() 30 | } 31 | -------------------------------------------------------------------------------- /src/app/src/components/shared/CopyButton.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 43 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 Vercel, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /src/app/src/assets/css/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "@nuxt/ui"; 3 | @plugin "@tailwindcss/typography"; 4 | 5 | @source "../../**"; 6 | 7 | @source inline('ring-orange-200 hover:ring-orange-300 hover:dark:ring-orange-700'); 8 | @source inline('ring-blue-200 hover:ring-blue-300 hover:dark:ring-blue-700'); 9 | @source inline('ring-gray-200 hover:ring-gray-300 hover:dark:ring-gray-700'); 10 | @source inline('ring-red-200 hover:ring-red-300 hover:dark:ring-red-700'); 11 | @source inline('ring-green-200 hover:ring-green-300 hover:dark:ring-green-700'); 12 | 13 | :root, .light { 14 | --ui-primary: black; 15 | } 16 | 17 | .dark { 18 | --ui-primary: white; 19 | } 20 | 21 | @theme static { 22 | --color-green-50: '#EFFDF5'; 23 | --color-green-100: '#D9FBE8'; 24 | --color-green-200: '#B3F5D1'; 25 | --color-green-300: '#75EDAE'; 26 | --color-green-400: '#00DC82'; 27 | --color-green-500: '#00C16A'; 28 | --color-green-600: '#00A155'; 29 | --color-green-700: '#007F45'; 30 | --color-green-800: '#016538'; 31 | --color-green-900: '#0A5331'; 32 | --color-green-950: '#052e16'; 33 | } 34 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/image-picker.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import TiptapExtensionImagePicker from '../../../components/tiptap/extension/TiptapExtensionImagePicker.vue' 4 | 5 | declare module '@tiptap/vue-3' { 6 | interface Commands { 7 | imagePicker: { 8 | insertImagePicker: () => ReturnType 9 | } 10 | } 11 | } 12 | 13 | export const ImagePicker = Node.create({ 14 | name: 'image-picker', 15 | group: 'block', 16 | atom: true, 17 | addAttributes() { 18 | return {} 19 | }, 20 | parseHTML() { 21 | return [{ 22 | tag: 'div[data-type="image-picker"]', 23 | }] 24 | }, 25 | renderHTML({ HTMLAttributes }) { 26 | return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'image-picker' })] 27 | }, 28 | addNodeView() { 29 | return VueNodeViewRenderer(TiptapExtensionImagePicker) 30 | }, 31 | addCommands() { 32 | return { 33 | insertImagePicker: () => ({ commands }) => { 34 | return commands.insertContent({ type: this.name }) 35 | }, 36 | } 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/video-picker.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import TiptapExtensionVideoPicker from '../../../components/tiptap/extension/TiptapExtensionVideoPicker.vue' 4 | 5 | declare module '@tiptap/vue-3' { 6 | interface Commands { 7 | videoPicker: { 8 | insertVideoPicker: () => ReturnType 9 | } 10 | } 11 | } 12 | 13 | export const VideoPicker = Node.create({ 14 | name: 'video-picker', 15 | group: 'block', 16 | atom: true, 17 | addAttributes() { 18 | return {} 19 | }, 20 | parseHTML() { 21 | return [{ 22 | tag: 'div[data-type="video-picker"]', 23 | }] 24 | }, 25 | renderHTML({ HTMLAttributes }) { 26 | return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'video-picker' })] 27 | }, 28 | addNodeView() { 29 | return VueNodeViewRenderer(TiptapExtensionVideoPicker) 30 | }, 31 | addCommands() { 32 | return { 33 | insertVideoPicker: () => ({ commands }) => { 34 | return commands.insertContent({ type: this.name }) 35 | }, 36 | } 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /src/app/test/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { joinURL } from 'ufo' 2 | 3 | /** 4 | * Normalize a storage key using the same logic as unstorage 5 | */ 6 | export function normalizeKey(key: string): string { 7 | if (!key) { 8 | return '' 9 | } 10 | 11 | return key 12 | .split('?')[0] // Remove query parameters if any 13 | ?.replace(/[/\\]/g, ':') // Replace forward/back slashes with colons 14 | .replace(/:+/g, ':') // Replace multiple consecutive colons with single colon 15 | .replace(/^:|:$/g, '') // Remove leading/trailing colons 16 | || '' 17 | } 18 | 19 | export function generateUniqueDocumentFsPath(filename = 'document', subdirectory = ''): string { 20 | const uniqueId = Math.random().toString(36).substr(2, 9) 21 | const file = `${filename}-${uniqueId}.md` 22 | return subdirectory ? joinURL(subdirectory, file) : file 23 | } 24 | 25 | export function generateUniqueMediaFsPath(filename = 'media', extension = 'png', subdirectory = ''): string { 26 | const uniqueId = Math.random().toString(36).substr(2, 9) 27 | const file = `${filename}-${uniqueId}.${extension}` 28 | return subdirectory ? joinURL(subdirectory, file) : file 29 | } 30 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/frontmatter.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import TiptapExtensionFrontmatter from '../../../components/tiptap/extension/TiptapExtensionFrontmatter.vue' 4 | 5 | export interface FrontmatterOptions { 6 | HTMLAttributes: Record 7 | } 8 | 9 | export const Frontmatter = Node.create({ 10 | name: 'frontmatter', 11 | priority: 1000, 12 | group: 'block', 13 | selectable: false, 14 | inline: false, 15 | 16 | addOptions() { 17 | return { 18 | HTMLAttributes: {}, 19 | } 20 | }, 21 | 22 | addAttributes() { 23 | return { 24 | frontmatter: { 25 | default: '', 26 | }, 27 | } 28 | }, 29 | 30 | parseHTML() { 31 | return [{ tag: 'div[data-type="Frontmatter"]' }] 32 | }, 33 | 34 | renderHTML({ HTMLAttributes }) { 35 | return [ 36 | 'div', 37 | mergeAttributes(HTMLAttributes, { 'data-type': 'Frontmatter' }), 38 | 0, 39 | ] 40 | }, 41 | 42 | addNodeView() { 43 | return VueNodeViewRenderer(TiptapExtensionFrontmatter) 44 | }, 45 | }) 46 | -------------------------------------------------------------------------------- /.github/workflows/module.yml: -------------------------------------------------------------------------------- 1 | name: module 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - feat/* 8 | - fix/* 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | build: 15 | runs-on: ${{ matrix.os }} 16 | 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest] 20 | node: [22] 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v5 25 | 26 | - name: Install pnpm 27 | uses: pnpm/action-setup@v4 28 | 29 | - name: Install node 30 | uses: actions/setup-node@v5 31 | with: 32 | node-version: ${{ matrix.node }} 33 | cache: pnpm 34 | 35 | - name: Install dependencies 36 | run: pnpm install 37 | 38 | - name: Prepare 39 | run: pnpm run dev:prepare 40 | 41 | - name: Lint 42 | run: pnpm run lint 43 | 44 | - name: Prepack 45 | run: pnpm run prepack 46 | 47 | - name: Typecheck 48 | run: pnpm run typecheck 49 | 50 | - name: Test 51 | run: pnpm run test 52 | 53 | - name: Publish 54 | run: pnpx pkg-pr-new publish --no-template --pnpm -------------------------------------------------------------------------------- /src/app/src/components/shared/MDCFormattingBanner.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 45 | -------------------------------------------------------------------------------- /src/module/src/types/content.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionInfo, CollectionQueryBuilder, Collections, PageCollections, ContentNavigationItem, SurroundOptions, DatabaseAdapter } from '@nuxt/content' 2 | 3 | type ChainablePromise = { then: (fn: (value: R) => void) => ChainablePromise } 4 | 5 | export interface ContentProvide { 6 | queryCollection: (collection: string) => CollectionQueryBuilder 7 | queryCollectionNavigation: (collection: string, fields?: Array) => ChainablePromise 8 | queryCollectionItemSurroundings: (collection: T, path: string, opts?: SurroundOptions) => ChainablePromise 9 | queryCollectionSearchSections: (collection: keyof Collections, opts?: { ignoredTags: string[] }) => Promise> 10 | collections: Record 11 | } 12 | export type ContentDatabaseAdapter = (collection: string) => DatabaseAdapter 13 | 14 | export { ContentFileExtension } from '../runtime/types/content' 15 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/sidebar.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_SIDEBAR_WIDTH = 440 2 | const SIDEBAR_WIDTH_STORAGE_KEY = 'studio-sidebar-width' 3 | 4 | export function getSidebarWidth(): number { 5 | if (typeof window !== 'undefined' && window.localStorage) { 6 | const savedWidth = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY) 7 | if (savedWidth) { 8 | const width = Number.parseInt(savedWidth, 10) 9 | if (!Number.isNaN(width)) { 10 | return width 11 | } 12 | } 13 | } 14 | 15 | return DEFAULT_SIDEBAR_WIDTH 16 | } 17 | 18 | export function adjustFixedElements(sidebarWidth: number) { 19 | document.querySelectorAll('*').forEach((el) => { 20 | const htmlEl = el as HTMLElement 21 | if (window.getComputedStyle(htmlEl).position === 'fixed') { 22 | htmlEl.style.left = sidebarWidth > 0 ? `${sidebarWidth}px` : '' 23 | } 24 | }) 25 | } 26 | 27 | export function getHostStyles(): Record> { 28 | const currentWidth = getSidebarWidth() 29 | return { 30 | 'body[data-studio-active]': { 31 | transition: 'margin 0.2s ease', 32 | }, 33 | 'body[data-studio-active][data-expand-sidebar]': { 34 | marginLeft: `${currentWidth}px`, 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] # macos-latest, windows-latest 15 | node: [22] 16 | 17 | env: 18 | NUXT_GITHUB_TOKEN: ${{ secrets.NUXT_GITHUB_TOKEN }} 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v5 23 | 24 | - name: Install pnpm 25 | uses: pnpm/action-setup@v4 26 | 27 | - name: Install node 28 | uses: actions/setup-node@v5 29 | with: 30 | node-version: ${{ matrix.node }} 31 | cache: pnpm 32 | 33 | - name: Install dependencies 34 | run: pnpm install 35 | 36 | - name: Prepare 37 | run: pnpm run dev:prepare 38 | 39 | - name: Lint 40 | run: pnpm run lint 41 | 42 | - name: Prepack 43 | run: pnpm run prepack 44 | 45 | - name: Typecheck 46 | run: pnpm run typecheck 47 | 48 | - name: Test 49 | run: pnpm run test 50 | 51 | # - name: Publish 52 | # run: ./scripts/release.sh 53 | # env: 54 | # NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} 55 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/7.essentials/2.images-embeds.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Images and Embeds 3 | description: Add image, video, and other HTML elements 4 | navigation: 5 | icon: i-lucide-image 6 | seo: 7 | description: Add image, video, and other HTML elements using Docus theme 8 | --- 9 | 10 | ## Markdown 11 | 12 | Display images or videos using standard Markdown syntax. 13 | 14 | ### Images 15 | 16 | ::code-preview 17 | ![Nuxt Social Image](https://nuxt.com/new-social.jpg) 18 | 19 | #code 20 | ```mdc 21 | ![Nuxt Social Image](https://nuxt.com/new-social.jpg) 22 | ``` 23 | :: 24 | 25 | Or with your local images 26 | 27 | ::code-preview 28 | ![Snow-capped mountains in a sea of clouds at sunset](/mountains.webp) 29 | 30 | #code 31 | ```mdc 32 | ![Snow-capped mountains in a sea of clouds at sunset](/mountains.webp) 33 | ``` 34 | :: 35 | 36 | ::note{to="https://image.nuxt.com/"} 37 | Docus will use `` component under the hood instead of the native `img` tag. 38 | :: 39 | 40 | ### Videos 41 | 42 | ::prose-code-preview 43 | :video{autoplay controls loop src="https://res.cloudinary.com/dcrl8q2g3/video/upload/v1745404403/landing_od8epr.mp4"} 44 | 45 | 46 | 47 | #code 48 | ```mdc 49 | :video{autoplay controls loop src="https://res.cloudinary.com/dcrl8q2g3/video/upload/v1745404403/landing_od8epr.mp4"} 50 | ``` 51 | :: 52 | 53 | ### 54 | -------------------------------------------------------------------------------- /src/app/test/mocks/tree.ts: -------------------------------------------------------------------------------- 1 | import type { TreeItem } from '../../src/types/tree' 2 | 3 | export const tree: TreeItem[] = [ 4 | { 5 | name: 'home', 6 | fsPath: 'index.md', 7 | type: 'file', 8 | routePath: '/', 9 | prefix: null, 10 | }, 11 | { 12 | name: 'getting-started', 13 | fsPath: '1.getting-started', 14 | type: 'directory', 15 | prefix: '1', 16 | children: [ 17 | { 18 | name: 'introduction', 19 | fsPath: '1.getting-started/2.introduction.md', 20 | type: 'file', 21 | routePath: '/getting-started/introduction', 22 | prefix: '2', 23 | }, 24 | { 25 | name: 'installation', 26 | fsPath: '1.getting-started/3.installation.md', 27 | type: 'file', 28 | routePath: '/getting-started/installation', 29 | prefix: '3', 30 | }, 31 | { 32 | name: 'advanced', 33 | fsPath: '1.getting-started/1.advanced', 34 | type: 'directory', 35 | prefix: '1', 36 | children: [ 37 | { 38 | name: 'studio', 39 | fsPath: '1.getting-started/1.advanced/1.studio.md', 40 | type: 'file', 41 | routePath: '/getting-started/installation/advanced/studio', 42 | prefix: '1', 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | ] 49 | -------------------------------------------------------------------------------- /src/module/src/templates.ts: -------------------------------------------------------------------------------- 1 | import type { Storage } from 'unstorage' 2 | import type { Nuxt } from '@nuxt/schema' 3 | import { withLeadingSlash } from 'ufo' 4 | 5 | export async function getAssetsStorageDevTemplate(_assetsStorage: Storage, _nuxt: Nuxt) { 6 | return [ 7 | 'import { createStorage } from \'unstorage\'', 8 | 'import httpDriver from \'unstorage/drivers/http\'', 9 | '', 10 | `const storage = createStorage({ driver: httpDriver({ base: '/__nuxt_studio/dev/public' }) })`, 11 | 'export const publicAssetsStorage = storage', 12 | ].join('\n') 13 | } 14 | 15 | export async function getAssetsStorageTemplate(assetsStorage: Storage, _nuxt: Nuxt) { 16 | const keys = await assetsStorage.getKeys() 17 | 18 | return [ 19 | 'import { createStorage } from \'unstorage\'', 20 | 'const storage = createStorage({})', 21 | '', 22 | ...keys.map((key) => { 23 | const path = withLeadingSlash(key.replace(/:/g, '/')) 24 | const value = { 25 | id: `public-assets/${key.replace(/:/g, '/')}`, 26 | extension: key.split('.').pop(), 27 | stem: key.split('.').join('.'), 28 | path, 29 | fsPath: path, 30 | } 31 | return `storage.setItem('${value.id}', ${JSON.stringify(value)})` 32 | }), 33 | '', 34 | 'export const publicAssetsStorage = storage', 35 | ].join('\n') 36 | } 37 | -------------------------------------------------------------------------------- /src/app/src/composables/useUI.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable } from '@vueuse/core' 2 | import { getCurrentInstance, ref, watch } from 'vue' 3 | import type { StudioHost } from '../types' 4 | import { useSidebar } from './useSidebar' 5 | 6 | export const useUI = createSharedComposable((host: StudioHost) => { 7 | const sidebar = useSidebar() 8 | const isOpen = ref(false) 9 | const colorMode = ref(host.ui.colorMode) 10 | 11 | host.on.colorModeChange((newColorMode) => { 12 | colorMode.value = newColorMode 13 | }) 14 | 15 | watch(isOpen, (value) => { 16 | if (value) { 17 | host.ui.expandSidebar() 18 | } 19 | else { 20 | host.ui.collapseSidebar() 21 | } 22 | }) 23 | 24 | function setLocale(locale: string) { 25 | const currentVueInstance = getCurrentInstance() 26 | if (currentVueInstance) { 27 | import(`../locales/${locale}.json`).then((locales) => { 28 | const i18n = currentVueInstance.appContext.provides.i18n.global 29 | i18n.locale.value = locale 30 | i18n.setLocaleMessage(locale, locales.default) 31 | }) 32 | } 33 | } 34 | 35 | return { 36 | colorMode, 37 | sidebar, 38 | isOpen, 39 | open() { 40 | isOpen.value = true 41 | }, 42 | toggle: () => isOpen.value = !isOpen.value, 43 | close: () => isOpen.value = false, 44 | setLocale, 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /src/app/src/components/form/FormPanelSection.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 52 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/object.ts: -------------------------------------------------------------------------------- 1 | export const omit = (obj: Record, keys: string | string[]) => { 2 | return Object.fromEntries(Object.entries(obj) 3 | .filter(([key]) => !keys.includes(key))) 4 | } 5 | 6 | export const pick = (obj: Record, keys: string | string[]) => { 7 | return Object.fromEntries(Object.entries(obj) 8 | .filter(([key]) => keys.includes(key))) 9 | } 10 | 11 | export function doObjectsMatch(base: Record, target: Record) { 12 | if (typeof base !== 'object' || typeof target !== 'object') { 13 | const _base = (base as unknown as string) === '' ? undefined : base 14 | const _target = (target as unknown as string) === '' ? undefined : target 15 | 16 | return _base === _target 17 | } 18 | if (Array.isArray(base) && Array.isArray(target)) { 19 | if (base.length !== target.length) { 20 | return false 21 | } 22 | for (let index = 0; index < base.length; index++) { 23 | const item = base[index] 24 | const targetItem = target[index] 25 | if (!doObjectsMatch(item, targetItem)) { 26 | return false 27 | } 28 | } 29 | return true 30 | } 31 | 32 | for (const key in base) { 33 | if (!doObjectsMatch(base[key] as Record, target[key] as Record)) { 34 | return false 35 | } 36 | } 37 | return true 38 | } 39 | -------------------------------------------------------------------------------- /src/app/src/components/media/MediaEditor.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 50 | -------------------------------------------------------------------------------- /src/app/src/components/tiptap/extension/TiptapExtensionImagePicker.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 58 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/input-rules.ts: -------------------------------------------------------------------------------- 1 | import { InputRule, callOrReturn } from '@tiptap/core' 2 | import type { ExtendedRegExpMatchArray, nodeInputRule } from '@tiptap/core' 3 | 4 | type Config = Parameters[0] & { getText?: (match: ExtendedRegExpMatchArray) => string } 5 | 6 | export function textInputRule(config: Config) { 7 | return new InputRule({ 8 | find: config.find, 9 | handler: ({ state, range, match }) => { 10 | if (!match[1]) { 11 | return 12 | } 13 | 14 | const attributes = callOrReturn(config.getAttributes, undefined, match) || {} 15 | const text = callOrReturn(config.getText, undefined, match) || '' 16 | const { tr } = state 17 | const start = range.from 18 | let end = range.to 19 | 20 | const newNode = config.type.create(attributes) 21 | 22 | const offset = match[0].lastIndexOf(match[1]) 23 | let matchStart = start + offset 24 | 25 | if (matchStart > end) { 26 | matchStart = end 27 | } 28 | else { 29 | end = matchStart + match[1].length 30 | } 31 | 32 | // insert last typed character 33 | const lastChar = match[0][match[0].length - 1] 34 | 35 | tr.insertText(lastChar, start + match[0].length - 1) 36 | 37 | // insert node from input rule 38 | tr.replaceWith(matchStart, end, newNode) 39 | tr.insertText(text, matchStart + 1) 40 | 41 | tr.scrollIntoView() 42 | }, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/module/src/runtime/plugins/studio.client.dev.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin, useRuntimeConfig } from '#imports' 2 | import { defineStudioActivationPlugin } from '../utils/activation' 3 | import type { Repository, UseStudioHost } from 'nuxt-studio/app' 4 | 5 | export default defineNuxtPlugin(() => { 6 | defineStudioActivationPlugin(async (user) => { 7 | const config = useRuntimeConfig() 8 | console.log(` 9 | ███████╗████████╗██╗ ██╗██████╗ ██╗ ██████╗ ██████╗ ███████╗██╗ ██╗ 10 | ██╔════╝╚══██╔══╝██║ ██║██╔══██╗██║██╔═══██╗ ██╔══██╗██╔════╝██║ ██║ 11 | ███████╗ ██║ ██║ ██║██║ ██║██║██║ ██║ ██║ ██║█████╗ ██║ ██║ 12 | ╚════██║ ██║ ██║ ██║██║ ██║██║██║ ██║ ██║ ██║██╔══╝ ╚██╗ ██╔╝ 13 | ███████║ ██║ ╚██████╔╝██████╔╝██║╚██████╔╝ ██████╔╝███████╗ ╚████╔╝ 14 | ╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═══╝ 15 | `) 16 | 17 | // Initialize host 18 | const host = await import('../host.dev').then(m => m.useStudioHost); 19 | (window as unknown as { useStudioHost: UseStudioHost }).useStudioHost = () => host(user, config.public.studio.repository as unknown as Repository) 20 | 21 | const el = document.createElement('script') 22 | el.src = `${config.public.studio?.development?.server}/src/main.ts` 23 | el.type = 'module' 24 | document.body.appendChild(el) 25 | 26 | const wp = document.createElement('nuxt-studio') 27 | document.body.appendChild(wp) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /playground/docus/content.config.ts: -------------------------------------------------------------------------------- 1 | import type { DefinedCollection } from '@nuxt/content' 2 | import { defineContentConfig, defineCollection, z } from '@nuxt/content' 3 | 4 | const createDocsSchema = () => z.object({ 5 | layout: z.string().optional(), 6 | links: z.array(z.object({ 7 | label: z.string(), 8 | icon: z.string(), 9 | to: z.string(), 10 | target: z.string().optional(), 11 | })).optional(), 12 | }) 13 | 14 | const createAuthorsSchema = () => z.object({ 15 | name: z.string(), 16 | avatar: z.object({ 17 | src: z.string(), 18 | alt: z.string(), 19 | }), 20 | to: z.string(), 21 | username: z.string(), 22 | modules: z.array(z.string()), 23 | }) 24 | 25 | const collections: Record = { 26 | pages: defineCollection({ 27 | type: 'page', 28 | source: { 29 | include: '3.pages/**/*.md', 30 | prefix: '/', 31 | }, 32 | }), 33 | landing: defineCollection({ 34 | type: 'page', 35 | source: { 36 | include: 'index.md', 37 | }, 38 | }), 39 | docs: defineCollection({ 40 | type: 'page', 41 | source: { 42 | include: '**', 43 | exclude: ['index.md', '3.pages/**/*.md', 'authors/**/*'], 44 | }, 45 | schema: createDocsSchema(), 46 | }), 47 | authors: defineCollection({ 48 | type: 'data', 49 | source: { 50 | include: 'authors/**/*', 51 | }, 52 | schema: createAuthorsSchema(), 53 | }), 54 | } 55 | 56 | export default defineContentConfig({ collections }) 57 | -------------------------------------------------------------------------------- /src/app/src/components/shared/Collapsible.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 46 | -------------------------------------------------------------------------------- /src/app/src/components/content/ContentCardForm.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 53 | -------------------------------------------------------------------------------- /src/app/src/components/tiptap/extension/TiptapExtensionVideoPicker.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 60 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/routes/meta.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentMeta } from 'vue-component-meta' 2 | import { eventHandler, useSession } from 'h3' 3 | import { useRuntimeConfig, createError } from '#imports' 4 | // @ts-expect-error import does exist 5 | import components from '#nuxt-component-meta/nitro' 6 | // @ts-expect-error import does exist 7 | import { highlight } from '#mdc-imports' 8 | 9 | interface NuxtComponentMeta { 10 | pascalName: string 11 | filePath: string 12 | meta: ComponentMeta 13 | global: boolean 14 | } 15 | 16 | export default eventHandler(async (event) => { 17 | if (!import.meta.dev) { 18 | const session = await useSession(event, { 19 | name: 'studio-session', 20 | password: useRuntimeConfig(event).studio?.auth?.sessionSecret, 21 | }) 22 | 23 | if (!session?.data?.user) { 24 | throw createError({ 25 | statusCode: 404, 26 | message: 'Not found', 27 | }) 28 | } 29 | } 30 | 31 | const mappedComponents = (Object.values(components) as NuxtComponentMeta[]) 32 | .map(({ pascalName, filePath, meta }) => { 33 | return { 34 | name: pascalName, 35 | path: filePath, 36 | meta: { 37 | props: meta.props, 38 | slots: meta.slots, 39 | events: meta.events, 40 | }, 41 | } 42 | }) 43 | 44 | return { 45 | highlightTheme: highlight?.theme || { default: 'github-light', dark: 'github-dark', light: 'github-light' }, 46 | components: mappedComponents, 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /src/app/src/composables/useGitProvider.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable } from '@vueuse/core' 2 | import type { GitOptions, GitProviderAPI, GitProviderType } from '../types' 3 | import { createGitHubProvider, createGitLabProvider, createNullProvider } from '../utils/providers' 4 | 5 | function getProviderIcon(provider: GitProviderType | null): string { 6 | switch (provider) { 7 | case 'github': 8 | return 'i-simple-icons:github' 9 | case 'gitlab': 10 | return 'i-simple-icons:gitlab' 11 | default: 12 | return 'i-simple-icons:git' 13 | } 14 | } 15 | 16 | function getProviderName(provider: GitProviderType | null): string { 17 | switch (provider) { 18 | case 'github': 19 | return 'GitHub' 20 | case 'gitlab': 21 | return 'GitLab' 22 | default: 23 | return 'Local' 24 | } 25 | } 26 | 27 | function createProvider(provider: GitProviderType | null, options: GitOptions): GitProviderAPI { 28 | switch (provider) { 29 | case 'gitlab': 30 | return createGitLabProvider(options) 31 | case 'github': 32 | return createGitHubProvider(options) 33 | default: 34 | return createNullProvider(options) 35 | } 36 | } 37 | 38 | export const useGitProvider = createSharedComposable((options: GitOptions, devMode: boolean = false) => { 39 | const provider = devMode ? null : options.provider 40 | 41 | return { 42 | name: getProviderName(provider), 43 | icon: getProviderIcon(provider), 44 | api: createProvider(provider, options), 45 | } 46 | }) 47 | -------------------------------------------------------------------------------- /playground/docus/app/pages/authors.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 47 | -------------------------------------------------------------------------------- /src/app/src/composables/useStudioState.ts: -------------------------------------------------------------------------------- 1 | import { readonly, ref } from 'vue' 2 | import { useStorage, createSharedComposable } from '@vueuse/core' 3 | import type { StudioConfig, StudioLocation } from '../types' 4 | import { StudioFeature } from '../types/context' 5 | 6 | export const useStudioState = createSharedComposable(() => { 7 | const devMode = ref(false) 8 | const manifestId = ref('') 9 | const preferences = useStorage('studio-preferences', { syncEditorAndRoute: true, showTechnicalMode: false, editorMode: 'tiptap', debug: false }) 10 | const location = useStorage('studio-active', { active: false, feature: StudioFeature.Content, fsPath: '/' }) 11 | 12 | function setLocation(feature: StudioFeature, fsPath: string) { 13 | location.value = { active: true, feature, fsPath } 14 | } 15 | 16 | function unsetActiveLocation() { 17 | location.value.active = false 18 | } 19 | 20 | function setManifestId(id: string) { 21 | manifestId.value = id 22 | } 23 | 24 | function enableDevMode() { 25 | devMode.value = true 26 | } 27 | 28 | function updatePreference(key: K, value: StudioConfig[K]) { 29 | preferences.value = { ...preferences.value, [key]: value } 30 | } 31 | 32 | return { 33 | devMode: readonly(devMode), 34 | manifestId: readonly(manifestId), 35 | preferences: readonly(preferences), 36 | location: readonly(location), 37 | enableDevMode, 38 | setLocation, 39 | unsetActiveLocation, 40 | setManifestId, 41 | updatePreference, 42 | } 43 | }) 44 | -------------------------------------------------------------------------------- /playground/docus/content/9.studio/6.medias.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Manage and integrate Medias in Nuxt Content Studio CMS 3 | description: Explore how to browse and manage media files, and integrate them into your projects using Nuxt Content Studio CMS features. 4 | navigation: 5 | title: Media Library 6 | --- 7 | 8 | ## Browse your medias 9 | 10 | All medias located in the `/public` directory are available in the **Media** tab of the Studio interface. 11 | 12 | It's an intuitive interface for non technical users to manage their `/public` directory. 13 | 14 | Users can easily browse folders, upload new media at any level, and drag and drop media, making medias organization straightforward. 15 | 16 | The interface is designed to be intuitive for non-technical users. It can be viewed as a user friendly IDE. 17 | 18 | ## Use it in the Notion-like editor 19 | 20 | The TipTap visual editor provides seamless media integration: 21 | 22 | - **Drag and drop** - Simply drag and drop images directly into the editor. An upload modal will open to let you choose the destination folder :badge[Coming Soon] 23 | - **Slash commands** - Type `/` and search for `Image` to quickly insert a media. A modal will open to let you choose the media from your library 24 | - **Alt text support** - From the media modal, you can set the [alt attribute](https://www.w3schools.com/tags/att_img_alt.asp) for SEO and accessibility :badge[Coming Soon] 25 | 26 | ## Supported File Types 27 | 28 | The media library supports a wide range of file types: 29 | 30 | - **Images**: JPEG, PNG, GIF, WebP, AVIF, SVG 31 | - **Videos**: MP4, WebM 32 | 33 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/6.migration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Migration 3 | description: How to migrate your documentation from an existing Markdown solution to Docus 4 | navigation: 5 | icon: i-lucide-replace 6 | --- 7 | 8 | ## **Migrating to Docus** 9 | 10 | Already using a Markdown-based solution for your documentation? Whether it’s **Docus v1**, the **Nuxt UI Pro docs template**, or another static site setup, migrating to Docus is simple and straightforward. 11 | 12 | Docus offers a clean and maintainable solution with a single dependency: the Docus library itself. There’s no need to manage multiple .dependencies With everything built-in and maintained together, keeping your documentation up to date is easier than ever. 13 | 14 | To migrate, just move your existing Markdown files into the `content/` directory of the Docus starter. 15 | 16 | From there, you have two scenarios: 17 | 18 | - **If your current docs already use Nuxt Content and the MDC syntax**, make sure the components used in your content exist in Nuxt UI. If any components are missing, you can easily create your own custom ones. 19 | - **If you’re using standard Markdown**, you can copy your files as is. Then, enhance your documentation progressively using the [built-in components](https://docus.dev/essentials/components) provided by Nuxt UI. 20 | 21 | Once your content has been moved to the `content/` folder, you can go through the [configuration section](https://docus.dev/concepts/configuration) to easily customize your app. 22 | 23 | Docus is designed to focus on writing content, so if you're already using Markdown, you can easily switch to it. 24 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/activation.ts: -------------------------------------------------------------------------------- 1 | import { getAppManifest, useState, useRuntimeConfig, useCookie } from '#imports' 2 | import type { StudioUser } from 'nuxt-studio/app' 3 | 4 | export async function defineStudioActivationPlugin(onStudioActivation: (user: StudioUser) => Promise) { 5 | const user = useState('studio-session', () => null) 6 | const config = useRuntimeConfig().public.studio 7 | const cookie = useCookie('studio-session-check') 8 | 9 | if (config.dev) { 10 | return await onStudioActivation({ 11 | provider: 'github', 12 | email: 'dev@nuxt.com', 13 | name: 'Dev', 14 | accessToken: '', 15 | providerId: '', 16 | avatar: '', 17 | }) 18 | } 19 | 20 | user.value = String(cookie.value) === 'true' 21 | ? await $fetch<{ user: StudioUser }>('/__nuxt_studio/auth/session').then(session => session?.user ?? null) 22 | : null 23 | 24 | let mounted = false 25 | if (user.value?.email) { 26 | // Disable prerendering for Studio 27 | const manifest = await getAppManifest() 28 | manifest.prerendered = [] 29 | 30 | await onStudioActivation(user.value!) 31 | mounted = true 32 | } 33 | else if (mounted) { 34 | window.location.reload() 35 | } 36 | else { 37 | // Listen to CMD + . to toggle the studio or redirect to the login page 38 | document.addEventListener('keydown', (event) => { 39 | if (event.metaKey && event.key === '.') { 40 | setTimeout(() => { 41 | window.location.href = config.route + '?redirect=' + encodeURIComponent(window.location.pathname) 42 | }) 43 | } 44 | }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/test/unit/utils/file.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { slugifyFileName } from '../../../src/utils/file' 3 | 4 | describe('slugifyFileName', () => { 5 | it('should slugify filename with spaces', () => { 6 | const result = slugifyFileName('My Image.png') 7 | expect(result).toBe('My-Image.png') 8 | }) 9 | 10 | it('should slugify filename with special characters', () => { 11 | const result = slugifyFileName('Beautiful_Photo@2024.jpg') 12 | expect(result).toBe('Beautiful-Photo-2024.jpg') 13 | }) 14 | 15 | it('should preserve file extension', () => { 16 | const result = slugifyFileName('Document File (Final).pdf') 17 | expect(result).toBe('Document-File-Final.pdf') 18 | }) 19 | 20 | it('should handle filename with multiple dots', () => { 21 | const result = slugifyFileName('my.backup.image.png') 22 | expect(result).toBe('my.backup.image.png') 23 | }) 24 | 25 | it('should handle filename with uppercase letters', () => { 26 | const result = slugifyFileName('MyAwesomeFile.TXT') 27 | expect(result).toBe('MyAwesomeFile.TXT') 28 | }) 29 | 30 | it('should handle filename without extension', () => { 31 | const result = slugifyFileName('my-file-name') 32 | expect(result).toBe('my-file-name') 33 | }) 34 | 35 | it('should handle accented characters', () => { 36 | const result = slugifyFileName('Café_Münchën.mp4') 37 | expect(result).toBe('Cafe-Munchen.mp4') 38 | }) 39 | 40 | it('should handle filenames with unicode characters', () => { 41 | const result = slugifyFileName('日本語ファイル名.txt') 42 | expect(result).toBe('日本語ファイル名.txt') 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /src/app/test/mocks/git.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | import type { GithubFile } from '../../src/types/git' 3 | 4 | export const createMockGit = (remoteFile?: GithubFile) => ({ 5 | provider: 'github', 6 | name: 'GitHub', 7 | icon: 'i-simple-icons:github', 8 | api: { 9 | fetchFile: vi.fn().mockResolvedValue(remoteFile || createMockGithubFile()), 10 | commitFiles: vi.fn().mockResolvedValue({ success: true, commitSha: 'abc123', url: 'https://example.com/commit/abc123' }), 11 | getRepositoryUrl: vi.fn().mockReturnValue('https://github.com/owner/repo'), 12 | getBranchUrl: vi.fn().mockReturnValue('https://github.com/owner/repo/tree/main'), 13 | getCommitUrl: vi.fn().mockReturnValue('https://github.com/owner/repo/commit/abc123'), 14 | getContentRootDirUrl: vi.fn().mockReturnValue('https://github.com/owner/repo/tree/main/content'), 15 | getRepositoryInfo: vi.fn().mockReturnValue({ owner: 'owner', repo: 'repo', branch: 'main', provider: 'github' }), 16 | }, 17 | }) 18 | 19 | export const createMockGithubFile = (overrides?: Partial): GithubFile => ({ 20 | provider: 'github', 21 | path: 'content/document.md', 22 | name: 'document.md', 23 | content: 'Test content', 24 | sha: 'abc123', 25 | size: 100, 26 | encoding: 'utf-8', 27 | type: 'file', 28 | url: 'https://example.com/document.md', 29 | html_url: 'https://example.com/document.md', 30 | git_url: 'https://example.com/document.md', 31 | download_url: 'https://example.com/document.md', 32 | _links: { 33 | self: 'https://example.com/document.md', 34 | git: 'https://example.com/document.md', 35 | html: 'https://example.com/document.md', 36 | }, 37 | ...overrides, 38 | }) 39 | -------------------------------------------------------------------------------- /src/module/src/dev.ts: -------------------------------------------------------------------------------- 1 | import { addServerHandler, addVitePlugin } from '@nuxt/kit' 2 | import { resolve } from 'node:path' 3 | import type { Nuxt } from '@nuxt/schema' 4 | import type { ViteDevServer } from 'vite' 5 | import type { Storage } from 'unstorage' 6 | 7 | export function setupDevMode( 8 | nuxt: Nuxt, 9 | runtime: (...args: string[]) => string, 10 | assetsStorage: Storage, 11 | ) { 12 | // Setup Nitro storage for content and public assets 13 | nuxt.options.nitro.storage = { 14 | ...nuxt.options.nitro.storage, 15 | nuxt_studio_content: { 16 | driver: 'fs', 17 | base: resolve(nuxt.options.rootDir, 'content'), 18 | }, 19 | nuxt_studio_public_assets: { 20 | driver: 'fs', 21 | base: resolve(nuxt.options.rootDir, 'public'), 22 | }, 23 | } 24 | 25 | // Add dev server handlers for content 26 | addServerHandler({ 27 | route: '/__nuxt_studio/dev/content/**', 28 | handler: runtime('./server/routes/dev/content/[...path]'), 29 | }) 30 | 31 | // Add dev server handlers for public assets 32 | addServerHandler({ 33 | route: '/__nuxt_studio/dev/public/**', 34 | handler: runtime('./server/routes/dev/public/[...path]'), 35 | }) 36 | 37 | // Register Vite plugin to watch public assets 38 | addVitePlugin({ 39 | name: 'nuxt-studio', 40 | configureServer: (server: ViteDevServer) => { 41 | assetsStorage.watch((type, file) => { 42 | server.ws.send({ 43 | type: 'custom', 44 | event: 'nuxt-studio:media:update', 45 | data: { type, id: `public-assets/${file}` }, 46 | }) 47 | }) 48 | }, 49 | closeWatcher: () => { 50 | assetsStorage.unwatch() 51 | }, 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/4.project-structure.md: -------------------------------------------------------------------------------- 1 | --- 2 | navigation: 3 | icon: i-lucide-folder-tree 4 | title: Project Structure 5 | --- 6 | 7 | Docus provides a ready-to-use [documentation website starter](https://github.com/nuxt-content/docus/tree/.starter). 8 | 9 | This is the minimal directory structure to get an up and running Docus website. 10 | 11 | ```bash 12 | content/ 13 | index.md 14 | public/ 15 | favicon.ico 16 | package.json 17 | ``` 18 | 19 | ### `content/` directory 20 | 21 | This is where you [write pages](https://docus.dev/concepts/edition) in Markdown. 22 | 23 | ### `public/` directory 24 | 25 | Files contained within the `public/` directory are served at the root and are not modified by the build process of your documentation. This is where you can locate your medias. 26 | 27 | ### `package.json` 28 | 29 | This file contains all the dependencies and scripts for your application. The `package.json` of a Docus application si really minimal and looks like: 30 | 31 | ```json [package.json] 32 | { 33 | "name": "docus-starter", 34 | "scripts": { 35 | "dev": "docus dev", 36 | "build": "docus build" 37 | }, 38 | "devDependencies": { 39 | "docus": "latest" 40 | } 41 | } 42 | ``` 43 | 44 | ### `app.config.ts` 45 | 46 | *This file is not mandatory to start a Docus application.* 47 | 48 | This is where you can [configure Docus](https://docus.dev/concepts/configuration) to fit your branding, handle SEO and adapt links and socials. 49 | 50 | ::prose-tip{to="https://docus.dev/concepts/nuxt"} 51 | Docus uses a layer system, you can go further and use any feature or file of a classical Nuxt project from `nuxt.config.ts` to `app/components` or `server/` directory. 52 | :: 53 | -------------------------------------------------------------------------------- /src/app/src/components/content/ContentCard.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 61 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/document/utils.ts: -------------------------------------------------------------------------------- 1 | import slugify from 'slugify' 2 | import { withoutTrailingSlash, withLeadingSlash } from 'ufo' 3 | import { pascalCase } from 'scule' 4 | import { cleanUrlSegment } from '../url' 5 | import type { DatabaseItem } from 'nuxt-studio/app' 6 | 7 | export function addPageTypeFields(dbItem: DatabaseItem) { 8 | const { basename, extension, stem } = parseDocumentId(dbItem.id) 9 | const filePath = generatePathFromStem(stem) 10 | 11 | return { 12 | path: filePath, 13 | ...dbItem, 14 | title: dbItem.title || generateTitleFromPath(cleanUrlSegment(basename)), 15 | stem, 16 | extension, 17 | } 18 | } 19 | 20 | export function generateTitleFromPath(path: string) { 21 | return path.split(/[\s-]/g).map(pascalCase).join(' ') 22 | } 23 | 24 | export function generateStemFromId(id: string) { 25 | return id.split('/').slice(1).join('/').split('.').slice(0, -1).join('.') 26 | } 27 | 28 | export function generatePathFromStem(stem: string): string { 29 | stem = stem.split('/').map(part => slugify(cleanUrlSegment(part), { lower: true })).join('/') 30 | return withLeadingSlash(withoutTrailingSlash(stem)) 31 | } 32 | 33 | export function parseDocumentId(id: string) { 34 | const [source, ...parts] = id.split(/[:/]/) 35 | 36 | const [, basename, extension] = parts[parts.length - 1]?.match(/(.*)\.([^.]+)$/) || [] 37 | 38 | if (basename) { 39 | parts[parts.length - 1] = basename 40 | } 41 | 42 | const stem = (parts || []).join('/') 43 | 44 | return { 45 | source, 46 | stem, 47 | extension: extension!, 48 | basename: basename || '', 49 | } 50 | } 51 | 52 | export function getFileExtension(id: string) { 53 | return id.split('#')[0]?.split('.').pop()!.toLowerCase() 54 | } 55 | -------------------------------------------------------------------------------- /src/app/src/components/media/MediaEditorAudio.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 62 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/document/tree.ts: -------------------------------------------------------------------------------- 1 | import type { MarkdownRoot } from '@nuxt/content' 2 | import type { DatabaseItem } from 'nuxt-studio/app' 3 | import type { MinimarkNode, MinimarkTree } from 'minimark' 4 | import { visit as minimarkVisit } from 'minimark' 5 | 6 | export function sanitizeDocumentTree(document: DatabaseItem) { 7 | if (!document.body && (document.meta?.body as unknown as MinimarkTree)?.type === 'minimark') { 8 | document.body = (document.meta?.body as unknown as MinimarkTree) 9 | Reflect.deleteProperty(document.meta, 'body') 10 | } 11 | 12 | if ((document.body as unknown as MinimarkTree)?.type === 'minimark') { 13 | document.body = removeLastStylesFromTree(document.body as MarkdownRoot) 14 | 15 | // remove the codeblock token and convert highlighted code blocks to plain code blocks 16 | minimarkVisit(document.body as MinimarkTree, (node: MinimarkNode) => node[0] === 'pre', (node: MinimarkNode) => { 17 | const tag = node[0] 18 | const props = node[1] as Record 19 | 20 | if ((props as Record || {}).code) { 21 | // TODO: We need to make sure that there is no custom class 22 | Reflect.deleteProperty(props, 'className') 23 | return [ 24 | tag, 25 | { 26 | ...(props || {}), 27 | style: props.style || undefined, 28 | meta: props.meta || undefined, 29 | }, 30 | [ 31 | 'code', 32 | { __ignoreMap: '' }, 33 | (props as Record || {}).code, 34 | ], 35 | ] 36 | } 37 | return node 38 | }) 39 | } 40 | 41 | return document 42 | } 43 | 44 | export function removeLastStylesFromTree(body: MarkdownRoot) { 45 | if (body.value[body.value.length - 1]?.[0] === 'style') { 46 | return { ...body, value: body.value.slice(0, -1) } 47 | } 48 | return body 49 | } 50 | -------------------------------------------------------------------------------- /src/app/src/components/tiptap/extension/TiptapExtensionCodeBlock.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 57 | -------------------------------------------------------------------------------- /src/app/test/unit/composables/draft-base.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { useDraftBase } from '../../../src/composables/useDraftBase' 3 | import { dbItemsList } from '../../mocks/database' 4 | import { DraftStatus } from '../../../src/types' 5 | import { createMockHost } from '../../mocks/host' 6 | 7 | const { getStatus } = useDraftBase('document', createMockHost(), null as never, null as never) 8 | 9 | describe('getStatus', () => { 10 | it('returns Deleted status when modified item is undefined', () => { 11 | const original = dbItemsList[0] // landing/index.md 12 | 13 | expect(getStatus(undefined as never, original)).toBe(DraftStatus.Deleted) 14 | }) 15 | 16 | it('returns Created status when original is undefined', () => { 17 | const modified = dbItemsList[1] // docs/1.getting-started/2.introduction.md 18 | 19 | expect(getStatus(modified, undefined as never)).toBe(DraftStatus.Created) 20 | }) 21 | 22 | it('returns Created status when original has different id', () => { 23 | const original = dbItemsList[0] // landing/index.md 24 | const modified = dbItemsList[1] // docs/1.getting-started/2.introduction.md 25 | 26 | expect(getStatus(modified, original)).toBe(DraftStatus.Created) 27 | }) 28 | 29 | it('returns Updated status when markdown content is different', () => { 30 | const original = dbItemsList[1] // docs/1.getting-started/2.introduction.md 31 | const modified = { 32 | ...original, 33 | body: { 34 | type: 'minimark', 35 | value: ['text', 'Modified'], 36 | }, 37 | } 38 | 39 | expect(getStatus(modified, original)).toBe(DraftStatus.Updated) 40 | }) 41 | 42 | it('returns Pristine status when markdown content is identical', () => { 43 | const original = dbItemsList[1] // docs/1.getting-started/2.introduction.md 44 | 45 | expect(getStatus(original, original)).toBe(DraftStatus.Pristine) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /src/app/src/components/form/FormSchemaBased.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 59 | -------------------------------------------------------------------------------- /src/app/src/components/media/MediaCardForm.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 61 | -------------------------------------------------------------------------------- /src/module/test/mocks/collection.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionInfo } from '@nuxt/content' 2 | 3 | export const collections: Record = { 4 | landing: { 5 | name: 'landing', 6 | pascalName: 'Landing', 7 | tableName: '_content_landing', 8 | source: [ 9 | { 10 | _resolved: true, 11 | prefix: '/', 12 | cwd: '/Users/larbish/Documents/nuxt/modules/studio/playground/content', 13 | include: 'index.md', 14 | }, 15 | ], 16 | type: 'page', 17 | fields: { 18 | id: 'string', 19 | title: 'string', 20 | body: 'json', 21 | description: 'string', 22 | extension: 'string', 23 | meta: 'json', 24 | navigation: 'json', 25 | path: 'string', 26 | seo: 'json', 27 | stem: 'string', 28 | }, 29 | schema: { 30 | $schema: 'http://json-schema.org/draft-07/schema#', 31 | $ref: '#/definitions/docs', 32 | definitions: {}, 33 | }, 34 | tableDefinition: '', 35 | }, 36 | docs: { 37 | name: 'docs', 38 | pascalName: 'Docs', 39 | tableName: '_content_docs', 40 | source: [ 41 | { 42 | _resolved: true, 43 | prefix: '/', 44 | cwd: '/Users/larbish/Documents/nuxt/modules/studio/playground/content', 45 | include: '**', 46 | exclude: [ 47 | 'index.md', 48 | ], 49 | }, 50 | ], 51 | type: 'page', 52 | fields: { 53 | id: 'string', 54 | title: 'string', 55 | body: 'json', 56 | description: 'string', 57 | extension: 'string', 58 | links: 'json', 59 | meta: 'json', 60 | navigation: 'json', 61 | path: 'string', 62 | seo: 'json', 63 | stem: 'string', 64 | }, 65 | schema: { 66 | $schema: 'http://json-schema.org/draft-07/schema#', 67 | $ref: '#/definitions/__SCHEMA__', 68 | definitions: {}, 69 | }, 70 | tableDefinition: 'CREATE TABLE IF NOT EXISTS', 71 | }, 72 | } 73 | -------------------------------------------------------------------------------- /src/app/src/components/media/MediaCard.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 65 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/source.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionSource, ResolvedCollectionSource } from '@nuxt/content' 2 | import { withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo' 3 | import { join } from 'pathe' 4 | import { minimatch } from 'minimatch' 5 | 6 | export function parseSourceBase(source: CollectionSource) { 7 | const [fixPart, ...rest] = source.include.includes('*') ? source.include.split('*') : ['', source.include] 8 | return { 9 | fixed: fixPart || '', 10 | dynamic: '*' + rest.join('*'), 11 | } 12 | } 13 | 14 | /** 15 | * On Nuxt Content, Id is built like this: {collection.name}/{source.prefix}/{path} 16 | * But 'source.prefix' can be different from the fixed part of 'source.include' 17 | * We need to remove the 'source.prefix' from the path and add the fixed part of the 'source.include' to get the fsPath (used to match the source) 18 | */ 19 | export function getCollectionSourceById(id: string, sources: ResolvedCollectionSource[]) { 20 | const [_, ...rest] = id.split(/[/:]/) 21 | const prefixAndPath = rest.join('/') 22 | 23 | const matchedSource = sources.find((source) => { 24 | const prefix = source.prefix 25 | if (!prefix) { 26 | return false 27 | } 28 | 29 | if (!withLeadingSlash(prefixAndPath).startsWith(prefix)) { 30 | return false 31 | } 32 | 33 | let fsPath 34 | const [fixPart] = source.include.includes('*') ? source.include.split('*') : ['', source.include] 35 | const fixed = withoutTrailingSlash(fixPart || '/') 36 | if (withoutLeadingSlash(fixed) === withoutLeadingSlash(prefix)) { 37 | fsPath = prefixAndPath 38 | } 39 | else { 40 | const path = prefixAndPath.replace(prefix, '') 41 | fsPath = join(fixed, path) 42 | } 43 | 44 | const include = minimatch(fsPath, source.include, { dot: true }) 45 | const exclude = source.exclude?.some(exclude => minimatch(fsPath, exclude)) 46 | 47 | return include && !exclude 48 | }) 49 | 50 | return matchedSource 51 | } 52 | -------------------------------------------------------------------------------- /src/app/src/components/AppLayout.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 73 | -------------------------------------------------------------------------------- /src/app/src/types/context.ts: -------------------------------------------------------------------------------- 1 | import type { TreeItem } from './tree' 2 | 3 | export enum StudioFeature { 4 | Content = 'content', 5 | Media = 'media', 6 | } 7 | 8 | export enum StudioItemActionId { 9 | CreateDocumentFolder = 'create-document-folder', 10 | CreateMediaFolder = 'create-media-folder', 11 | CreateDocument = 'create-document', 12 | UploadMedia = 'upload-media', 13 | RevertItem = 'revert-item', 14 | RenameItem = 'rename-item', 15 | DeleteItem = 'delete-item', 16 | DuplicateItem = 'duplicate-item', 17 | RevertAllItems = 'revert-all-items', 18 | } 19 | 20 | export enum StudioBranchActionId { 21 | PublishBranch = 'publish-branch', 22 | } 23 | 24 | export interface StudioActionInProgress { 25 | id: StudioItemActionId | StudioBranchActionId 26 | item?: TreeItem 27 | } 28 | 29 | export interface StudioAction { 30 | id: K 31 | label: string 32 | icon: string 33 | tooltip: string 34 | handler?: (args: ActionHandlerParams[K]) => void 35 | } 36 | 37 | export interface CreateFolderParams { 38 | fsPath: string 39 | } 40 | 41 | export interface CreateFileParams { 42 | fsPath: string 43 | content: string 44 | } 45 | 46 | export interface RenameFileParams { 47 | item: TreeItem 48 | newFsPath: string 49 | } 50 | 51 | export interface UploadMediaParams { 52 | parentFsPath: string 53 | files: File[] 54 | } 55 | 56 | export interface PublishBranchParams { 57 | commitMessage: string 58 | } 59 | 60 | export type ActionHandlerParams = { 61 | // Items 62 | [StudioItemActionId.CreateDocumentFolder]: CreateFolderParams 63 | [StudioItemActionId.CreateMediaFolder]: CreateFolderParams 64 | [StudioItemActionId.CreateDocument]: CreateFileParams 65 | [StudioItemActionId.UploadMedia]: UploadMediaParams 66 | [StudioItemActionId.RevertItem]: TreeItem 67 | [StudioItemActionId.RenameItem]: TreeItem | RenameFileParams 68 | [StudioItemActionId.DeleteItem]: TreeItem 69 | [StudioItemActionId.DuplicateItem]: TreeItem 70 | [StudioItemActionId.RevertAllItems]: never 71 | 72 | // Branches 73 | [StudioBranchActionId.PublishBranch]: PublishBranchParams 74 | } 75 | -------------------------------------------------------------------------------- /src/app/src/components/shared/ResizableElement.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 73 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/routes/dev/content/[...path].ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { createError, eventHandler, getRequestHeader, readRawBody, setResponseHeader } from 'h3' 3 | import type { StorageMeta } from 'unstorage' 4 | // @ts-expect-error useStorage is not defined in .nuxt/imports.d.ts 5 | import { useStorage } from '#imports' 6 | 7 | export default eventHandler(async (event) => { 8 | const path = event.path.replace('/__nuxt_studio/dev/content/', '') 9 | const key = path.replace(/\//g, ':').replace(/^content:/, '') 10 | const storage = useStorage('nuxt_studio_content') 11 | 12 | // GET => getItem / getKeys 13 | if (event.method === 'GET') { 14 | const isRaw 15 | = getRequestHeader(event, 'accept') === 'application/octet-stream' 16 | const driverValue = await (isRaw 17 | ? storage.getItemRaw(key) 18 | : storage.getItem(key)) 19 | if (driverValue === null) { 20 | throw createError({ 21 | statusCode: 404, 22 | statusMessage: 'KV value not found', 23 | }) 24 | } 25 | setMetaHeaders(event, await storage.getMeta(key)) 26 | return isRaw ? driverValue : String(driverValue) 27 | } 28 | 29 | if (event.method === 'PUT') { 30 | if (getRequestHeader(event, 'content-type') === 'application/octet-stream') { 31 | const value = await readRawBody(event, false) 32 | await storage.setItemRaw(key, value) 33 | } 34 | else if (getRequestHeader(event, 'content-type') === 'text/plain') { 35 | const value = await readRawBody(event, 'utf8') 36 | await storage.setItem(key, value) 37 | } 38 | 39 | return 'OK' 40 | } 41 | 42 | // DELETE => removeItem 43 | if (event.method === 'DELETE') { 44 | await storage.removeItem(key) 45 | return 'OK' 46 | } 47 | }) 48 | 49 | function setMetaHeaders(event: H3Event, meta: StorageMeta) { 50 | if (meta.mtime) { 51 | setResponseHeader( 52 | event, 53 | 'last-modified', 54 | new Date(meta.mtime).toUTCString(), 55 | ) 56 | } 57 | if (meta.ttl) { 58 | setResponseHeader(event, 'x-ttl', `${meta.ttl}`) 59 | setResponseHeader(event, 'cache-control', `max-age=${meta.ttl}`) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/slot.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import TiptapExtensionSlot from '../../../components/tiptap/extension/TiptapExtensionSlot.vue' 4 | 5 | export interface ElementOptions { 6 | HTMLAttributes: Record 7 | nestable: boolean 8 | } 9 | 10 | declare module '@tiptap/core' { 11 | interface Commands { 12 | Slot: { 13 | /** 14 | * Override backspace command 15 | */ 16 | handleSlotBackspace: () => ReturnType 17 | } 18 | } 19 | } 20 | 21 | export const Slot = Node.create({ 22 | name: 'slot', 23 | priority: 1000, 24 | group: 'block', 25 | content: 'block+', 26 | selectable: false, 27 | inline: false, 28 | isolating: true, 29 | 30 | addOptions() { 31 | return { 32 | tag: 'div', 33 | nestable: false, 34 | HTMLAttributes: {}, 35 | } 36 | }, 37 | 38 | addAttributes() { 39 | return { 40 | name: { 41 | default: 'default', 42 | }, 43 | props: { 44 | parseHTML(element) { 45 | return JSON.parse(element.getAttribute('props') || '{}') 46 | }, 47 | default: {}, 48 | }, 49 | } 50 | }, 51 | 52 | parseHTML() { 53 | return [{ tag: 'div[data-type="Slot"]' }] 54 | }, 55 | 56 | renderHTML({ HTMLAttributes }) { 57 | return [ 58 | 'div', 59 | mergeAttributes(HTMLAttributes, { 'data-type': 'Slot' }), 60 | 0, 61 | ] 62 | }, 63 | 64 | addCommands() { 65 | return { 66 | handleSlotBackspace: () => () => { 67 | return false 68 | }, 69 | } 70 | }, 71 | 72 | addKeyboardShortcuts() { 73 | return { 74 | 'Backspace': ({ editor }) => editor.commands.handleSlotBackspace(), 75 | 'Shift-Backspace': ({ editor }) => editor.commands.handleSlotBackspace(), 76 | 'Mod-Backspace': ({ editor }) => editor.commands.handleSlotBackspace(), 77 | 'Alt-Backspace': ({ editor }) => editor.commands.handleSlotBackspace(), 78 | } 79 | }, 80 | 81 | addNodeView() { 82 | return VueNodeViewRenderer(TiptapExtensionSlot) 83 | }, 84 | }) 85 | -------------------------------------------------------------------------------- /src/app/src/components/form/input/FormInputObject.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 72 | -------------------------------------------------------------------------------- /playground/docus/content/9.studio/1.introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: Learn what the new Nuxt Studio module is and how it differs from the previous hosted platform. 4 | seo: 5 | title: Learn about the Nuxt Studio Open Source Module 6 | description: Nuxt Studio has evolved into an open-source Nuxt module that you can self-host alongside your Nuxt Content website. 7 | navigation: 8 | title: Introduction 9 | --- 10 | 11 | ## The new Nuxt Studio module 12 | 13 | When NuxtLabs joined Vercel, the team promised to release [nuxt.studio](https://nuxt.studio) as a free open-source, self-hosted module for Nuxt projects. 14 | 15 | ::u-button{to="https://github.com/nuxt-content/studio" icon="i-simple-icons-github" target="_blank" color="neutral" variant="subtle"} 16 | Discover the Nuxt Studio module on GitHub. 17 | :: 18 | 19 | That promise is now fulfilled. Thanks to vercel support the first version of the **Nuxt Studio module** is available. 20 | 21 | You can now enable **content editing directly in production**, with real-time preview and GitHub integration, all from within your own Nuxt application. 22 | 23 | ::u-button{to="/admin?redirect=/docs/studio/introduction" icon="i-lucide-mouse-pointer-click" external color="neutral"} 24 | Try editing this page 25 | :: 26 | 27 | ## How does it differ from the standalone platform? 28 | 29 | Originally offered as a hosted platform at [nuxt.studio](https://nuxt.studio), Studio has evolved into an open-source Nuxt module that you can deploy alongside your Nuxt Content website. 30 | 31 | This means content editors can manage and update content directly in production, without needing local development tools or Git knowledge. 32 | 33 | ### Key differences 34 | 35 | - ✅ **Self-hosted** — runs entirely on your own infrastructure 36 | - ✅ **No external dependencies** — no APIs or third-party services required 37 | - ✅ **Free and open-source** — released under the MIT license 38 | - ✅ **Built-in integration** — works within your Nuxt app 39 | 40 | ::warning 41 | The new Nuxt Studio module requires a server-side route for authentication. 42 | While static generation remains supported, your site must be **deployed on a platform that supports server-side rendering (SSR)**. 43 | :: 44 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/video.ts: -------------------------------------------------------------------------------- 1 | import { mergeAttributes, Node } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import TiptapExtensionVideo from '../../../components/tiptap/extension/TiptapExtensionVideo.vue' 4 | 5 | declare module '@tiptap/core' { 6 | interface Commands { 7 | Video: { 8 | /** 9 | * Add video element 10 | */ 11 | addVideo: () => ReturnType 12 | } 13 | } 14 | } 15 | 16 | // https://www.codemzy.com/blog/tiptap-video-embed-extension 17 | export const Video = Node.create({ 18 | name: 'video', 19 | priority: 1000, 20 | group: 'block', 21 | selectable: false, 22 | inline: false, 23 | 24 | addOptions() { 25 | return { 26 | HTMLAttributes: {}, 27 | } 28 | }, 29 | 30 | addAttributes() { 31 | return { 32 | key: { 33 | default: '', 34 | }, 35 | src: { 36 | default: null, 37 | }, 38 | alt: { 39 | default: null, 40 | }, 41 | title: { 42 | default: null, 43 | }, 44 | width: { 45 | default: null, 46 | }, 47 | height: { 48 | default: null, 49 | }, 50 | props: { 51 | parseHTML(element) { 52 | return JSON.parse(element.getAttribute('props') || '{}') 53 | }, 54 | default: null, 55 | }, 56 | } 57 | }, 58 | 59 | parseHTML() { 60 | return [{ tag: 'div[data-type="video"]' }] 61 | }, 62 | 63 | renderHTML({ HTMLAttributes }) { 64 | const mergedAttributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { 'data-type': 'video' }) 65 | return [ 66 | 'div', 67 | mergedAttributes, 68 | ] 69 | }, 70 | 71 | addCommands() { 72 | return { 73 | addVideo: () => ({ state, chain }) => { 74 | const { selection } = state 75 | const range = { from: selection.from, to: selection.to } 76 | const key = `${Date.now() % 1e6}-${Number.parseInt(String(Math.random() * 1e3))}` 77 | 78 | return chain() 79 | .insertContentAt(range, { 80 | type: this.name, 81 | attrs: { tag: 'video', key }, 82 | }) 83 | .run() 84 | }, 85 | } 86 | }, 87 | 88 | addNodeView() { 89 | return VueNodeViewRenderer(TiptapExtensionVideo) 90 | }, 91 | }) 92 | -------------------------------------------------------------------------------- /src/app/src/types/git.ts: -------------------------------------------------------------------------------- 1 | import type { DraftStatus } from './draft' 2 | import type { StudioFeature } from '../types' 3 | 4 | export type GitProviderType = 'github' | 'gitlab' 5 | 6 | export interface Repository { 7 | provider: GitProviderType | null 8 | owner: string 9 | repo: string 10 | branch: string 11 | rootDir: string 12 | /** 13 | * Can be used to specify the instance URL for self-hosted GitLab instances. 14 | * @default 'https://gitlab.com' 15 | */ 16 | instanceUrl?: string 17 | } 18 | 19 | export interface GitBaseOptions { 20 | owner: string 21 | repo: string 22 | branch: string 23 | authorName: string 24 | authorEmail: string 25 | } 26 | 27 | export interface GitOptions extends GitBaseOptions { 28 | provider: GitProviderType | null 29 | rootDir: string 30 | token: string 31 | instanceUrl?: string 32 | } 33 | 34 | export interface CommitFilesOptions extends GitBaseOptions { 35 | files: RawFile[] 36 | message: string 37 | } 38 | 39 | export interface RawFile { 40 | path: string 41 | content: string | null 42 | status: DraftStatus 43 | encoding?: 'utf-8' | 'base64' 44 | } 45 | 46 | export interface GitProviderAPI { 47 | fetchFile(path: string, options?: { cached?: boolean }): Promise 48 | commitFiles(files: RawFile[], message: string): Promise 49 | getRepositoryUrl(): string 50 | getBranchUrl(): string 51 | getCommitUrl(sha: string): string 52 | getFileUrl(feature: StudioFeature, fsPath: string): string 53 | getRepositoryInfo(): { owner: string, repo: string, branch: string, provider: GitProviderType | null } 54 | } 55 | 56 | export interface CommitResult { 57 | success: boolean 58 | commitSha: string 59 | url: string 60 | } 61 | 62 | export interface GitFile { 63 | provider: GitProviderType 64 | name: string 65 | path: string 66 | sha: string 67 | size: number 68 | url: string 69 | content?: string 70 | encoding?: 'utf-8' | 'base64' 71 | } 72 | 73 | export interface GithubFile extends GitFile { 74 | html_url: string 75 | git_url: string 76 | download_url: string 77 | type: string 78 | _links: { 79 | self: string 80 | git: string 81 | html: string 82 | } 83 | } 84 | 85 | export interface GitLabFile extends GitFile { 86 | file_path: string 87 | ref: string 88 | blob_id: string 89 | commit_id: string 90 | last_commit_id: string 91 | } 92 | -------------------------------------------------------------------------------- /src/app/src/components/header/HeaderMain.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 80 | -------------------------------------------------------------------------------- /src/module/src/auth.ts: -------------------------------------------------------------------------------- 1 | import { useLogger } from '@nuxt/kit' 2 | import type { ModuleOptions } from './module' 3 | 4 | const logger = useLogger('nuxt-studio') 5 | 6 | export function validateAuthConfig(options: ModuleOptions): void { 7 | const provider = options.repository?.provider || 'github' 8 | const providerUpperCase = provider.toUpperCase() 9 | 10 | const hasGitHubAuth = options.auth?.github?.clientId && options.auth?.github?.clientSecret 11 | const hasGitLabAuth = options.auth?.gitlab?.applicationId && options.auth?.gitlab?.applicationSecret 12 | const hasGoogleAuth = options.auth?.google?.clientId && options.auth?.google?.clientSecret 13 | const hasGoogleModerators = (process.env.STUDIO_GOOGLE_MODERATORS?.split(',') || []).length > 0 14 | const hasGitToken = process.env.STUDIO_GITHUB_TOKEN || process.env.STUDIO_GITLAB_TOKEN 15 | 16 | // Google OAuth enabled 17 | if (hasGoogleAuth) { 18 | // Google OAuth moderators required 19 | if (!hasGoogleModerators) { 20 | logger.error([ 21 | 'The `STUDIO_GOOGLE_MODERATORS` environment variable is required when using Google OAuth.', 22 | 'Please set the `STUDIO_GOOGLE_MODERATORS` environment variable to a comma-separated list of email of the allowed users.', 23 | 'Only users with these email addresses will be able to access studio with Google OAuth.', 24 | ].join(' ')) 25 | } 26 | 27 | if (!hasGitToken) { 28 | logger.warn([ 29 | `The \`STUDIO_${providerUpperCase}_TOKEN\` environment variable is required when using Google OAuth with ${providerUpperCase} provider.`, 30 | `This token is used to push changes to the repository when using Google OAuth.`, 31 | ].join(' ')) 32 | } 33 | } // Google OAuth disabled 34 | else { 35 | const missingProviderEnv = provider === 'github' ? !hasGitHubAuth : !hasGitLabAuth 36 | if (missingProviderEnv) { 37 | logger.error([ 38 | `In order to authenticate users, you need to set up a ${providerUpperCase} OAuth application.`, 39 | `Please set the \`STUDIO_${providerUpperCase}_CLIENT_ID\` and \`STUDIO_${providerUpperCase}_CLIENT_SECRET\` environment variables,`, 40 | `Alternatively, you can set up a Google OAuth application and set the \`STUDIO_GOOGLE_CLIENT_ID\` and \`STUDIO_GOOGLE_CLIENT_SECRET\` environment variables alongside with \`STUDIO_${providerUpperCase}_TOKEN\` to push changes to the repository.`, 41 | ].join(' ')) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/src/components/shared/item/ItemTree.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 79 | -------------------------------------------------------------------------------- /src/app/src/utils/draft.ts: -------------------------------------------------------------------------------- 1 | import type { DatabaseItem, MediaItem, DraftItem, ContentConflict, StudioHost } from '../types' 2 | import { DraftStatus } from '../types' 3 | import { fromBase64ToUTF8 } from '../utils/string' 4 | import { isMediaFile } from './file' 5 | 6 | export async function checkConflict(host: StudioHost, draftItem: DraftItem): Promise { 7 | const generateContentFromDocument = host.document.generate.contentFromDocument 8 | const isDocumentMatchingContent = host.document.utils.isMatchingContent 9 | 10 | if (isMediaFile(draftItem.fsPath) || draftItem.fsPath.endsWith('.gitkeep')) { 11 | return 12 | } 13 | 14 | if (draftItem.status === DraftStatus.Deleted) { 15 | return 16 | } 17 | 18 | // TODO: No remote file found (might have been deleted remotely) 19 | if (!draftItem.remoteFile || !draftItem.remoteFile.content) { 20 | return 21 | } 22 | 23 | const remoteContent = draftItem.remoteFile?.encoding === 'base64' 24 | ? fromBase64ToUTF8(draftItem.remoteFile.content!) 25 | : draftItem.remoteFile!.content! 26 | 27 | if (draftItem.status === DraftStatus.Created && remoteContent) { 28 | return { 29 | remoteContent, 30 | localContent: await generateContentFromDocument(draftItem.modified as DatabaseItem) as string, 31 | } 32 | } 33 | 34 | if (await isDocumentMatchingContent(remoteContent, draftItem.original! as DatabaseItem)) { 35 | return 36 | } 37 | 38 | const localContent = await generateContentFromDocument(draftItem.original as DatabaseItem) as string 39 | if (localContent.trim() === remoteContent.trim()) { 40 | return 41 | } 42 | 43 | return { 44 | remoteContent, 45 | localContent, 46 | } 47 | } 48 | 49 | export function findDescendantsFromFsPath(list: DraftItem[], fsPath: string): DraftItem[] { 50 | if (fsPath === '/') { 51 | return list 52 | } 53 | 54 | const descendants: DraftItem[] = [] 55 | for (const item of list) { 56 | const isExactMatch = item.fsPath === fsPath 57 | // If exact match it means id refers to a file, there is no need to browse the list further 58 | if (isExactMatch) { 59 | return [item] 60 | } 61 | 62 | // Else it means id refers to a directory, we need to browse the list further to find all descendants 63 | const isDescendant = item.fsPath.startsWith(fsPath + '/') 64 | if (isDescendant) { 65 | descendants.push(item) 66 | } 67 | } 68 | 69 | return descendants 70 | } 71 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/2.introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | description: Welcome to Docus theme documentation. 4 | navigation: 5 | icon: i-lucide-house 6 | seo: 7 | title: Introduction 8 | description: Discover how to create, manage, and publish documentation effortlessly with Docus. 9 | --- 10 | 11 | Welcome to **Docus**, a fully integrated documentation solution built with [Nuxt UI Pro](https://ui.nuxt.com/pro). 12 | 13 | ## What is Docus? 14 | 15 | Docus is a theme based on the [UI Pro documentation template](https://docs-template.nuxt.dev/). While the visual style comes ready out of the box, your focus should be on writing content using the Markdown and [MDC syntax](https://content.nuxt.com/docs/files/markdown#mdc-syntax) provided by [Nuxt Content](https://content.nuxt.com). 16 | 17 | We use this theme across all our Nuxt module documentations, including: 18 | 19 | ::card-group 20 | :::card 21 | --- 22 | icon: i-simple-icons-nuxtdotjs 23 | target: _blank 24 | title: Nuxt Image 25 | to: https://image.nuxt.com 26 | --- 27 | The documentation of `@nuxt/image` 28 | ::: 29 | 30 | :::card 31 | --- 32 | icon: i-simple-icons-nuxtdotjs 33 | target: _blank 34 | title: Nuxt Supabase 35 | to: https://supabase.nuxtjs.org 36 | --- 37 | The documentation of `@nuxt/supabase` 38 | ::: 39 | :: 40 | 41 | ## Key Features 42 | 43 | This theme includes a range of features designed to improve documentation management: 44 | 45 | - **Powered by** [**Nuxt 4**](https://nuxt.com): Utilizes the latest Nuxt framework for optimal performance. 46 | - **Built with** [**Nuxt UI**](https://ui.nuxt.com) **and** [**Nuxt UI Pro**](https://ui.nuxt.com/pro): Integrates a comprehensive suite of UI components. 47 | - [**MDC Syntax**](https://content.nuxt.com/usage/markdown) **via** [**Nuxt Content**](https://content.nuxt.com): Supports Markdown with component integration for dynamic content. 48 | - [**Nuxt Studio**](https://content.nuxt.com/docs/studio) **Compatible**: Write and edit your content visually. No Markdown knowledge is required! 49 | - **Auto-generated Sidebar Navigation**: Automatically generates navigation from content structure. 50 | - **Full-Text Search**: Includes built-in search functionality for content discovery. 51 | - **Optimized Typography**: Features refined typography for enhanced readability. 52 | - **Dark Mode**: Offers dark mode support for user preference. 53 | - **Extensive Functionality**: Explore the theme to fully appreciate its capabilities. 54 | -------------------------------------------------------------------------------- /src/app/test/mocks/form.ts: -------------------------------------------------------------------------------- 1 | import type { FormTree } from '../../src/types' 2 | 3 | export const farnabazFormTree: FormTree = { 4 | authors: { 5 | id: '#authors', 6 | title: 'Authors', 7 | type: 'object', 8 | children: { 9 | name: { 10 | id: '#authors/name', 11 | title: 'Name', 12 | type: 'string', 13 | value: 'Ahad Birang', 14 | }, 15 | avatar: { 16 | id: '#authors/avatar', 17 | title: 'Avatar', 18 | type: 'object', 19 | children: { 20 | src: { 21 | id: '#authors/avatar/src', 22 | title: 'Src', 23 | type: 'string', 24 | value: 'https://avatars.githubusercontent.com/farnabaz', 25 | }, 26 | alt: { 27 | id: '#authors/avatar/alt', 28 | title: 'Alt', 29 | type: 'string', 30 | value: '', 31 | }, 32 | }, 33 | }, 34 | to: { 35 | id: '#authors/to', 36 | title: 'To', 37 | type: 'string', 38 | value: 'https://x.com/farnabaz', 39 | }, 40 | username: { 41 | id: '#authors/username', 42 | title: 'Username', 43 | type: 'string', 44 | value: 'farnabaz', 45 | }, 46 | }, 47 | }, 48 | } 49 | 50 | export const larbishFormTree: FormTree = { 51 | authors: { 52 | id: '#authors', 53 | title: 'Authors', 54 | type: 'object', 55 | children: { 56 | name: { 57 | id: '#authors/name', 58 | title: 'Name', 59 | type: 'string', 60 | value: 'Baptiste Leproux', 61 | }, 62 | avatar: { 63 | id: '#authors/avatar', 64 | title: 'Avatar', 65 | type: 'object', 66 | children: { 67 | src: { 68 | id: '#authors/avatar/src', 69 | title: 'Src', 70 | type: 'string', 71 | value: 'https://avatars.githubusercontent.com/larbish', 72 | }, 73 | alt: { 74 | id: '#authors/avatar/alt', 75 | title: 'Alt', 76 | type: 'string', 77 | value: '', 78 | }, 79 | }, 80 | }, 81 | to: { 82 | id: '#authors/to', 83 | title: 'To', 84 | type: 'string', 85 | value: 'https://x.com/_larbish', 86 | }, 87 | username: { 88 | id: '#authors/username', 89 | title: 'Username', 90 | type: 'string', 91 | value: 'larbish', 92 | }, 93 | }, 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /src/app/src/composables/useMonacoDiff.ts: -------------------------------------------------------------------------------- 1 | import { watch, unref, type Ref, shallowRef } from 'vue' 2 | import type { editor as Editor } from 'modern-monaco/editor-core' 3 | import { setupMonaco } from '../utils/monaco/index' 4 | 5 | export interface UseMonacoDiffOptions { 6 | original: string 7 | modified: string 8 | language: string 9 | colorMode: Ref<'light' | 'dark'> 10 | editorOptions?: Editor.IStandaloneDiffEditorConstructionOptions 11 | } 12 | 13 | export function useMonacoDiff(target: Ref, options: UseMonacoDiffOptions) { 14 | const editor = shallowRef(null) 15 | let isInitialized = false 16 | 17 | const getTheme = (mode: 'light' | 'dark' = 'dark') => { 18 | return mode === 'light' ? 'vitesse-light' : 'vitesse-dark' 19 | } 20 | 21 | const init = async () => { 22 | const el = unref(target) 23 | if (!el || isInitialized) return 24 | 25 | const monaco = await setupMonaco() 26 | const colorMode = unref(options.colorMode) || 'dark' 27 | 28 | editor.value = monaco.createDiffEditor(el, { 29 | theme: getTheme(colorMode), 30 | lineNumbers: 'off', 31 | readOnly: true, 32 | renderSideBySide: true, 33 | renderSideBySideInlineBreakpoint: 0, 34 | wordWrap: 'on', 35 | scrollBeyondLastLine: false, 36 | automaticLayout: true, 37 | ...options.editorOptions, 38 | }) 39 | 40 | watch(options.colorMode, (newMode) => { 41 | editor.value?.updateOptions({ 42 | // @ts-expect-error -- theme is missing from IDiffEditorOptions 43 | theme: getTheme(newMode), 44 | }) 45 | }) 46 | 47 | editor.value.setModel({ 48 | original: monaco.editor.createModel(options.original, options.language), 49 | modified: monaco.editor.createModel(options.modified, options.language), 50 | }) 51 | 52 | isInitialized = true 53 | } 54 | 55 | // Watch target to initialize when it becomes available 56 | watch( 57 | target, 58 | () => { 59 | const el = unref(target) 60 | if (el && !isInitialized) { 61 | init() 62 | } 63 | else if (!el && isInitialized) { 64 | isInitialized = false 65 | editor.value?.dispose() 66 | editor.value = null 67 | } 68 | }, 69 | { immediate: true, flush: 'post' }, 70 | ) 71 | 72 | const setOptions = (opts: Editor.IStandaloneDiffEditorConstructionOptions) => { 73 | editor.value?.updateOptions(opts) 74 | } 75 | 76 | return { 77 | editor, 78 | setOptions, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/app/test/unit/utils/draft.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { findDescendantsFromFsPath } from '../../../src/utils/draft' 3 | import { draftItemsList } from '../../../test/mocks/draft' 4 | 5 | describe('findDescendantsFromFsPath', () => { 6 | it('returns exact match for a root level file', () => { 7 | const descendants = findDescendantsFromFsPath(draftItemsList, 'index.md') 8 | expect(descendants).toHaveLength(1) 9 | expect(descendants[0].fsPath).toBe('index.md') 10 | }) 11 | 12 | it('returns empty array for non-existent fsPath', () => { 13 | const descendants = findDescendantsFromFsPath(draftItemsList, 'non-existent/file.md') 14 | expect(descendants).toHaveLength(0) 15 | }) 16 | 17 | it('returns all descendants files for a directory path', () => { 18 | const descendants = findDescendantsFromFsPath(draftItemsList, '1.getting-started') 19 | 20 | expect(descendants).toHaveLength(5) 21 | 22 | expect(descendants.some(item => item.fsPath === '1.getting-started/2.introduction.md')).toBe(true) 23 | expect(descendants.some(item => item.fsPath === '1.getting-started/3.installation.md')).toBe(true) 24 | expect(descendants.some(item => item.fsPath === '1.getting-started/4.configuration.md')).toBe(true) 25 | expect(descendants.some(item => item.fsPath === '1.getting-started/1.advanced/1.studio.md')).toBe(true) 26 | expect(descendants.some(item => item.fsPath === '1.getting-started/1.advanced/2.deployment.md')).toBe(true) 27 | }) 28 | 29 | it('returns all descendants for a nested directory path', () => { 30 | const descendants = findDescendantsFromFsPath(draftItemsList, '1.getting-started/1.advanced') 31 | 32 | expect(descendants).toHaveLength(2) 33 | 34 | expect(descendants.some(item => item.fsPath === '1.getting-started/1.advanced/1.studio.md')).toBe(true) 35 | expect(descendants.some(item => item.fsPath === '1.getting-started/1.advanced/2.deployment.md')).toBe(true) 36 | }) 37 | 38 | it('returns all descendants for root item', () => { 39 | const descendants = findDescendantsFromFsPath(draftItemsList, '/') 40 | 41 | expect(descendants).toHaveLength(draftItemsList.length) 42 | }) 43 | 44 | it('returns only the file itself when searching for a specific file', () => { 45 | const descendants = findDescendantsFromFsPath(draftItemsList, '1.getting-started/1.advanced/1.studio.md') 46 | 47 | expect(descendants).toHaveLength(1) 48 | expect(descendants[0].fsPath).toBe('1.getting-started/1.advanced/1.studio.md') 49 | }) 50 | }) 51 | -------------------------------------------------------------------------------- /src/app/src/utils/tiptap/extensions/inline-element.ts: -------------------------------------------------------------------------------- 1 | import { Node, mergeAttributes } from '@tiptap/core' 2 | import { VueNodeViewRenderer } from '@tiptap/vue-3' 3 | import { textInputRule } from '../input-rules' 4 | import TiptapExtensionInlineElement from '../../../components/tiptap/extension/TiptapExtensionInlineElement.vue' 5 | 6 | export interface InlineElementOptions { 7 | HTMLAttributes: Record 8 | } 9 | 10 | declare module '@tiptap/core' { 11 | interface Commands { 12 | InlineElement: { 13 | /** 14 | * Toggle a InlineElement 15 | */ 16 | setInlineElement: (tag: string) => ReturnType 17 | } 18 | } 19 | } 20 | 21 | export const InlineElement = Node.create({ 22 | name: 'inline-element', 23 | group: 'inline', 24 | priority: 1000, 25 | inline: true, 26 | content: 'text*', 27 | 28 | addOptions() { 29 | return { 30 | tag: 'div', 31 | HTMLAttributes: {}, 32 | } 33 | }, 34 | 35 | addAttributes() { 36 | return { 37 | tag: { 38 | default: 'div', 39 | }, 40 | props: { 41 | parseHTML(element) { 42 | return JSON.parse(element.getAttribute('props') || '{}') 43 | }, 44 | default: {}, 45 | }, 46 | } 47 | }, 48 | 49 | parseHTML() { 50 | return [{ tag: 'span[data-type="inline-element"]' }] 51 | }, 52 | 53 | renderHTML({ HTMLAttributes }) { 54 | const mergedAttributes = mergeAttributes(HTMLAttributes, { 'data-type': 'inline-element' }) 55 | mergedAttributes.props = JSON.stringify(mergedAttributes.props || {}) 56 | return [ 57 | 'span', 58 | mergedAttributes, 59 | 0, 60 | ] 61 | }, 62 | 63 | addInputRules() { 64 | return [ 65 | textInputRule({ 66 | find: /(?:^|\s)(:([a-z-]+)(?:\[([^\]]*)\])?(?:\{[^}]*\})?)\s/i, 67 | type: this.type, 68 | getText: (match: string[]) => match[3], 69 | getAttributes: (match: string[]) => ({ tag: match[2] }), 70 | }), 71 | ] 72 | }, 73 | 74 | addCommands() { 75 | return { 76 | setInlineElement: (tag: string) => ({ state, chain }) => { 77 | const { 78 | selection: { from }, 79 | } = state 80 | 81 | return chain() 82 | .insertContentAt(from, { 83 | type: 'inline-element', 84 | attrs: { tag }, 85 | }) 86 | .run() 87 | }, 88 | } 89 | }, 90 | 91 | addNodeView() { 92 | return VueNodeViewRenderer(TiptapExtensionInlineElement) 93 | }, 94 | }) 95 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/utils/session.ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { createError, deleteCookie, setCookie, useSession } from 'h3' 3 | import { defu } from 'defu' 4 | import type { StudioUser, GitProviderType } from 'nuxt-studio/app' 5 | import { useRuntimeConfig } from '#imports' 6 | 7 | interface StudioUserSession { 8 | name: string 9 | email: string 10 | providerId?: string 11 | avatar?: string 12 | } 13 | 14 | const requiredUserFields: Array = ['name', 'email'] 15 | 16 | export async function setStudioUserSession(event: H3Event, userSession: StudioUserSession) { 17 | const config = useRuntimeConfig().public 18 | const provider = config.studio.repository.provider as GitProviderType 19 | const accessToken 20 | = provider === 'github' 21 | ? process.env.STUDIO_GITHUB_TOKEN 22 | : provider === 'gitlab' 23 | ? process.env.STUDIO_GITLAB_TOKEN 24 | : null 25 | 26 | if (!accessToken) { 27 | throw createError({ 28 | statusCode: 500, 29 | statusMessage: `Missing access token for ${provider} Git provider`, 30 | }) 31 | } 32 | 33 | await setInternalStudioUserSession(event, { 34 | ...userSession, 35 | provider, 36 | accessToken, 37 | }) 38 | } 39 | 40 | export async function setInternalStudioUserSession(event: H3Event, user: StudioUser) { 41 | const missingFields = requiredUserFields.filter(key => !user[key]) 42 | 43 | if (missingFields.length > 0) { 44 | throw createError({ 45 | statusCode: 400, 46 | statusMessage: `Missing required Studio user fields: ${missingFields.join(', ')}`, 47 | }) 48 | } 49 | 50 | const session = await useSession(event, { 51 | name: 'studio-session', 52 | password: useRuntimeConfig(event).studio?.auth?.sessionSecret, 53 | }) 54 | 55 | const payload = defu({ 56 | user: { 57 | ...user, 58 | }, 59 | }, session.data) 60 | 61 | await session.update(payload) 62 | 63 | // Set a cookie to indicate that the session is active for the client runtime 64 | setCookie(event, 'studio-session-check', 'true', { httpOnly: false }) 65 | 66 | return { 67 | ...payload, 68 | id: session.id!, 69 | } 70 | } 71 | 72 | export async function clearStudioUserSession(event: H3Event) { 73 | const session = await useSession(event, { 74 | name: 'studio-session', 75 | password: useRuntimeConfig(event).studio?.auth?.sessionSecret, 76 | }) 77 | 78 | await session.clear() 79 | 80 | // Delete the cookie to indicate that the session is inactive 81 | deleteCookie(event, 'studio-session-check') 82 | 83 | return { loggedOut: true } 84 | } 85 | -------------------------------------------------------------------------------- /src/app/test/unit/utils/object.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from 'vitest' 2 | import { applyValueByPath } from '../../../src/utils/object' 3 | 4 | describe('applyValueByPath', () => { 5 | test('Browse object until key is found based on path, then override value for this key', () => { 6 | const obj = { 7 | icon: { 8 | class: 'old value', 9 | }, 10 | } 11 | 12 | expect(applyValueByPath(obj, 'icon/class', 'new value')).toStrictEqual({ 13 | icon: { 14 | class: 'new value', 15 | }, 16 | }) 17 | }) 18 | 19 | test('Browse object until key is found based on path, create field if not found', () => { 20 | const obj = { 21 | icon: { 22 | class: 'h-4 w-4', 23 | }, 24 | } 25 | 26 | expect(applyValueByPath(obj, 'icon/size', 'new value')).toStrictEqual({ 27 | icon: { 28 | class: 'h-4 w-4', 29 | size: 'new value', 30 | }, 31 | }) 32 | }) 33 | 34 | test('Browse object until key is found based on path, update the object when it already exists', () => { 35 | const obj = { 36 | socials: { 37 | discord: 'https://discord.gg/test', 38 | twitter: 'https://twitter.com/test', 39 | x: 'https://x.com/test', 40 | }, 41 | } 42 | 43 | expect(applyValueByPath(obj, 'socials', { x: undefined })).toStrictEqual({ 44 | socials: { 45 | discord: 'https://discord.gg/test', 46 | twitter: 'https://twitter.com/test', 47 | }, 48 | }) 49 | }) 50 | 51 | test('Browse object until key is found based on path, update array of strings when it already exists', () => { 52 | const obj = { 53 | items: ['one', 'two', 'three'], 54 | } 55 | 56 | expect(applyValueByPath(obj, 'items', ['one', 'two'])).toStrictEqual({ 57 | items: ['one', 'two'], 58 | }) 59 | }) 60 | 61 | test('Browse object until key is found based on path, update array of objects when it already exists', () => { 62 | const obj = { 63 | items: [{ id: 1, name: 'one' }, { id: 2, name: 'two' }, { id: 3, name: 'three' }], 64 | } 65 | 66 | expect(applyValueByPath(obj, 'items', [{ id: 1, name: 'one' }, { id: 2, name: 'two' }])).toStrictEqual({ 67 | items: [{ id: 1, name: 'one' }, { id: 2, name: 'two' }], 68 | }) 69 | }) 70 | 71 | test('Browse object until key is found based on path, set value directly when key does not exist', () => { 72 | const obj = {} 73 | 74 | expect(applyValueByPath(obj, 'socials', { x: 'https://x.com/test' })).toStrictEqual({ 75 | socials: { 76 | x: 'https://x.com/test', 77 | }, 78 | }) 79 | }) 80 | }) 81 | -------------------------------------------------------------------------------- /playground/docus/content/1.getting-started/3.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | description: Get started with Docus. 4 | navigation: 5 | icon: i-lucide-download 6 | seo: 7 | description: Get started with Docus documentation theme. 8 | title: Installation 9 | --- 10 | 11 | ## Local development 12 | 13 | ::steps 14 | ### Create your docs directory 15 | 16 | Use the `docus` CLI to create a new Docus project in the `docs/` directory: 17 | 18 | ```bash [Terminal] 19 | npx docus init docs 20 | ``` 21 | 22 | We recommend using the `npm` package manager. 23 | 24 | ### Start your docs server in development 25 | 26 | Move to the `docs/` directory and start your docs server in development mode: 27 | 28 | ```bash [Terminal] 29 | npm run dev 30 | ``` 31 | 32 | A local preview of your documentation will be available at 33 | 34 | ### Write your documentation 35 | 36 | Head over the [Edition](https://docus.dev/concepts/edition) section to learn how to write your documentation. 37 | :: 38 | 39 | ## Online Edition with Nuxt Studio 40 | 41 | ::prose-steps 42 | ### Create a new project on Nuxt Studio 43 | 44 | Choose `Start from a template` and select **Docus.** Clone it on your GitHub personal account or any organisation of your choice. 45 | 46 | ### Deploy in one click 47 | 48 | Once your project has been created and you're in the project dashboard, navigate to the `Deploy` section, choose the `GitHub Pages` tab and set your [Nuxt UI Pro license](https://ui.nuxt.com/pro/pricing) (`NUXT_UI_PRO_LICENSE` ) in the environment variables then click on the **Deploy** button. 49 | 50 | :::prose-note 51 | --- 52 | to: https://content.nuxt.com/docs/studio/setup#enable-the-full-editing-experience 53 | --- 54 | This is a one click static deployment available with [GitHub Pages](https://docs.github.com/en/pages/getting-started-with-github-pages/creating-a-github-pages-site) but you can also handle deployment yourself and use the `Selfhosted` tab. 55 | ::: 56 | 57 | ### Write your documentation in the editor 58 | 59 | Once the deployment is achieved, you'll be able to display the preview of your documentation. You can browse your content pages to edit them or create new ones. 60 | 61 | :video{controls loop poster="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.jpg" src="https://res.cloudinary.com/nuxt/video/upload/v1747230893/studio/wzt9zfmdvk7hgmdx3cnt.mp4"} 62 | :: 63 | 64 | ::prose-tip{to="https://content.nuxt.com/docs/studio/debug"} 65 | If you want to try Docus and Nuxt Studio in develoment mode without an UI Pro license, you can check the Nuxt Content documentation for local setup with Nuxt Studio. 66 | :: 67 | -------------------------------------------------------------------------------- /src/app/src/components/AppBanner.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 79 | -------------------------------------------------------------------------------- /src/app/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import ui from '@nuxt/ui/vite' 4 | import path from 'node:path' 5 | import libCss from 'vite-plugin-libcss' 6 | import dts from 'vite-plugin-dts' 7 | 8 | // https://vitejs.dev/config/ 9 | export default defineConfig({ 10 | resolve: { 11 | alias: { 12 | '#mdc-imports': path.resolve(__dirname, './mock/mdc-import.ts'), 13 | '#mdc-configs': path.resolve(__dirname, './mock/mdc-import.ts'), 14 | }, 15 | }, 16 | plugins: [ 17 | vue(), 18 | ui({ 19 | theme: { 20 | defaultVariants: { 21 | size: 'sm', 22 | }, 23 | }, 24 | ui: { 25 | colors: { 26 | neutral: 'neutral', 27 | }, 28 | button: { 29 | variants: { 30 | size: { 31 | '2xs': { 32 | base: 'p-1 text-xs gap-1', 33 | leadingIcon: 'size-3', 34 | leadingAvatarSize: '3xs', 35 | trailingIcon: 'size-3', 36 | }, 37 | }, 38 | }, 39 | }, 40 | pageCard: { 41 | slots: { 42 | wrapper: 'min-w-0', 43 | container: 'p-0 sm:p-0 gap-y-0', 44 | body: 'p-2 sm:p-2 w-full', 45 | }, 46 | }, 47 | navigationMenu: { 48 | slots: { 49 | link: 'cursor-pointer', 50 | }, 51 | }, 52 | breadcrumb: { 53 | slots: { 54 | link: 'cursor-pointer', 55 | }, 56 | }, 57 | }, 58 | }), 59 | libCss(), 60 | dts({ 61 | include: ['src/**/*.ts'], 62 | exclude: ['src/**/*.vue'], 63 | insertTypesEntry: true, 64 | rollupTypes: true, 65 | entryRoot: 'src', 66 | tsconfigPath: './tsconfig.app.json', 67 | }), 68 | ], 69 | optimizeDeps: { 70 | include: ['vue', 'vue-router', '@unhead/vue/client', '@nuxt/content/runtime', '@vueuse/core', '@unpic/vue', 'scule', 'zod', 'ufo', 'unstorage', 'unstorage/drivers/indexedb', 'unstorage/drivers/null', 'hookable', 'ofetch', '@nuxtjs/mdc/runtime', 'remark-mdc', 'unist-util-visit', 'destr', 'minimark/stringify', 'prosemirror-state', 'prosemirror-transform', 'prosemirror-model', 'prosemirror-view'], 71 | }, 72 | build: { 73 | cssCodeSplit: false, 74 | outDir: '../../dist/app', 75 | lib: { 76 | entry: ['./src/main.ts', './src/shared.ts', './src/service-worker.ts'], 77 | formats: ['es'], 78 | }, 79 | sourcemap: false, 80 | minify: 'terser', 81 | terserOptions: { 82 | mangle: { 83 | reserved: ['h'], // Reserve 'h' to avoid conflicts 84 | }, 85 | }, 86 | }, 87 | }) 88 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: "\U0001F41E Bug Report" 2 | description: Create a report to help us improve Nuxt Studio 3 | labels: ["pending triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please carefully read the contribution docs before creating a bug report 9 | 👉 https://nuxt.com/docs/community/reporting-bugs 10 | - type: markdown 11 | attributes: 12 | value: | 13 | Before reporting a bug, please make sure that you have read through our [documentation](https://content.nuxt.com/docs/studio/setup). 14 | - type: textarea 15 | id: env 16 | attributes: 17 | label: Environment 18 | description: You can use `npx nuxi info` to fill this section 19 | placeholder: | 20 | - Operating System: `Darwin` 21 | - Node Version: `v18.16.0` 22 | - Nuxt Version: `3.7.3` 23 | - CLI Version: `3.8.4` 24 | - Nitro Version: `2.6.3` 25 | - Package Manager: `pnpm@8.7.4` 26 | - Builder: `-` 27 | - User Config: `-` 28 | - Runtime Modules: `-` 29 | - Build Modules: `-` 30 | validations: 31 | required: true 32 | - type: input 33 | id: version 34 | attributes: 35 | label: Version 36 | placeholder: v2 37 | validations: 38 | required: true 39 | - type: textarea 40 | id: reproduction 41 | attributes: 42 | label: Reproduction 43 | description: Please provide a reproduction link. A minimal [reproduction is required](https://antfu.me/posts/why-reproductions-are-required) unless you are absolutely sure that the issue is obvious and the provided information is enough to understand the problem. If a report is vague (e.g. just a generic error message) and has no reproduction, it will receive a "needs reproduction" label. If no reproduction is provided we might close it. 44 | placeholder: https://github.com/my/reproduction 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: description 49 | attributes: 50 | label: Description 51 | description: A clear and concise description of what the bug is. If you intend to submit a PR for this issue, tell us in the description. 52 | validations: 53 | required: true 54 | - type: textarea 55 | id: additonal 56 | attributes: 57 | label: Additional context 58 | description: If applicable, add any other context or screenshots here. 59 | - type: textarea 60 | id: logs 61 | attributes: 62 | label: Logs 63 | description: | 64 | Optional if provided reproduction. Please try not to insert an image but copy paste the log text. 65 | render: shell-script -------------------------------------------------------------------------------- /src/app/src/main.ts: -------------------------------------------------------------------------------- 1 | import type { VueElementConstructor } from 'vue' 2 | import { defineCustomElement } from 'vue' 3 | import { createRouter, createMemoryHistory } from 'vue-router' 4 | // @ts-expect-error -- inline css 5 | import styles from './assets/css/main.css?inline' 6 | import { createHead } from '@unhead/vue/client' 7 | import { generateColors, tailwindColors } from './utils/colors' 8 | import { convertPropertyToVar } from './utils/styles' 9 | import { createI18n } from 'vue-i18n' 10 | import App from './app.vue' 11 | import Content from './pages/content.vue' 12 | import Media from './pages/media.vue' 13 | import Review from './pages/review.vue' 14 | import Success from './pages/success.vue' 15 | import Error from './pages/error.vue' 16 | 17 | if (typeof window !== 'undefined' && 'customElements' in window) { 18 | const NuxtStudio = defineCustomElement( 19 | App, 20 | { 21 | shadowRoot: true, 22 | configureApp(app) { 23 | const router = createRouter({ 24 | routes: [ 25 | { 26 | name: 'content', 27 | path: '/content', 28 | alias: '/', 29 | component: Content, 30 | }, 31 | { 32 | name: 'media', 33 | path: '/media', 34 | component: Media, 35 | }, 36 | { 37 | name: 'review', 38 | path: '/review', 39 | component: Review, 40 | }, 41 | { 42 | name: 'success', 43 | path: '/success', 44 | component: Success, 45 | }, 46 | { 47 | name: 'error', 48 | path: '/error', 49 | component: Error, 50 | }, 51 | ], 52 | history: createMemoryHistory(), 53 | }) 54 | 55 | app.use(router) 56 | 57 | const i18n = createI18n({ 58 | legacy: false, 59 | locale: 'en', 60 | fallbackLocale: 'en', 61 | globalInjection: true, 62 | }) 63 | 64 | app.provide('i18n', i18n) 65 | 66 | app.use(i18n) 67 | 68 | app.use({ 69 | install() { 70 | const head = createHead({ 71 | hooks: { 72 | 'dom:beforeRender': (args) => { 73 | args.shouldRender = false 74 | }, 75 | }, 76 | }) 77 | app.use(head) 78 | }, 79 | }) 80 | }, 81 | styles: [ 82 | tailwindColors, 83 | generateColors(), 84 | convertPropertyToVar(styles), 85 | ], 86 | }, 87 | ) as VueElementConstructor 88 | 89 | customElements.define('nuxt-studio', NuxtStudio) 90 | } 91 | 92 | export * from './types/index.ts' 93 | export default {} 94 | -------------------------------------------------------------------------------- /src/app/src/pages/error.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 86 | -------------------------------------------------------------------------------- /src/module/src/runtime/server/routes/dev/public/[...path].ts: -------------------------------------------------------------------------------- 1 | import type { H3Event } from 'h3' 2 | import { createError, eventHandler, getRequestHeader, readRawBody, setResponseHeader } from 'h3' 3 | import type { Storage, StorageMeta } from 'unstorage' 4 | import { withLeadingSlash } from 'ufo' 5 | // @ts-expect-error useStorage is not defined in .nuxt/imports.d.ts 6 | import { useStorage } from '#imports' 7 | 8 | export default eventHandler(async (event) => { 9 | const path = event.path.replace('/__nuxt_studio/dev/public/', '') 10 | const key = path.replace(/\//g, ':').replace(/^public-assets:/, '') 11 | const storage = useStorage('nuxt_studio_public_assets') as Storage 12 | 13 | // GET => getItem / getKeys 14 | if (event.method === 'GET') { 15 | const lastChar = key[key.length - 1]; 16 | const isBaseKey = lastChar === "/" || lastChar === ":"; 17 | if (isBaseKey) { 18 | const keys = await storage.getKeys(key); 19 | return keys.map((key) => key.replace(/:/g, "/")); 20 | } 21 | 22 | const item = await storage.getMeta(key) 23 | if (!item) { 24 | throw createError({ 25 | statusCode: 404, 26 | statusMessage: 'KV value not found', 27 | }) 28 | } 29 | return { 30 | id: `public-assets/${key.replace(/:/g, '/')}`, 31 | extension: key.split('.').pop(), 32 | stem: key.split('.').join('.'), 33 | path: '/' + key.replace(/:/g, '/'), 34 | fsPath: withLeadingSlash(key.replace(/:/g, '/')), 35 | version: new Date(item.mtime || new Date()).getTime(), 36 | } 37 | } 38 | 39 | if (event.method === 'PUT') { 40 | if (getRequestHeader(event, 'content-type') === 'application/octet-stream') { 41 | const value = await readRawBody(event, false) 42 | await storage.setItemRaw(key, value) 43 | } 44 | else if (getRequestHeader(event, 'content-type') === 'text/plain') { 45 | const value = await readRawBody(event, 'utf8') 46 | await storage.setItem(key, value!) 47 | } 48 | else { 49 | const value = await readRawBody(event, 'utf8') 50 | const json = JSON.parse(value || '{}') 51 | 52 | const data = json.raw.split(';base64,')[1] 53 | await storage.setItemRaw(key, Buffer.from(data, 'base64')) 54 | } 55 | 56 | return 'OK' 57 | } 58 | 59 | // DELETE => removeItem 60 | if (event.method === 'DELETE') { 61 | await storage.removeItem(key) 62 | return 'OK' 63 | } 64 | }) 65 | 66 | function setMetaHeaders(event: H3Event, meta: StorageMeta) { 67 | if (meta.mtime) { 68 | setResponseHeader( 69 | event, 70 | 'last-modified', 71 | new Date(meta.mtime).toUTCString(), 72 | ) 73 | } 74 | if (meta.ttl) { 75 | setResponseHeader(event, 'x-ttl', `${meta.ttl}`) 76 | setResponseHeader(event, 'cache-control', `max-age=${meta.ttl}`) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/app/src/components/tiptap/extension/TiptapExtensionFrontmatter.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 81 | -------------------------------------------------------------------------------- /src/app/src/components/form/FormPanelInput.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 108 | -------------------------------------------------------------------------------- /src/app/src/components/shared/item/ItemCard.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 83 | -------------------------------------------------------------------------------- /src/app/src/components/content/ContentEditorTipTapDebug.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 83 | -------------------------------------------------------------------------------- /src/app/src/components/content/ContentEditorForm.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 104 | -------------------------------------------------------------------------------- /src/app/src/components/shared/item/ItemCardReview.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 96 | -------------------------------------------------------------------------------- /src/module/src/runtime/composables/useMeta.ts: -------------------------------------------------------------------------------- 1 | import { createSharedComposable } from '@vueuse/core' 2 | import type { ComponentMeta } from 'nuxt-studio/app' 3 | import { shallowRef } from 'vue' 4 | import { kebabCase } from 'scule' 5 | 6 | interface Meta { 7 | components: ComponentMeta[] 8 | highlightTheme: { default: string, dark?: string, light?: string } 9 | } 10 | 11 | const defaultMeta: Meta = { 12 | components: [], 13 | highlightTheme: { default: 'github-light', dark: 'github-dark' }, 14 | } 15 | 16 | export const useHostMeta = createSharedComposable(() => { 17 | const components = shallowRef([]) 18 | const highlightTheme = shallowRef() 19 | 20 | async function fetch() { 21 | // TODO: look into this approach and consider possible refactors 22 | const data = await $fetch('/__nuxt_studio/meta', { 23 | headers: { 'content-type': 'application/json' }, 24 | }).catch(() => defaultMeta) 25 | 26 | highlightTheme.value = data.highlightTheme 27 | 28 | // Markdown elements to exclude (in kebab-case) 29 | const markdownElements = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'a', 'p', 'li', 'ul', 'ol', 'blockquote', 'code', 'code-block', 'image', 'video', 'link', 'hr', 'img', 'pre', 'em', 'bold', 'italic', 'strike', 'strong', 'tr', 'thead', 'tbody', 'tfoot', 'th', 'td']) 30 | 31 | const renamedComponents: ComponentMeta[] = [] 32 | 33 | for (const component of (data.components || [])) { 34 | // Remove "Prose" prefix 35 | let name = component.name 36 | if (component.name.startsWith('Prose')) { 37 | name = name.slice(5) 38 | } 39 | 40 | if (component.path.endsWith('.d.vue.ts')) { 41 | name = name.slice(0, -4) 42 | } 43 | 44 | renamedComponents.push({ 45 | ...component, 46 | name, 47 | }) 48 | } 49 | 50 | const processedComponents = new Map() 51 | 52 | for (const component of renamedComponents) { 53 | // Remove duplicated U-prefixed components 54 | if (component.name.startsWith('U')) { 55 | const nameWithoutU = component.name.slice(1) 56 | if (renamedComponents.find(c => c.name === nameWithoutU)) continue 57 | } 58 | 59 | // Convert to kebab-case 60 | const kebabName = kebabCase(component.name) 61 | 62 | // Filter out markdown elements 63 | if (markdownElements.has(kebabName)) continue 64 | 65 | // Handle duplicates 66 | const existing = processedComponents.get(kebabName) 67 | if (existing) { 68 | if (existing.path.endsWith('.d.vue.ts')) { 69 | continue 70 | } 71 | 72 | // Prioritize .d.vue.ts versions for more accurate metadata 73 | processedComponents.set(kebabName, { 74 | ...component, 75 | name: kebabName, 76 | }) 77 | 78 | continue 79 | } 80 | 81 | // Add component 82 | processedComponents.set(kebabName, { 83 | ...component, 84 | name: kebabName, 85 | }) 86 | } 87 | 88 | components.value = Array.from(processedComponents.values()) 89 | } 90 | 91 | return { 92 | fetch, 93 | components, 94 | highlightTheme, 95 | } 96 | }) 97 | -------------------------------------------------------------------------------- /src/module/src/runtime/utils/document/schema.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionInfo, CollectionItemBase } from '@nuxt/content' 2 | import type { DatabaseItem, DatabasePageItem } from 'nuxt-studio/app' 3 | import { getOrderedSchemaKeys } from '../collection' 4 | import { omit, pick } from '../object' 5 | import { addPageTypeFields } from './utils' 6 | 7 | export const reservedKeys = ['id', 'fsPath', 'stem', 'extension', '__hash__', 'path', 'body', 'meta', 'rawbody'] 8 | 9 | export function applyCollectionSchema(id: string, collectionInfo: CollectionInfo, document: CollectionItemBase) { 10 | let parsedContent = { ...document, id } 11 | if (collectionInfo.type === 'page') { 12 | parsedContent = addPageTypeFields(parsedContent) 13 | } 14 | 15 | const result = { id } as DatabaseItem 16 | const meta = parsedContent.meta 17 | 18 | const collectionKeys = getOrderedSchemaKeys(collectionInfo.schema) 19 | for (const key of Object.keys(parsedContent)) { 20 | if (collectionKeys.includes(key)) { 21 | result[key] = parsedContent[key as keyof typeof parsedContent] 22 | } 23 | else { 24 | meta[key] = parsedContent[key as keyof typeof parsedContent] 25 | } 26 | } 27 | 28 | // Clean fsPath from meta to avoid storing it in the database 29 | if (meta.fsPath) { 30 | Reflect.deleteProperty(meta, 'fsPath') 31 | } 32 | 33 | result.meta = meta 34 | 35 | // Storing `content` into `rawbody` field 36 | // TODO: handle rawbody 37 | // if (collectionKeys.includes('rawbody')) { 38 | // result.rawbody = result.rawbody ?? file.body 39 | // } 40 | 41 | if (collectionKeys.includes('seo')) { 42 | const seo = result.seo = (result.seo || {}) as DatabasePageItem['seo'] 43 | seo.title = seo.title || result.title as string 44 | seo.description = seo.description || result.description as string 45 | } 46 | 47 | return result 48 | } 49 | 50 | export function pickReservedKeysFromDocument(document: DatabaseItem): DatabaseItem { 51 | return pick(document, reservedKeys) as DatabaseItem 52 | } 53 | 54 | export function removeReservedKeysFromDocument(document: DatabaseItem): DatabaseItem { 55 | const result = omit(document, reservedKeys) 56 | // Default value of navigation is true, so we can safely remove it 57 | if (result.navigation === true) { 58 | Reflect.deleteProperty(result, 'navigation') 59 | } 60 | 61 | if (document.seo) { 62 | const seo = document.seo as Record 63 | if ( 64 | (!seo.title || seo.title === document.title) 65 | && (!seo.description || seo.description === document.description) 66 | ) { 67 | Reflect.deleteProperty(result, 'seo') 68 | } 69 | } 70 | 71 | if (!document.title) { 72 | Reflect.deleteProperty(result, 'title') 73 | } 74 | if (!document.description) { 75 | Reflect.deleteProperty(result, 'description') 76 | } 77 | 78 | // expand meta to the root 79 | for (const key in (document.meta || {})) { 80 | if (!reservedKeys.includes(key)) { 81 | result[key] = (document.meta as Record)[key] 82 | } 83 | } 84 | 85 | for (const key in (result || {})) { 86 | if (result[key] === null) { 87 | Reflect.deleteProperty(result, key) 88 | } 89 | } 90 | 91 | return result as DatabaseItem 92 | } 93 | -------------------------------------------------------------------------------- /src/app/src/components/shared/item/ItemBreadcrumb.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 98 | -------------------------------------------------------------------------------- /src/module/src/runtime/host.dev.ts: -------------------------------------------------------------------------------- 1 | import { useStudioHost as useStudioHostBase } from './host' 2 | import type { StudioUser, DatabaseItem, Repository } from 'nuxt-studio/app' 3 | import { getCollectionByFilePath, generateIdFromFsPath, generateFsPathFromId, getCollectionById } from './utils/collection' 4 | import { applyCollectionSchema } from './utils/document' 5 | import { createStorage } from 'unstorage' 6 | import httpDriver from 'unstorage/drivers/http' 7 | import { useRuntimeConfig } from '#imports' 8 | import { collections } from '#content/preview' 9 | import { debounce } from 'perfect-debounce' 10 | import { getCollectionSourceById } from './utils/source' 11 | 12 | export function useStudioHost(user: StudioUser, repository: Repository) { 13 | const host = useStudioHostBase(user, repository) 14 | 15 | if (!useRuntimeConfig().public.studio.dev) { 16 | return host 17 | } 18 | 19 | // enable dev mode 20 | host.meta.dev = true 21 | 22 | const devStorage = createStorage({ 23 | driver: httpDriver({ base: '/__nuxt_studio/dev/content' }), 24 | }) 25 | 26 | host.app.requestRerender = () => { 27 | // no operation let hmr do the job 28 | } 29 | 30 | // TODO @farnabaz to check 31 | host.document.db.upsert = debounce(async (fsPath: string, upsertedDocument: DatabaseItem) => { 32 | const collectionInfo = getCollectionByFilePath(fsPath, collections) 33 | if (!collectionInfo) { 34 | throw new Error(`Collection not found for fsPath: ${fsPath}`) 35 | } 36 | 37 | const id = generateIdFromFsPath(fsPath, collectionInfo) 38 | const document = applyCollectionSchema(id, collectionInfo, upsertedDocument) 39 | 40 | const content = await host.document.generate.contentFromDocument(document) 41 | 42 | await $fetch('/__nuxt_studio/dev/content/' + fsPath, { 43 | method: 'PUT', 44 | body: content, 45 | headers: { 'content-type': 'text/plain' }, 46 | timeout: 100, 47 | }).catch(() => { /* do nothing, expected error if request timeout, API errors can be detected in server logs */ }) 48 | }, 100) 49 | 50 | // TODO @farnabaz to check 51 | host.document.db.delete = async (fsPath: string) => { 52 | await devStorage.removeItem(fsPath) 53 | } 54 | 55 | // TODO @farnabaz 56 | host.on.documentUpdate = (fn: (id: string, type: 'remove' | 'update') => void) => { 57 | // @ts-expect-error import.meta.hot is not defined in types 58 | import.meta.hot.on('nuxt-content:update', (data: { key: string, queries: string[] }) => { 59 | const collection = getCollectionById(data.key, collections) 60 | const source = getCollectionSourceById(data.key, collection.source) 61 | const fsPath = generateFsPathFromId(data.key, source!) 62 | 63 | const isRemoved = data.queries.length === 0 // In case of update there is one remove and one insert query 64 | fn(fsPath, isRemoved ? 'remove' : 'update') 65 | }) 66 | } 67 | 68 | // TODO @farnabaz 69 | host.on.mediaUpdate = (fn: (id: string, type: 'remove' | 'update') => void) => { 70 | // @ts-expect-error import.meta.hot is not defined in types 71 | import.meta.hot.on('nuxt-studio:media:update', (data: { type: string, id: string }) => { 72 | fn(data.id, data.type as 'remove' | 'update') 73 | }) 74 | } 75 | 76 | return host 77 | } 78 | --------------------------------------------------------------------------------