├── .editorconfig ├── .github └── workflows │ └── studio.yml ├── .gitignore ├── .npmrc ├── .nuxtrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.mjs ├── package.json ├── playground ├── app.config.ts ├── app.vue ├── components │ └── content │ │ ├── AppConfig.vue │ │ ├── ArticlesList.vue │ │ └── StudioComponents.vue ├── content │ ├── 1.index.md │ ├── 2.articles.md │ ├── 4.app-config.md │ ├── 5.components.md │ └── articles │ │ ├── 1.get-started.md │ │ ├── 2.configure.md │ │ ├── 3.write-articles.md │ │ └── 4.design-tokens.md ├── nuxt.config.ts ├── nuxt.schema.ts ├── pages │ └── [...slug].vue ├── public │ ├── alpine-0.webp │ ├── alpine-1.webp │ ├── alpine-2.webp │ ├── articles │ │ ├── configure-alpine.webp │ │ ├── design-tokens.webp │ │ ├── get-started.webp │ │ └── write-articles.webp │ ├── logo-dark.svg │ ├── logo.svg │ ├── robots.txt │ └── social-card-preview.png └── tsconfig.json ├── pnpm-lock.yaml ├── renovate.json ├── src ├── module.ts ├── runtime │ ├── components │ │ └── ContentPreviewMode.vue │ ├── composables │ │ ├── useContentStorage.ts │ │ └── useStudio.ts │ ├── plugins │ │ └── preview.client.ts │ ├── server │ │ ├── dev-api │ │ │ └── files.ts │ │ └── routes │ │ │ └── studio.ts │ ├── types │ │ ├── api.d.ts │ │ └── index.d.ts │ └── utils │ │ ├── files.ts │ │ └── index.ts └── theme.ts ├── theme.d.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.github/workflows/studio.yml: -------------------------------------------------------------------------------- 1 | name: Publish Studio (Nightly) 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | nightly: 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest] 11 | node: [20] 12 | 13 | runs-on: ${{ matrix.os }} 14 | permissions: 15 | id-token: write 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 # v4 19 | - name: Add pnpm 20 | run: corepack enable 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 20 24 | registry-url: "https://registry.npmjs.org/" 25 | cache: "pnpm" 26 | - name: Install dependencies 27 | run: pnpm install 28 | - name: Prepare 29 | run: pnpm dev:prepare 30 | - name: Build 31 | run: pnpm build 32 | - name: Release Nightly 33 | run: pnpm changelogen --bump --canary --publish --publishTag nightly 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 36 | NPM_CONFIG_PROVENANCE: true 37 | -------------------------------------------------------------------------------- /.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 | .pnp.* 14 | .yarn/* 15 | !.yarn/patches 16 | !.yarn/plugins 17 | !.yarn/releases 18 | !.yarn/sdks 19 | !.yarn/versions 20 | 21 | # Generated dirs 22 | dist 23 | 24 | # Nuxt 25 | .nuxt 26 | .output 27 | .vercel_build_output 28 | .build-* 29 | .env 30 | .netlify 31 | 32 | # Env 33 | .env 34 | 35 | # Testing 36 | reports 37 | coverage 38 | *.lcov 39 | .nyc_output 40 | 41 | # Intellij idea 42 | *.iml 43 | .idea 44 | 45 | # OSX 46 | .DS_Store 47 | .AppleDouble 48 | .LSOverride 49 | .AppleDB 50 | .AppleDesktop 51 | Network Trash Folder 52 | Temporary Items 53 | .apdisk 54 | 55 | # VSCode 56 | .history 57 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.nuxtrc: -------------------------------------------------------------------------------- 1 | typescript.includeWorkspace=true 2 | # imports.autoImport=false 3 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "name": "Launch", 7 | "request": "launch", 8 | "preLaunchTask": "start" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "files.associations": { 6 | "*.css": "postcss" 7 | }, 8 | "editor.formatOnSave": false 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "ui", 7 | "problemMatcher": [], 8 | "label": "Studio UI", 9 | "isBackground": true, 10 | "icon": { 11 | "id": "layout-sidebar-left", 12 | "color": "terminal.ansiGreen" 13 | } 14 | }, 15 | { 16 | "type": "npm", 17 | "script": "play", 18 | "problemMatcher": [], 19 | "label": "Playground", 20 | "isBackground": true, 21 | "icon": { 22 | "id": "layout-sidebar-right", 23 | "color": "terminal.ansiGreen" 24 | } 25 | }, 26 | { 27 | "type": "shell", 28 | "label": "start", 29 | "dependsOn": [ 30 | "Studio UI", 31 | "Playground" 32 | ], 33 | "detail": "Start playground and UI at once", 34 | "isBackground": true, 35 | "problemMatcher": [], 36 | "group": { 37 | "isDefault": true 38 | } 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [0.3.4](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.3.3...v0.3.4) (2022-12-06) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * support baseURL for app.config ([f875826](https://github.com/nuxtlabs/studio.nuxt.com/commit/f87582667728ddbd430924865964b3916984ac2e)) 11 | 12 | ### [0.3.3](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.3.2...v0.3.3) (2022-12-06) 13 | 14 | 15 | ### Features 16 | 17 | * **app-config:** add support for `.studio` files ([#80](https://github.com/nuxtlabs/studio.nuxt.com/issues/80)) ([8804f40](https://github.com/nuxtlabs/studio.nuxt.com/commit/8804f40da086a704f91ba2bcaa018d62ce75bcf3)) 18 | 19 | ### [0.3.2](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.3.1...v0.3.2) (2022-12-05) 20 | 21 | 22 | ### Features 23 | 24 | * appConfig improvements ([#78](https://github.com/nuxtlabs/studio.nuxt.com/issues/78)) ([6e4a6da](https://github.com/nuxtlabs/studio.nuxt.com/commit/6e4a6da71e8109fedc9345ebf5129601fc76bd2a)) 25 | 26 | ### [0.3.1](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.3.0...v0.3.1) (2022-11-29) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * enable studio only in production ([#76](https://github.com/nuxtlabs/studio.nuxt.com/issues/76)) ([bc8234c](https://github.com/nuxtlabs/studio.nuxt.com/commit/bc8234c4bfef20e00488a7c23b1779c67a74bdcd)) 32 | * force nuxt-config-schema for now until part of core ([5805047](https://github.com/nuxtlabs/studio.nuxt.com/commit/58050476b1f95dee13ebb0624d9322de537adcfa)) 33 | 34 | ## [0.3.0](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.7...v0.3.0) (2022-11-29) 35 | 36 | 37 | ### ⚠ BREAKING CHANGES 38 | 39 | * expose app config (#74) 40 | 41 | ### Features 42 | 43 | * expose app config ([#74](https://github.com/nuxtlabs/studio.nuxt.com/issues/74)) ([4d0d200](https://github.com/nuxtlabs/studio.nuxt.com/commit/4d0d20088eb3b47d7e05e1adc8fda61e0b60a3e4)) 44 | 45 | ### [0.2.7](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.6...v0.2.7) (2022-11-25) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * **components:** drop exposed field in meta ([#71](https://github.com/nuxtlabs/studio.nuxt.com/issues/71)) ([e2570fb](https://github.com/nuxtlabs/studio.nuxt.com/commit/e2570fbd71eba45b5cb460d685822f17cd918222)) 51 | * **preview:** prevent calling `refreshNuxtData` when preview mode is disabled ([#73](https://github.com/nuxtlabs/studio.nuxt.com/issues/73)) ([955e2a4](https://github.com/nuxtlabs/studio.nuxt.com/commit/955e2a409045a65b6392a559aaf9342390846ede)) 52 | 53 | ### [0.2.6](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.5...v0.2.6) (2022-11-21) 54 | 55 | 56 | ### Features 57 | 58 | * disable preview mode is query exists and its falsy ([#68](https://github.com/nuxtlabs/studio.nuxt.com/issues/68)) ([8611eea](https://github.com/nuxtlabs/studio.nuxt.com/commit/8611eeae70fc9873f74fa64b6ea5ce352b67467e)) 59 | * **preview:** send events through message to studio ([#65](https://github.com/nuxtlabs/studio.nuxt.com/issues/65)) ([4c7d8d1](https://github.com/nuxtlabs/studio.nuxt.com/commit/4c7d8d17404f506b503feda57f2bd0342a9e5d7a)) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * **preview:** change loading background in dark mode ([#64](https://github.com/nuxtlabs/studio.nuxt.com/issues/64)) ([6397d6b](https://github.com/nuxtlabs/studio.nuxt.com/commit/6397d6b3fd3bc7ba424120f5b7bb56e2831b9b9a)) 65 | 66 | ### [0.2.5](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.4...v0.2.5) (2022-11-18) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * **socket:** add auth token ([#67](https://github.com/nuxtlabs/studio.nuxt.com/issues/67)) ([9962ae5](https://github.com/nuxtlabs/studio.nuxt.com/commit/9962ae5dd4d57819472f9df5d74a6a316b64a7fb)) 72 | 73 | ### [0.2.4](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.3...v0.2.4) (2022-11-18) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * **preview:** send token from params for sync ([#66](https://github.com/nuxtlabs/studio.nuxt.com/issues/66)) ([c985aa3](https://github.com/nuxtlabs/studio.nuxt.com/commit/c985aa3fbc446b8c7ec62b0820fff6fe0e03459c)) 79 | 80 | ### [0.2.3](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.2...v0.2.3) (2022-11-16) 81 | 82 | 83 | ### Features 84 | 85 | * **preview:** loading overlay ([#63](https://github.com/nuxtlabs/studio.nuxt.com/issues/63)) ([084b5d7](https://github.com/nuxtlabs/studio.nuxt.com/commit/084b5d7d46f34f422ac72acf992dbaff973f4476)) 86 | 87 | ### [0.2.2](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.1...v0.2.2) (2022-11-10) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * missing useRuntimeConfig import ([dcf7d30](https://github.com/nuxtlabs/studio.nuxt.com/commit/dcf7d30b46f82063077890e375c536644758d6d4)) 93 | 94 | ### [0.2.1](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.2.0...v0.2.1) (2022-11-10) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * add missing import ([55364f5](https://github.com/nuxtlabs/studio.nuxt.com/commit/55364f5ef7aea2e4f4f2fdd03de89e8e416033ad)) 100 | 101 | ## [0.2.0](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.1.5...v0.2.0) (2022-11-10) 102 | 103 | 104 | ### ⚠ BREAKING CHANGES 105 | 106 | * Rename to /__studio and expose modules meta (#60) 107 | 108 | ### Features 109 | 110 | * Rename to /__studio and expose modules meta ([#60](https://github.com/nuxtlabs/studio.nuxt.com/issues/60)) ([fbb3147](https://github.com/nuxtlabs/studio.nuxt.com/commit/fbb3147f76b6b60a9ed8ec99744014a511ca079d)) 111 | 112 | ### [0.1.5](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.1.4...v0.1.5) (2022-11-09) 113 | 114 | 115 | ### Bug Fixes 116 | 117 | * handle missing content in drafts ([#58](https://github.com/nuxtlabs/studio.nuxt.com/issues/58)) ([50f88d7](https://github.com/nuxtlabs/studio.nuxt.com/commit/50f88d7ebbfb1734cb5037e66e0d8419d3c10533)) 118 | 119 | ### [0.1.4](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.1.3...v0.1.4) (2022-11-03) 120 | 121 | 122 | ### Features 123 | 124 | * add /_studio_enabled.json route ([2e24f0a](https://github.com/nuxtlabs/studio.nuxt.com/commit/2e24f0acf766e05d19b0aaf7532383e5b51648ce)), closes [#56](https://github.com/nuxtlabs/studio.nuxt.com/issues/56) 125 | 126 | ### [0.1.3](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.1.2...v0.1.3) (2022-11-03) 127 | 128 | 129 | ### Features 130 | 131 | * support STUDIO_API as env variable ([14a333a](https://github.com/nuxtlabs/studio.nuxt.com/commit/14a333a4327463225b6894503c366ecc6ccd2364)) 132 | 133 | ### [0.1.2](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.1.1...v0.1.2) (2022-11-03) 134 | 135 | 136 | ### Bug Fixes 137 | 138 | * lint ([8ddc1f4](https://github.com/nuxtlabs/studio.nuxt.com/commit/8ddc1f4d9accfad13ccd3d79283c7646137931c3)) 139 | 140 | ### [0.1.1](https://github.com/nuxtlabs/studio.nuxt.com/compare/v0.1.0...v0.1.1) (2022-11-03) 141 | 142 | 143 | ### Bug Fixes 144 | 145 | * add z-index and padding for preview bar ([aaa2b72](https://github.com/nuxtlabs/studio.nuxt.com/commit/aaa2b72c59d823bb6df7c7d8bb3755b055f5545b)) 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-2023 NuxtLabs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Studio Module 2 | 3 | 8 | 9 | > [!CAUTION] 10 | > This module is now deprecated as it has been integrated in [Nuxt Content](https://content.nuxt.com) version 3. Check the [documentation](https://content.nuxt.com/docs/studio/setup) for more info about how to enable Studio preview with Content v3. 11 | > You can still use it if you're using Nuxt Content v2 but we recommend moving to the v3. 12 | 13 | Official module of [Nuxt Studio](https://nuxt.studio). 14 | 15 | Edit your websites made with [Nuxt Content](https://content.nuxt.com/), in production on any device. 16 | 17 | 📖  Official [Documentation](https://nuxt.studio/docs/get-started/setup) 18 | 19 | ## Features 20 | 21 | - 🚀  Production [live preview](https://nuxt.studio/docs/projects/preview) 22 | - ⌨️  Edit your [content](https://nuxt.studio/docs/developers/content) 23 | - ⚙️  Update your [configs](https://nuxt.studio/docs/developers/app-config) 24 | 25 | ## Installation 26 | 27 | Install the dependency to your project: 28 | 29 | ```bash 30 | npx nuxi@latest module add studio 31 | ``` 32 | 33 | Then, register the module in your `nuxt.config.ts`: 34 | 35 | ```ts 36 | export default defineNuxtConfig({ 37 | modules: [ 38 | '@nuxthq/studio' 39 | ] 40 | }) 41 | ``` 42 | 43 | ## Configuration 44 | 45 | Check out our setup [requirements](https://nuxt.studio/docs/projects/setup#requirements-to-use-the-studio-editor). 46 | 47 | By default the Studio API is `https://api.nuxt.studio`. If you want to customise it, you can set the `STUDIO_API` environement variable. 48 | 49 | ```bash 50 | # .env 51 | STUDIO_API=http://localhost:{PORT} 52 | ``` 53 | 54 | ## Nightly Builds 55 | 56 | You can install the latest nightly build of the Studio module by running: 57 | 58 | ```bash 59 | npx nuxi@latest module add studio 60 | ``` 61 | 62 | ### Development 63 | 64 | - Run `pnpm i` to install dependencies. 65 | - Run `pnpm dev:prepare` to prepare the module in development mode. 66 | - Run `pnpm dev` to start the dev server using [`playground/`](./playground/) as the project. 67 | - Visit http://localhost:3000/ 68 | 69 | ## License 70 | 71 | [MIT License](./LICENSE) 72 | 73 | Copyright (c) NuxtLabs 74 | 75 | 76 | [npm-version-src]: https://img.shields.io/npm/v/@nuxthq/studio/latest.svg 77 | [npm-version-href]: https://npmjs.com/package/@nuxthq/studio 78 | 79 | [npm-downloads-src]: https://img.shields.io/npm/dm/@nuxthq/studio.svg 80 | [npm-downloads-href]: https://npmjs.com/package/@nuxthq/studio 81 | 82 | [github-actions-ci-src]: https://github.com/nuxtlabs/studio/workflows/studio/badge.svg 83 | [github-actions-ci-href]: https://github.com/nuxtlabs/studio/actions/workflows/studio.yml 84 | 85 | [codecov-src]: https://img.shields.io/codecov/c/github/@nuxthq/studio.svg 86 | [codecov-href]: https://codecov.io/gh/@nuxthq/studio 87 | 88 | [license-src]: https://img.shields.io/npm/l/@nuxthq/studio.svg 89 | [license-href]: https://npmjs.com/package/@nuxthq/studio 90 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 3 | 4 | export default createConfigForNuxt({ 5 | features: { 6 | tooling: true, 7 | stylistic: true, 8 | }, 9 | }, { 10 | rules: { 11 | 'vue/multi-word-component-names': 'off', 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nuxthq/studio", 3 | "description": "Nuxt Studio is a visual editor for content-driven Nuxt application with Markdown.", 4 | "version": "2.2.1", 5 | "packageManager": "pnpm@9.3.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/nuxtlabs/studio-module.git" 9 | }, 10 | "homepage": "https://nuxt.studio", 11 | "keywords": [ 12 | "vue", 13 | "nuxt", 14 | "markdown", 15 | "studio", 16 | "nuxtcms", 17 | "nuxtcontent" 18 | ], 19 | "license": "MIT", 20 | "files": [ 21 | "dist", 22 | "theme.d.ts" 23 | ], 24 | "type": "module", 25 | "exports": { 26 | ".": { 27 | "import": "./dist/module.mjs", 28 | "require": "./dist/module.cjs", 29 | "types": "./dist/module.d.ts" 30 | }, 31 | "./theme": { 32 | "import": "./dist/theme.mjs", 33 | "require": "./dist/theme.cjs", 34 | "types": "./theme.d.ts" 35 | } 36 | }, 37 | "main": "./dist/module.cjs", 38 | "types": "./dist/types.d.ts", 39 | "scripts": { 40 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground", 41 | "dev": "nuxt dev playground --tunnel", 42 | "build": "nuxt-module-build build", 43 | "typecheck": "nuxi typecheck", 44 | "lint": "eslint .", 45 | "lint:fix": "eslint . --fix", 46 | "prepack": "pnpm lint && pnpm build", 47 | "release": "pnpm lint && release-it", 48 | "pre:release": "pnpm lint && release-it --preRelease" 49 | }, 50 | "dependencies": { 51 | "@nuxt/kit": "^3.13.2", 52 | "defu": "^6.1.4", 53 | "git-url-parse": "^15.0.0", 54 | "nuxt-component-meta": "^0.9.0", 55 | "parse-git-config": "^3.0.0", 56 | "pkg-types": "^1.2.1", 57 | "socket.io-client": "^4.8.1", 58 | "ufo": "^1.5.4", 59 | "untyped": "^1.5.1" 60 | }, 61 | "devDependencies": { 62 | "@nuxt/content": "^2.13.4", 63 | "@nuxt/eslint": "^0.6.1", 64 | "@nuxt/image": "^1.8.1", 65 | "@nuxt/module-builder": "^0.8.4", 66 | "@nuxt/schema": "^3.13.2", 67 | "@nuxt/ui-pro": "^1.4.4", 68 | "@types/node": "^22.8.2", 69 | "changelogen": "^0.5.7", 70 | "consola": "^3.2.3", 71 | "eslint": "^9.13.0", 72 | "nuxt": "^3.13.2", 73 | "release-it": "^17.10.0", 74 | "typescript": "^5.6.3" 75 | }, 76 | "release-it": { 77 | "git": { 78 | "commitMessage": "chore(release): release v${version}" 79 | }, 80 | "github": { 81 | "release": true, 82 | "releaseName": "v${version}" 83 | } 84 | }, 85 | "pnpm": { 86 | "peerDependencyRules": { 87 | "allowedVersions": { 88 | "vue": "*" 89 | }, 90 | "ignoreMissing": [ 91 | "@babel/core", 92 | "webpack", 93 | "axios", 94 | "postcss", 95 | "vue" 96 | ] 97 | } 98 | }, 99 | "unbuild": { 100 | "entries": [ 101 | "./src/module", 102 | "./src/theme" 103 | ], 104 | "externals": [ 105 | "untyped" 106 | ], 107 | "rollup": { 108 | "emitCJS": true 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /playground/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | 3 | }) 4 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 19 | -------------------------------------------------------------------------------- /playground/components/content/AppConfig.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /playground/components/content/ArticlesList.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /playground/components/content/StudioComponents.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /playground/content/1.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: About 3 | --- 4 | 5 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed non risus. Suspendisse lectus tortor, dignissim sit amet, adipiscing nec, ultricies sed, dolor. Cras elementum ultrices diam. Maecenas ligula massa, varius a, semper congue, euismod non, mi. Proin porttitor, orci nec nonummy molestie, enim est eleifend mi, non fermentum diam nisl sit amet erat. Duis semper. Duis arcu massa, scelerisque vitae, consequat in, pretium a, enim. 6 | 7 | Pellentesque congue. Ut in risus volutpat libero pharetra tempor. Cras vestibulum bibendum augue. Praesent egestas leo in pede. Praesent blandit odio eu enim. Pellentesque sed dui ut augue blandit sodales. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam nibh. Mauris ac mauris sed pede pellentesque fermentum. Maecenas adipiscing ante non diam sodales hendrerit. 8 | -------------------------------------------------------------------------------- /playground/content/2.articles.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Articles' 3 | --- 4 | 5 | :articles-list 6 | -------------------------------------------------------------------------------- /playground/content/4.app-config.md: -------------------------------------------------------------------------------- 1 | # App Config 2 | 3 | :app-config 4 | -------------------------------------------------------------------------------- /playground/content/5.components.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | :studio-components 4 | -------------------------------------------------------------------------------- /playground/content/articles/1.get-started.md: -------------------------------------------------------------------------------- 1 | # Get started with Alpine 2 | 3 | Creating a blog with Alpine is a command away, as well as deploying to many platforms. 4 | 5 | ## Create a blog 6 | 7 | Open a terminal an run the following command: 8 | 9 | ```bash 10 | npx nuxi@latest init -t themes/alpine 11 | ``` 12 | 13 | ## Dependencies 14 | 15 | Next, go to to `my-blog/` directory and install the dependencies: 16 | 17 | ```bash 18 | npm install 19 | ``` 20 | 21 | ## Development 22 | 23 | Start the development server on port 3000: 24 | 25 | ```bash 26 | npm run dev 27 | ``` 28 | 29 | Next, you can start creating your content in Markdown in the `content/` directory. 30 | 31 | 32 | ## Deploy 33 | 34 | ### Static hosting 35 | 36 | You can deploy Alpine to any static hosting by running the following command: 37 | 38 | ```bash 39 | npm run generate 40 | ``` 41 | 42 | This command will create a `dist/` directory with the compiled files ready to be uploaded to any static hosting. 43 | 44 | ### Edge platforms 45 | 46 | Alpine supports deploying to the following platforms with **zero configuration**: 47 | 48 | - [Vercel](https://vercel.com) 49 | - [Netlify](https://netlify.com) 50 | - [and more...](https://v3.nuxtjs.org/guide/deploy/presets#supported-hosting-providers) 51 | 52 | ### Node server 53 | 54 | You can deploy Alpine to a Node server by running the following command: 55 | 56 | ```bash 57 | npm run build 58 | ``` 59 | 60 | This command will create a `.output/` directory with the compiled files ready to be uploaded to any Node server. 61 | 62 | To start the production server, run the following command: 63 | 64 | ```bash 65 | node .output/server/index.mjs 66 | ``` 67 | -------------------------------------------------------------------------------- /playground/content/articles/2.configure.md: -------------------------------------------------------------------------------- 1 | # Configure Alpine 2 | 3 | To configure meta tags, social links or even the Alpine theme display, update the `alpine` key in the `app.config.ts` at the root of your project: 4 | 5 | ```ts [app.config.ts] 6 | export default defineAppConfig({ 7 | alpine: { 8 | /* Alpine configuration goes here */ 9 | } 10 | } 11 | ``` 12 | 13 | You can look at the [default config](https://github.com/nuxt-themes/alpine/tree/dev/app.config.ts). 14 | 15 | The [config schema](https://github.com/nuxt-themes/alpine/tree/dev/app.config.ts) also gives comments on all the configuration parameters. 16 | 17 | ## Meta tags 18 | 19 | Configure the title, description and social image of your website: 20 | 21 | ```ts [app.config.ts] 22 | export default defineAppConfig({ 23 | alpine: { 24 | title: 'Alpine', 25 | description: 'The minimalist blog theme', 26 | image: '/social-card-preview.png', 27 | // image can also be an object: 28 | image: { 29 | src: '/social-card-preview.png', 30 | alt: 'An image showcasing my project.', 31 | width: 400, 32 | height: 300 33 | } 34 | } 35 | }) 36 | ``` 37 | 38 | ## Social links 39 | 40 | To configure the social links displayed in the footer, use the `socials` property: 41 | 42 | ```ts [app.config.ts] 43 | export default defineAppConfig({ 44 | alpine: { 45 | socials: { 46 | twitter: 'nuxtlabs', 47 | instagram: 'wearenuxt', 48 | linkedin: { 49 | icon: 'uil:linkedin', 50 | label: 'LinkedIn', 51 | href: 'https://www.linkedin.com/company/nuxtlabs' 52 | } 53 | } 54 | } 55 | }) 56 | ``` 57 | 58 | ## Theme display 59 | 60 | Alpine Header and Footer can also be customized via the `app.config.ts` file: 61 | 62 | ```ts [app.config.ts] 63 | defineAppConfig({ 64 | alpine: { 65 | // Remove header with header: false 66 | header: { 67 | position: 'inline', // possible value are : 'none' | 'left' | 'center' | 'right' | 'inline' 68 | logo: false 69 | }, 70 | // Remove header with footer: false 71 | footer: { 72 | credits: { 73 | enabled: true, // possible value are : true | false 74 | repository: 'https://www.github.com/nuxt-themes/alpine' // our github repository 75 | }, 76 | navigation: false, // possible value are : true | false 77 | position: 'center', // possible value are : 'none' | 'left' | 'center' | 'right' 78 | message: 'Follow me on' // string that will be displayed on the footer (leave empty or delete to disable) 79 | } 80 | }) 81 | ``` 82 | -------------------------------------------------------------------------------- /playground/content/articles/3.write-articles.md: -------------------------------------------------------------------------------- 1 | # Write Articles 2 | 3 | Write Markdown articles in Alpine is straightforward. 4 | 5 | ## Create an articles list 6 | 7 | Create a new file in the `content/` directory: 8 | 9 | ```bash 10 | touch content/2.articles.md 11 | ``` 12 | 13 | The numbered prefix determines the order of the menu items. 14 | 15 | In this file, use the `articles-list` component to display the list of articles: 16 | 17 | ```md [2.articles.md] 18 | --- 19 | title: 'Articles' 20 | layout: 'page' 21 | --- 22 | 23 | ::articles-list 24 | --- 25 | path: articles 26 | --- 27 | :: 28 | 29 | ``` 30 | 31 | The `path` prop corresponds to the directory where the articles are stored. 32 | 33 | ## Display an article in the list 34 | 35 | Create a new file in the `/content/articles` directory: 36 | 37 | ```bash 38 | mkdir content/articles 39 | touch content/articles/1.my-new-article.md 40 | ``` 41 | 42 | For your article to be correctly displayed in the [articles list](/articles), define a `cover` and `date` property in the frontmatter: 43 | 44 | ```yaml [content/articles/1.my-new-article.md] 45 | --- 46 | cover: path/to/cover 47 | date: 2022-08-23 48 | --- 49 | ``` 50 | 51 | The `cover` property can be a local path relative to the `/public` directory or an external URL. 52 | 53 | Your article will now be displayed in the list with its filename as a default title. 54 | 55 | ## Edit your article 56 | 57 | Under the frontmatter block, enter a Markdown `h1` tag and a line of text: 58 | 59 | ```md [content/articles/1.my-new-article.md] 60 | --- 61 | cover: path/to/cover 62 | date: 2022-08-23 63 | --- 64 | 65 | # An awesome article 66 | 67 | This article is little by size but big by heart. 68 | ``` 69 | 70 | Your article will now be displayed in the list with the title and description you wrote in Markdown. 71 | 72 | ## Override title and description 73 | 74 | If you want to change the title and description displayed on the list and in the meta tags of the article, add the `title` and `description` property to your frontmatter: 75 | 76 | ```md[content/articles/1.my-new-article.md] 77 | --- 78 | cover: path/to/cover 79 | date: 2022-08-23 80 | title: Another title 81 | description: Another description 82 | --- 83 | ``` 84 | 85 | You are now ready to edit your article and create new ones! 86 | 87 | ## Read more 88 | 89 | Alpine is a Nuxt theme using the Content module in `documentDriven` mode. 90 | 91 | 👉 Learn more in the [Nuxt Content documentation](https://content.nuxtjs.org/). 92 | -------------------------------------------------------------------------------- /playground/content/articles/4.design-tokens.md: -------------------------------------------------------------------------------- 1 | # Customize Alpine 2 | 3 | Leverage the `tokens.config.ts` to give your identity to Alpine. 4 | 5 | Look at the [default tokens config](https://github.com/nuxt-themes/alpine/tree/dev/tokens.config.ts) to check all the Alpine related Design Tokens. 6 | 7 | Alpine is also powered by [@nuxt-themes/tokens](https://github.com/nuxt-themes/tokens). 8 | 9 | You can configure all the theme tokens to change the apperance of Alpine. 10 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { consola } from 'consola' 2 | import { defineNuxtConfig } from 'nuxt/config' 3 | 4 | export default defineNuxtConfig({ 5 | extends: '@nuxt/ui-pro', 6 | modules: ['@nuxt/ui', '@nuxt/content', '../src/module', '@nuxt/image'], 7 | 8 | compatibilityDate: '2024-09-11', 9 | 10 | hooks: { 11 | // Set all components to global 12 | 'components:extend': () => { 13 | // components.forEach(component => { 14 | // if (component.pascalName[0] === 'U') { 15 | // component.global = true 16 | // } 17 | // }) 18 | }, 19 | 'listen': async (_, { getURLs }) => { 20 | const urls = await getURLs() 21 | const tunnelURL = urls.find((u: { type: string }) => u.type === 'tunnel') 22 | if (!tunnelURL) return consola.warn('Could not get Tunnel URL') 23 | consola.box( 24 | 'Nuxt Studio Playground Ready.\n\n' 25 | + '1. Go to https://nuxt.studio/@studio/studio-module\n' 26 | + '2. Paste `' + tunnelURL.url + '` in the Deployed URL field\n' 27 | + '3. Play with the Studio Playground!', 28 | ) 29 | }, 30 | }, 31 | 32 | studio: { 33 | enabled: true, 34 | }, 35 | }) 36 | -------------------------------------------------------------------------------- /playground/nuxt.schema.ts: -------------------------------------------------------------------------------- 1 | import { field, group } from '@nuxthq/studio/theme' 2 | 3 | export default defineNuxtSchema({ 4 | appConfig: { 5 | header: group({ 6 | title: 'Header', 7 | fields: { 8 | title: field({ type: 'string' }), 9 | }, 10 | }), 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /playground/pages/[...slug].vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /playground/public/alpine-0.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/alpine-0.webp -------------------------------------------------------------------------------- /playground/public/alpine-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/alpine-1.webp -------------------------------------------------------------------------------- /playground/public/alpine-2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/alpine-2.webp -------------------------------------------------------------------------------- /playground/public/articles/configure-alpine.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/articles/configure-alpine.webp -------------------------------------------------------------------------------- /playground/public/articles/design-tokens.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/articles/design-tokens.webp -------------------------------------------------------------------------------- /playground/public/articles/get-started.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/articles/get-started.webp -------------------------------------------------------------------------------- /playground/public/articles/write-articles.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/articles/write-articles.webp -------------------------------------------------------------------------------- /playground/public/logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /playground/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /playground/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: -------------------------------------------------------------------------------- /playground/public/social-card-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nuxtlabs/studio-module/fa9431483d6160fd0baa9b3c84734a6fa36e6640/playground/public/social-card-preview.png -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "@nuxtjs" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'node:fs' 2 | import path from 'node:path' 3 | import { defu } from 'defu' 4 | import { addPrerenderRoutes, installModule, defineNuxtModule, addPlugin, extendViteConfig, createResolver, logger, addComponentsDir, addServerHandler, resolveAlias, addVitePlugin } from '@nuxt/kit' 5 | import { findNearestFile } from 'pkg-types' 6 | // @ts-expect-error import does exist 7 | import gitUrlParse from 'git-url-parse' 8 | import { version } from '../package.json' 9 | 10 | const log = logger.withTag('@nuxt/studio') 11 | 12 | export interface ModuleOptions { 13 | /** 14 | * Enable Studio mode 15 | * @default: 'production' 16 | */ 17 | enabled: 'production' | true 18 | gitInfo: GitInfo | null 19 | } 20 | 21 | export default defineNuxtModule({ 22 | meta: { 23 | name: 'studio', 24 | configKey: 'studio', 25 | }, 26 | defaults: { 27 | enabled: 'production', 28 | gitInfo: null, 29 | }, 30 | async setup(options, nuxt) { 31 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 32 | nuxt.hook('schema:resolved', (schema: any) => { 33 | nuxt.options.runtimeConfig.appConfigSchema = { 34 | properties: schema.properties?.appConfig, 35 | default: schema.default?.appConfig, 36 | } 37 | nuxt.options.runtimeConfig.contentSchema = schema.properties?.content || {} 38 | }) 39 | 40 | // Support custom ~/.studio/app.config.json 41 | nuxt.hook('app:resolve', (appCtx) => { 42 | const studioAppConfigPath = resolveAlias('~/.studio/app.config.json') 43 | if (existsSync(studioAppConfigPath)) { 44 | appCtx.configs.unshift(studioAppConfigPath) 45 | } 46 | }) 47 | 48 | // Only enable Studio in production build 49 | if (options.enabled === 'production' && nuxt.options.dev === true) { 50 | return 51 | } 52 | 53 | const contentModule = '@nuxt/content' 54 | // Check Content module is installed 55 | if ( 56 | !nuxt.options.runtimeConfig.content 57 | && !nuxt.options.modules.includes(contentModule) 58 | ) { 59 | log.warn('Could not find `@nuxt/content` module. Please install it to enable preview mode.') 60 | return 61 | } 62 | // Check Content module version 63 | const contentModuleVersion = await import(contentModule) 64 | .then(m => m.default || m) 65 | .then(m => m.getMeta()) 66 | .then(m => m.version) 67 | .catch(() => '0') 68 | if (contentModuleVersion < '2.1.1') { 69 | log.warn('Please update `@nuxt/content` to version 2.1.1 or higher to enable preview mode.') 70 | return 71 | } 72 | 73 | // @ts-expect-error Check Pinceau module activated 74 | nuxt.hook('pinceau:options', (options) => { 75 | options.studio = true 76 | }) 77 | 78 | const { resolve } = createResolver(import.meta.url) 79 | 80 | const apiURL = process.env.NUXT_PUBLIC_STUDIO_API_URL || process.env.STUDIO_API || 'https://api.nuxt.studio' 81 | const publicToken = process.env.NUXT_PUBLIC_STUDIO_TOKENS 82 | const iframeMessagingAllowedOrigins = process.env.IFRAME_MESSAGING_ALLOWED_ORIGINS 83 | const gitInfo = options.gitInfo || await _getLocalGitInfo(nuxt.options.rootDir) || _getGitEnv() || {} 84 | nuxt.options.runtimeConfig.studio = defu(nuxt.options.runtimeConfig.studio, { 85 | version, 86 | publicToken, 87 | gitInfo, 88 | }) 89 | nuxt.options.runtimeConfig.public.studio = defu(nuxt.options.runtimeConfig.public.studio, { apiURL, iframeMessagingAllowedOrigins }) 90 | 91 | extendViteConfig((config) => { 92 | config.optimizeDeps = config.optimizeDeps || {} 93 | config.optimizeDeps.include = config.optimizeDeps.include || [] 94 | config.optimizeDeps.include.push( 95 | 'socket.io-client', 'slugify', 96 | ) 97 | }) 98 | 99 | if (contentModuleVersion === '2.10.0') { 100 | addVitePlugin({ 101 | name: 'content-resolver', 102 | enforce: 'pre', 103 | resolveId(id, importer) { 104 | if (id.endsWith('.mjs') && ((importer || '').includes('@nuxt/content/dist') || id.includes('@nuxt/content/dist'))) { 105 | id = id 106 | .replace('.mjs', '.js') 107 | .replace(/^\/node_modules/, './node_modules/') 108 | 109 | return path.resolve(path.dirname(importer || __dirname), id.replace('.mjs', '.js')) 110 | } 111 | }, 112 | }) 113 | } 114 | 115 | // Add plugins 116 | addPlugin(resolve('./runtime/plugins/preview.client')) 117 | 118 | // Register components 119 | addComponentsDir({ path: resolve('./runtime/components') }) 120 | 121 | // Add server route to know Studio is enabled 122 | addServerHandler({ 123 | method: 'get', 124 | route: '/__studio.json', 125 | handler: resolve('./runtime/server/routes/studio'), 126 | }) 127 | addPrerenderRoutes('/__studio.json') 128 | 129 | // Install dependencies 130 | await installModule('nuxt-component-meta', { 131 | globalsOnly: true, 132 | }) 133 | }, 134 | }) 135 | 136 | // --- Utilities to get git info --- 137 | 138 | interface GitInfo { 139 | // Repository name 140 | name: string 141 | // Repository owner/organization 142 | owner: string 143 | // Repository URL 144 | url: string 145 | } 146 | 147 | async function _getLocalGitInfo(rootDir: string): Promise { 148 | const remote = await _getLocalGitRemote(rootDir) 149 | if (!remote) { 150 | return 151 | } 152 | 153 | // https://www.npmjs.com/package/git-url-parse#clipboard-example 154 | const { name, owner, source } = gitUrlParse(remote) as Record 155 | const url = `https://${source}/${owner}/${name}` 156 | 157 | return { 158 | name, 159 | owner, 160 | url, 161 | } 162 | } 163 | 164 | async function _getLocalGitRemote(dir: string) { 165 | try { 166 | // https://www.npmjs.com/package/parse-git-config#options 167 | const parseGitConfig = await import('parse-git-config' as string).then( 168 | m => m.promise || m.default || m, 169 | ) as (opts: { path: string }) => Promise>> 170 | const gitDir = await findNearestFile('.git/config', { startingFrom: dir }) 171 | const parsed = await parseGitConfig({ path: gitDir }) 172 | if (!parsed) { 173 | return 174 | } 175 | const gitRemote = parsed['remote "origin"'].url 176 | return gitRemote 177 | } 178 | catch { 179 | // Ignore error 180 | } 181 | } 182 | 183 | function _getGitEnv(): GitInfo { 184 | // https://github.com/unjs/std-env/issues/59 185 | const envInfo = { 186 | // Provider 187 | provider: process.env.VERCEL_GIT_PROVIDER // vercel 188 | || (process.env.GITHUB_SERVER_URL ? 'github' : undefined) // github 189 | || '', 190 | // Owner 191 | owner: process.env.VERCEL_GIT_REPO_OWNER // vercel 192 | || process.env.GITHUB_REPOSITORY_OWNER // github 193 | || process.env.CI_PROJECT_PATH?.split('/').shift() // gitlab 194 | || '', 195 | // Name 196 | name: process.env.VERCEL_GIT_REPO_SLUG 197 | || process.env.GITHUB_REPOSITORY?.split('/').pop() // github 198 | || process.env.CI_PROJECT_PATH?.split('/').splice(1).join('/') // gitlab 199 | || '', 200 | // Url 201 | url: process.env.REPOSITORY_URL || '', // netlify 202 | } 203 | 204 | if (!envInfo.url && envInfo.provider && envInfo.owner && envInfo.name) { 205 | envInfo.url = `https://${envInfo.provider}.com/${envInfo.owner}/${envInfo.name}` 206 | } 207 | 208 | // If only url available (ex: Netlify) 209 | if (!envInfo.name && !envInfo.owner && envInfo.url) { 210 | try { 211 | const { name, owner } = gitUrlParse(envInfo.url) as Record 212 | envInfo.name = name 213 | envInfo.owner = owner 214 | } 215 | catch { 216 | // Ignore error 217 | } 218 | } 219 | 220 | return { 221 | name: envInfo.name, 222 | owner: envInfo.owner, 223 | url: envInfo.url, 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/runtime/components/ContentPreviewMode.vue: -------------------------------------------------------------------------------- 1 | 168 | 169 | 236 | 237 | 242 | 243 | 422 | -------------------------------------------------------------------------------- /src/runtime/composables/useContentStorage.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedContent } from '@nuxt/content/types' 2 | import type { PreviewFile } from '../types' 3 | import { useNuxtApp, useState, queryContent } from '#imports' 4 | 5 | export const useContentStorage = () => { 6 | const nuxtApp = useNuxtApp() 7 | const contentPathMap = {} as Record 8 | const storage = useState('studio-client-db', () => null) 9 | 10 | // Initialize storage 11 | if (!storage.value) { 12 | nuxtApp.hook('content:storage', (_storage: Storage) => { 13 | storage.value = _storage 14 | }) 15 | 16 | // Call `queryContent` to trigger `content:storage` hook 17 | queryContent('/non-existing-path').findOne() 18 | } 19 | 20 | const findContentItem = async (path: string): Promise => { 21 | const previewToken = window.sessionStorage.getItem('previewToken') 22 | if (!path) { 23 | return null 24 | } 25 | path = path.replace(/\/$/, '') 26 | let content = await storage.value?.getItem(`${previewToken}:${path}`) 27 | if (!content) { 28 | content = await storage.value?.getItem(`cached:${path}`) 29 | } 30 | if (!content) { 31 | content = content = await storage.value?.getItem(path) 32 | } 33 | 34 | // try finding content from contentPathMap 35 | if (!content) { 36 | content = contentPathMap[path || '/'] 37 | } 38 | 39 | return content as ParsedContent 40 | } 41 | 42 | const updateContentItem = (previewToken: string, file: PreviewFile) => { 43 | if (!storage.value) return 44 | 45 | contentPathMap[file.parsed!._path!] = file.parsed! 46 | storage.value.setItem(`${previewToken}:${file.parsed?._id}`, JSON.stringify(file.parsed)) 47 | } 48 | 49 | const removeContentItem = async (previewToken: string, path: string) => { 50 | const content = await findContentItem(path) 51 | await storage.value?.removeItem(`${previewToken}:${path}`) 52 | 53 | if (content) { 54 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 55 | delete contentPathMap[content._path!] 56 | const nonDraftContent = await findContentItem(content._id) 57 | if (nonDraftContent) { 58 | contentPathMap[nonDraftContent._path!] = nonDraftContent 59 | } 60 | } 61 | } 62 | 63 | const removeAllContentItems = async (previewToken: string) => { 64 | const keys: string[] = await storage.value.getKeys(`${previewToken}:`) 65 | await Promise.all(keys.map(key => storage.value.removeItem(key))) 66 | } 67 | 68 | const setPreviewMetaItems = async (previewToken: string, files: PreviewFile[]) => { 69 | const sources = new Set(files.map(file => file.parsed!._id.split(':').shift()!)) 70 | await storage.value.setItem(`${previewToken}$`, JSON.stringify({ ignoreSources: Array.from(sources) })) 71 | } 72 | 73 | return { 74 | storage, 75 | findContentItem, 76 | updateContentItem, 77 | removeContentItem, 78 | removeAllContentItems, 79 | setPreviewMetaItems, 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/runtime/composables/useStudio.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import type { ParsedContent } from '@nuxt/content' 3 | import type { RouteLocationNormalized } from 'vue-router' 4 | import type { AppConfig } from 'nuxt/schema' 5 | import ContentPreviewMode from '../components/ContentPreviewMode.vue' 6 | import { createSingleton, deepAssign, deepDelete, defu, mergeDraft, StudioConfigFiles } from '../utils' 7 | import type { PreviewFile, PreviewResponse, FileChangeMessagePayload } from '../types' 8 | import { useContentStorage } from './useContentStorage' 9 | import { callWithNuxt } from '#app' 10 | import { useAppConfig, useNuxtApp, useRuntimeConfig, useContentState, ref, toRaw, useRoute, useRouter } from '#imports' 11 | 12 | const useDefaultAppConfig = createSingleton(() => JSON.parse(JSON.stringify((useAppConfig())))) 13 | 14 | let dbFiles: PreviewFile[] = [] 15 | 16 | export const useStudio = () => { 17 | const nuxtApp = useNuxtApp() 18 | const { storage, findContentItem, updateContentItem, removeContentItem, removeAllContentItems, setPreviewMetaItems } = useContentStorage() 19 | const { studio: studioConfig, content: contentConfig } = useRuntimeConfig().public 20 | const apiURL = window.sessionStorage.getItem('previewAPI') || studioConfig?.apiURL 21 | 22 | // App config (required) 23 | const initialAppConfig = useDefaultAppConfig() 24 | 25 | const syncPreviewFiles = async (files: PreviewFile[]) => { 26 | const previewToken = window.sessionStorage.getItem('previewToken') as string 27 | 28 | // Remove previous preview data 29 | removeAllContentItems(previewToken) 30 | 31 | // Set preview meta 32 | setPreviewMetaItems(previewToken, files) 33 | 34 | // Handle content files 35 | await Promise.all( 36 | files.map((file) => { 37 | updateContentItem(previewToken, file) 38 | }), 39 | ) 40 | } 41 | 42 | const syncPreviewAppConfig = (appConfig?: ParsedContent) => { 43 | const _appConfig = callWithNuxt(nuxtApp, useAppConfig) as AppConfig 44 | 45 | // Set dynamic icons for preview if user is using @nuxt/ui 46 | if (_appConfig?.ui) { 47 | (_appConfig.ui as Record).icons = { ...(_appConfig.ui as Record).icons as AppConfig, dynamic: true } 48 | } 49 | 50 | // Using `defu` to merge with initial config 51 | // This is important to revert to default values for missing properties 52 | deepAssign(_appConfig, defu(appConfig as ParsedContent, initialAppConfig)) 53 | 54 | // Reset app config to initial state if no appConfig is provided 55 | // Makes sure that app config does not contain any preview data 56 | if (!appConfig) { 57 | deepDelete(_appConfig, initialAppConfig) 58 | } 59 | } 60 | 61 | const syncPreview = async (data: PreviewResponse) => { 62 | // Preserve db files in case storage is not ready yet (see check below) 63 | dbFiles = data.files = data.files || dbFiles || [] 64 | 65 | if (!storage.value) { 66 | // Postpone sync if storage is not ready 67 | return false 68 | } 69 | 70 | // Empty dbFiles array once storage is ready to clear memory 71 | dbFiles = [] 72 | 73 | const mergedFiles = mergeDraft(data.files, data.additions, data.deletions) 74 | 75 | // Handle content files 76 | const contentFiles = mergedFiles.filter(item => !([StudioConfigFiles.appConfig, StudioConfigFiles.appConfigV4, StudioConfigFiles.nuxtConfig].includes(item.path))) 77 | await syncPreviewFiles(contentFiles) 78 | 79 | const appConfig = mergedFiles.find(item => [StudioConfigFiles.appConfig, StudioConfigFiles.appConfigV4].includes(item.path)) 80 | syncPreviewAppConfig(appConfig?.parsed as ParsedContent) 81 | 82 | requestRerender() 83 | 84 | return true 85 | } 86 | 87 | const requestPreviewSynchronization = async () => { 88 | const previewToken = window.sessionStorage.getItem('previewToken') 89 | // Fetch preview data from station 90 | await $fetch('api/projects/preview/sync', { 91 | baseURL: apiURL, 92 | method: 'POST', 93 | params: { 94 | token: previewToken, 95 | }, 96 | }) 97 | } 98 | 99 | const mountPreviewUI = () => { 100 | const previewToken = window.sessionStorage.getItem('previewToken') 101 | // Show loading 102 | const el = document.createElement('div') 103 | el.id = '__nuxt_preview_wrapper' 104 | document.body.appendChild(el) 105 | createApp(ContentPreviewMode, { 106 | previewToken, 107 | apiURL, 108 | syncPreview, 109 | requestPreviewSyncAPI: requestPreviewSynchronization, 110 | }).mount(el) 111 | } 112 | 113 | const requestRerender = async () => { 114 | if (contentConfig?.documentDriven) { 115 | const { pages } = callWithNuxt(nuxtApp, useContentState) 116 | 117 | const contents = await Promise.all(Object.keys(pages.value).map(async (key) => { 118 | return await findContentItem(pages.value[key]?._id ?? key) 119 | })) 120 | 121 | pages.value = contents.reduce((acc, item, index) => { 122 | if (item) { 123 | acc[Object.keys(pages.value)[index]] = item 124 | } 125 | return acc 126 | }, {} as Record) 127 | } 128 | // Directly call `app:data:refresh` hook to refresh all data (!Calling `refreshNuxtData` causing some delay in data refresh!) 129 | await nuxtApp.hooks.callHookParallel('app:data:refresh') 130 | } 131 | 132 | return { 133 | mountPreviewUI, 134 | initiateIframeCommunication, 135 | } 136 | 137 | function initiateIframeCommunication() { 138 | // Not in an iframe 139 | if (!window.parent || window.self === window.parent) { 140 | return 141 | } 142 | const router = useRouter() 143 | const route = useRoute() 144 | 145 | const editorSelectedPath = ref('') 146 | 147 | // Evaluate route payload 148 | const routePayload = (route: RouteLocationNormalized) => ({ 149 | path: route.path, 150 | query: toRaw(route.query), 151 | params: toRaw(route.params), 152 | fullPath: route.fullPath, 153 | meta: toRaw(route.meta), 154 | }) 155 | 156 | window.addEventListener('keydown', (e) => { 157 | if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) { 158 | window.parent.postMessage({ 159 | type: 'nuxt-studio:preview:keydown', 160 | payload: { 161 | key: e.key, 162 | metaKey: e.metaKey, 163 | ctrlKey: e.ctrlKey, 164 | shiftKey: e.shiftKey, 165 | altKey: e.altKey, 166 | }, 167 | }, '*') 168 | } 169 | }) 170 | 171 | window.addEventListener('message', async (e) => { 172 | // IFRAME_MESSAGING_ALLOWED_ORIGINS format must be a comma separated string of allowed origins 173 | const allowedOrigins = studioConfig?.iframeMessagingAllowedOrigins?.split(',').map((origin: string) => origin.trim()) || [] 174 | if (!['https://nuxt.studio', 'https://new.nuxt.studio', 'https://new.dev.nuxt.studio', 'https://dev.nuxt.studio', 'http://localhost:3000', ...allowedOrigins].includes(e.origin)) { 175 | return 176 | } 177 | 178 | const { type, payload = {} } = e.data || {} 179 | 180 | switch (type) { 181 | case 'nuxt-studio:editor:file-selected': { 182 | const content = await findContentItem(payload.path) 183 | if (!content || content._partial) { 184 | // Do not navigate to another page if content is not found 185 | // This makes sure that user stays on the same page when navigation through directories in the editor 186 | // Also, We should not navigate if content is a partial 187 | return 188 | } 189 | 190 | // Ensure that the content is related to a valid route for non markdown files 191 | if (!String(payload.path).endsWith('.md')) { 192 | const resolvedRoute = router.resolve(content._path) 193 | if (!resolvedRoute || !resolvedRoute.matched || resolvedRoute.matched.length === 0) { 194 | return 195 | } 196 | } 197 | 198 | // Navigate to the selected content 199 | if (content._path !== useRoute().path) { 200 | editorSelectedPath.value = content._path! 201 | router.push(content._path!) 202 | } 203 | break 204 | } 205 | case 'nuxt-studio:editor:media-changed': 206 | case 'nuxt-studio:editor:file-changed': { 207 | const previewToken = window.sessionStorage.getItem('previewToken') as string 208 | const { additions = [], deletions = [] } = payload as FileChangeMessagePayload 209 | for (const addition of additions) { 210 | await updateContentItem(previewToken, addition) 211 | } 212 | for (const deletion of deletions) { 213 | await removeContentItem(previewToken, deletion.path) 214 | } 215 | requestRerender() 216 | break 217 | } 218 | case 'nuxt-studio:config:file-changed': { 219 | const { additions = [], deletions = [] } = payload as FileChangeMessagePayload 220 | 221 | const appConfig = additions.find(item => [StudioConfigFiles.appConfig, StudioConfigFiles.appConfigV4].includes(item.path)) 222 | if (appConfig) { 223 | syncPreviewAppConfig(appConfig?.parsed) 224 | } 225 | const shouldRemoveAppConfig = deletions.find(item => [StudioConfigFiles.appConfig, StudioConfigFiles.appConfigV4].includes(item.path)) 226 | if (shouldRemoveAppConfig) { 227 | syncPreviewAppConfig(undefined) 228 | } 229 | } 230 | } 231 | }) 232 | 233 | nuxtApp.hook('page:finish', () => { 234 | detectRenderedContents() 235 | 236 | if (nuxtApp.payload.prerenderedAt) { 237 | requestRerender() 238 | } 239 | }) 240 | 241 | // @ts-expect-error custom hook 242 | nuxtApp.hook('content:document-driven:finish', ({ route, page }) => { 243 | route.meta.studio_page_contentId = page?._id 244 | }) 245 | 246 | // @ts-expect-error custom hook 247 | nuxtApp.hook('nuxt-studio:preview:ready', () => { 248 | window.parent.postMessage({ 249 | type: 'nuxt-studio:preview:ready', 250 | payload: routePayload(useRoute()), 251 | }, '*') 252 | 253 | setTimeout(() => { 254 | // Initial sync 255 | detectRenderedContents() 256 | }, 100) 257 | }) 258 | 259 | // Inject Utils to window 260 | function detectRenderedContents() { 261 | const renderedContents = Array.from(window.document.querySelectorAll('[data-content-id]')) 262 | .map(el => el.getAttribute('data-content-id')!) 263 | 264 | const contentIds = Array 265 | .from(new Set([route.meta.studio_page_contentId as string, ...renderedContents])) 266 | .filter(Boolean) 267 | 268 | if (editorSelectedPath.value === contentIds[0]) { 269 | editorSelectedPath.value = '' 270 | return 271 | } 272 | 273 | window.openContentInStudioEditor(contentIds, { navigate: true, pageContentId: route.meta.studio_page_contentId as string }) 274 | } 275 | 276 | window.openContentInStudioEditor = (contentIds: string[], data = {}) => { 277 | window.parent.postMessage({ 278 | type: 'nuxt-studio:preview:navigate', 279 | payload: { 280 | ...routePayload(route), 281 | contentIds, 282 | ...data, 283 | }, 284 | }, '*') 285 | } 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /src/runtime/plugins/preview.client.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin, useCookie, useRoute, useRuntimeConfig, useState } from '#imports' 2 | 3 | export default defineNuxtPlugin((nuxtApp) => { 4 | const runtimeConfig = useRuntimeConfig().public.studio || {} 5 | const route = useRoute() 6 | const previewToken = useCookie('previewToken', { sameSite: 'none', secure: true }) 7 | const storage = useState('studio-client-db', () => null) 8 | 9 | async function initializePreview() { 10 | const useStudio = await import('../composables/useStudio').then(m => m.useStudio) 11 | const { mountPreviewUI, initiateIframeCommunication } = useStudio() 12 | 13 | mountPreviewUI() 14 | 15 | initiateIframeCommunication() 16 | } 17 | 18 | if (runtimeConfig.apiURL) { 19 | // Disable preview mode if token value is null, undefined or empty 20 | if (Object.prototype.hasOwnProperty.call(route.query, 'preview') && !route.query.preview) { 21 | return 22 | } 23 | 24 | if (!route.query.preview && !previewToken.value) { 25 | return 26 | } 27 | 28 | if (route.query.preview) { 29 | previewToken.value = String(route.query.preview) 30 | } 31 | window.sessionStorage.setItem('previewToken', String(previewToken.value)) 32 | window.sessionStorage.setItem('previewAPI', typeof route.query.staging !== 'undefined' ? 'https://dev-api.nuxt.studio' : runtimeConfig.apiURL) 33 | 34 | // Listen to `content:storage` hook to get storage instance 35 | // There is some cases that `content:storage` hook is called before initializing preview 36 | nuxtApp.hook('content:storage', (_storage: Storage) => { 37 | storage.value = _storage 38 | }) 39 | 40 | nuxtApp.hook('app:mounted', async () => { 41 | await initializePreview() 42 | }) 43 | } 44 | }) 45 | -------------------------------------------------------------------------------- /src/runtime/server/dev-api/files.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import fsp from 'node:fs/promises' 3 | import type { Dirent } from 'node:fs' 4 | import anymatch from 'anymatch' 5 | import { eventHandler, isMethod, readBody } from 'h3' 6 | import { withoutLeadingSlash, withoutTrailingSlash } from 'ufo' 7 | 8 | interface FilesHandlerOptions { 9 | rootDir: string 10 | } 11 | 12 | export default (option: FilesHandlerOptions) => eventHandler(async (event) => { 13 | const path = event.node.req.url 14 | const { rootDir } = option 15 | const filePath = withoutTrailingSlash(rootDir + path) 16 | 17 | if (isMethod(event, 'POST')) { 18 | const body = await readBody(event) 19 | await fsp.writeFile(filePath, body.source, 'utf-8') 20 | return { 21 | id: path, 22 | } 23 | } 24 | 25 | if (isMethod(event, 'PUT')) { 26 | await fsp.writeFile(filePath, '', 'utf-8').catch(ignoreNotfound) 27 | return { 28 | id: path, 29 | } 30 | } 31 | 32 | if (isMethod(event, 'DELETE')) { 33 | await fsp.unlink(filePath).catch(ignoreNotfound) 34 | return { 35 | id: path, 36 | } 37 | } 38 | 39 | const stats = await fsp.stat(filePath).catch(ignoreNotfound) 40 | if (stats.isDirectory()) { 41 | const ignore = anymatch([ 42 | '**/node_modules/**', 43 | '**/.git/**', 44 | '**/.nuxt/**', 45 | ]) 46 | 47 | return readdirRecursive(filePath, ignore, { 48 | id: withoutLeadingSlash(path === '/' ? '' : path), 49 | children: [], 50 | }) 51 | } 52 | 53 | const source = await fsp.readFile(filePath, 'utf-8').catch(ignoreNotfound) 54 | return { 55 | id: path, 56 | source, 57 | } 58 | }) 59 | 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | function ignoreNotfound(err: any) { 62 | return err.code === 'ENOENT' || err.code === 'EISDIR' ? null : err 63 | } 64 | 65 | function readdir(dir: string) { 66 | return fsp.readdir(dir, { withFileTypes: true }).catch(ignoreNotfound).then(r => r || []) 67 | } 68 | 69 | async function readdirRecursive(dir: string, ignore: (match: string) => unknown, parent: Record = {}) { 70 | if (ignore && ignore(dir)) { 71 | return [] 72 | } 73 | const entries = await readdir(dir) as Dirent[] 74 | const files = (parent?.children as Array>) || [] 75 | await Promise.all(entries.map(async (entry) => { 76 | const entryPath = resolve(dir, entry.name) 77 | if (entry.isDirectory() && !ignore(entry.name)) { 78 | const dir = { 79 | name: entry.name, 80 | type: 'directory', 81 | id: parent.id ? `${parent.id}/${entry.name}` : entry.name, 82 | path: parent.id ? `${parent.id}/${entry.name}` : entry.name, 83 | children: [], 84 | } 85 | files.push(dir) 86 | await readdirRecursive(entryPath, ignore, dir) 87 | } 88 | else if (ignore && !ignore(entry.name)) { 89 | files.push({ 90 | name: entry.name, 91 | type: 'file', 92 | id: parent.id ? `${parent.id}/${entry.name}` : entry.name, 93 | path: parent.id ? `${parent.id}/${entry.name}` : entry.name, 94 | }) 95 | } 96 | })) 97 | return files 98 | } 99 | -------------------------------------------------------------------------------- /src/runtime/server/routes/studio.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentMeta } from 'vue-component-meta' 2 | import { eventHandler } from 'h3' 3 | import { useRuntimeConfig, useAppConfig } from '#imports' 4 | // @ts-expect-error import does exist 5 | import components from '#nuxt-component-meta/nitro' 6 | 7 | interface NuxtComponentMeta { 8 | pascalName: string 9 | filePath: string 10 | meta: ComponentMeta 11 | global: boolean 12 | } 13 | 14 | export default eventHandler(async () => { 15 | const componentsIgnoredPrefix = ['Content', 'DocumentDriven', 'Markdown'] 16 | const filteredComponents = (Object.values(components) as NuxtComponentMeta[]) 17 | .filter(c => c.global && !componentsIgnoredPrefix.some(prefix => c.pascalName.startsWith(prefix))) 18 | .map(({ pascalName, filePath, meta }) => { 19 | return { 20 | name: pascalName, 21 | path: filePath, 22 | meta: { 23 | props: meta.props, 24 | slots: meta.slots, 25 | events: meta.events, 26 | }, 27 | } 28 | }) 29 | 30 | const appConfig = useAppConfig() 31 | const runtimeConfig = useRuntimeConfig() 32 | const { contentSchema, appConfigSchema, studio, content } = runtimeConfig 33 | const { sources, ignores, locales, defaultLocale, highlight, navigation, documentDriven, experimental } = content as Record 34 | 35 | // Delete GitHub tokens for multiple source to avoid exposing them 36 | const safeSources: Record = {} 37 | Object.keys(sources as Record).forEach((name) => { 38 | const { driver, prefix, base, repo, branch, dir } = (sources as Record)[name] as Record || {} 39 | safeSources[name] = { 40 | driver, 41 | prefix, 42 | base, 43 | repo, 44 | branch, 45 | dir, 46 | } 47 | }) 48 | 49 | return { 50 | // Studio version 51 | version: (studio as Record)?.version, 52 | tokens: (studio as Record)?.publicToken, 53 | gitInfo: (studio as Record)?.gitInfo || {}, 54 | // nuxt.schema for Nuxt Content frontmatter 55 | contentSchema: contentSchema || {}, 56 | // app.config 57 | appConfigSchema: appConfigSchema || {}, 58 | appConfig, 59 | // @nuxt/content 60 | content: { sources: safeSources, ignores, locales, defaultLocale, highlight, navigation, documentDriven, experimental }, 61 | // nuxt-component-meta 62 | components: filteredComponents, 63 | } 64 | }) 65 | -------------------------------------------------------------------------------- /src/runtime/types/api.d.ts: -------------------------------------------------------------------------------- 1 | import type { ParsedContent } from '@nuxt/content/dist/runtime/types' 2 | 3 | export interface PreviewFile { 4 | path: string 5 | parsed?: ParsedContent 6 | } 7 | 8 | export interface DraftFile { 9 | path: string 10 | parsed?: ParsedContent 11 | new?: boolean 12 | oldPath?: string 13 | pathMeta?: Record 14 | } 15 | 16 | export interface PreviewResponse { 17 | files: PreviewFile[] 18 | additions: DraftFile[] 19 | deletions: DraftFile[] 20 | } 21 | 22 | export interface FileChangeMessagePayload { 23 | additions: Array 24 | deletions: Array 25 | } 26 | -------------------------------------------------------------------------------- /src/runtime/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './api' 2 | 3 | declare global { 4 | interface Window { 5 | openContentInStudioEditor: (ids: string[], navigate?: { navigate?: boolean, pageContentId?: string }) => void 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/runtime/utils/files.ts: -------------------------------------------------------------------------------- 1 | import type { DraftFile, PreviewFile } from '../types' 2 | 3 | export const mergeDraft = (dbFiles: PreviewFile[] = [], draftAdditions: DraftFile[], draftDeletions: DraftFile[]) => { 4 | const additions = [...(draftAdditions || [])] 5 | const deletions = [...(draftDeletions || [])] 6 | 7 | // Compute file name 8 | const mergedFiles: PreviewFile[] = JSON.parse(JSON.stringify(dbFiles)) 9 | 10 | // Merge darft additions 11 | for (const addition of additions) { 12 | // File is new 13 | if (addition.new) { 14 | mergedFiles.push({ path: addition.path, parsed: addition.parsed }) 15 | } 16 | // File has been renamed 17 | else if (addition.oldPath) { 18 | // Remove old file from deletions (only display renamed one) 19 | deletions.splice(deletions.findIndex(d => d.path === addition.oldPath), 1) 20 | 21 | // Custom case of #447 22 | const oldPathExistInCache = additions.find(a => a.path === addition.oldPath) 23 | if (oldPathExistInCache) { 24 | mergedFiles.push({ path: addition.path, parsed: addition.parsed }) 25 | // Update exsiting renamed file data 26 | } 27 | else { 28 | const file = mergedFiles.find(f => f.path === addition.oldPath) 29 | if (file) { 30 | file.path = addition.path 31 | 32 | // If file is also modified, set draft content 33 | if (addition.parsed) { 34 | file.parsed = addition.parsed 35 | } 36 | else if (addition.pathMeta) { 37 | // Apply new path metadata 38 | ['_file', '_path', '_id', '_locale'].forEach((key) => { 39 | file.parsed![key] = addition.pathMeta![key] 40 | }) 41 | } 42 | } 43 | } 44 | // File has been added 45 | } 46 | // File has been modified 47 | else { 48 | const file = mergedFiles.find(f => f.path === addition.path) 49 | if (file) { 50 | Object.assign(file, { path: addition.path, parsed: addition.parsed }) 51 | } 52 | else { 53 | mergedFiles.push({ path: addition.path, parsed: addition.parsed }) 54 | } 55 | } 56 | } 57 | 58 | // Merge draft deletions (set deletion status) 59 | for (const deletion of deletions) { 60 | // File has been deleted 61 | mergedFiles.splice(mergedFiles.findIndex(f => f.path === deletion.path), 1) 62 | } 63 | 64 | const comperable = new Intl.Collator(undefined, { numeric: true }) 65 | mergedFiles.sort((a, b) => comperable.compare(a.path, b.path)) 66 | 67 | return mergedFiles 68 | } 69 | -------------------------------------------------------------------------------- /src/runtime/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { createDefu } from 'defu' 2 | 3 | export * from './files' 4 | 5 | export const StudioConfigFiles = { 6 | appConfig: 'app.config.ts', 7 | appConfigV4: 'app/app.config.ts', 8 | nuxtConfig: 'nuxt.config.ts', 9 | } 10 | 11 | export const defu = createDefu((obj, key, value) => { 12 | if (Array.isArray(obj[key]) && Array.isArray(value)) { 13 | obj[key] = value 14 | return true 15 | } 16 | }) 17 | 18 | export const createSingleton = >(fn: () => T) => { 19 | let instance: T | undefined 20 | return (_args?: Params) => { 21 | if (!instance) { 22 | instance = fn() 23 | } 24 | return instance 25 | } 26 | } 27 | 28 | // https://github.com/nuxt/framework/blob/02df51dd577000082694423ea49e1c90737585af/packages/nuxt/src/app/config.ts#L12 29 | export function deepDelete(obj: Record, newObj: Record) { 30 | for (const key in obj) { 31 | const val = newObj[key] 32 | if (!(key in newObj)) { 33 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 34 | delete obj[key] 35 | } 36 | 37 | if (val !== null && typeof val === 'object') { 38 | deepDelete(obj[key] as Record, newObj[key] as Record) 39 | } 40 | } 41 | } 42 | 43 | // https://github.com/nuxt/framework/blob/02df51dd577000082694423ea49e1c90737585af/packages/nuxt/src/app/config.ts#L25 44 | export function deepAssign(obj: Record, newObj: Record) { 45 | for (const key in newObj) { 46 | const val = newObj[key] 47 | if (val !== null && typeof val === 'object') { 48 | // Replace array types 49 | if (Array.isArray(val) && Array.isArray(obj[key])) { 50 | obj[key] = val 51 | } 52 | else { 53 | obj[key] = obj[key] || {} 54 | deepAssign(obj[key] as Record, val as Record) 55 | } 56 | } 57 | else { 58 | obj[key] = val 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { defu } from 'defu' 2 | import type { JSType, Schema, InputValue } from 'untyped' 3 | 4 | export type ConfigInputsTypes = 5 | | Exclude 6 | | 'default' | 'icon' | 'file' | 'media' | 'component' 7 | 8 | export type PickerTypes = 'media-picker' | 'icon-picker' 9 | 10 | export type PartialSchema = Pick & { [key: string]: unknown } 11 | 12 | const supportedFields: { [key in ConfigInputsTypes]: Schema } = { 13 | /** 14 | * Raw types 15 | */ 16 | default: { 17 | type: 'string', 18 | tags: [ 19 | '@studioInput string', 20 | ], 21 | }, 22 | string: { 23 | type: 'string', 24 | tags: [ 25 | '@studioInput string', 26 | ], 27 | }, 28 | number: { 29 | type: 'number', 30 | tags: [ 31 | '@studioInput number', 32 | ], 33 | }, 34 | boolean: { 35 | type: 'boolean', 36 | tags: [ 37 | '@studioInput boolean', 38 | ], 39 | }, 40 | array: { 41 | type: 'array', 42 | tags: [ 43 | '@studioInput array', 44 | ], 45 | }, 46 | object: { 47 | type: 'object', 48 | tags: [ 49 | '@studioInput object', 50 | ], 51 | }, 52 | file: { 53 | type: 'string', 54 | tags: [ 55 | '@studioInput file', 56 | ], 57 | }, 58 | media: { 59 | type: 'string', 60 | tags: [ 61 | '@studioInput media', 62 | ], 63 | }, 64 | component: { 65 | type: 'string', 66 | tags: [ 67 | '@studioInput component', 68 | ], 69 | }, 70 | icon: { 71 | type: 'string', 72 | tags: [ 73 | '@studioInput icon', 74 | ], 75 | }, 76 | } 77 | 78 | export type StudioFieldData = 79 | PartialSchema & 80 | { 81 | type?: keyof typeof supportedFields 82 | icon?: string 83 | fields?: { [key: string]: InputValue } 84 | } 85 | 86 | /** 87 | * Helper to build aNuxt Studio compatible configuration schema. 88 | * Supports all type of fields provided by Nuxt Studio and all fields supported from Untyped Schema interface. 89 | */ 90 | export function field(schema: StudioFieldData): InputValue { 91 | if (!schema.type) { 92 | throw new Error(`Missing type in schema ${JSON.stringify(schema)}`) 93 | } 94 | 95 | // copy of supportedFields 96 | const base = JSON.parse(JSON.stringify(supportedFields[schema.type])) 97 | const result = defu(base, schema) 98 | 99 | if (!result.tags) { 100 | result.tags = [] 101 | } 102 | if (result.icon) { 103 | result.tags.push(`@studioIcon ${result.icon}`) 104 | delete result.icon 105 | } 106 | return { 107 | $schema: result, 108 | } 109 | } 110 | 111 | export function group(schema: StudioFieldData): InputValue { 112 | const result = { ...schema } 113 | 114 | if (result.icon) { 115 | result.tags = [`@studioIcon ${result.icon}`] 116 | delete result.icon 117 | } 118 | 119 | const fields: Record = {} 120 | if (result.fields) { 121 | for (const key of Object.keys(result.fields)) { 122 | fields[key] = result.fields[key] 123 | } 124 | delete result.fields 125 | } 126 | 127 | return { 128 | $schema: result, 129 | ...fields, 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /theme.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/theme.d.ts' 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./playground/.nuxt/tsconfig.json" 3 | } 4 | --------------------------------------------------------------------------------