├── .prettierignore ├── tsconfig.json ├── .eslintignore ├── .prettierrc ├── src ├── index.ts ├── router.ts ├── builder │ ├── nodes │ │ ├── divider.ts │ │ ├── multiPage.ts │ │ └── page.ts │ ├── index.ts │ ├── nodeBuilder.ts │ └── structure.ts ├── components │ ├── Page.tsx │ ├── PageLink.tsx │ ├── PageTree.tsx │ ├── Error.tsx │ ├── MultiPage.tsx │ ├── Inspector.tsx │ └── Tool.tsx ├── plugin.ts ├── utils │ ├── slugify.ts │ └── structureUtils.ts ├── styled │ └── markdown.ts └── documentInspector.ts ├── sanity.json ├── tsconfig.dist.json ├── .editorconfig ├── .eslintrc ├── v2-incompatible.js ├── package.config.ts ├── tsconfig.settings.json ├── .gitignore ├── LICENSE ├── package.json └── README.md /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | pnpm-lock.yaml 3 | yarn.lock 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./package.config.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js 2 | .eslintrc.js 3 | commitlint.config.js 4 | dist 5 | lint-staged.config.js 6 | package.config.ts 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './builder' 2 | export * from './documentInspector' 3 | export * from './plugin' 4 | export * from './router' 5 | -------------------------------------------------------------------------------- /sanity.json: -------------------------------------------------------------------------------- 1 | { 2 | "parts": [ 3 | { 4 | "implements": "part:@sanity/base/sanity-root", 5 | "path": "./v2-incompatible.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src"], 4 | "exclude": [ 5 | "./src/**/__fixtures__", 6 | "./src/**/__mocks__", 7 | "./src/**/*.test.ts", 8 | "./src/**/*.test.tsx" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import {route} from 'sanity/router' 2 | 3 | export type UserGuideState = { 4 | page?: string 5 | subPage?: string 6 | } 7 | 8 | export const router = route.create('/', [ 9 | route.create({ 10 | path: '/:page', 11 | }), 12 | route.create({ 13 | path: '/:page/:subPage', 14 | }), 15 | ]) 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "extends": [ 8 | "sanity", 9 | "sanity/typescript", 10 | "sanity/react", 11 | "plugin:react-hooks/recommended", 12 | "plugin:prettier/recommended", 13 | "plugin:react/jsx-runtime" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /v2-incompatible.js: -------------------------------------------------------------------------------- 1 | const {showIncompatiblePluginDialog} = require('@sanity/incompatible-plugin') 2 | const {name, version, sanityExchangeUrl} = require('./package.json') 3 | 4 | export default showIncompatiblePluginDialog({ 5 | name: name, 6 | versions: { 7 | v3: version, 8 | v2: undefined, 9 | }, 10 | sanityExchangeUrl, 11 | }) 12 | -------------------------------------------------------------------------------- /src/builder/nodes/divider.ts: -------------------------------------------------------------------------------- 1 | import {UserGuideNodeBuilder} from '../nodeBuilder' 2 | import {UserGuideBaseNode} from '../structure' 3 | 4 | export type Divider = UserGuideBaseNode & {_type: 'divider'} 5 | 6 | export class DividerBuilder extends UserGuideNodeBuilder { 7 | constructor() { 8 | super({_type: 'divider'}) 9 | } 10 | 11 | build(): Divider { 12 | return this.node 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/builder/index.ts: -------------------------------------------------------------------------------- 1 | import {DividerBuilder} from './nodes/divider' 2 | import {MultiPageBuilder} from './nodes/multiPage' 3 | import {PageBuilder} from './nodes/page' 4 | 5 | export * from './structure' 6 | 7 | export const divider: () => DividerBuilder = () => new DividerBuilder() 8 | export const page: () => PageBuilder = () => new PageBuilder() 9 | export const multiPage: () => MultiPageBuilder = () => new MultiPageBuilder() 10 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | dist: 'dist', 5 | tsconfig: 'tsconfig.dist.json', 6 | 7 | // Remove this block to enable strict export validation 8 | extract: { 9 | rules: { 10 | 'ae-forgotten-export': 'off', 11 | 'ae-incompatible-release-tags': 'off', 12 | 'ae-internal-missing-underscore': 'off', 13 | 'ae-missing-release-tag': 'off', 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionComponent} from 'react' 2 | import Markdown from 'react-markdown' 3 | import remarkGfm from 'remark-gfm' 4 | 5 | import {UserGuidePage} from '../builder/nodes/page' 6 | import {MarkdownStyle} from '../styled/markdown' 7 | 8 | export const Page: FunctionComponent<{page: UserGuidePage}> = ({page}) => { 9 | if ('component' in page) { 10 | return 11 | } 12 | 13 | return ( 14 | <> 15 | 16 | 17 | {page.markdown} 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": ".", 4 | "outDir": "./dist", 5 | 6 | "target": "esnext", 7 | "jsx": "preserve", 8 | "module": "preserve", 9 | "moduleResolution": "bundler", 10 | "esModuleInterop": true, 11 | "resolveJsonModule": true, 12 | "moduleDetection": "force", 13 | "strict": true, 14 | "allowSyntheticDefaultImports": true, 15 | "skipLibCheck": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "isolatedModules": true, 18 | 19 | // Don't emit by default, pkg-utils will ignore this when generating .d.ts files 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/builder/nodeBuilder.ts: -------------------------------------------------------------------------------- 1 | import {uuid} from '@sanity/uuid' 2 | 3 | import {UserGuideNode} from './index' 4 | 5 | export type PartialNodeWithType = Partial & {_type: Node['_type']} 6 | export type PartialNodeWithTypeAndKey = Partial & { 7 | _type: Node['_type'] 8 | _key: string 9 | } 10 | 11 | export abstract class UserGuideNodeBuilder { 12 | protected node: PartialNodeWithTypeAndKey 13 | 14 | constructor(node: PartialNodeWithType) { 15 | this.node = {...node, _key: uuid()} 16 | } 17 | 18 | abstract build(): Node 19 | } 20 | -------------------------------------------------------------------------------- /src/components/PageLink.tsx: -------------------------------------------------------------------------------- 1 | import type {ElementType} from 'react' 2 | import {FunctionComponent} from 'react' 3 | import {PreviewCard, SanityDefaultPreview} from 'sanity' 4 | import {useStateLink} from 'sanity/router' 5 | 6 | export type PageLinkProps = { 7 | page: string 8 | subPage?: string 9 | title: string 10 | selected: boolean 11 | icon?: ElementType 12 | } 13 | 14 | export const PageLink: FunctionComponent = ({ 15 | page, 16 | subPage, 17 | title, 18 | selected, 19 | icon, 20 | }) => { 21 | const {onClick, href} = useStateLink({ 22 | state: {page, subPage}, 23 | }) 24 | 25 | return ( 26 | 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import {definePlugin} from 'sanity' 2 | 3 | import {UserGuideStructure} from './builder' 4 | import {Tool} from './components/Tool' 5 | import {documentInspector} from './documentInspector' 6 | import {router} from './router' 7 | import {getContentPages} from './utils/structureUtils' 8 | 9 | export type UserGuideOptions = { 10 | userGuideStructure: UserGuideStructure 11 | } 12 | export const userGuidePlugin = definePlugin((options) => { 13 | return { 14 | name: 'sanity-plugin-user-guide', 15 | tools: [ 16 | { 17 | title: 'Guide', 18 | name: 'guide', 19 | component: () => Tool(options), 20 | router, 21 | }, 22 | ], 23 | document: { 24 | inspectors: (prev) => [ 25 | ...prev, 26 | documentInspector(getContentPages(options.userGuideStructure)), 27 | ], 28 | }, 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /src/utils/slugify.ts: -------------------------------------------------------------------------------- 1 | /* https://mhagemann.medium.com/the-ultimate-way-to-slugify-a-url-string-in-javascript-b8e4a0d849e1 */ 2 | export function slugify(string: string): string { 3 | const a = 'àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;' 4 | const b = 'aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------' 5 | const p = new RegExp(a.split('').join('|'), 'g') 6 | 7 | return string 8 | .toString() 9 | .toLowerCase() 10 | .replace(/\s+/g, '-') // Replace spaces with - 11 | .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters 12 | .replace(/&/g, '-and-') // Replace & with 'and' 13 | .replace(/[^\w-]+/g, '') // Remove all non-word characters 14 | .replace(/--+/g, '-') // Replace multiple - with single - 15 | .replace(/^-+/, '') // Trim - from start of text 16 | .replace(/-+$/, '') // Trim - from end of text 17 | .slice(0, 200) 18 | } 19 | -------------------------------------------------------------------------------- /src/builder/structure.ts: -------------------------------------------------------------------------------- 1 | import {UserGuideNodeBuilder} from './nodeBuilder' 2 | import {Divider} from './nodes/divider' 3 | import {MultiPage} from './nodes/multiPage' 4 | import {UserGuidePage} from './nodes/page' 5 | 6 | export class UserGuideError extends Error { 7 | url?: string 8 | 9 | constructor(msg: string, url?: string) { 10 | super(msg) 11 | this.url = url 12 | } 13 | } 14 | 15 | export type UserGuideBaseNode = {_type: string; _key: string} 16 | export type UserGuideNode = Divider | UserGuidePage | MultiPage 17 | export type UserGuideStructure = UserGuideNode[] | UserGuideError 18 | export type UserGuideStructureBuilder = UserGuideNodeBuilder[] 19 | 20 | export function defineUserGuide(structure: UserGuideStructureBuilder): UserGuideStructure { 21 | try { 22 | return structure.map((builder) => builder.build()) 23 | } catch (error) { 24 | if (error instanceof Error) { 25 | return error 26 | } 27 | 28 | return new Error('An unknown error occurred') 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | # macOS finder cache file 40 | .DS_Store 41 | 42 | # VS Code settings 43 | .vscode 44 | 45 | # IntelliJ 46 | .idea 47 | *.iml 48 | 49 | # Cache 50 | .cache 51 | 52 | # Yalc 53 | .yalc 54 | yalc.lock 55 | 56 | # npm package zips 57 | *.tgz 58 | 59 | # Compiled plugin 60 | dist 61 | 62 | # Build cache?? 63 | dev/null 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Q42 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 | -------------------------------------------------------------------------------- /src/components/PageTree.tsx: -------------------------------------------------------------------------------- 1 | import {Card, Stack} from '@sanity/ui' 2 | import {FunctionComponent} from 'react' 3 | 4 | import {UserGuideNode} from '../builder' 5 | import {PageLink} from './PageLink' 6 | 7 | type PageTreeProps = { 8 | structure: UserGuideNode[] 9 | basePage?: string 10 | activeSlug?: string 11 | } 12 | 13 | export const PageTree: FunctionComponent = ({structure, basePage, activeSlug}) => ( 14 | 15 | {structure.map((node) => { 16 | switch (node._type) { 17 | case 'jsxPage': 18 | case 'markdownPage': 19 | case 'multiPage': 20 | return ( 21 | 29 | ) 30 | case 'divider': 31 | return 32 | default: 33 | return <> 34 | } 35 | })} 36 | 37 | ) 38 | -------------------------------------------------------------------------------- /src/styled/markdown.ts: -------------------------------------------------------------------------------- 1 | import {createGlobalStyle} from 'styled-components' 2 | 3 | export const MarkdownStyle = createGlobalStyle` 4 | .markdown { 5 | padding-bottom: 1.5rem; 6 | } 7 | 8 | .markdown table { 9 | border-collapse: collapse; 10 | } 11 | 12 | .markdown th { 13 | background-color: var(--card-code-bg-color); 14 | } 15 | 16 | .markdown th, 17 | .markdown td { 18 | padding: 6px 13px; 19 | border: 1px solid var(--card-border-color); 20 | } 21 | 22 | .markdown h2 { 23 | margin-top: 2.25rem; 24 | } 25 | 26 | .markdown h3 { 27 | margin-top: 1.75rem; 28 | } 29 | 30 | .markdown p { 31 | line-height: 1.5; 32 | } 33 | 34 | .markdown blockquote { 35 | margin: 0 0 2rem; 36 | padding: 0 1em; 37 | border-left: 2px solid var(--card-border-color); 38 | } 39 | 40 | .markdown code { 41 | display: inline-block; 42 | padding: 0.2em 0.4em; 43 | border-radius: 0.25em; 44 | background-color: var(--card-badge-default-bg-color); 45 | } 46 | 47 | .markdown img { 48 | margin-top: 1.5rem; 49 | max-width: 100%; 50 | border: 1px solid var(--card-border-color); 51 | } 52 | 53 | .markdown li { 54 | margin-bottom: 0.5em; 55 | }` 56 | -------------------------------------------------------------------------------- /src/utils/structureUtils.ts: -------------------------------------------------------------------------------- 1 | import {UserGuideStructure} from '../builder' 2 | import {MultiPage} from '../builder/nodes/multiPage' 3 | import {UserGuidePage} from '../builder/nodes/page' 4 | 5 | export function getRootPages(structure: UserGuideStructure): (UserGuidePage | MultiPage)[] { 6 | if (!Array.isArray(structure)) { 7 | return [] 8 | } 9 | 10 | return structure.reduce<(UserGuidePage | MultiPage)[]>((acc, node) => { 11 | if (node._type === 'jsxPage' || node._type === 'markdownPage' || node._type === 'multiPage') { 12 | return [...acc, node] 13 | } 14 | 15 | return acc 16 | }, []) 17 | } 18 | 19 | export function getContentPages(structure: UserGuideStructure): UserGuidePage[] { 20 | if (!Array.isArray(structure)) { 21 | return [] 22 | } 23 | 24 | return structure.reduce((acc, node) => { 25 | if (node._type === 'jsxPage' || node._type === 'markdownPage') { 26 | return [...acc, node] 27 | } 28 | 29 | if (node._type === 'multiPage') { 30 | return [...acc, ...node.pages] 31 | } 32 | 33 | return acc 34 | }, []) 35 | } 36 | 37 | export function getHref(node: UserGuidePage | MultiPage): string { 38 | return 'parentSlug' in node ? `/guide/${node.parentSlug}/${node.slug}` : `/guide/${node.slug}` 39 | } 40 | -------------------------------------------------------------------------------- /src/components/Error.tsx: -------------------------------------------------------------------------------- 1 | import {Card, Container, Flex, Stack, Text} from '@sanity/ui' 2 | import {FunctionComponent} from 'react' 3 | 4 | import {UserGuideError} from '../builder' 5 | 6 | type ErrorProps = { 7 | error: UserGuideError 8 | } 9 | 10 | export const Error: FunctionComponent = ({error}) => ( 11 | 12 | 13 | 14 | 15 | Encountered an error while reading the user guide structure 16 | 17 | 18 | 19 | 20 | Error: 21 | 22 | {error.message} 23 | 24 | 25 | {error.url && ( 26 | 27 | 28 | View documentation 29 | 30 | 31 | )} 32 | 33 | 34 | 35 | 36 | 37 | ) 38 | -------------------------------------------------------------------------------- /src/documentInspector.ts: -------------------------------------------------------------------------------- 1 | import {HelpCircleIcon} from '@sanity/icons' 2 | import {createElement} from 'react' 3 | import {defineDocumentInspector} from 'sanity' 4 | 5 | import {UserGuidePage} from './builder/nodes/page' 6 | import {Inspector} from './components/Inspector' 7 | 8 | export const documentInspector = (pages: UserGuidePage[]) => 9 | defineDocumentInspector({ 10 | name: 'userGuide', 11 | useMenuItem: ({documentId, documentType}) => ({ 12 | icon: HelpCircleIcon, 13 | title: 'User Guide', 14 | hidden: !getDocumentPage(documentId, documentType, pages), 15 | hotkeys: ['ctrl+shift+h'], 16 | }), 17 | component: (props) => createElement(Inspector, {pages, ...props}), 18 | }) 19 | 20 | export function getDocumentPage( 21 | documentId: string, 22 | documentType: string, 23 | pages: UserGuidePage[], 24 | ): UserGuidePage | undefined { 25 | return pages.find((page) => { 26 | if (page.documentId === documentId) { 27 | return true 28 | } 29 | 30 | if (Array.isArray(page.documentId) && page.documentId.some((id) => id === documentId)) { 31 | return true 32 | } 33 | 34 | if (page.documentType === documentType) { 35 | return true 36 | } 37 | 38 | if ( 39 | Array.isArray(page.documentType) && 40 | page.documentType.some((type) => type === documentType) 41 | ) { 42 | return true 43 | } 44 | 45 | return false 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /src/components/MultiPage.tsx: -------------------------------------------------------------------------------- 1 | import {ChevronLeftIcon, ChevronRightIcon} from '@sanity/icons' 2 | import {Button, Card, Flex} from '@sanity/ui' 3 | import {FunctionComponent} from 'react' 4 | import {StateLink} from 'sanity/router' 5 | 6 | import {UserGuidePage} from '../builder/nodes/page' 7 | import {Page} from './Page' 8 | 9 | export type MultiPageProps = { 10 | slug: string 11 | currentPage: UserGuidePage 12 | previousPage?: UserGuidePage 13 | nextPage?: UserGuidePage 14 | } 15 | export const MultiPage: FunctionComponent = ({ 16 | slug, 17 | currentPage, 18 | previousPage, 19 | nextPage, 20 | }) => { 21 | return ( 22 | <> 23 | 24 | 25 | {previousPage ? ( 26 | 32 |