├── .eslintignore ├── types ├── hast-util-to-string.d.ts ├── webpack-hot-middleware.d.ts ├── opn.d.ts ├── @mdx-js │ └── mdx.d.ts └── react-docgen.d.ts ├── bin └── component-docs.js ├── linaria.config.js ├── .stylelintrc ├── src ├── index.tsx ├── babel-register.tsx ├── utils │ ├── getNameFromPath.tsx │ ├── buildPageInfo.tsx │ ├── stringifyData.tsx │ ├── getBabelOptions.tsx │ ├── build404.tsx │ ├── highlight.tsx │ ├── collectData.tsx │ ├── getOptions.tsx │ ├── buildEntry.tsx │ ├── rehypePrism.tsx │ ├── buildHTML.tsx │ └── configureWebpack.tsx ├── templates │ ├── Layout.tsx │ ├── Header.tsx │ ├── 404.tsx │ ├── EditButton.tsx │ ├── Link.tsx │ ├── HTML.tsx │ ├── Router.tsx │ ├── Markdown.tsx │ ├── App.tsx │ ├── Content.tsx │ ├── ThemeToggle.tsx │ ├── Documentation.tsx │ └── Sidebar.tsx ├── styles │ ├── reset.css │ └── globals.css ├── parsers │ ├── md.tsx │ ├── custom.tsx │ ├── component.tsx │ └── mdx.tsx ├── build.tsx ├── types.tsx ├── configs │ └── santize-config.json ├── cli.tsx └── serve.tsx ├── example ├── __fixtures__ │ ├── assets │ │ ├── images │ │ │ ├── logo.ico │ │ │ └── logo.svg │ │ ├── screenshots │ │ │ ├── button-custom.png │ │ │ ├── button-primary.png │ │ │ └── button-raised.png │ │ └── styles.css │ ├── markdown │ │ ├── 1.Home.md │ │ ├── 3.MDX.mdx │ │ └── 2.Markdown.md │ ├── custom │ │ └── Custom.js │ └── component │ │ ├── ListItem.js │ │ ├── DialogList.js │ │ ├── DialogButton.js │ │ ├── Link.js │ │ ├── Button.js │ │ └── Dialog.tsx ├── .babelrc └── component-docs.config.js ├── .eslintrc ├── components.js ├── .gitignore ├── .release-it.json ├── babel.config.js ├── .editorconfig ├── tsconfig.json ├── .circleci └── config.yml ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /types/hast-util-to-string.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'hast-util-to-string'; 2 | -------------------------------------------------------------------------------- /types/webpack-hot-middleware.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'webpack-hot-middleware'; 2 | -------------------------------------------------------------------------------- /types/opn.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'opn' { 2 | export default function opn(url: string): void; 3 | } 4 | -------------------------------------------------------------------------------- /bin/component-docs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* eslint-disable */ 4 | 5 | require('../dist/cli'); 6 | -------------------------------------------------------------------------------- /linaria.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | displayName: process.env.NODE_ENV !== 'production', 3 | }; 4 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-recommended", 4 | "linaria/stylelint-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'ignore-styles'; 2 | import build from './build'; 3 | import serve from './serve'; 4 | 5 | export { build, serve }; 6 | -------------------------------------------------------------------------------- /example/__fixtures__/assets/images/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/component-docs/HEAD/example/__fixtures__/assets/images/logo.ico -------------------------------------------------------------------------------- /example/__fixtures__/markdown/1.Home.md: -------------------------------------------------------------------------------- 1 | --- 2 | link: index 3 | description: Simple documentation for your React components. 4 | --- 5 | 6 | /../../../README.md 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@callstack/eslint-config", 3 | 4 | "rules": { 5 | "global-require": "off", 6 | "react-native/no-raw-text": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /example/__fixtures__/assets/screenshots/button-custom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/component-docs/HEAD/example/__fixtures__/assets/screenshots/button-custom.png -------------------------------------------------------------------------------- /example/__fixtures__/assets/screenshots/button-primary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/component-docs/HEAD/example/__fixtures__/assets/screenshots/button-primary.png -------------------------------------------------------------------------------- /example/__fixtures__/assets/screenshots/button-raised.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/component-docs/HEAD/example/__fixtures__/assets/screenshots/button-raised.png -------------------------------------------------------------------------------- /src/babel-register.tsx: -------------------------------------------------------------------------------- 1 | require('@babel/register')({ 2 | ...require('./utils/getBabelOptions')({ 3 | targets: { node: 'current' }, 4 | }), 5 | extensions: ['.tsx', '.ts', '.js'], 6 | }); 7 | -------------------------------------------------------------------------------- /components.js: -------------------------------------------------------------------------------- 1 | exports.Link = require('./dist/templates/Link').default; 2 | exports.ThemeToggle = require('./dist/templates/ThemeToggle').default; 3 | exports.Header = require('./dist/templates/Header').default; 4 | -------------------------------------------------------------------------------- /src/utils/getNameFromPath.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export default function (file: string): string { 4 | return path 5 | .parse(file) 6 | .name.replace(/^\d+(\.|-)/, '') 7 | .trim(); 8 | } 9 | -------------------------------------------------------------------------------- /example/__fixtures__/assets/styles.css: -------------------------------------------------------------------------------- 1 | .screenshots { 2 | padding: 12px 0 4px 0; 3 | margin: 0 -4px; 4 | } 5 | 6 | .screenshots > img { 7 | height: 160px; 8 | width: auto; 9 | margin: 4px; 10 | } 11 | -------------------------------------------------------------------------------- /example/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | "@babel/preset-flow", 6 | "@babel/preset-react" 7 | ], 8 | "plugins": ["@babel/plugin-proposal-class-properties"] 9 | } 10 | -------------------------------------------------------------------------------- /types/@mdx-js/mdx.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@mdx-js/mdx' { 2 | export function sync( 3 | text: string, 4 | options: { 5 | filepath: string; 6 | hastPlugins?: (() => (tree: any) => void)[]; 7 | } 8 | ): string; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/buildPageInfo.tsx: -------------------------------------------------------------------------------- 1 | import type { Separator, Metadata, PageInfo } from '../types'; 2 | 3 | export default function buildPageInfo( 4 | data: Array 5 | ): PageInfo[] { 6 | return data.filter((it) => it.type !== 'separator') as any; 7 | } 8 | -------------------------------------------------------------------------------- /src/templates/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from 'linaria/react'; 2 | 3 | const Layout = styled.div` 4 | display: flex; 5 | height: 100%; 6 | flex-direction: column; 7 | 8 | @media (min-width: 640px) { 9 | flex-direction: row; 10 | } 11 | `; 12 | 13 | export default Layout; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # VSCode 6 | .vscode/ 7 | .history/ 8 | 9 | # node.js 10 | # 11 | node_modules/ 12 | npm-debug.log 13 | 14 | # Build 15 | # 16 | dist/ 17 | 18 | # Linaria 19 | # 20 | .linaria-cache/ 21 | 22 | # Yarn 23 | # 24 | yarn-error.log 25 | yarn-debug.log 26 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release ${version}", 4 | "tagName": "v${version}" 5 | }, 6 | "npm": { 7 | "publish": true 8 | }, 9 | "github": { 10 | "release": true 11 | }, 12 | "plugins": { 13 | "@release-it/conventional-changelog": { 14 | "preset": "angular" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: '8.0.0', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-react', 12 | '@babel/preset-typescript', 13 | 'linaria/babel', 14 | ], 15 | plugins: ['@babel/plugin-proposal-class-properties'], 16 | }; 17 | -------------------------------------------------------------------------------- /src/utils/stringifyData.tsx: -------------------------------------------------------------------------------- 1 | import type { Separator, Metadata } from '../types'; 2 | 3 | export default function stringifyData( 4 | data: Array 5 | ): string { 6 | return `module.exports = [ 7 | ${data 8 | .map((item) => 9 | item.type === 'mdx' || item.type === 'custom' 10 | ? item.stringify() 11 | : JSON.stringify(item) 12 | ) 13 | .join(',')} 14 | ]`; 15 | } 16 | -------------------------------------------------------------------------------- /example/__fixtures__/markdown/3.MDX.mdx: -------------------------------------------------------------------------------- 1 | export const meta = { 2 | title: 'MDX 🔥' 3 | } 4 | 5 | import Button from '../component/Button' 6 | 7 | # What is it? 8 | 9 | [MDX](https://mdxjs.com/) is a format that lets you seamlessly use JSX in your Markdown documents. 10 | 11 | It supports normal markdown content such as codeblocks: 12 | 13 | ```js 14 | var foo = 42; 15 | ``` 16 | 17 | As well as React components: 18 | 19 | 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # change these settings to your own preference 11 | indent_style = space 12 | indent_size = 2 13 | 14 | # we recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /example/__fixtures__/custom/Custom.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import * as React from 'react'; 4 | import { styled } from 'linaria/react'; 5 | 6 | const Container = styled.div` 7 | padding: 24px; 8 | font-size: 24px; 9 | `; 10 | 11 | export default class Custom extends React.Component<{}> { 12 | render() { 13 | return 🌹 🌻 🌷 🌿 🌵 🌾 🌼⁣; 14 | } 15 | } 16 | 17 | export const meta = { 18 | title: 'Custom 🎉', 19 | description: 'Custom React Component', 20 | link: 'custom-component', 21 | }; 22 | -------------------------------------------------------------------------------- /example/__fixtures__/component/ListItem.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable class-methods-use-this, no-unused-vars, react/no-unused-prop-types */ 3 | 4 | import * as React from 'react'; 5 | 6 | type Props = { 7 | /** 8 | * Title text of the list item 9 | */ 10 | title: string, 11 | }; 12 | 13 | /** 14 | * List.Item allows you to show some content in a list. 15 | */ 16 | export default class ListItem extends React.Component { 17 | static displayName = 'List.Item'; 18 | 19 | render() { 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /example/__fixtures__/component/DialogList.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable class-methods-use-this, no-unused-vars, react/no-unused-prop-types */ 3 | 4 | import * as React from 'react'; 5 | import Button from './Button'; 6 | 7 | type Props = { 8 | /** 9 | * Callback to trigger on press. 10 | */ 11 | onPress: () => mixed, 12 | }; 13 | 14 | /** 15 | * Dialog.List allows you to add lists in a dialog. 16 | */ 17 | export default class DialogList extends React.Component { 18 | static displayName = 'Dialog.List'; 19 | 20 | render() { 21 | return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/getBabelOptions.tsx: -------------------------------------------------------------------------------- 1 | // Keep this commonjs as this is imported before babel is registered 2 | module.exports = function getBabelOptions(options: Object): Object { 3 | return { 4 | presets: [ 5 | [require.resolve('@babel/preset-env'), options], 6 | require.resolve('@babel/preset-react'), 7 | require.resolve('@babel/preset-flow'), 8 | require.resolve('@babel/preset-typescript'), 9 | [require.resolve('linaria/babel'), {}, 'component-docs-linaria'], 10 | ], 11 | plugins: [require.resolve('@babel/plugin-proposal-class-properties')], 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /example/__fixtures__/component/DialogButton.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable class-methods-use-this, no-unused-vars, react/no-unused-prop-types */ 3 | 4 | import * as React from 'react'; 5 | import Button from './Button'; 6 | 7 | type Props = { 8 | /** 9 | * Callback to trigger on press. 10 | */ 11 | onPress: () => mixed, 12 | }; 13 | 14 | /** 15 | * Dialog.Button allows you to add buttons in a dialog. 16 | */ 17 | export default class DialogButton extends React.Component { 18 | static displayName = 'Dialog.Button'; 19 | 20 | render() { 21 | return null; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/__fixtures__/component/Link.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | /* eslint-disable react/no-unused-prop-types */ 3 | 4 | import * as React from 'react'; 5 | 6 | type Props = { 7 | source: { uri: string }, 8 | onPress: () => mixed, 9 | style?: any, 10 | }; 11 | 12 | /** 13 | * Link allows to visit a remote URL on tap 14 | * 15 | * **Usage:** 16 | * ```js 17 | * const MyComponent = () => ( 18 | * 19 | * Press me 20 | * 21 | * ); 22 | * ``` 23 | */ 24 | export default class Link extends React.Component { 25 | render() { 26 | return null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "allowUnusedLabels": false, 5 | "esModuleInterop": true, 6 | "importsNotUsedAsValues": "error", 7 | "forceConsistentCasingInFileNames": true, 8 | "jsx": "react", 9 | "lib": ["esnext", "dom"], 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "noFallthroughCasesInSwitch": true, 13 | "noImplicitReturns": true, 14 | "noImplicitUseStrict": false, 15 | "noStrictGenericChecks": false, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "resolveJsonModule": true, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "target": "esnext" 22 | }, 23 | "exclude": ["example"] 24 | } 25 | -------------------------------------------------------------------------------- /example/__fixtures__/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/templates/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled } from 'linaria/react'; 3 | import ThemeToggle from './ThemeToggle'; 4 | 5 | const HeaderWrapper = styled.div` 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: space-between; 9 | padding: 16px; 10 | border-bottom: 1px solid rgba(0, 0, 55, 0.08); 11 | `; 12 | 13 | const LogoContainer = styled.div` 14 | display: flex; 15 | align-items: flex-end; 16 | `; 17 | 18 | const LogoImage = styled.img` 19 | display: block; 20 | height: 24px; 21 | width: auto; 22 | `; 23 | 24 | type Props = { 25 | logo?: string; 26 | }; 27 | 28 | export default function Header({ logo }: Props) { 29 | return ( 30 | 31 | 32 | {logo ? : null} 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/build404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import HTML from '../templates/HTML'; 4 | import Fallback from '../templates/404'; 5 | import type { Metadata, Separator } from '../types'; 6 | import buildPageInfo from './buildPageInfo'; 7 | 8 | type Options = { 9 | data: Array; 10 | sheets: string[]; 11 | favicon?: string; 12 | }; 13 | 14 | export default function build404({ data, sheets, favicon }: Options): string { 15 | const info = buildPageInfo(data); 16 | const body = ReactDOMServer.renderToStaticMarkup(); 17 | 18 | return ReactDOMServer.renderToStaticMarkup( 19 | // eslint-disable-next-line react/jsx-pascal-case 20 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/templates/404.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { PageInfo } from '../types'; 3 | import Content from './Content'; 4 | import Link from './Link'; 5 | 6 | type Props = { 7 | logo?: string; 8 | data: PageInfo[]; 9 | }; 10 | 11 | export default class Fallback extends React.Component { 12 | render() { 13 | return ( 14 | 15 |

Page not found.

16 |

17 | Looks like the page you requested doesn't exist. You can try 18 | selecting one of the available pages shown below. 19 |

20 |

This error page is shown only during development.

21 |
    22 | {this.props.data.map((item) => ( 23 |
  • 24 | {item.title} 25 |
  • 26 | ))} 27 |
28 |
29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/highlight.tsx: -------------------------------------------------------------------------------- 1 | import refractor from 'refractor/core'; 2 | 3 | const aliases: Record = { 4 | js: 'jsx', 5 | }; 6 | 7 | refractor.register(require('refractor/lang/clike')); 8 | refractor.register(require('refractor/lang/javascript')); 9 | refractor.register(require('refractor/lang/js-extras')); 10 | refractor.register(require('refractor/lang/js-templates')); 11 | refractor.register(require('refractor/lang/typescript')); 12 | refractor.register(require('refractor/lang/markup')); 13 | refractor.register(require('refractor/lang/jsx')); 14 | refractor.register(require('refractor/lang/json')); 15 | refractor.register(require('refractor/lang/bash')); 16 | refractor.register(require('refractor/lang/swift')); 17 | refractor.register(require('refractor/lang/java')); 18 | refractor.register(require('refractor/lang/diff')); 19 | 20 | export default function highlight(code: string, lang: string) { 21 | return refractor.highlight(code, aliases[lang] || lang); 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/collectData.tsx: -------------------------------------------------------------------------------- 1 | import md from '../parsers/md'; 2 | import mdx from '../parsers/mdx'; 3 | import component from '../parsers/component'; 4 | import custom from '../parsers/custom'; 5 | import type { Page, Metadata } from '../types'; 6 | 7 | export default function ( 8 | page: Page, 9 | options: { root: string; logo?: string; github?: string } 10 | ): Metadata { 11 | let data: Metadata; 12 | 13 | switch (page.type) { 14 | case 'md': 15 | data = md(page.file, options); 16 | break; 17 | case 'mdx': 18 | data = mdx(page.file, options); 19 | break; 20 | case 'component': 21 | data = component(page.file, options); 22 | break; 23 | case 'custom': 24 | data = custom(page.file, options); 25 | break; 26 | default: 27 | throw new Error(`Invalid type ${String(page.type)} for ${page.file}`); 28 | } 29 | 30 | return { 31 | ...data, 32 | group: page.group !== undefined ? page.group : data.group, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/getOptions.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { cosmiconfigSync } from 'cosmiconfig'; 3 | import type { Options } from '../types'; 4 | 5 | const explorer = cosmiconfigSync('component-docs'); 6 | 7 | export default function getOptions( 8 | overrides: Options 9 | ): Options & { 10 | root: string; 11 | output: string; 12 | port: number; 13 | } { 14 | const root: string = overrides.root 15 | ? path.isAbsolute(overrides.root) 16 | ? (overrides.root as any) 17 | : path.join(process.cwd(), overrides.root || '') 18 | : process.cwd(); 19 | 20 | const result: { config?: Partial } | null = explorer.search(root); 21 | const options = { 22 | port: 3031, 23 | output: 'dist', 24 | ...(result ? result.config : null), 25 | ...overrides, 26 | root, 27 | }; 28 | 29 | options.output = path.isAbsolute(options.output) 30 | ? options.output 31 | : path.join(root, options.output); 32 | 33 | (['assets', 'styles', 'scripts'] as const).forEach((t) => { 34 | options[t] = options[t] 35 | ? options[t]?.map((name: string) => 36 | path.isAbsolute(name) ? name : path.join(root, name) 37 | ) 38 | : undefined; 39 | }); 40 | 41 | return options; 42 | } 43 | -------------------------------------------------------------------------------- /src/templates/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled } from 'linaria/react'; 3 | 4 | type Props = { 5 | filepath: string; 6 | github?: string; 7 | }; 8 | 9 | const EditButtonContainer = styled.a` 10 | display: inline-block; 11 | vertical-align: middle; 12 | margin-top: 32px; 13 | 14 | svg { 15 | stroke: currentColor; 16 | margin-right: 8px; 17 | margin-bottom: -2px; 18 | } 19 | `; 20 | 21 | export default function EditButton({ github, filepath }: Props) { 22 | return github ? ( 23 | 24 | 25 | 30 | 31 | Edit this page 32 | 33 | ) : null; 34 | } 35 | -------------------------------------------------------------------------------- /src/styles/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | vertical-align: baseline; 25 | } 26 | /* HTML5 display-role reset for older browsers */ 27 | article, aside, details, figcaption, figure, 28 | footer, header, hgroup, menu, nav, section { 29 | display: block; 30 | } 31 | body { 32 | line-height: 1; 33 | } 34 | ol, ul { 35 | list-style: none; 36 | } 37 | blockquote, q { 38 | quotes: none; 39 | } 40 | blockquote:before, blockquote:after, 41 | q:before, q:after { 42 | content: ''; 43 | content: none; 44 | } 45 | table { 46 | border-collapse: collapse; 47 | border-spacing: 0; 48 | } 49 | -------------------------------------------------------------------------------- /src/parsers/md.tsx: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import dashify from 'dashify'; 4 | import frontmatter from 'front-matter'; 5 | import getNameFromPath from '../utils/getNameFromPath'; 6 | import type { Metadata } from '../types'; 7 | 8 | export default function md( 9 | filepath: string, 10 | { root }: { root: string } 11 | ): Metadata { 12 | let text = fs.readFileSync(filepath, 'utf-8'); 13 | 14 | const dependencies: string[] = []; 15 | 16 | // Inline file references 17 | text = text 18 | .split('\n') 19 | .map((line) => { 20 | if (/^\/.+\.md$/.test(line)) { 21 | const f = path.join(path.dirname(filepath), line); 22 | const result = md(f, { root }); 23 | 24 | dependencies.push(...result.dependencies, f); 25 | 26 | return result.data; 27 | } 28 | return line; 29 | }) 30 | .join('\n'); 31 | 32 | // Load YAML frontmatter 33 | const { body: data, attributes: meta } = frontmatter<{ 34 | title?: string; 35 | description?: string; 36 | link?: string; 37 | }>(text); 38 | 39 | const title = meta.title || getNameFromPath(filepath); 40 | 41 | return { 42 | filepath: path.relative(root, filepath), 43 | title, 44 | description: meta.description || '', 45 | link: meta.link || dashify(title), 46 | data, 47 | type: 'md', 48 | dependencies, 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /example/component-docs.config.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import path from 'path'; 4 | import fs from 'fs'; 5 | 6 | const output = path.join(__dirname, 'dist'); 7 | const fixtures = path.join(__dirname, '__fixtures__'); 8 | const assets = path.join(fixtures, 'assets'); 9 | const github = 'https://github.com/callstack/component-docs/edit/master'; 10 | 11 | function pages() { 12 | const markdown = path.join(fixtures, 'markdown'); 13 | const component = path.join(fixtures, 'component'); 14 | const custom = path.join(fixtures, 'custom'); 15 | 16 | return [ 17 | ...fs 18 | .readdirSync(markdown) 19 | .map((f) => path.join(markdown, f)) 20 | .map((file) => ({ type: file.endsWith('mdx') ? 'mdx' : 'md', file })), 21 | { type: 'separator' }, 22 | ...fs 23 | .readdirSync(component) 24 | .map((f) => path.join(component, f)) 25 | .map((file) => ({ type: 'component', file })), 26 | { type: 'separator' }, 27 | ...fs 28 | .readdirSync(custom) 29 | .map((f) => path.join(custom, f)) 30 | .map((file) => ({ type: 'custom', file })), 31 | ]; 32 | } 33 | 34 | module.exports = { 35 | logo: 'images/logo.svg', 36 | favicon: 'images/logo.ico', 37 | assets: [path.join(assets, 'screenshots'), path.join(assets, 'images')], 38 | styles: [path.join(assets, 'styles.css')], 39 | pages, 40 | output, 41 | github, 42 | title: '[title] - Component Docs', 43 | }; 44 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | defaults: &defaults 4 | docker: 5 | - image: circleci/node:10 6 | working_directory: ~/component-docs 7 | 8 | jobs: 9 | install-dependencies: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | - attach_workspace: 14 | at: ~/component-docs 15 | - restore_cache: 16 | keys: 17 | - dependencies-{{ checksum "package.json" }} 18 | - dependencies- 19 | - run: | 20 | yarn install --frozen-lockfile 21 | - save_cache: 22 | key: dependencies-{{ checksum "package.json" }} 23 | paths: node_modules 24 | - persist_to_workspace: 25 | root: . 26 | paths: . 27 | lint-and-typecheck: 28 | <<: *defaults 29 | steps: 30 | - attach_workspace: 31 | at: ~/component-docs 32 | - run: | 33 | yarn lint 34 | yarn typescript 35 | build-package: 36 | <<: *defaults 37 | steps: 38 | - attach_workspace: 39 | at: ~/component-docs 40 | - run: yarn prepare 41 | 42 | workflows: 43 | version: 2 44 | build-and-test: 45 | jobs: 46 | - install-dependencies 47 | - lint-and-typecheck: 48 | requires: 49 | - install-dependencies 50 | - unit-tests: 51 | requires: 52 | - install-dependencies 53 | - build-package: 54 | requires: 55 | - install-dependencies 56 | -------------------------------------------------------------------------------- /src/utils/buildEntry.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export default function buildEntry({ 4 | styles, 5 | github, 6 | logo, 7 | title, 8 | }: { 9 | styles?: string[]; 10 | github?: string; 11 | logo?: string; 12 | title?: string; 13 | }): string { 14 | return ` 15 | import React from 'react'; 16 | import ReactDOM from 'react-dom'; 17 | import RedBox from 'redbox-react'; 18 | import App from '${require.resolve('../templates/App')}'; 19 | import data from './app.data'; 20 | import '${path.resolve(__dirname, '../styles/reset.css')}'; 21 | import '${path.resolve(__dirname, '../styles/globals.css')}'; 22 | 23 | ${ 24 | styles 25 | ? styles 26 | .map((sheet) => `import '${path.resolve(__dirname, sheet)}';`) 27 | .join('\n') 28 | : '' 29 | } 30 | 31 | const root = document.getElementById('root'); 32 | const render = () => { 33 | try { 34 | ReactDOM.hydrate( 35 | , 42 | root 43 | ); 44 | } catch (e) { 45 | ReactDOM.render( 46 | , 47 | root 48 | ); 49 | } 50 | }; 51 | 52 | if (module.hot) { 53 | module.hot.accept(() => { 54 | render(); 55 | }); 56 | } 57 | 58 | render(); 59 | `; 60 | } 61 | -------------------------------------------------------------------------------- /src/parsers/custom.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import dedent from 'dedent'; 3 | import dashify from 'dashify'; 4 | import getNameFromPath from '../utils/getNameFromPath'; 5 | import type { Metadata } from '../types'; 6 | 7 | export default function custom( 8 | filepath: string, 9 | { root }: { root: string } 10 | ): Metadata { 11 | const exported = require(filepath); 12 | const component = 13 | typeof exported.default === 'function' ? exported.default : exported; 14 | const name = 15 | component.displayName || component.name || getNameFromPath(filepath); 16 | const meta = exported.meta || {}; 17 | 18 | const title = meta.title || name; 19 | const description = meta.description; 20 | const link = meta.link || dashify(name); 21 | const type = 'custom'; 22 | 23 | return { 24 | filepath: path.relative(root, filepath), 25 | title, 26 | description, 27 | type, 28 | link, 29 | data: component, 30 | stringify() { 31 | return dedent` 32 | (function() { 33 | var e = require(${JSON.stringify(filepath)}); 34 | var c = typeof e.default === 'function' ? e.default : e; 35 | var m = e.meta || {}; 36 | return { 37 | title: m.title || ${JSON.stringify(title)}, 38 | link: m.link || ${JSON.stringify(link)}, 39 | description: m.description, 40 | type: ${JSON.stringify(type)}, 41 | data: c 42 | }; 43 | }())`; 44 | }, 45 | dependencies: [], 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/templates/Link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { history } from './Router'; 3 | 4 | type Props = { 5 | to: string; 6 | onClick?: (event: React.MouseEvent) => void; 7 | children: string; 8 | }; 9 | 10 | export default class Link extends React.Component { 11 | private handleClick = ( 12 | event: React.MouseEvent 13 | ) => { 14 | this.props.onClick && this.props.onClick(event); 15 | 16 | if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) { 17 | return; 18 | } 19 | 20 | event.preventDefault(); 21 | 22 | if (!this.props.to) { 23 | return; 24 | } 25 | 26 | const path = `${this.props.to}.html`; 27 | 28 | try { 29 | if (history) { 30 | history.push(path); 31 | } else { 32 | throw new Error(''); 33 | } 34 | } catch (e) { 35 | if (!e.message.startsWith("Failed to execute 'pushState' on 'History'")) { 36 | // Google Chrome throws for file URLs 37 | throw e; 38 | } 39 | const { pathname } = window.location; 40 | if (pathname.endsWith('/')) { 41 | window.location.pathname = `${pathname}/${path}`; 42 | } else { 43 | const parts = pathname.split('/'); 44 | parts.pop(); 45 | window.location.pathname = `${parts.join('/')}/${path}`; 46 | } 47 | } 48 | }; 49 | 50 | render() { 51 | const { to, ...rest } = this.props; 52 | 53 | return ( 54 | 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/utils/rehypePrism.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Used under license from Mapbox 3 | * https://github.com/mapbox/rehype-prism/blob/master/LICENSE 4 | */ 5 | 6 | import visit from 'unist-util-visit'; 7 | import nodeToString from 'hast-util-to-string'; 8 | import nodeToHTML from 'hast-util-to-html'; 9 | import highlight from './highlight'; 10 | 11 | export default function () { 12 | /* eslint-disable no-param-reassign */ 13 | return (tree: any) => { 14 | visit(tree, 'element', visitor); 15 | }; 16 | 17 | function visitor(node: any, _: number, parent: any) { 18 | if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') { 19 | return; 20 | } 21 | 22 | const lang = getLanguage(node); 23 | 24 | if (lang === null) { 25 | return; 26 | } 27 | 28 | let result = node; 29 | 30 | try { 31 | parent.properties.className = (parent.properties.className || []).concat( 32 | `language-${lang}` 33 | ); 34 | 35 | result = highlight(nodeToString(node), lang); 36 | } catch (err) { 37 | if (/Unknown language/.test(err.message)) { 38 | return; 39 | } 40 | 41 | throw err; 42 | } 43 | 44 | node.children = []; 45 | node.properties.dangerouslySetInnerHTML = { 46 | __html: nodeToHTML({ 47 | type: 'root', 48 | children: result, 49 | }), 50 | }; 51 | } 52 | } 53 | 54 | function getLanguage(node: any) { 55 | const className = node.properties.className || []; 56 | 57 | for (const classListItem of className) { 58 | if (classListItem.slice(0, 9) === 'language-') { 59 | return classListItem.slice(9).replace(/{.*/, ''); 60 | } 61 | } 62 | 63 | return null; 64 | } 65 | -------------------------------------------------------------------------------- /example/__fixtures__/component/Button.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-unused-prop-types */ 2 | 3 | import React, { Component } from 'react'; 4 | import PropTypes from 'prop-types'; 5 | 6 | /** 7 | * Buttons communicate the action that will occur when the user touches them 8 | * 9 | *
10 | * 11 | * 12 | * 13 | *
14 | * 15 | * ## Usage 16 | * ```js 17 | * const MyComponent = () => ( 18 | * 21 | * ); 22 | * ``` 23 | * @extends TouchableWithoutFeedback props https://facebook.github.io/react-native/docs/touchablewithoutfeedback.html#props 24 | */ 25 | export default class Button extends Component { 26 | static propTypes = { 27 | /** 28 | * Whether to disable the button. 29 | */ 30 | disabled: PropTypes.bool, 31 | /** 32 | * Whether to use to primary color from theme. 33 | */ 34 | primary: PropTypes.bool, 35 | /** 36 | * Content of the button. 37 | */ 38 | children: PropTypes.oneOfType([ 39 | PropTypes.string, 40 | PropTypes.arrayOf(PropTypes.string), 41 | ]).isRequired, 42 | /** 43 | * Function to execute on press. 44 | */ 45 | onPress: PropTypes.func, 46 | style: PropTypes.any, 47 | }; 48 | 49 | static defaultProps = { 50 | disabled: false, 51 | }; 52 | 53 | static getDerivedStateFromProps() { 54 | return null; 55 | } 56 | 57 | state = {}; 58 | 59 | render() { 60 | return ; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/templates/HTML.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/no-danger */ 2 | 3 | import * as React from 'react'; 4 | import mime from 'mime-types'; 5 | 6 | type Props = { 7 | title: string; 8 | description: string; 9 | body: string; 10 | sheets: string[]; 11 | favicon: string; 12 | }; 13 | 14 | export default function HTML({ 15 | title, 16 | description, 17 | body, 18 | sheets, 19 | favicon, 20 | }: Props) { 21 | return ( 22 | 23 | 24 | 25 | 30 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {title} 48 | 49 | {sheets.map((sheet) => ( 50 | 51 | ))} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/templates/Router.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { createBrowserHistory } from 'history'; 3 | import type { Route } from '../types'; 4 | 5 | type Props = { 6 | path: string; 7 | routes: Array; 8 | title?: string; 9 | }; 10 | 11 | type State = { 12 | path: string; 13 | }; 14 | 15 | // eslint-disable-next-line import/no-mutable-exports 16 | export let history: import('history').BrowserHistory | undefined; 17 | 18 | try { 19 | history = createBrowserHistory(); 20 | } catch (e) { 21 | // Ignore 22 | } 23 | 24 | export default class Router extends Component { 25 | constructor(props: Props) { 26 | super(props); 27 | this.state = { 28 | path: history ? this.parse(history.location.pathname) : props.path, 29 | }; 30 | } 31 | 32 | state: State; 33 | 34 | componentDidMount() { 35 | this.unlisten = history?.listen(({ location }) => 36 | this.setState({ 37 | path: this.parse(location.pathname), 38 | }) 39 | ); 40 | } 41 | 42 | componentDidUpdate(_: Props, prevState: State) { 43 | const { routes, title } = this.props; 44 | if (prevState.path !== this.state.path) { 45 | const route = routes.find((r) => r.link === this.state.path); 46 | if (route) { 47 | // eslint-disable-next-line no-undef 48 | document.title = title 49 | ? title.replace(/\[title\]/g, route.title) 50 | : route.title || ''; 51 | } 52 | } 53 | } 54 | 55 | componentWillUnmount() { 56 | this.unlisten?.(); 57 | } 58 | 59 | private parse = (pathname: string) => 60 | pathname.split('/').pop()?.split('.')[0] || 'index'; 61 | 62 | private unlisten: (() => void) | undefined; 63 | 64 | render() { 65 | const route = this.props.routes.find((r) => r.link === this.state.path); 66 | 67 | if (route) { 68 | return route.render({ 69 | ...route.props, 70 | path: this.state.path, 71 | }); 72 | } 73 | 74 | return null; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/templates/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import marked from 'marked'; 3 | import sanitize from 'sanitize-html'; 4 | import escape from 'escape-html'; 5 | import rehype from 'rehype'; 6 | import highlight from '../utils/highlight'; 7 | 8 | type Props = { 9 | source: string; 10 | className?: string; 11 | }; 12 | 13 | const renderer = new marked.Renderer(); 14 | 15 | renderer.heading = function heading(...args) { 16 | return marked.Renderer.prototype.heading.apply(this, args).replace( 17 | /^(]+>)(.+)(<\/h[1-3]>)/, 18 | (_match: string, p1: string, p2: string, p3: string) => `${p1} 19 |
23 | 26 | 27 | ${p2}${p3}` 28 | ); 29 | }; 30 | 31 | export default class Markdown extends React.Component { 32 | render() { 33 | let html = marked(this.props.source, { 34 | renderer, 35 | gfm: true, 36 | silent: true, 37 | highlight: (code: string, lang: string) => { 38 | try { 39 | const nodes = highlight(code, lang); 40 | 41 | return rehype() 42 | .stringify({ type: 'root', children: nodes }) 43 | .toString(); 44 | } catch (err) { 45 | if (/Unknown language/.test(err.message)) { 46 | return escape(code); 47 | } 48 | 49 | throw err; 50 | } 51 | }, 52 | }); 53 | 54 | html = sanitize(html, require('../configs/santize-config.json')); 55 | 56 | return ( 57 |
63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /types/react-docgen.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-docgen' { 2 | import { namedTypes } from 'ast-types'; 3 | 4 | export type Documentation = { 5 | displayName?: string; 6 | description: string; 7 | props?: { 8 | [prop: string]: { 9 | description: string; 10 | required?: boolean; 11 | defaultValue?: { 12 | value: string | number; 13 | }; 14 | flowType?: { 15 | name?: string; 16 | raw: string; 17 | }; 18 | tsType?: { 19 | name?: string; 20 | raw: string; 21 | }; 22 | type?: { 23 | name?: string; 24 | raw: string; 25 | }; 26 | }; 27 | }; 28 | methods: Array<{ 29 | name: string; 30 | description?: string; 31 | docblock?: string; 32 | params: Array<{ 33 | name: string; 34 | type?: { 35 | name?: string; 36 | raw: string; 37 | }; 38 | }>; 39 | returns?: { 40 | type?: { 41 | name?: string; 42 | raw: string; 43 | }; 44 | }; 45 | modifiers: Array<'static' | 'generator' | 'async'>; 46 | }>; 47 | statics: Array<{ 48 | name: string; 49 | description?: string; 50 | type?: { 51 | name?: string; 52 | raw: string; 53 | }; 54 | value?: string; 55 | link?: string; 56 | }>; 57 | 58 | set(type: T, value: Documentation[T]): void; 59 | }; 60 | 61 | export type Node = namedTypes.Node & { 62 | static?: boolean; 63 | key: { name: string }; 64 | value: { value: string }; 65 | }; 66 | 67 | export type PropertyPath = { 68 | node: Node; 69 | get(...args: string[]): { node: Node }[]; 70 | }; 71 | 72 | export function parse( 73 | text: string, 74 | what: any, 75 | handlers: ((docs: Documentation, propertyPath: PropertyPath) => void)[], 76 | options: { 77 | cwd: string; 78 | filename: string; 79 | } 80 | ): Documentation; 81 | 82 | export const defaultHandlers: (( 83 | docs: Documentation, 84 | propertyPath: PropertyPath 85 | ) => void)[]; 86 | 87 | export const utils: { 88 | getTypeAnnotation(path: { node: Node }): { name: string; raw: string }; 89 | getFlowType(type: { 90 | name: string; 91 | raw: string; 92 | }): { name: string; raw: string }; 93 | 94 | docblock: { 95 | getDocblock(p: any): any; 96 | }; 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /src/templates/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { Metadata, Route, Separator } from '../types'; 3 | import Router from './Router'; 4 | import Documentation from './Documentation'; 5 | import Markdown from './Markdown'; 6 | import Layout from './Layout'; 7 | import Sidebar from './Sidebar'; 8 | import Content from './Content'; 9 | 10 | type Data = Array; 11 | 12 | const buildRoutes = ( 13 | data: Data, 14 | github?: string, 15 | logo?: string 16 | ): Array => { 17 | const items = data.filter((item) => item.type !== 'separator') as Metadata[]; 18 | 19 | return items.map((item) => { 20 | let render; 21 | 22 | switch (item.type) { 23 | case 'md': 24 | { 25 | const source = item.data; 26 | render = (props: { path: string }) => ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | break; 36 | case 'component': 37 | { 38 | const info = item.data; 39 | render = (props: { path: string }) => ( 40 | 41 | 42 | 49 | 50 | ); 51 | } 52 | break; 53 | case 'custom': 54 | { 55 | const CustomComponent = item.data; 56 | render = (props: { path: string }) => ( 57 | 58 | 59 | 60 | 61 | ); 62 | } 63 | break; 64 | default: 65 | throw new Error(`Unknown type ${item.type}`); 66 | } 67 | 68 | return { 69 | ...item, 70 | render, 71 | }; 72 | }); 73 | }; 74 | 75 | type Props = { 76 | path: string; 77 | data: Data; 78 | github?: string; 79 | logo?: string; 80 | title?: string; 81 | }; 82 | 83 | export default function App({ path, data, github, logo, title }: Props) { 84 | const routes = buildRoutes(data, github, logo); 85 | return ; 86 | } 87 | -------------------------------------------------------------------------------- /src/templates/Content.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled } from 'linaria/react'; 3 | import Header from './Header'; 4 | 5 | const Container = styled.main` 6 | flex: 1; 7 | 8 | @media (min-width: 640px) { 9 | height: 100%; 10 | overflow: auto; 11 | -webkit-overflow-scrolling: touch; 12 | } 13 | `; 14 | 15 | const Inner = styled.div` 16 | padding: 64px 24px 24px; 17 | 18 | @media (min-width: 640px) { 19 | padding: 64px; 20 | } 21 | 22 | @media (min-width: 960px) { 23 | padding: 86px; 24 | } 25 | 26 | .anchor { 27 | margin-left: -20px; 28 | margin-right: -1.5px; 29 | opacity: 0; 30 | 31 | &:hover { 32 | opacity: 1; 33 | } 34 | } 35 | 36 | h1:hover > .anchor, 37 | h2:hover > .anchor, 38 | h3:hover > .anchor, 39 | h4:hover > .anchor, 40 | h5:hover > .anchor, 41 | h6:hover > .anchor { 42 | opacity: 1; 43 | } 44 | 45 | /* Syntax highlighting */ 46 | .token.comment, 47 | .token.prolog, 48 | .token.doctype, 49 | .token.cdata { 50 | color: #90a4ae; 51 | } 52 | 53 | .token.punctuation { 54 | color: #9e9e9e; 55 | } 56 | 57 | .namespace { 58 | opacity: 0.7; 59 | } 60 | 61 | .token.property, 62 | .token.tag, 63 | .token.boolean, 64 | .token.number, 65 | .token.constant, 66 | .token.symbol, 67 | .token.deleted { 68 | color: #e91e63; 69 | } 70 | 71 | .token.selector, 72 | .token.attr-name, 73 | .token.string, 74 | .token.char, 75 | .token.builtin, 76 | .token.inserted { 77 | color: #4caf50; 78 | } 79 | 80 | .token.operator, 81 | .token.entity, 82 | .token.url, 83 | .language-css .token.string, 84 | .style .token.string { 85 | color: #795548; 86 | } 87 | 88 | .token.atrule, 89 | .token.attr-value, 90 | .token.keyword { 91 | color: #3f51b5; 92 | } 93 | 94 | .token.function { 95 | color: #f44336; 96 | } 97 | 98 | .token.regex, 99 | .token.important, 100 | .token.variable { 101 | color: #ff9800; 102 | } 103 | 104 | .token.important, 105 | .token.bold { 106 | font-weight: bold; 107 | } 108 | 109 | .token.italic { 110 | font-style: italic; 111 | } 112 | 113 | .token.entity { 114 | cursor: help; 115 | } 116 | `; 117 | 118 | type Props = { 119 | logo?: string; 120 | children: React.ReactNode; 121 | }; 122 | 123 | export default function Content({ logo, children }: Props) { 124 | return ( 125 | 126 |
127 | {children} 128 | 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /src/build.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import fs from 'fs-extra'; 4 | import buildEntry from './utils/buildEntry'; 5 | import buildHTML from './utils/buildHTML'; 6 | import configureWebpack from './utils/configureWebpack'; 7 | import collectData from './utils/collectData'; 8 | import stringifyData from './utils/stringifyData'; 9 | import buildPageInfo from './utils/buildPageInfo'; 10 | import getOptions from './utils/getOptions'; 11 | import type { Options } from './types'; 12 | 13 | export default async function build(o: Options) { 14 | const options = getOptions(o); 15 | const { 16 | root, 17 | assets, 18 | scripts, 19 | styles, 20 | pages: getPages, 21 | github, 22 | logo, 23 | output, 24 | colors, 25 | title, 26 | favicon, 27 | } = options; 28 | 29 | const pages = typeof getPages === 'function' ? getPages() : getPages; 30 | const data = pages.map((page) => 31 | page.type === 'separator' ? page : collectData(page, { root, logo, github }) 32 | ); 33 | 34 | if (!fs.existsSync(output)) { 35 | fs.mkdirSync(output); 36 | } 37 | 38 | if (assets) { 39 | assets.forEach((dir) => { 40 | fs.copySync(dir, path.join(output, path.basename(dir))); 41 | }); 42 | } 43 | 44 | if (scripts) { 45 | scripts.forEach((name) => { 46 | fs.copySync(name, path.join(output, 'scripts', path.basename(name))); 47 | }); 48 | } 49 | 50 | fs.writeFileSync( 51 | path.join(output, 'app.src.js'), 52 | buildEntry({ styles, github, logo, title }) 53 | ); 54 | 55 | fs.writeFileSync(path.join(output, 'app.data.js'), stringifyData(data)); 56 | 57 | buildPageInfo(data).forEach((info) => { 58 | fs.writeFileSync( 59 | path.join(output, `${info.link}.html`), 60 | buildHTML({ 61 | data, 62 | info, 63 | github, 64 | logo, 65 | sheets: ['app.css'], 66 | scripts: scripts 67 | ? scripts.map((s) => `scripts/${path.basename(s)}`) 68 | : [], 69 | colors, 70 | title, 71 | favicon, 72 | }) 73 | ); 74 | }); 75 | 76 | const config = configureWebpack({ 77 | root, 78 | entry: path.join(output, 'app.src.js'), 79 | output: { 80 | path: output, 81 | bundle: 'app.bundle.js', 82 | style: 'app.css', 83 | }, 84 | production: true, 85 | }); 86 | 87 | return new Promise<{ stats: webpack.Stats }>((resolve, reject) => { 88 | webpack(config).run((err, stats) => { 89 | if (err) { 90 | reject(err); 91 | } else { 92 | resolve({ stats: stats as webpack.Stats }); 93 | } 94 | }); 95 | }); 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/buildHTML.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | import HTML from '../templates/HTML'; 4 | import App from '../templates/App'; 5 | import type { Metadata, PageInfo, Separator } from '../types'; 6 | 7 | type Options = { 8 | data: Array; 9 | info: PageInfo; 10 | github?: string; 11 | logo?: string; 12 | sheets: string[]; 13 | scripts: string[]; 14 | colors?: { 15 | primary?: string; 16 | }; 17 | title?: string; 18 | favicon?: string; 19 | }; 20 | 21 | export default function buildHTML({ 22 | data, 23 | info, 24 | github, 25 | logo, 26 | sheets, 27 | scripts, 28 | colors = {}, 29 | title, 30 | favicon, 31 | }: Options): string { 32 | const html = ReactDOMServer.renderToString( 33 | 40 | ); 41 | 42 | let body = ` 43 | 68 | `; 69 | 70 | body += `
${html}
`; 71 | 72 | body += ` 73 | 78 | `; 79 | 80 | body += ` 81 | 84 | `; 85 | 86 | body += ''; 87 | 88 | scripts.forEach((s) => { 89 | body += ``; 90 | }); 91 | 92 | const pageTitle = 93 | title !== undefined ? title.replace(/\[title\]/g, info.title) : info.title; 94 | 95 | return ReactDOMServer.renderToStaticMarkup( 96 | // eslint-disable-next-line react/jsx-pascal-case 97 | 104 | ); 105 | } 106 | -------------------------------------------------------------------------------- /src/types.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from 'react'; 2 | 3 | export type Page = { 4 | type: 'md' | 'mdx' | 'component' | 'custom'; 5 | file: string; 6 | group?: string | undefined; 7 | }; 8 | 9 | export type Separator = { type: 'separator' }; 10 | 11 | export type Group = { 12 | type: 'group'; 13 | title: string; 14 | link?: string; 15 | items: (Separator | Group | GroupItem)[]; 16 | }; 17 | 18 | export type GroupItem = { 19 | type: 'md' | 'mdx' | 'component' | 'custom'; 20 | link: string; 21 | title: string; 22 | }; 23 | 24 | export type Options = { 25 | root?: string; 26 | logo?: string; 27 | assets?: string[]; 28 | styles?: string[]; 29 | scripts?: string[]; 30 | pages: Array | (() => Array); 31 | output?: string; 32 | port?: number; 33 | open?: boolean; 34 | github?: string; 35 | colors?: { 36 | primary?: string; 37 | }; 38 | title?: string; 39 | favicon?: string; 40 | }; 41 | 42 | export type PageInfo = { 43 | title: string; 44 | description: string; 45 | link: string; 46 | filepath: string; 47 | dependencies: string[]; 48 | group?: string; 49 | }; 50 | 51 | export type TypeAnnotation = { 52 | name?: string; 53 | raw: string; 54 | }; 55 | 56 | export type Docs = { 57 | description: string; 58 | props?: { 59 | [prop: string]: { 60 | description: string; 61 | required?: boolean; 62 | defaultValue?: { 63 | value: string | number; 64 | }; 65 | flowType?: TypeAnnotation; 66 | tsType?: TypeAnnotation; 67 | type?: TypeAnnotation; 68 | }; 69 | }; 70 | methods: Array<{ 71 | name: string; 72 | description?: string; 73 | docblock?: string; 74 | params: Array<{ 75 | name: string; 76 | type?: TypeAnnotation; 77 | }>; 78 | returns?: { 79 | type?: TypeAnnotation; 80 | }; 81 | modifiers: Array<'static' | 'generator' | 'async'>; 82 | }>; 83 | statics: Array<{ 84 | name: string; 85 | description?: string; 86 | type?: TypeAnnotation; 87 | value?: string; 88 | link?: string; 89 | }>; 90 | }; 91 | 92 | export type Metadata = 93 | | (PageInfo & { type: 'component'; data: Docs }) 94 | | (PageInfo & { type: 'md'; data: string }) 95 | | (PageInfo & { 96 | type: 'mdx'; 97 | data: React.ComponentType<{}>; 98 | stringify: () => string; 99 | }) 100 | | (PageInfo & { 101 | type: 'custom'; 102 | data: React.ComponentType<{}>; 103 | stringify: () => string; 104 | }); 105 | 106 | export type Route = PageInfo & { 107 | render: (props: { path: string }) => React.ReactNode; 108 | props?: {}; 109 | }; 110 | -------------------------------------------------------------------------------- /example/__fixtures__/component/Dialog.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable class-methods-use-this, react/no-unused-prop-types, @typescript-eslint/no-unused-vars */ 2 | 3 | import * as React from 'react'; 4 | import DialogButton from './DialogButton'; 5 | import DialogList from './DialogList'; 6 | 7 | type Props = { 8 | /** 9 | * Image to show in dialog. 10 | */ 11 | source: { uri: string }; 12 | /** 13 | * Callback to trigger on press. 14 | */ 15 | onPress: () => void; 16 | children: React.ReactNode; 17 | /** 18 | * @internal 19 | */ 20 | siblings: string[]; 21 | /** 22 | * @optional 23 | */ 24 | theme: any; 25 | style?: any; 26 | }; 27 | 28 | /** 29 | * Dialog allows you to show information in a dialog. 30 | * 31 | * **Usage:** 32 | * ```js 33 | * export default class MyComponent extends React.Component { 34 | * state = { 35 | * // Whether dialog is visible 36 | * visible: false, 37 | * }; 38 | * 39 | * _showDialog = () => this.setState({ visible: true }); 40 | * 41 | * _hideDialog = () => this.setState({ visible: false }); 42 | * 43 | * render() { 44 | * const { visible } = this.state; 45 | * 46 | * return ( 47 | * 48 | * 49 | * 53 | * Alert 54 | * 55 | * This is a simple dialog 56 | * 57 | * 58 | * 59 | * 60 | * 61 | * 62 | * ); 63 | * } 64 | * } 65 | * ``` 66 | */ 67 | export default class Dialog extends React.Component { 68 | /** 69 | * Duration for showing the dialog. 70 | */ 71 | static DURATION: number = 300; 72 | 73 | /** 74 | * Builder can be used to build dialogs. 75 | */ 76 | static Builder(description: string) { 77 | return null; 78 | } 79 | 80 | /** 81 | * Static prop that allows you to access DialogButton as Dialog.Button 82 | */ 83 | static Button = DialogButton; 84 | 85 | /** 86 | * Static prop that allows you to access DialogList as Dialog.List 87 | */ 88 | static List = DialogList; 89 | 90 | /** 91 | * Show the dialog 92 | */ 93 | show(animated: boolean, duration: number): Promise { 94 | return Promise.resolve(); 95 | } 96 | 97 | private animate() {} 98 | 99 | render() { 100 | return null; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/configureWebpack.tsx: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack'; 2 | import MiniCssExtractPlugin from 'mini-css-extract-plugin'; 3 | 4 | type Options = { 5 | root: string; 6 | entry: string; 7 | production: boolean; 8 | output: { 9 | path: string; 10 | bundle: string; 11 | style: string; 12 | }; 13 | }; 14 | 15 | const babelrc = require('./getBabelOptions')({ 16 | modules: false, 17 | targets: { 18 | browsers: ['last 2 versions', 'safari >= 7'], 19 | }, 20 | }); 21 | 22 | export default ({ 23 | root, 24 | entry, 25 | output, 26 | production, 27 | }: Options): webpack.Configuration => ({ 28 | context: root, 29 | mode: production ? 'production' : 'development', 30 | devtool: 'source-map', 31 | entry: production 32 | ? entry 33 | : [require.resolve('webpack-hot-middleware/client'), entry], 34 | output: { 35 | path: output.path, 36 | filename: output.bundle, 37 | publicPath: '/', 38 | }, 39 | optimization: { 40 | minimize: production, 41 | concatenateModules: true, 42 | }, 43 | // @ts-ignore 44 | plugins: [ 45 | new webpack.DefinePlugin({ 46 | process: { 47 | cwd: () => {}, 48 | env: { 49 | NODE_ENV: JSON.stringify(production ? 'production' : 'development'), 50 | }, 51 | }, 52 | }), 53 | new MiniCssExtractPlugin({ 54 | filename: output.style, 55 | }), 56 | ].concat( 57 | production 58 | ? [new webpack.LoaderOptionsPlugin({ minimize: true, debug: false })] 59 | : [ 60 | new webpack.HotModuleReplacementPlugin(), 61 | new webpack.NoEmitOnErrorsPlugin(), 62 | ] 63 | ), 64 | module: { 65 | rules: [ 66 | { 67 | test: /\.(js|tsx?)$/, 68 | exclude: /node_modules/, 69 | use: [ 70 | { 71 | loader: require.resolve('babel-loader'), 72 | options: babelrc, 73 | }, 74 | { 75 | loader: require.resolve('linaria/loader'), 76 | options: { sourceMap: !production, babelOptions: babelrc }, 77 | }, 78 | ], 79 | }, 80 | { 81 | test: /\.css$/, 82 | use: [ 83 | { loader: require.resolve('css-hot-loader') }, 84 | { 85 | loader: MiniCssExtractPlugin.loader, 86 | options: { sourceMap: !production }, 87 | }, 88 | { 89 | loader: require.resolve('css-loader'), 90 | options: { sourceMap: !production }, 91 | }, 92 | ], 93 | }, 94 | { 95 | test: /\.(bmp|gif|jpg|jpeg|png|svg|webp|eot|woff|woff2|ttf)$/, 96 | use: { 97 | loader: require.resolve('file-loader'), 98 | options: { 99 | outputPath: 'assets/', 100 | publicPath: 'assets/', 101 | }, 102 | }, 103 | }, 104 | ], 105 | }, 106 | resolve: { 107 | extensions: ['.tsx', '.ts', '.js'], 108 | fallback: { 109 | path: false, 110 | url: require.resolve('url'), 111 | }, 112 | }, 113 | stats: 'minimal', 114 | }); 115 | -------------------------------------------------------------------------------- /src/configs/santize-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowedTags": [ 3 | "h1", 4 | "h2", 5 | "h3", 6 | "h4", 7 | "h5", 8 | "h6", 9 | "h7", 10 | "h8", 11 | "br", 12 | "b", 13 | "i", 14 | "strong", 15 | "em", 16 | "a", 17 | "pre", 18 | "code", 19 | "img", 20 | "figure", 21 | "figcaption", 22 | "tt", 23 | "div", 24 | "ins", 25 | "del", 26 | "sup", 27 | "sub", 28 | "p", 29 | "ol", 30 | "ul", 31 | "table", 32 | "thead", 33 | "tbody", 34 | "tfoot", 35 | "blockquote", 36 | "dl", 37 | "dt", 38 | "dd", 39 | "kbd", 40 | "q", 41 | "samp", 42 | "var", 43 | "hr", 44 | "ruby", 45 | "rt", 46 | "rp", 47 | "li", 48 | "tr", 49 | "td", 50 | "th", 51 | "s", 52 | "strike", 53 | "summary", 54 | "details", 55 | "span", 56 | "svg", 57 | "path" 58 | ], 59 | "allowedAttributes": { 60 | "a": ["href", "target"], 61 | "img": ["src", "longDesc"], 62 | "div": ["itemScope", "itemType"], 63 | "blockquote": ["cite"], 64 | "del": ["cite"], 65 | "ins": ["cite"], 66 | "q": ["cite"], 67 | "svg": ["viewBox"], 68 | "path": ["fill-rule", "d"], 69 | "*": [ 70 | "area-hidden", 71 | "abbr", 72 | "accept", 73 | "acceptCharset", 74 | "accessKey", 75 | "action", 76 | "align", 77 | "alt", 78 | "axis", 79 | "border", 80 | "cellPadding", 81 | "cellSpacing", 82 | "char", 83 | "charoff", 84 | "charSet", 85 | "checked", 86 | "clear", 87 | "cols", 88 | "colSpan", 89 | "color", 90 | "compact", 91 | "coords", 92 | "dateTime", 93 | "dir", 94 | "disabled", 95 | "encType", 96 | "htmlFor", 97 | "frame", 98 | "headers", 99 | "height", 100 | "hrefLang", 101 | "hspace", 102 | "isMap", 103 | "id", 104 | "label", 105 | "lang", 106 | "maxLength", 107 | "media", 108 | "method", 109 | "multiple", 110 | "name", 111 | "nohref", 112 | "noshade", 113 | "nowrap", 114 | "open", 115 | "prompt", 116 | "readOnly", 117 | "rel", 118 | "rev", 119 | "rows", 120 | "rowSpan", 121 | "rules", 122 | "scope", 123 | "selected", 124 | "shape", 125 | "size", 126 | "span", 127 | "start", 128 | "summary", 129 | "tabIndex", 130 | "target", 131 | "title", 132 | "type", 133 | "useMap", 134 | "valign", 135 | "value", 136 | "vspace", 137 | "width", 138 | "itemProp", 139 | "class" 140 | ] 141 | }, 142 | "selfClosing": ["img", "br", "hr", "area", "base", "basefont", "input", "link", "meta"], 143 | "allowedSchemes": ["http", "https"], 144 | "allowedSchemesByTag": { 145 | "a": ["http", "https", "mailto"] 146 | }, 147 | "allowedSchemesAppliedToAttributes": ["href", "src", "cite"], 148 | "allowProtocolRelative": true, 149 | "allowedIframeHostnames": ["www.youtube.com", "player.vimeo.com"] 150 | } 151 | -------------------------------------------------------------------------------- /src/cli.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first */ 2 | 3 | import './babel-register'; 4 | 5 | import path from 'path'; 6 | import chalk from 'chalk'; 7 | import glob from 'glob'; 8 | import ora from 'ora'; 9 | import yargs from 'yargs'; 10 | import { serve, build } from './index'; 11 | 12 | const { argv } = yargs 13 | .usage('Usage: $0 [options] [files...]') 14 | .options({ 15 | root: { 16 | type: 'string', 17 | description: 'The root directory for the project', 18 | requiresArg: true, 19 | }, 20 | output: { 21 | type: 'string', 22 | description: 'Output directory for generated files', 23 | requiresArg: true, 24 | }, 25 | assets: { 26 | type: 'array', 27 | description: 'Directories containing the asset files', 28 | requiresArg: true, 29 | }, 30 | styles: { 31 | type: 'array', 32 | description: 'Additional CSS files to include in the HTML', 33 | requiresArg: true, 34 | }, 35 | scripts: { 36 | type: 'array', 37 | description: 'Additional JS files to include in the HTML', 38 | requiresArg: true, 39 | }, 40 | logo: { 41 | type: 'string', 42 | description: 'Logo image from assets to show in sidebar', 43 | requiresArg: true, 44 | }, 45 | github: { 46 | type: 'string', 47 | description: 'Link to github folder to show edit button', 48 | requiresArg: true, 49 | }, 50 | title: { 51 | type: 'string', 52 | description: 'Title of a web page', 53 | requiresArg: true, 54 | }, 55 | favicon: { 56 | type: 'string', 57 | description: 'Favicon image to show in web tab', 58 | requiresArg: true, 59 | }, 60 | }) 61 | .command('serve', 'serve pages for development', (y) => { 62 | y.options({ 63 | port: { 64 | type: 'number', 65 | description: 'Port to run the server on', 66 | requiresArg: true, 67 | }, 68 | open: { 69 | type: 'boolean', 70 | description: 'Whether to open the browser window', 71 | }, 72 | }); 73 | }) 74 | .command('build', 'build pages for deploying') 75 | .demandCommand() 76 | .epilogue('See $0 --help for more information') 77 | .alias('help', 'h') 78 | .alias('version', 'v') 79 | .strict(); 80 | 81 | const [command, ...files] = argv._; 82 | 83 | if (files.length) { 84 | argv.pages = () => { 85 | const result = files.reduce( 86 | (acc, pattern) => [...acc, ...glob.sync(pattern, { absolute: true })], 87 | [] 88 | ); 89 | 90 | return result.map((file) => ({ 91 | type: file.endsWith('mdx') ? 'mdx' : 'md', 92 | file, 93 | })); 94 | }; 95 | } 96 | 97 | if (command === 'build') { 98 | const spinner = ora('Building pages…').start(); 99 | 100 | // eslint-disable-next-line promise/catch-or-return 101 | build(argv as any).then( 102 | ({ stats }) => { 103 | if (stats.compilation.errors && stats.compilation.errors.length) { 104 | spinner.fail(`Failed to build pages`); 105 | console.log(JSON.stringify(stats.compilation.errors)); 106 | process.exit(1); 107 | } 108 | 109 | spinner.succeed( 110 | `Successfully built pages in ${chalk.bold( 111 | String(stats.endTime - stats.startTime) 112 | // eslint-disable-next-line promise/always-return 113 | )}ms (${chalk.blue(path.relative(process.cwd(), argv.output ?? ''))})` 114 | ); 115 | }, 116 | (e) => { 117 | spinner.fail(`Failed to build pages ${e.message}`); 118 | process.exit(1); 119 | } 120 | ); 121 | } else { 122 | serve(argv as any); 123 | } 124 | -------------------------------------------------------------------------------- /src/parsers/component.tsx: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { types } from 'recast'; 4 | import { 5 | parse, 6 | utils, 7 | defaultHandlers, 8 | Documentation, 9 | PropertyPath, 10 | } from 'react-docgen'; 11 | import doctrine from 'doctrine'; 12 | import dashify from 'dashify'; 13 | import getNameFromPath from '../utils/getNameFromPath'; 14 | import type { Metadata } from '../types'; 15 | 16 | type StaticInfo = { 17 | name: string; 18 | description?: string; 19 | docblock?: string; 20 | value?: string; 21 | type?: { 22 | name: string; 23 | raw: string; 24 | }; 25 | link?: string; 26 | }; 27 | 28 | const REACT_STATICS = [ 29 | 'childContextTypes', 30 | 'contextTypes', 31 | 'defaultProps', 32 | 'displayName', 33 | 'getDerivedStateFromProps', 34 | 'propTypes', 35 | ]; 36 | 37 | function staticPropertyHandler( 38 | documentation: Documentation, 39 | propertyPath: PropertyPath, 40 | componentName: string 41 | ) { 42 | let statics: StaticInfo[] = []; 43 | 44 | if (types.namedTypes.ClassDeclaration.check(propertyPath.node)) { 45 | statics = propertyPath 46 | .get('body', 'body') 47 | .filter( 48 | (p) => 49 | p.node.static && 50 | types.namedTypes.ClassProperty.check(p.node) && 51 | !REACT_STATICS.includes(p.node.key.name) 52 | ) 53 | .map((p) => { 54 | let type; 55 | 56 | const typeAnnotation = utils.getTypeAnnotation(p); 57 | 58 | if (typeAnnotation) { 59 | type = utils.getFlowType(typeAnnotation); 60 | } 61 | 62 | const docblock = utils.docblock.getDocblock(p); 63 | const name = p.node.key.name; 64 | 65 | const showLink = docblock == null; 66 | 67 | const staticInfo = { 68 | name, 69 | description: docblock 70 | ? doctrine.parse(docblock).description 71 | : undefined, 72 | docblock, 73 | value: p.node.value.value, 74 | type, 75 | }; 76 | 77 | if (showLink) { 78 | return { 79 | ...staticInfo, 80 | type: { name: 'static', raw: 'static' }, 81 | link: `${dashify(componentName)}-${dashify(name)}.html`, 82 | }; 83 | } 84 | 85 | return staticInfo; 86 | }); 87 | } 88 | 89 | documentation.set('statics', statics); 90 | } 91 | 92 | export default function component( 93 | filepath: string, 94 | { root }: { root: string } 95 | ): Metadata { 96 | let content = ''; 97 | 98 | const lines = fs.readFileSync(filepath, 'utf-8').split('\n'); 99 | 100 | let skip = false; 101 | 102 | for (const line of lines) { 103 | if (line === '// @component-docs ignore-next-line') { 104 | skip = true; 105 | continue; 106 | } 107 | 108 | if (skip) { 109 | skip = false; 110 | continue; 111 | } 112 | 113 | content += line + '\n'; 114 | } 115 | 116 | const info = parse( 117 | content, 118 | undefined, 119 | [ 120 | ...defaultHandlers, 121 | (documentation, propertyPath) => 122 | staticPropertyHandler( 123 | documentation, 124 | propertyPath, 125 | getNameFromPath(filepath) 126 | ), 127 | ], 128 | { 129 | cwd: root, 130 | filename: filepath, 131 | } 132 | ); 133 | const name = info.displayName || getNameFromPath(filepath); 134 | 135 | return { 136 | filepath: path.relative(root, filepath), 137 | title: name, 138 | description: info.description, 139 | link: dashify(name), 140 | data: info, 141 | type: 'component', 142 | dependencies: [filepath], 143 | group: name.includes('.') ? name.split('.')[0] : undefined, 144 | }; 145 | } 146 | -------------------------------------------------------------------------------- /src/parsers/mdx.tsx: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import vm from 'vm'; 4 | import * as React from 'react'; 5 | import relative from 'require-relative'; 6 | import dashify from 'dashify'; 7 | import mdx from '@mdx-js/mdx'; 8 | import { transformSync } from '@babel/core'; 9 | import getNameFromPath from '../utils/getNameFromPath'; 10 | import rehypePrism from '../utils/rehypePrism'; 11 | import Content from '../templates/Content'; 12 | import type { Metadata } from '../types'; 13 | 14 | export default function ( 15 | filepath: string, 16 | { root, logo }: { root: string; logo?: string } 17 | ): Metadata { 18 | const text = fs.readFileSync(filepath, 'utf-8'); 19 | 20 | const code = mdx.sync(text, { 21 | filepath, 22 | hastPlugins: [rehypePrism], 23 | }); 24 | 25 | // Compile code to ES5 so we can run it 26 | const result = transformSync( 27 | ` 28 | import * as React from 'react'; 29 | import { MDXTag } from '@mdx-js/tag'; 30 | 31 | ${code} 32 | `, 33 | { 34 | babelrc: false, 35 | configFile: false, 36 | presets: [ 37 | require.resolve('@babel/preset-env'), 38 | require.resolve('@babel/preset-react'), 39 | ], 40 | } 41 | )?.code; 42 | 43 | const m: { 44 | exports: { 45 | default?: React.ComponentType<{ children?: React.ReactNode }>; 46 | meta?: { 47 | title?: string; 48 | description?: string; 49 | link?: string; 50 | }; 51 | }; 52 | } = { exports: {} }; 53 | const r: Record = {}; 54 | 55 | const script = new vm.Script(result ?? '', { filename: filepath }); 56 | const dirname = path.dirname(filepath); 57 | 58 | script.runInContext( 59 | vm.createContext({ 60 | module: m, 61 | exports: m.exports, 62 | require: (name: string) => { 63 | const resolved = relative.resolve(name, dirname); 64 | 65 | r[name] = resolved; 66 | 67 | return require(resolved); 68 | }, 69 | process, 70 | __filename: filepath, 71 | __dirname: dirname, 72 | }) 73 | ); 74 | 75 | const component = m.exports.default; 76 | const meta = m.exports.meta || {}; 77 | 78 | const title = 79 | meta.title || 80 | component?.displayName || 81 | component?.name || 82 | getNameFromPath(filepath); 83 | const description = meta.description || ''; 84 | const link = meta.link || dashify(title); 85 | const type = 'custom'; 86 | 87 | return { 88 | filepath: path.relative(root, filepath), 89 | title, 90 | description, 91 | type, 92 | link, 93 | data: function MDXContent(props) { 94 | return ( 95 | 96 | {component ? React.createElement(component, props) : null} 97 | 98 | ); 99 | }, 100 | stringify() { 101 | return String.raw` 102 | (function() { 103 | var React = require('react'); 104 | var Content = require(${JSON.stringify( 105 | require.resolve('../templates/Content') 106 | )}).default; 107 | 108 | var m = { exports: {} }; 109 | var r = { 110 | ${Object.keys(r) 111 | .map((n) => `${JSON.stringify(n)}: require(${JSON.stringify(r[n])})`) 112 | .join(',\n')} 113 | }; 114 | 115 | (function(module, exports, require, __filename, __dirname) { 116 | ${result}; 117 | }( 118 | m, 119 | m.exports, 120 | function(name) { 121 | return r[name]; 122 | }, 123 | ${JSON.stringify(filepath)}, 124 | ${JSON.stringify(dirname)} 125 | )); 126 | 127 | var meta = m.exports.meta || {}; 128 | 129 | return { 130 | title: meta.title || ${JSON.stringify(title)}, 131 | link: meta.link || ${JSON.stringify(link)}, 132 | description: meta.description, 133 | type: ${JSON.stringify(type)}, 134 | data: function MDXContent(props) { 135 | return React.createElement( 136 | Content, 137 | { logo: '${logo}' }, 138 | React.createElement(m.exports.default, props) 139 | ); 140 | }, 141 | }; 142 | }())`; 143 | }, 144 | dependencies: [], 145 | }; 146 | } 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "component-docs", 3 | "version": "0.24.0", 4 | "description": "Documentation for React components", 5 | "bin": "bin/component-docs.js", 6 | "main": "dist/index.js", 7 | "files": [ 8 | "bin/", 9 | "dist/", 10 | "components.js" 11 | ], 12 | "author": "", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/satya164/component-docs.git" 17 | }, 18 | "scripts": { 19 | "build": "babel src/ --extensions '.ts,.tsx' --source-maps --out-dir dist/ --copy-files && linaria -o dist/css -r src -i dist src/**/*.js", 20 | "lint:js": "eslint \"**/*.{js,ts,tsx}\"", 21 | "lint:css": "stylelint src/**/*.{js,ts,tsx}", 22 | "lint": "yarn lint:js && yarn lint:css", 23 | "typescript": "tsc --noEmit", 24 | "prepare": "yarn build", 25 | "test": "echo \"Error: no test specified\" && exit 1", 26 | "example": "babel-node --extensions '.ts,.tsx' src/cli serve --root example", 27 | "prebuild": "del dist/", 28 | "preexample": "del example/dist && yarn build", 29 | "release": "release-it" 30 | }, 31 | "husky": { 32 | "hooks": { 33 | "pre-commit": "yarn lint && yarn typescript" 34 | } 35 | }, 36 | "publishConfig": { 37 | "registry": "https://registry.npmjs.org/" 38 | }, 39 | "dependencies": { 40 | "@babel/core": "^7.12.3", 41 | "@babel/plugin-proposal-class-properties": "^7.12.1", 42 | "@babel/preset-env": "^7.12.1", 43 | "@babel/preset-flow": "^7.12.1", 44 | "@babel/preset-react": "^7.12.1", 45 | "@babel/preset-typescript": "^7.12.1", 46 | "@babel/register": "^7.12.1", 47 | "@mdx-js/mdx": "^0.20.3", 48 | "@mdx-js/tag": "^0.20.3", 49 | "ast-types": "^0.14.2", 50 | "babel-loader": "^8.1.0", 51 | "chalk": "^4.1.0", 52 | "core-js": "3.6.5", 53 | "cosmiconfig": "^7.0.0", 54 | "css-hot-loader": "^1.4.4", 55 | "css-loader": "^5.0.0", 56 | "dashify": "^2.0.0", 57 | "dedent": "^0.7.0", 58 | "doctrine": "^3.0.0", 59 | "escape-html": "^1.0.3", 60 | "express": "^4.17.1", 61 | "file-loader": "^5.1.0", 62 | "front-matter": "^4.0.2", 63 | "fs-extra": "^9.0.1", 64 | "glob": "^7.1.6", 65 | "hast-util-to-html": "^7.1.2", 66 | "hast-util-to-string": "^1.0.4", 67 | "history": "^5.0.0", 68 | "ignore-styles": "^5.0.1", 69 | "linaria": "^1.3.3", 70 | "marked": "^1.2.2", 71 | "mime-types": "^2.1.27", 72 | "mini-css-extract-plugin": "^1.2.1", 73 | "opn": "^6.0.0", 74 | "ora": "^5.1.0", 75 | "path-is-inside": "^1.0.2", 76 | "prop-types": "^15.7.2", 77 | "react": "~17.0.1", 78 | "react-docgen": "^5.3.1", 79 | "react-dom": "~17.0.1", 80 | "recast": "^0.20.4", 81 | "redbox-react": "^1.6.0", 82 | "refractor": "^3.2.0", 83 | "rehype": "^11.0.0", 84 | "require-relative": "^0.8.7", 85 | "sane": "^4.1.0", 86 | "sanitize-html": "^1.22.0", 87 | "unist-util-visit": "^2.0.3", 88 | "url": "^0.11.0", 89 | "webpack": "^5.3.2", 90 | "webpack-dev-middleware": "^4.0.0", 91 | "webpack-hot-middleware": "^2.25.0", 92 | "yargs": "^16.1.0" 93 | }, 94 | "devDependencies": { 95 | "@babel/cli": "^7.12.1", 96 | "@babel/node": "^7.12.1", 97 | "@callstack/eslint-config": "^10.1.0", 98 | "@release-it/conventional-changelog": "^2.0.0", 99 | "@types/babel__core": "^7.1.11", 100 | "@types/chalk": "^2.2.0", 101 | "@types/cosmiconfig": "^6.0.0", 102 | "@types/dashify": "^1.0.0", 103 | "@types/dedent": "^0.7.0", 104 | "@types/doctrine": "^0.0.3", 105 | "@types/escape-html": "^1.0.0", 106 | "@types/express": "^4.17.8", 107 | "@types/fs-extra": "^9.0.2", 108 | "@types/marked": "^1.1.0", 109 | "@types/mime-types": "^2.1.0", 110 | "@types/mini-css-extract-plugin": "^1.2.0", 111 | "@types/path-is-inside": "^1.0.0", 112 | "@types/react": "^16.9.55", 113 | "@types/react-dom": "^16.9.9", 114 | "@types/refractor": "^3.0.0", 115 | "@types/require-relative": "^0.8.0", 116 | "@types/sane": "^2.0.0", 117 | "@types/sanitize-html": "^1.27.0", 118 | "@types/webpack-dev-middleware": "^3.7.2", 119 | "@types/yargs": "^15.0.9", 120 | "babel-eslint": "^10.1.0", 121 | "del-cli": "^3.0.1", 122 | "eslint": "^7.12.1", 123 | "husky": "^4.3.0", 124 | "prettier": "^2.1.2", 125 | "release-it": "^14.2.1", 126 | "stylelint": "^13.7.2", 127 | "stylelint-config-recommended": "^3.0.0", 128 | "typescript": "^4.0.5" 129 | }, 130 | "prettier": { 131 | "tabWidth": 2, 132 | "useTabs": false, 133 | "singleQuote": true, 134 | "trailingComma": "es5" 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/templates/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | 3 | import * as React from 'react'; 4 | import { styled } from 'linaria/react'; 5 | 6 | const Label = styled.label<{ isDark: boolean }>` 7 | cursor: pointer; 8 | background: ${(props) => (props.isDark ? '#6200ee' : '#000')}; 9 | padding: 3px; 10 | width: 33px; 11 | height: 20px; 12 | border-radius: 33.5px; 13 | display: grid; 14 | margin-right: 5px; 15 | `; 16 | 17 | const ThemeSwitchDiv = styled.div` 18 | display: flex; 19 | flex-direction: row; 20 | `; 21 | 22 | const Switch = styled.div` 23 | height: 14px; 24 | width: 26px; 25 | display: grid; 26 | grid-template-columns: 0fr 1fr 1fr; 27 | transition: 0.2s; 28 | 29 | &:after { 30 | content: ''; 31 | border-radius: 50%; 32 | background: #fff; 33 | grid-column: 2; 34 | transition: background 0.2s; 35 | } 36 | `; 37 | 38 | const Input = styled.input` 39 | position: absolute; 40 | opacity: 0; 41 | width: 0; 42 | height: 0; 43 | 44 | &:checked + ${Switch} { 45 | grid-template-columns: 1fr 1fr 0fr; 46 | } 47 | `; 48 | 49 | function ThemeIcon({ value }: { value: 'light' | 'dark' }) { 50 | if (value === 'dark') { 51 | return ( 52 | 58 | 65 | 66 | ); 67 | } else if (value === 'light') { 68 | return ( 69 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ); 93 | } else { 94 | return null; 95 | } 96 | } 97 | 98 | export default function ThemeToggle() { 99 | const [isReady, setIsReady] = React.useState(false); 100 | const [isDark, setIsDark] = React.useState(false); 101 | 102 | React.useEffect(() => { 103 | if (isReady) { 104 | // Apply theme update to body if theme changes 105 | if (isDark) { 106 | document.body && document.body.classList.add('dark-theme'); 107 | } else { 108 | document.body && document.body.classList.remove('dark-theme'); 109 | } 110 | 111 | localStorage.setItem('preference-theme', isDark ? 'dark' : 'light'); 112 | } else { 113 | // Correct the switch by reading theme from body on mount 114 | if (document.body && document.body.classList.contains('dark-theme')) { 115 | setIsDark(true); 116 | } 117 | 118 | setIsReady(true); 119 | } 120 | }, [isDark, isReady]); 121 | 122 | return ( 123 | 124 | 133 | 134 | 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Component Docs 2 | 3 | [![Build Status][build-badge]][build] 4 | [![Code Coverage][coverage-badge]][coverage] 5 | [![MIT License][license-badge]][license] 6 | [![Version][version-badge]][package] 7 | [![Styled with Linaria][linaria-badge]][linaria] 8 | 9 | 📝 Simple documentation for your React components. 10 | 11 | ## Goals 12 | 13 | - Simple API with minimal configuration 14 | - Fully static, deployable on GitHub pages 15 | - Both server + client routing 16 | - Optimized for mobile screens 17 | - Improved DX with useful features like hot reload 18 | - Supports rendering React Components as well as markdown and MDX files 19 | - Support including markdown from a file reference in markdown files 20 | 21 | ## Installation 22 | 23 | ```sh 24 | yarn add --dev component-docs 25 | ``` 26 | 27 | ## Configuration 28 | 29 | You can specify the configuration using a JavaScript, JSON or YAML file. This can be in form of: 30 | 31 | - `component-docs.config.js` JS file exporting the object (recommended). 32 | - `component-docs` property in a `package.json` file. 33 | - `.component-docs` file with JSON or YAML syntax. 34 | - `.component-docs.json`, `.component-docs.yaml`, `.component-docs.yml`, or `.component-docs.js` file. 35 | 36 | Example `component-docs.config.js`: 37 | 38 | ```js 39 | module.exports = { 40 | port: 1234, 41 | pages: [ 42 | { type: 'md', file: path.resolve(__dirname, 'index.md') }, 43 | { type: 'md', file: path.resolve(__dirname, 'guide.md') }, 44 | ], 45 | }; 46 | ``` 47 | 48 | ### Options 49 | 50 | The configuration object can contain the following properties: 51 | 52 | - `pages` (required): An array of items or a function returning an array of items to show as pages 53 | - `root`: The root directory for the project. 54 | - `output`: Output directory for generated files. 55 | - `assets`: Directories containing the asset files. 56 | - `styles`: Additional CSS files to include in the HTML. 57 | - `scripts`: Additional JS files to include in the HTML. 58 | - `logo`: Logo image from assets to show in sidebar. 59 | - `colors`: Colors to use in the page. This is implemented using CSS variables and falls back to default grey colors on IE. 60 | - `primary`: Primary color used in highlighted items, links etc. 61 | - `github`: Link to github folder to show edit button. 62 | - `port`: Port to run the server on. 63 | - `open`: Whether to open the browser window. 64 | 65 | ### Format for pages 66 | 67 | Each item in your pages array can contain 3 properties: 68 | 69 | - `type` (required): `md` for markdown files, `mdx` for MDX files, `component` to extract component documentation using `react-docgen` or `custom` to render provided file as a React component. 70 | - `file` (required): absolute path to the file. 71 | - `group`: A heading to group this item under. By default, grouping is done for component documentation pages with a dot (`.`) in the name. You can pass `null` here to disable this behavior. 72 | 73 | ## CLI 74 | 75 | To serve docs with your config, run: 76 | 77 | ```sh 78 | yarn component-docs serve 79 | ``` 80 | 81 | You can also specify a glob of files to use as pages: 82 | 83 | ```sh 84 | yarn component-docs serve "*.{md,mdx}" 85 | ``` 86 | 87 | The CLI accepts several arguments. See `--help` for details. 88 | 89 | ## API 90 | 91 | If you want to use `component-docs` programmatically, you can use the exported `serve` and `build` functions. 92 | 93 | Example: 94 | 95 | ```js 96 | import path from 'path'; 97 | import { build } from 'component-docs'; 98 | 99 | const pages = [ 100 | { type: 'md', file: '../docs/Get Started.md' }, 101 | { type: 'mdx', file: '../docs/Contributing.mdx' }, 102 | { type: 'separator' }, 103 | { type: 'component', file: '../src/Button.js', } 104 | { type: 'component', file: '../src/Calendar.js' }, 105 | ]; 106 | 107 | build({ 108 | pages: pages.map(page => ({ ...page, file: path.join(__dirname, page.file) })), 109 | output: path.join(__dirname, 'pages'), 110 | }); 111 | ``` 112 | 113 | The `serve` and `build` functions accept the same options object that's used for the configuration file. If a configuration file already exists, the options will be merged. 114 | 115 | ## Extras 116 | 117 | ### MDX support 118 | 119 | [MDX](https://mdxjs.com/) is a format that lets you seamlessly use JSX in your Markdown documents. This allows you to write your documentation using markdown and have interactive React components inside the documentation. 120 | 121 | ### File references in Markdown 122 | 123 | You can refer to another markdown file and the content of the markdown file will be inlined. When a line starts with a `/` and ends in `.md`, we recognize it as a file reference. 124 | 125 | For example: 126 | 127 | ```md 128 | ## Some heading 129 | 130 | ​/../Details.md 131 | 132 | Some more text here. 133 | ``` 134 | 135 | Here, there is a reference to the `../Details.md` file. Its content will be inlined into the markdown file where it's referenced. 136 | 137 | ### Specifying metadata 138 | 139 | Documents can specify metadata such as the page `title`, `description` and `link` to use. The methods vary according to the type of the document. 140 | 141 | For markdown documents, metadata can be specified in the YAML front-matter: 142 | 143 | ```md 144 | --- 145 | title: Home 146 | description: This is the homepage. 147 | link: index 148 | --- 149 | ``` 150 | 151 | For MDX and React documents, metadata can be exported as a named export named `meta`: 152 | 153 | ```js 154 | export const meta = { 155 | title: 'Home', 156 | description: 'This is the homepage.', 157 | link: 'index', 158 | }; 159 | ``` 160 | 161 | ## Example 162 | 163 | `component-docs` is used for [react-native-paper](https://callstack.github.io/react-native-paper) 164 | 165 | 166 | 167 | [build-badge]: https://img.shields.io/circleci/project/github/callstack/component-docs/master.svg?style=flat-square 168 | [build]: https://circleci.com/gh/callstack/component-docs 169 | [license-badge]: https://img.shields.io/npm/l/babel-test.svg?style=flat-square 170 | [license]: https://opensource.org/licenses/MIT 171 | [version-badge]: https://img.shields.io/npm/v/babel-test.svg?style=flat-square 172 | [package]: https://www.npmjs.com/package/babel-test 173 | [linaria-badge]: https://img.shields.io/badge/styled_with-linaria-de2d68.svg?style=flat-square 174 | [linaria]: https://github.com/callstack/linaria 175 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | 5 | *, *:before, *:after { 6 | box-sizing: inherit; 7 | } 8 | 9 | :root { 10 | --theme-primary-color: #000; 11 | --theme-heading-color: #000; 12 | --theme-text-color: #333; 13 | --theme-main-bg: #fff; 14 | --theme-secondary-bg: #f8f9fa; 15 | } 16 | 17 | :root .dark-theme { 18 | --theme-primary-color: #fff; 19 | --theme-text-color: #fff; 20 | --theme-heading-color: #fff; 21 | --theme-main-bg: #000; 22 | --theme-secondary-bg: #111; 23 | } 24 | 25 | body { 26 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 27 | font-size: 14px; 28 | line-height: 1.6; 29 | background-color: white; 30 | background-color: var(--theme-main-bg); 31 | color: #333; 32 | color: var(--theme-text-color); 33 | } 34 | 35 | a { 36 | color: #000; 37 | color: var(--theme-primary-color); 38 | text-decoration-line: underline; 39 | text-decoration-color: rgba(0, 0, 0, 0.2); 40 | } 41 | 42 | a:hover { 43 | text-decoration-color: #000; 44 | text-decoration-color: var(--theme-primary-color); 45 | } 46 | 47 | h1, h2, h3, h4, h5, h6 { 48 | margin: 24px 0 12px; 49 | padding: 0; 50 | font-weight: bold; 51 | -webkit-font-smoothing: antialiased; 52 | -moz-osx-font-smoothing: grayscale; 53 | cursor: text; 54 | position: relative; 55 | } 56 | 57 | h2:first-child, h1:first-child, h1:first-child + h2, h3:first-child, h4:first-child, h5:first-child, h6:first-child { 58 | margin-top: 0; 59 | padding-top: 0; 60 | } 61 | 62 | h1 tt, h1 code { 63 | font-size: inherit; 64 | } 65 | 66 | h2 tt, h2 code { 67 | font-size: inherit; 68 | } 69 | 70 | h3 tt, h3 code { 71 | font-size: inherit; 72 | } 73 | 74 | h4 tt, h4 code { 75 | font-size: inherit; 76 | } 77 | 78 | h5 tt, h5 code { 79 | font-size: inherit; 80 | } 81 | 82 | h6 tt, h6 code { 83 | font-size: inherit; 84 | } 85 | 86 | h1 { 87 | font-size: 36px; 88 | line-height: 1.4; 89 | margin-top: 48px; 90 | margin-bottom: 24px; 91 | color: black; 92 | color: var(--theme-heading-color); 93 | font-weight: 400; 94 | } 95 | 96 | h2 { 97 | font-size: 24px; 98 | line-height: 1.4; 99 | margin-top: 36px; 100 | margin-bottom: 12px; 101 | color: black; 102 | color: var(--theme-heading-color); 103 | font-weight: 500; 104 | } 105 | 106 | h3 { 107 | font-size: 18px; 108 | line-height: 1.4; 109 | color: black; 110 | color: var(--theme-heading-color); 111 | font-weight: 500; 112 | } 113 | 114 | h4 { 115 | font-size: 16px; 116 | } 117 | 118 | h5 { 119 | font-size: 14px; 120 | } 121 | 122 | h6 { 123 | color: #777777; 124 | font-size: 14px; 125 | } 126 | 127 | ul, ol { 128 | list-style: initial; 129 | } 130 | 131 | p, blockquote, ul, ol, dl, li, table, pre { 132 | margin: 14px 0; 133 | } 134 | 135 | hr { 136 | border: 0; 137 | color: #cccccc; 138 | color: var(--theme-secondary-bg); 139 | height: 1px; 140 | padding: 0; 141 | } 142 | 143 | body > h2:first-child { 144 | margin-top: 0; 145 | padding-top: 0; 146 | } 147 | 148 | body > h1:first-child { 149 | margin-top: 0; 150 | padding-top: 0; 151 | } 152 | 153 | body > h1:first-child + h2 { 154 | margin-top: 0; 155 | padding-top: 0; 156 | } 157 | 158 | body > h3:first-child, body > h4:first-child, body > h5:first-child, body > h6:first-child { 159 | margin-top: 0; 160 | padding-top: 0; 161 | } 162 | 163 | a:first-child h1, a:first-child h2, a:first-child h3, a:first-child h4, a:first-child h5, a:first-child h6 { 164 | margin-top: 0; 165 | padding-top: 0; 166 | } 167 | 168 | h1 p, h2 p, h3 p, h4 p, h5 p, h6 p { 169 | margin-top: 0; 170 | } 171 | 172 | ul, ol { 173 | padding-left: 36px; 174 | } 175 | 176 | ul :first-child, ol :first-child { 177 | margin-top: 0; 178 | } 179 | 180 | ul :last-child, ol :last-child { 181 | margin-bottom: 0; 182 | } 183 | 184 | dl { 185 | padding: 0; 186 | } 187 | 188 | dl dt { 189 | font-size: 14px; 190 | font-weight: bold; 191 | font-style: italic; 192 | padding: 0; 193 | margin: 14px 0 8px; 194 | } 195 | 196 | dl dt:first-child { 197 | padding: 0; 198 | } 199 | 200 | dl dt > :first-child { 201 | margin-top: 0; 202 | } 203 | 204 | dl dt > :last-child { 205 | margin-bottom: 0; 206 | } 207 | 208 | dl dd { 209 | margin: 0 0 14px; 210 | padding: 0 14px; 211 | } 212 | 213 | dl dd > :first-child { 214 | margin-top: 0; 215 | } 216 | 217 | dl dd > :last-child { 218 | margin-bottom: 0; 219 | } 220 | 221 | blockquote { 222 | border-left: 4px solid #dddddd; 223 | padding: 0 14px; 224 | opacity: 0.7; 225 | } 226 | 227 | blockquote blockquote { 228 | opacity: 1; 229 | } 230 | 231 | blockquote > :first-child { 232 | margin-top: 0; 233 | } 234 | 235 | blockquote > :last-child { 236 | margin-bottom: 0; 237 | } 238 | 239 | table { 240 | padding: 0; 241 | } 242 | 243 | table tr { 244 | border-top: 1px solid #cccccc; 245 | background-color: white; 246 | margin: 0; 247 | padding: 0; 248 | } 249 | 250 | table tr:nth-child(2n) { 251 | background-color: #f8f8f8; 252 | } 253 | 254 | table tr th { 255 | font-weight: bold; 256 | border: 1px solid #cccccc; 257 | text-align: left; 258 | margin: 0; 259 | padding: 8px 14px; 260 | } 261 | 262 | table tr td { 263 | border: 1px solid #cccccc; 264 | text-align: left; 265 | margin: 0; 266 | padding: 8px 14px; 267 | } 268 | 269 | table tr th :first-child, table tr td :first-child { 270 | margin-top: 0; 271 | } 272 | 273 | table tr th :last-child, table tr td :last-child { 274 | margin-bottom: 0; 275 | } 276 | 277 | img { 278 | max-width: 100%; 279 | } 280 | 281 | code, tt { 282 | font-family: "Fira Code", "Operator Mono", "Ubuntu Mono", "Droid Sans Mono", "Liberation Mono", "Source Code Pro", Menlo, Consolas, Courier, monospace; 283 | white-space: nowrap; 284 | font-size: 13px; 285 | font-weight: 500; 286 | } 287 | 288 | pre code { 289 | margin: 0; 290 | padding: 14px; 291 | display: block; 292 | white-space: pre; 293 | border: none; 294 | overflow-x: auto; 295 | } 296 | 297 | pre { 298 | background-color: #F8F9FA; 299 | background-color: var(--theme-secondary-bg); 300 | font-size: 13px; 301 | line-height: 1.5; 302 | border-radius: 2px; 303 | tab-size: 2; 304 | hyphens: none; 305 | } 306 | 307 | pre code, pre tt { 308 | background-color: transparent; 309 | border: none; 310 | font-weight: normal; 311 | } 312 | 313 | @media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { 314 | body { 315 | -webkit-font-smoothing: antialiased; 316 | -moz-osx-font-smoothing: grayscale; 317 | } 318 | } 319 | -------------------------------------------------------------------------------- /src/serve.tsx: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express from 'express'; 3 | import webpack from 'webpack'; 4 | import devMiddleware from 'webpack-dev-middleware'; 5 | import hotMiddleware from 'webpack-hot-middleware'; 6 | import fs from 'fs-extra'; 7 | import inside from 'path-is-inside'; 8 | import sane from 'sane'; 9 | import chalk from 'chalk'; 10 | import opn from 'opn'; 11 | import buildEntry from './utils/buildEntry'; 12 | import buildHTML from './utils/buildHTML'; 13 | import build404 from './utils/build404'; 14 | import buildPageInfo from './utils/buildPageInfo'; 15 | import configureWebpack from './utils/configureWebpack'; 16 | import collectData from './utils/collectData'; 17 | import stringifyData from './utils/stringifyData'; 18 | import getOptions from './utils/getOptions'; 19 | import type { Options, Page, Separator, Metadata } from './types'; 20 | 21 | export default function serve(o: Options) { 22 | const options = getOptions(o); 23 | const { 24 | root, 25 | assets, 26 | scripts, 27 | styles, 28 | pages: getPages, 29 | github, 30 | logo, 31 | output, 32 | port, 33 | open, 34 | colors, 35 | title, 36 | favicon, 37 | } = options; 38 | 39 | const cache: Map = new Map(); 40 | const collectAndCache = (page: Page | Separator) => { 41 | if (page.type === 'separator') { 42 | return page; 43 | } 44 | 45 | let result = cache.get(page.file); 46 | 47 | if (result) { 48 | return result; 49 | } 50 | 51 | result = collectData(page, { root, logo, github }); 52 | cache.set(page.file, result); 53 | 54 | return result; 55 | }; 56 | 57 | let pages = typeof getPages === 'function' ? getPages() : getPages; 58 | let data = pages.map(collectAndCache); 59 | 60 | if (!fs.existsSync(output)) { 61 | fs.mkdirSync(output); 62 | } 63 | 64 | fs.writeFileSync( 65 | path.join(output, 'app.src.js'), 66 | buildEntry({ styles, github, logo, title }) 67 | ); 68 | 69 | fs.writeFileSync(path.join(output, 'app.data.js'), stringifyData(data)); 70 | 71 | let fallback = build404({ data, sheets: ['app.css'], favicon }); 72 | let routes = buildPageInfo(data).reduce>( 73 | (acc, info) => { 74 | acc[info.link] = buildHTML({ 75 | data, 76 | info, 77 | github, 78 | logo, 79 | sheets: ['app.css'], 80 | scripts: scripts 81 | ? scripts.map((s) => `scripts/${path.basename(s)}`) 82 | : [], 83 | colors, 84 | title, 85 | favicon, 86 | }); 87 | return acc; 88 | }, 89 | {} 90 | ); 91 | 92 | const callback = (event: 'change' | 'add' | 'delete') => ( 93 | relative: string, 94 | base: string 95 | ) => { 96 | const file = path.join(base, relative); 97 | 98 | // Ignore files under the output directory 99 | if (inside(file, output)) { 100 | return; 101 | } 102 | 103 | if ( 104 | event === 'change' && 105 | !pages.some((page) => { 106 | // Check if the changed file was a page 107 | if (page.type !== 'separator' && page.file === file) { 108 | return true; 109 | } 110 | 111 | // Check if the changed file was a dependency 112 | return data.some((item) => 113 | item.type !== 'separator' ? item.dependencies.includes(file) : false 114 | ); 115 | }) 116 | ) { 117 | // Ignore if the changed file is not in the dependency tree 118 | return; 119 | } 120 | 121 | // When a file changes, invalidate it's cache and all files dependent on it 122 | cache.delete(file); 123 | cache.forEach((page, key) => { 124 | if (page.dependencies.includes(file)) { 125 | cache.delete(key); 126 | } 127 | }); 128 | 129 | try { 130 | pages = typeof getPages === 'function' ? getPages() : getPages; 131 | data = pages.map(collectAndCache); 132 | 133 | const filepath = path.join(output, 'app.data.js'); 134 | const content = stringifyData(data); 135 | 136 | if (content !== fs.readFileSync(filepath, 'utf-8')) { 137 | fs.writeFileSync(filepath, content); 138 | } 139 | 140 | fallback = build404({ data, sheets: ['app.css'], favicon }); 141 | routes = buildPageInfo(data).reduce>( 142 | (acc, info) => { 143 | acc[info.link] = buildHTML({ 144 | data, 145 | info, 146 | github, 147 | logo, 148 | sheets: ['app.css'], 149 | scripts: scripts 150 | ? scripts.map((s) => `scripts/${path.basename(s)}`) 151 | : [], 152 | colors, 153 | title, 154 | }); 155 | return acc; 156 | }, 157 | {} 158 | ); 159 | } catch (e) { 160 | console.log(chalk.red(`Error building files: ${e.toString()}`)); 161 | } 162 | }; 163 | 164 | const watcher = sane(root, { 165 | watchman: true, 166 | glob: ['**/*.md', '**/*.mdx', '**/*.js', '**/*.ts', '**/*.tsx'], 167 | ignored: /node_modules/, 168 | }); 169 | 170 | watcher.on('change', callback('change')); 171 | watcher.on('add', callback('add')); 172 | watcher.on('delete', callback('delete')); 173 | 174 | const cleanup = () => { 175 | watcher.close(); 176 | process.exit(); 177 | }; 178 | 179 | const error = (e: Error) => { 180 | console.log(e.stack || e.message); 181 | process.exitCode = 1; 182 | cleanup(); 183 | }; 184 | 185 | process.stdin.resume(); 186 | process.on('SIGINT', cleanup); 187 | process.on('SIGUSR1', cleanup); 188 | process.on('SIGUSR2', cleanup); 189 | process.on('uncaughtException', error); 190 | process.on('unhandledRejection', error); 191 | 192 | const app = express(); 193 | 194 | if (assets) { 195 | assets.forEach((dir) => { 196 | app.get(`/${path.basename(dir)}/*`, (req, res) => { 197 | res.sendFile(path.join(path.dirname(dir), req.path)); 198 | }); 199 | }); 200 | } 201 | 202 | if (scripts) { 203 | scripts.forEach((name) => { 204 | app.get(`/scripts/${path.basename(name)}`, (_, res) => { 205 | res.sendFile(name); 206 | }); 207 | }); 208 | } 209 | 210 | const config = configureWebpack({ 211 | root, 212 | entry: path.join(output, 'app.src.js'), 213 | output: { 214 | path: output, 215 | bundle: 'app.bundle.js', 216 | style: 'app.css', 217 | }, 218 | production: false, 219 | }); 220 | 221 | const compiler = webpack(config); 222 | 223 | app.use( 224 | devMiddleware(compiler, { 225 | publicPath: config?.output?.publicPath as string | undefined, 226 | }) 227 | ); 228 | 229 | app.use(hotMiddleware(compiler)); 230 | 231 | app.get('*', (req, res) => { 232 | const page = req.path.slice(1).replace(/\.html$/, ''); 233 | 234 | if (page === '' && routes.index) { 235 | res.send(routes.index); 236 | } else if (routes[page]) { 237 | res.send(routes[page]); 238 | } else { 239 | res.send(fallback); 240 | } 241 | }); 242 | 243 | app.listen(port, () => { 244 | const url = `http://localhost:${port}`; 245 | 246 | if (open) { 247 | console.log(`Opening ${chalk.blue(url)} in your browser…\n`); 248 | 249 | opn(url); 250 | } else { 251 | console.log(`Open ${chalk.blue(url)} in your browser.\n`); 252 | } 253 | }); 254 | } 255 | -------------------------------------------------------------------------------- /src/templates/Documentation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled } from 'linaria/react'; 3 | import type { TypeAnnotation, Docs } from '../types'; 4 | import Content from './Content'; 5 | import Markdown from './Markdown'; 6 | import EditButton from './EditButton'; 7 | 8 | type Props = { 9 | name: string; 10 | info: Docs; 11 | filepath: string; 12 | logo?: string; 13 | github?: string; 14 | }; 15 | 16 | const Title = styled.h1` 17 | margin-top: 0; 18 | `; 19 | 20 | const MarkdownContent = styled(Markdown)` 21 | p:first-of-type { 22 | margin-top: 0; 23 | } 24 | 25 | p:last-of-type { 26 | margin-bottom: 0; 27 | } 28 | `; 29 | 30 | const PropInfo = styled.div` 31 | margin: 14px 0; 32 | `; 33 | 34 | const PropLabel = styled.a<{ name: string }>` 35 | display: block; 36 | color: inherit; 37 | font-size: 18px; 38 | margin: 24px 0 8px 0; 39 | text-decoration: none; 40 | white-space: nowrap; 41 | 42 | &:hover { 43 | color: inherit; 44 | } 45 | 46 | & > code { 47 | font-size: inherit; 48 | background-color: transparent; 49 | border: 0; 50 | } 51 | `; 52 | 53 | const PropItem = styled.div` 54 | margin: 8px 0; 55 | 56 | & > code { 57 | font-size: inherit; 58 | white-space: normal; 59 | } 60 | `; 61 | 62 | const RestPropsLabel = styled.a` 63 | display: block; 64 | margin: 24px 0 8px 0; 65 | `; 66 | 67 | const REACT_STATIC_METHODS = ['getDerivedStateFromProps']; 68 | 69 | const ANNOTATION_OPTIONAL = '@optional'; 70 | const ANNOTATION_INTERNAL = '@internal'; 71 | const ANNOTATION_EXTENDS = '@extends'; 72 | 73 | const getTypeName = (type: TypeAnnotation) => type.raw || type.name || ''; 74 | 75 | const hasAnnotation = ( 76 | item: { 77 | description?: string; 78 | docblock?: string; 79 | }, 80 | annotation: string // eslint-disable-next-line no-nested-ternary 81 | ) => 82 | item.description 83 | ? item.description.startsWith(annotation) 84 | : item.docblock 85 | ? item.docblock.startsWith(annotation) 86 | : false; 87 | 88 | const pascalToCamelCase = (text: string) => 89 | text.replace(/^[A-Z]+/g, ($1) => $1.toLowerCase()); 90 | 91 | const PropTypeDoc = ({ 92 | name, 93 | description, 94 | flowType, 95 | tsType, 96 | type, 97 | required, 98 | defaultValue, 99 | }: { 100 | name: string; 101 | description?: string; 102 | flowType?: TypeAnnotation; 103 | tsType?: TypeAnnotation; 104 | type?: TypeAnnotation; 105 | required?: boolean; 106 | defaultValue?: { value: React.ReactText }; 107 | }) => { 108 | const isRequired = 109 | required && 110 | defaultValue == null && 111 | !description?.startsWith(ANNOTATION_OPTIONAL); 112 | 113 | const typeName = flowType // eslint-disable-next-line no-nested-ternary 114 | ? getTypeName(flowType) 115 | : tsType 116 | ? getTypeName(tsType) 117 | : type 118 | ? getTypeName(type) 119 | : null; 120 | 121 | return ( 122 | 123 | 124 | {name} 125 | {isRequired ? ' (required)' : ''} 126 | 127 | {typeName && typeName !== 'unknown' ? ( 128 | 129 | Type: 130 | {typeName} 131 | 132 | ) : null} 133 | {defaultValue ? ( 134 | 135 | Default value: 136 | {defaultValue.value} 137 | 138 | ) : null} 139 | {description ? ( 140 | 145 | ) : null} 146 | 147 | ); 148 | }; 149 | 150 | const MethodDoc = ({ 151 | name, 152 | description, 153 | type, 154 | params, 155 | returns, 156 | }: { 157 | name: string; 158 | description?: string; 159 | type?: TypeAnnotation; 160 | params?: { name: string; type?: TypeAnnotation }[]; 161 | returns?: { type?: TypeAnnotation }; 162 | }) => { 163 | const typeName = type ? getTypeName(type) : null; 164 | 165 | return ( 166 | 167 | 168 | {name} 169 | 170 | 171 | {typeName && typeName !== 'unknown' ? ( 172 | 173 | Type: 174 | {typeName} 175 | 176 | ) : null} 177 | {params?.length ? ( 178 | 179 | Params: 180 | 181 | {params 182 | .map( 183 | (p) => `${p.name}${p.type ? `: ${getTypeName(p.type)}` : ''}` 184 | ) 185 | .join(', ')} 186 | 187 | 188 | ) : null} 189 | {returns?.type ? ( 190 | 191 | Returns: 192 | {getTypeName(returns.type)} 193 | 194 | ) : null} 195 | {description ? ( 196 | // @ts-ignore 197 | 198 | ) : null} 199 | 200 | ); 201 | }; 202 | 203 | const PropertyDoc = ({ 204 | name, 205 | description, 206 | type, 207 | value, 208 | link, 209 | }: { 210 | name: string; 211 | description?: string; 212 | type?: TypeAnnotation; 213 | value?: string | number; 214 | link?: string; 215 | }) => { 216 | const typeName = type ? getTypeName(type) : null; 217 | 218 | return ( 219 | 220 | 224 | {name} 225 | 226 | {typeName && typeName !== 'unknown' ? ( 227 | 228 | Type: 229 | {typeName} 230 | 231 | ) : null} 232 | {typeof value === 'string' || typeof value === 'number' ? ( 233 | 234 | Value: 235 | {value} 236 | 237 | ) : null} 238 | {description ? ( 239 | 244 | ) : null} 245 | 246 | ); 247 | }; 248 | 249 | export default function Documentation({ 250 | name, 251 | info, 252 | logo, 253 | github, 254 | filepath, 255 | }: Props) { 256 | const restProps: { name: string; link?: string }[] = []; 257 | const description = info.description 258 | .split('\n') 259 | .filter((line) => { 260 | if (line.startsWith(ANNOTATION_EXTENDS)) { 261 | const parts = line.split(' ').slice(1); 262 | const link = parts.pop(); 263 | restProps.push({ 264 | name: parts.join(' '), 265 | link, 266 | }); 267 | return false; 268 | } 269 | return true; 270 | }) 271 | .join('\n'); 272 | 273 | const props = info.props || {}; 274 | 275 | const keys = Object.keys(props).filter( 276 | (prop) => !hasAnnotation(props[prop], ANNOTATION_INTERNAL) 277 | ); 278 | const methods = info.methods.filter( 279 | (method) => 280 | !( 281 | method.name.startsWith('_') || 282 | method.modifiers.includes('static') || 283 | method.docblock == null || 284 | hasAnnotation(method, ANNOTATION_INTERNAL) 285 | ) 286 | ); 287 | const statics = info.statics 288 | .map((prop) => ({ 289 | type: 'property', 290 | info: prop, 291 | })) 292 | .concat( 293 | info.methods 294 | .filter( 295 | (method) => 296 | method.modifiers.includes('static') && 297 | method.docblock != null && 298 | !REACT_STATIC_METHODS.includes(method.name) 299 | ) 300 | .map((method) => ({ 301 | type: 'method', 302 | info: { 303 | ...method, 304 | type: { raw: 'Function' }, 305 | }, 306 | })) 307 | ) 308 | .filter( 309 | (item) => 310 | !( 311 | item.info.name.startsWith('_') || 312 | hasAnnotation(item.info, ANNOTATION_INTERNAL) 313 | ) 314 | ); 315 | 316 | return ( 317 | 318 | {name} 319 | 320 | {keys.length || restProps.length ? ( 321 | 322 |

Props

323 | {keys.map((prop) => ( 324 | 325 | ))} 326 | {restProps.map((prop) => ( 327 | 328 | 329 | ... 330 | {prop.name} 331 | 332 | 333 | ))} 334 |
335 | ) : null} 336 | {methods.length ? ( 337 | 338 |

Methods

339 |

340 | These methods can be accessed on the ref of the 341 | component, e.g.{' '} 342 | 343 | {pascalToCamelCase(name)} 344 | Ref. 345 | {methods[0].name} 346 | (...args) 347 | 348 | . 349 |

350 | {methods.map((method) => ( 351 | 352 | ))} 353 |
354 | ) : null} 355 | {statics.length ? ( 356 | 357 |

Static properties

358 |

359 | These properties can be accessed on {name} by using the 360 | dot notation, e.g.{' '} 361 | 362 | {name}.{statics[0].info.name} 363 | 364 | . 365 |

366 | {statics.map((s) => { 367 | if (s.type === 'method') { 368 | return ; 369 | } 370 | 371 | return ; 372 | })} 373 |
374 | ) : null} 375 | 376 |
377 | ); 378 | } 379 | -------------------------------------------------------------------------------- /example/__fixtures__/markdown/2.Markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | link: md 3 | description: Overview of Markdown syntax. 4 | --- 5 | 6 | # Markdown: Syntax 7 | 8 | * [Overview](#overview) 9 | * [Philosophy](#philosophy) 10 | * [Inline HTML](#html) 11 | * [Automatic Escaping for Special Characters](#autoescape) 12 | * [Block Elements](#block) 13 | * [Paragraphs and Line Breaks](#p) 14 | * [Headers](#header) 15 | * [Blockquotes](#blockquote) 16 | * [Lists](#list) 17 | * [Code Blocks](#precode) 18 | * [Horizontal Rules](#hr) 19 | * [Span Elements](#span) 20 | * [Links](#link) 21 | * [Emphasis](#em) 22 | * [Code](#code) 23 | * [Images](#img) 24 | * [Miscellaneous](#misc) 25 | * [Backslash Escapes](#backslash) 26 | * [Automatic Links](#autolink) 27 | 28 | 29 | **Note:** This document is itself written using Markdown; you 30 | can [see the source for it by adding '.text' to the URL](/projects/markdown/syntax.text). 31 | 32 | ---- 33 | 34 | ## Overview 35 | 36 | ### Philosophy 37 | 38 | Markdown is intended to be as easy-to-read and easy-to-write as is feasible. 39 | 40 | Readability, however, is emphasized above all else. A Markdown-formatted 41 | document should be publishable as-is, as plain text, without looking 42 | like it's been marked up with tags or formatting instructions. While 43 | Markdown's syntax has been influenced by several existing text-to-HTML 44 | filters -- including [Setext](http://docutils.sourceforge.net/mirror/setext.html), [atx](http://www.aaronsw.com/2002/atx/), [Textile](http://textism.com/tools/textile/), [reStructuredText](http://docutils.sourceforge.net/rst.html), 45 | [Grutatext](http://www.triptico.com/software/grutatxt.html), and [EtText](http://ettext.taint.org/doc/) -- the single biggest source of 46 | inspiration for Markdown's syntax is the format of plain text email. 47 | 48 | ## Block Elements 49 | 50 | ### Paragraphs and Line Breaks 51 | 52 | A paragraph is simply one or more consecutive lines of text, separated 53 | by one or more blank lines. (A blank line is any line that looks like a 54 | blank line -- a line containing nothing but spaces or tabs is considered 55 | blank.) Normal paragraphs should not be indented with spaces or tabs. 56 | 57 | The implication of the "one or more consecutive lines of text" rule is 58 | that Markdown supports "hard-wrapped" text paragraphs. This differs 59 | significantly from most other text-to-HTML formatters (including Movable 60 | Type's "Convert Line Breaks" option) which translate every line break 61 | character in a paragraph into a `
` tag. 62 | 63 | When you *do* want to insert a `
` break tag using Markdown, you 64 | end a line with two or more spaces, then type return. 65 | 66 | ### Headers 67 | 68 | Markdown supports two styles of headers, [Setext] [1] and [atx] [2]. 69 | 70 | Optionally, you may "close" atx-style headers. This is purely 71 | cosmetic -- you can use this if you think it looks better. The 72 | closing hashes don't even need to match the number of hashes 73 | used to open the header. (The number of opening hashes 74 | determines the header level.) 75 | 76 | 77 | ### Blockquotes 78 | 79 | Markdown uses email-style `>` characters for blockquoting. If you're 80 | familiar with quoting passages of text in an email message, then you 81 | know how to create a blockquote in Markdown. It looks best if you hard 82 | wrap the text and put a `>` before every line: 83 | 84 | > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, 85 | > consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. 86 | > Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. 87 | > 88 | > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse 89 | > id sem consectetuer libero luctus adipiscing. 90 | 91 | Markdown allows you to be lazy and only put the `>` before the first 92 | line of a hard-wrapped paragraph: 93 | 94 | > This is a blockquote with two paragraphs. Lorem ipsum dolor sit amet, 95 | consectetuer adipiscing elit. Aliquam hendrerit mi posuere lectus. 96 | Vestibulum enim wisi, viverra nec, fringilla in, laoreet vitae, risus. 97 | 98 | > Donec sit amet nisl. Aliquam semper ipsum sit amet velit. Suspendisse 99 | id sem consectetuer libero luctus adipiscing. 100 | 101 | Blockquotes can be nested (i.e. a blockquote-in-a-blockquote) by 102 | adding additional levels of `>`: 103 | 104 | > This is the first level of quoting. 105 | > 106 | > > This is nested blockquote. 107 | > 108 | > Back to the first level. 109 | 110 | Blockquotes can contain other Markdown elements, including headers, lists, 111 | and code blocks: 112 | 113 | > ## This is a header. 114 | > 115 | > 1. This is the first list item. 116 | > 2. This is the second list item. 117 | > 118 | > Here's some example code: 119 | > 120 | > return shell_exec("echo $input | $markdown_script"); 121 | 122 | Any decent text editor should make email-style quoting easy. For 123 | example, with BBEdit, you can make a selection and choose Increase 124 | Quote Level from the Text menu. 125 | 126 | 127 | ### Lists 128 | 129 | Markdown supports ordered (numbered) and unordered (bulleted) lists. 130 | 131 | Unordered lists use asterisks, pluses, and hyphens -- interchangably 132 | -- as list markers: 133 | 134 | * Red 135 | * Green 136 | * Blue 137 | 138 | is equivalent to: 139 | 140 | + Red 141 | + Green 142 | + Blue 143 | 144 | and: 145 | 146 | - Red 147 | - Green 148 | - Blue 149 | 150 | Ordered lists use numbers followed by periods: 151 | 152 | 1. Bird 153 | 2. McHale 154 | 3. Parish 155 | 156 | It's important to note that the actual numbers you use to mark the 157 | list have no effect on the HTML output Markdown produces. The HTML 158 | Markdown produces from the above list is: 159 | 160 | If you instead wrote the list in Markdown like this: 161 | 162 | 1. Bird 163 | 1. McHale 164 | 1. Parish 165 | 166 | or even: 167 | 168 | 3. Bird 169 | 1. McHale 170 | 8. Parish 171 | 172 | you'd get the exact same HTML output. The point is, if you want to, 173 | you can use ordinal numbers in your ordered Markdown lists, so that 174 | the numbers in your source match the numbers in your published HTML. 175 | But if you want to be lazy, you don't have to. 176 | 177 | To make lists look nice, you can wrap items with hanging indents: 178 | 179 | * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. 180 | Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi, 181 | viverra nec, fringilla in, laoreet vitae, risus. 182 | * Donec sit amet nisl. Aliquam semper ipsum sit amet velit. 183 | Suspendisse id sem consectetuer libero luctus adipiscing. 184 | 185 | But if you want to be lazy, you don't have to: 186 | 187 | * Lorem ipsum dolor sit amet, consectetuer adipiscing elit. 188 | Aliquam hendrerit mi posuere lectus. Vestibulum enim wisi, 189 | viverra nec, fringilla in, laoreet vitae, risus. 190 | * Donec sit amet nisl. Aliquam semper ipsum sit amet velit. 191 | Suspendisse id sem consectetuer libero luctus adipiscing. 192 | 193 | List items may consist of multiple paragraphs. Each subsequent 194 | paragraph in a list item must be indented by either 4 spaces 195 | or one tab: 196 | 197 | 1. This is a list item with two paragraphs. Lorem ipsum dolor 198 | sit amet, consectetuer adipiscing elit. Aliquam hendrerit 199 | mi posuere lectus. 200 | 201 | Vestibulum enim wisi, viverra nec, fringilla in, laoreet 202 | vitae, risus. Donec sit amet nisl. Aliquam semper ipsum 203 | sit amet velit. 204 | 205 | 2. Suspendisse id sem consectetuer libero luctus adipiscing. 206 | 207 | It looks nice if you indent every line of the subsequent 208 | paragraphs, but here again, Markdown will allow you to be 209 | lazy: 210 | 211 | * This is a list item with two paragraphs. 212 | 213 | This is the second paragraph in the list item. You're 214 | only required to indent the first line. Lorem ipsum dolor 215 | sit amet, consectetuer adipiscing elit. 216 | 217 | * Another item in the same list. 218 | 219 | To put a blockquote within a list item, the blockquote's `>` 220 | delimiters need to be indented: 221 | 222 | * A list item with a blockquote: 223 | 224 | > This is a blockquote 225 | > inside a list item. 226 | 227 | To put a code block within a list item, the code block needs 228 | to be indented *twice* -- 8 spaces or two tabs: 229 | 230 | * A list item with a code block: 231 | 232 | 233 | 234 | ### Code Blocks 235 | 236 | Pre-formatted code blocks are used for writing about programming or 237 | markup source code. Rather than forming normal paragraphs, the lines 238 | of a code block are interpreted literally. Markdown wraps a code block 239 | in both `
` and `` tags.
240 | 
241 | To produce a code block in Markdown, simply indent every line of the
242 | block by at least 4 spaces or 1 tab.
243 | 
244 | This is a normal paragraph:
245 | 
246 |     This is a code block.
247 | 
248 | Here is an example of AppleScript:
249 | 
250 |     tell application "Foo"
251 |         beep
252 |     end tell
253 | 
254 | Here is another example with syntax highlighting for React JSX and Linaria:
255 | 
256 | ```js
257 | import { styled } from 'linaria/react';
258 | 
259 | // Create a styled component
260 | const Title = styled.h1`
261 |   font-size: 24px;
262 |   font-weight: bold;
263 | 
264 |   &.title {
265 |     text-transform: capitalize;
266 |   }
267 | `;
268 | 
269 | function Heading() {
270 |   // Use the styled component
271 |   return This is a title;
272 | }
273 | ```
274 | 
275 | A code block continues until it reaches a line that is not indented
276 | 
277 | (or the end of the article).
278 | 
279 | Within a code block, ampersands (`&`) and angle brackets (`<` and `>`)
280 | are automatically converted into HTML entities. This makes it very
281 | easy to include example HTML source code using Markdown -- just paste
282 | it and indent it, and Markdown will handle the hassle of encoding the
283 | ampersands and angle brackets. For example, this:
284 | 
285 |     
288 | 
289 | Regular Markdown syntax is not processed within code blocks. E.g.,
290 | asterisks are just literal asterisks within a code block. This means
291 | it's also easy to use Markdown to write about Markdown's own syntax.
292 | 
293 | ```
294 | tell application "Foo"
295 |     beep
296 | end tell
297 | ```
298 | 
299 | ## Span Elements
300 | 
301 | ### Links
302 | 
303 | Markdown supports two style of links: *inline* and *reference*.
304 | 
305 | In both styles, the link text is delimited by [square brackets].
306 | 
307 | To create an inline link, use a set of regular parentheses immediately
308 | after the link text's closing square bracket. Inside the parentheses,
309 | put the URL where you want the link to point, along with an *optional*
310 | title for the link, surrounded in quotes. For example:
311 | 
312 | This is [an example](http://example.com/) inline link.
313 | 
314 | [This link](http://example.net/) has no title attribute.
315 | 
316 | ### Emphasis
317 | 
318 | Markdown treats asterisks (`*`) and underscores (`_`) as indicators of
319 | emphasis. Text wrapped with one `*` or `_` will be wrapped with an
320 | HTML `` tag; double `*`'s or `_`'s will be wrapped with an HTML
321 | `` tag. E.g., this input:
322 | 
323 | *single asterisks*
324 | 
325 | _single underscores_
326 | 
327 | **double asterisks**
328 | 
329 | __double underscores__
330 | 
331 | ### Code
332 | 
333 | To indicate a span of code, wrap it with backtick quotes (`` ` ``).
334 | Unlike a pre-formatted code block, a code span indicates code within a
335 | normal paragraph. For example:
336 | 
337 | Use the `printf()` function.
338 | 


--------------------------------------------------------------------------------
/src/templates/Sidebar.tsx:
--------------------------------------------------------------------------------
  1 | import * as React from 'react';
  2 | import { styled } from 'linaria/react';
  3 | import type { Metadata, Separator, Page, Group, GroupItem } from '../types';
  4 | import Link from './Link';
  5 | 
  6 | const SidebarContent = styled.aside`
  7 |   background-color: #f8f9fa;
  8 |   background-color: var(--theme-secondary-bg);
  9 | 
 10 |   @media (min-width: 640px) {
 11 |     height: 100%;
 12 |     min-width: 240px;
 13 |     overflow: auto;
 14 |     -webkit-overflow-scrolling: touch;
 15 |   }
 16 | `;
 17 | 
 18 | const Navigation = styled.nav`
 19 |   padding: 12px 24px;
 20 | 
 21 |   @media (min-width: 640px) {
 22 |     padding: 20px 32px;
 23 |   }
 24 | `;
 25 | 
 26 | const Searchbar = styled.input`
 27 |   appearance: none;
 28 |   width: calc(100% - 48px);
 29 |   padding: 8px 12px;
 30 |   margin: 32px 24px 0;
 31 |   font-size: 1em;
 32 |   background-color: rgba(0, 0, 55, 0.08);
 33 |   transition: background-color 0.3s;
 34 |   border-radius: 3px;
 35 |   border: 0;
 36 |   outline: 0;
 37 |   color: #000;
 38 |   color: var(--theme-text-color);
 39 | 
 40 |   &:focus {
 41 |     background-color: rgba(0, 0, 55, 0.12);
 42 |   }
 43 | 
 44 |   .dark-theme & {
 45 |     background-color: rgba(255, 255, 200, 0.08);
 46 |   }
 47 | 
 48 |   .dark-theme &:focus {
 49 |     background-color: rgba(255, 255, 200, 0.08);
 50 |   }
 51 | 
 52 |   @media (min-width: 640px) {
 53 |     width: calc(100% - 64px);
 54 |     margin: 32px 32px 0;
 55 |   }
 56 | `;
 57 | 
 58 | const MenuContent = styled.div`
 59 |   position: fixed;
 60 |   opacity: 0;
 61 |   pointer-events: none;
 62 | 
 63 |   @media (min-width: 640px) {
 64 |     position: relative;
 65 |     opacity: 1;
 66 |     pointer-events: auto;
 67 |   }
 68 | 
 69 |   @media (max-width: 639px) {
 70 |     ${Searchbar}:first-child {
 71 |       margin-top: 72px;
 72 |     }
 73 |   }
 74 | `;
 75 | 
 76 | const MenuIcon = styled.label`
 77 |   font-size: 20px;
 78 |   line-height: 1;
 79 |   cursor: pointer;
 80 |   position: fixed;
 81 |   bottom: 0;
 82 |   right: 0;
 83 |   padding: 16px;
 84 |   margin: 16px;
 85 |   background-color: #f8f9fa;
 86 |   background-color: var(--theme-secondary-bg);
 87 |   border-radius: 3px;
 88 |   z-index: 10;
 89 |   -webkit-tap-highlight-color: transparent;
 90 | 
 91 |   @media (min-width: 640px) {
 92 |     display: none;
 93 |   }
 94 | `;
 95 | 
 96 | const MenuButton = styled.input`
 97 |   display: none;
 98 | 
 99 |   &:checked ~ ${MenuContent} {
100 |     position: relative;
101 |     opacity: 1;
102 |     pointer-events: auto;
103 |   }
104 | 
105 |   &:checked ~ label {
106 |     color: #111;
107 |     user-select: none;
108 |   }
109 | `;
110 | 
111 | const SeparatorItem = styled.hr`
112 |   border: 0;
113 |   background-color: rgba(0, 0, 0, 0.04);
114 |   height: 1px;
115 |   margin: 20px 0;
116 | `;
117 | 
118 | // @ts-ignore: FIXME
119 | const LinkItem = styled(Link)`
120 |   display: block;
121 |   padding: 12px 0;
122 |   text-decoration: none;
123 |   color: #888;
124 |   line-height: 1;
125 | 
126 |   &:hover {
127 |     color: #111;
128 |     color: var(--theme-primary-color);
129 |     text-decoration: none;
130 |   }
131 | 
132 |   &[data-selected='true'] {
133 |     color: #333;
134 |     color: var(--theme-primary-color);
135 | 
136 |     &:hover {
137 |       color: #333;
138 |       color: var(--theme-primary-color);
139 |     }
140 |   }
141 | `;
142 | 
143 | const Row = styled.div`
144 |   display: flex;
145 |   flex-direction: row;
146 |   justify-content: space-between;
147 |   align-items: center;
148 | 
149 |   ${LinkItem} {
150 |     flex: 1;
151 |   }
152 | `;
153 | 
154 | const GroupItems = styled.div`
155 |   position: relative;
156 |   padding-left: 12px;
157 |   transition: 0.3s;
158 | 
159 |   &:before {
160 |     content: '';
161 |     display: block;
162 |     position: absolute;
163 |     background-color: rgba(0, 0, 0, 0.04);
164 |     width: 1px;
165 |     top: 0;
166 |     bottom: 0;
167 |     left: 0;
168 |     margin: 12px 0;
169 |   }
170 | 
171 |   &[data-visible='true'] {
172 |     opacity: 1;
173 |   }
174 | 
175 |   &[data-visible='false'] {
176 |     opacity: 0;
177 |     pointer-events: none;
178 |   }
179 | `;
180 | 
181 | const ButtonIcon = styled.button`
182 |   background-color: transparent;
183 |   border: none;
184 |   color: #aaa;
185 |   cursor: pointer;
186 |   margin: 0;
187 |   padding: 10px 12px;
188 |   transition: 0.3s;
189 |   opacity: 0.8;
190 | 
191 |   &:hover {
192 |     color: #555;
193 |   }
194 | 
195 |   &:focus {
196 |     outline: none;
197 |   }
198 | 
199 |   &[data-expanded='true'] {
200 |     transform: rotate(0deg);
201 |   }
202 | 
203 |   &[data-expanded='false'] {
204 |     transform: rotate(-180deg);
205 |   }
206 | `;
207 | 
208 | type Props = {
209 |   path: string;
210 |   data: Array;
211 | };
212 | 
213 | type Expanded = Record<
214 |   string,
215 |   { height: number | undefined; expanded: boolean }
216 | >;
217 | 
218 | type State = {
219 |   query: string;
220 |   open: boolean;
221 |   expanded: Expanded;
222 | };
223 | 
224 | export default class Sidebar extends React.Component {
225 |   state = {
226 |     query: '',
227 |     open: false,
228 |     expanded: this.props.data.reduce((acc, item) => {
229 |       if (item.type === 'separator') {
230 |         return acc;
231 |       }
232 | 
233 |       if (item.group) {
234 |         const group = acc[item.group];
235 | 
236 |         if (!group) {
237 |           acc[item.group] = {
238 |             height: undefined,
239 |             expanded: true,
240 |           };
241 |         }
242 |       }
243 | 
244 |       return acc;
245 |     }, {}),
246 |     mode: 'light',
247 |   };
248 | 
249 |   componentDidMount() {
250 |     setTimeout(() => this.measureHeights(), 1000);
251 |   }
252 | 
253 |   componentDidUpdate(prevProps: Props) {
254 |     if (prevProps.data !== this.props.data) {
255 |       this.measureHeights();
256 |     }
257 |   }
258 | 
259 |   private measureHeights = () => {
260 |     this.setState({
261 |       expanded: this.props.data.reduce((acc, item) => {
262 |         if (item.type === 'separator') {
263 |           return acc;
264 |         }
265 | 
266 |         if (item.group) {
267 |           const group = acc[item.group];
268 | 
269 |           const height = this.items[item.group]
270 |             ? this.items[item.group]?.clientHeight
271 |             : undefined;
272 | 
273 |           if (!group) {
274 |             acc[item.group] = {
275 |               height,
276 |               expanded: true,
277 |             };
278 |           }
279 |         }
280 | 
281 |         return acc;
282 |       }, {}),
283 |     });
284 |   };
285 | 
286 |   private items: Record = {};
287 | 
288 |   render() {
289 |     const { path, data } = this.props;
290 |     const mapper = (item: Separator | Group | GroupItem, i: number) => {
291 |       if (item.type === 'separator') {
292 |         return ;
293 |       }
294 | 
295 |       if (item.type === 'group') {
296 |         const groupItem = this.state.expanded[item.title] || {
297 |           height: null,
298 |           expanded: true,
299 |         };
300 | 
301 |         return (
302 |           
303 | 304 | 308 | this.setState((state) => { 309 | const group = state.expanded[item.title]; 310 | 311 | return { 312 | expanded: { 313 | ...state.expanded, 314 | [item.title]: { 315 | ...group, 316 | expanded: 317 | path === item.link || !item.link 318 | ? !group.expanded 319 | : group.expanded, 320 | }, 321 | }, 322 | open: path === item.link ? state.open : false, 323 | query: '', 324 | }; 325 | }) 326 | } 327 | > 328 | {item.title} 329 | 330 | 336 | this.setState((state) => { 337 | const group = state.expanded[item.title]; 338 | 339 | return { 340 | expanded: { 341 | ...state.expanded, 342 | [item.title]: { 343 | ...group, 344 | expanded: !group.expanded, 345 | }, 346 | }, 347 | }; 348 | }) 349 | } 350 | > 351 | 352 | 359 | 360 | 361 | 362 | { 364 | this.items[item.title] = container; 365 | }} 366 | data-visible={!!groupItem.expanded} 367 | style={ 368 | typeof groupItem.height === 'number' 369 | ? { 370 | height: `${groupItem.expanded ? groupItem.height : 0}px`, 371 | } 372 | : {} 373 | } 374 | > 375 | {item.items.map(mapper)} 376 | 377 |
378 | ); 379 | } 380 | 381 | return ( 382 | this.setState({ open: false, query: '' })} 387 | > 388 | {item.title} 389 | 390 | ); 391 | }; 392 | 393 | let items; 394 | 395 | if (this.state.query) { 396 | items = data.filter((item) => { 397 | if (item.type === 'separator') { 398 | return false; 399 | } 400 | 401 | return item.title 402 | .toLowerCase() 403 | .includes(this.state.query.toLowerCase()); 404 | }); 405 | } else { 406 | // Find all groups names in our data and create a list of groups 407 | const groups = ((data.filter((item) => 408 | // @ts-ignore 409 | Boolean(item.group) 410 | ) as any) as (Page & { group: string })[]) 411 | .map((item): string => item.group) 412 | .filter((item, i, self) => self.lastIndexOf(item) === i) 413 | .reduce< 414 | Record< 415 | string, 416 | { 417 | type: 'group'; 418 | items: GroupItem[]; 419 | title: string; 420 | } 421 | > 422 | >( 423 | (acc, title: string) => 424 | Object.assign(acc, { 425 | [title]: { 426 | type: 'group', 427 | items: [], 428 | title, 429 | }, 430 | }), 431 | {} 432 | ); 433 | 434 | // Find items belonging to groups and add them to the groups 435 | items = data.reduce<(Separator | Group | GroupItem)[]>((acc, item) => { 436 | if (item.type === 'separator') { 437 | acc.push(item); 438 | } else if (item.title in groups) { 439 | // If the title of the item matches a group, replace the item with the group 440 | const group = groups[item.title]; 441 | 442 | acc.push({ ...group, link: item.link }); 443 | } else if (item.group) { 444 | // If the item belongs to a group, find an item matching the group first 445 | 446 | const index = acc.findIndex( 447 | (it) => it.type !== 'separator' && it.title === item.group 448 | ); 449 | 450 | let group = acc[index]; 451 | 452 | if (group) { 453 | if (group.type !== 'group') { 454 | // If the item exists, but is not a group, turn it a to a group first 455 | 456 | group = { ...groups[item.group], link: item.link }; 457 | 458 | acc[index] = group; 459 | } else { 460 | // If the group exists, add our item 461 | group.items.push(item); 462 | } 463 | } else { 464 | // If the item doesn't exist at all, add a new group to the list 465 | 466 | group = groups[item.group]; 467 | 468 | group.items.push(item); 469 | acc.push(group); 470 | } 471 | } else { 472 | acc.push(item); 473 | } 474 | 475 | return acc; 476 | }, []); 477 | } 478 | 479 | const links = items.map(mapper); 480 | 481 | return ( 482 | 483 | this.setState({ open: e.target.checked })} 489 | /> 490 | 491 | 492 | this.setState({ query: e.target.value })} 496 | placeholder="Filter…" 497 | /> 498 | {links} 499 | 500 | 501 | ); 502 | } 503 | } 504 | --------------------------------------------------------------------------------