├── .changeset ├── config.json └── rude-hotels-dress.md ├── .github ├── dependabot.yml └── workflows │ ├── actionlint.yml │ ├── canary-release.yml │ ├── ci.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── .vscode └── launch.json ├── CHANGELOG.md ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── examples └── basic │ ├── .gitignore │ ├── app │ ├── (app) │ │ ├── layout.tsx │ │ └── page.tsx │ └── (swingset) │ │ ├── layout.tsx │ │ └── swingset │ │ ├── [...path] │ │ └── page.tsx │ │ ├── content.mdx │ │ ├── docs │ │ └── foundations │ │ │ ├── color.mdx │ │ │ └── index.mdx │ │ └── page.tsx │ ├── components │ ├── accordion │ │ ├── docs.mdx │ │ └── index.tsx │ ├── button │ │ ├── docs.mdx │ │ ├── docs │ │ │ ├── design.mdx │ │ │ └── nested.mdx │ │ └── index.tsx │ ├── card │ │ ├── docs.mdx │ │ └── index.tsx │ ├── checkbox │ │ ├── docs.mdx │ │ └── index.tsx │ ├── nested │ │ └── title │ │ │ ├── docs.mdx │ │ │ └── index.tsx │ ├── text-input │ │ ├── docs.mdx │ │ └── index.tsx │ └── text │ │ ├── docs.mdx │ │ └── index.tsx │ ├── mdx-components.js │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── tsconfig.json │ └── turbo.json ├── img ├── github-icon.svg ├── npm-icon.svg ├── share.svg ├── swingset-dark.svg └── swingset-light.svg ├── package-lock.json ├── package.json ├── packages ├── swingset-theme-hashicorp │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── postcss.config.cjs │ ├── src │ │ ├── MDXComponents.tsx │ │ ├── components │ │ │ ├── app-wrapper.tsx │ │ │ ├── code-block │ │ │ │ ├── code-block.tsx │ │ │ │ ├── copy-button.tsx │ │ │ │ ├── helpers.ts │ │ │ │ ├── index.tsx │ │ │ │ └── theme.ts │ │ │ ├── link.tsx │ │ │ ├── live-component │ │ │ │ ├── code-theme.tsx │ │ │ │ ├── get-file-map.ts │ │ │ │ └── index.tsx │ │ │ ├── nav-bar │ │ │ │ ├── index.tsx │ │ │ │ └── logo.tsx │ │ │ ├── open-in-editor.tsx │ │ │ ├── props-table.tsx │ │ │ ├── side-nav │ │ │ │ ├── category.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── link-item.tsx │ │ │ │ └── toggle-button.tsx │ │ │ └── text │ │ │ │ ├── body.tsx │ │ │ │ ├── heading.tsx │ │ │ │ └── index.tsx │ │ ├── css │ │ │ └── styles.css │ │ ├── index.tsx │ │ ├── page.tsx │ │ └── types.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── tsup.config.ts └── swingset │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── __tests__ │ ├── get-navigation-tree.test.ts │ └── parse-component-path.test.ts │ ├── loader.cjs │ ├── package.json │ ├── postcss.config.cjs │ ├── src │ ├── cli │ │ ├── commands │ │ │ ├── bootstrap.ts │ │ │ └── default.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── constants.ts │ │ │ ├── get-pkg-install-cmd.ts │ │ │ └── logs.ts │ ├── config.ts │ ├── constants.ts │ ├── create-page.tsx │ ├── default-theme │ │ ├── css │ │ │ └── styles.css │ │ ├── index.tsx │ │ └── page.tsx │ ├── get-frontmatter.ts │ ├── get-navigation-tree.ts │ ├── get-props.ts │ ├── index.ts │ ├── loader.ts │ ├── meta.d.ts │ ├── meta.js │ ├── parse-component-path.ts │ ├── render.tsx │ ├── resolvers │ │ ├── build-load-function.ts │ │ ├── component.ts │ │ ├── doc.ts │ │ └── stringify-entity.ts │ ├── theme.d.ts │ ├── theme.js │ └── types.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ ├── tsup.config.ts │ └── vitest.config.ts ├── tsconfig.json └── turbo.json /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "hashicorp/swingset" } 6 | ], 7 | "commit": false, 8 | "linked": [], 9 | "access": "public", 10 | "baseBranch": "main", 11 | "updateInternalDependencies": "patch", 12 | "ignore": [], 13 | "snapshot": { 14 | "useCalculatedVersion": true 15 | }, 16 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 17 | "onlyUpdatePeerDependentsWhenOutOfRange": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.changeset/rude-hotels-dress.md: -------------------------------------------------------------------------------- 1 | --- 2 | 'swingset': minor 3 | 'swingset-theme-hashicorp': minor 4 | --- 5 | 6 | New swingset! 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: 'github-actions' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | -------------------------------------------------------------------------------- /.github/workflows/actionlint.yml: -------------------------------------------------------------------------------- 1 | name: Lint GitHub Actions Workflows 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | 7 | permissions: 8 | contents: none 9 | 10 | jobs: 11 | actionlint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 15 | - name: 'Check workflow files' 16 | uses: docker://docker.mirror.hashicorp.services/rhysd/actionlint:latest 17 | -------------------------------------------------------------------------------- /.github/workflows/canary-release.yml: -------------------------------------------------------------------------------- 1 | name: Canary Release 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - labeled 10 | 11 | jobs: 12 | release-canary: 13 | uses: hashicorp/web-platform-packages/.github/workflows/canary-release.yml@a66d88eda2cb3299c1817aec66052357ef41343d 14 | secrets: 15 | CHANGESETS_PAT: ${{ secrets.CHANGESETS_PAT }} 16 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: [main] 5 | push: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | node_test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3.5.0 16 | - id: npm-cache-dir 17 | run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT" 18 | - uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 19 | with: 20 | path: '${{ steps.npm-cache-dir.outputs.dir }}' 21 | key: "${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}" 22 | restore-keys: '${{ runner.os }}-node-' 23 | - run: npm ci 24 | - run: npx turbo run test --concurrency 1 --continue 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | release: 10 | uses: hashicorp/web-platform-packages/.github/workflows/release.yml@a66d88eda2cb3299c1817aec66052357ef41343d 11 | secrets: 12 | CHANGESETS_PAT: ${{ secrets.CHANGESETS_PAT }} 13 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .components-meta.js 4 | .next 5 | .yalc 6 | yalc.lock 7 | 8 | # VS Code 9 | .vscode/* 10 | !.vscode/launch.json 11 | 12 | # Turbo 13 | */**/.turbo 14 | 15 | # Vercel 16 | .vercel 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | examples 2 | .prettierrc 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug examples/basic app", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "npx next dev", 9 | "cwd": "${workspaceFolder}${pathSeparator}examples${pathSeparator}basic" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # swingset 2 | 3 | ## 0.17.0 4 | 5 | ### Minor Changes 6 | 7 | - [#47](https://github.com/hashicorp/swingset/pull/47) [`13d1aca`](https://github.com/hashicorp/swingset/commit/13d1aca561a22852b83dbc5c8448d7c554772869) Thanks [@dstaley](https://github.com/dstaley)! - Ignore node_modules folders when resolving components glob 8 | 9 | ## 0.16.0 10 | 11 | ### Minor Changes 12 | 13 | - [#48](https://github.com/hashicorp/swingset/pull/48) [`1377c06`](https://github.com/hashicorp/swingset/commit/1377c065d56de0e7f9ce3d43dfbe408401c6b7a0) Thanks [@nandereck](https://github.com/nandereck)! - Adding fullscreen button 14 | 15 | ## 0.15.0 16 | 17 | ### Minor Changes 18 | 19 | - [#49](https://github.com/hashicorp/swingset/pull/49) [`1da2cf9`](https://github.com/hashicorp/swingset/commit/1da2cf9431b110cdcd42f59ca45cfed11d4c8155) Thanks [@BRKalow](https://github.com/BRKalow)! - Support Next 13 by adding `legacyBehavior` to `` usage. 20 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | @hashicorp/web-platform 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Developing swingset 2 | 3 | ## Setup 4 | 5 | Install dependencies: 6 | 7 | ```shell-session 8 | npm install 9 | ``` 10 | 11 | ## Running the example app 12 | 13 | First, run the `dev` task for `example-basic`'s dependencies: 14 | 15 | ```shell-session 16 | npx turbo run dev --filter example-basic^... 17 | ``` 18 | 19 | Once the log output stops, we can stop the above process and run all of the dev tasks: 20 | 21 | ```shell-session 22 | npx turbo run dev --filter example-basic... 23 | ``` 24 | 25 | Finally, visit in your browser. Any changes to the core `swingset` package will be rebuilt and reflect in the running example app. 26 | 27 | ## Debugging the example app 28 | 29 | If you are iterating on the loader code, it might be helpful to use the Node.js [Debugger](https://nodejs.org/api/debugger.html) instead of relying on `console.log()` statements. To make this easier, we have included a VSCode launch configuration. 30 | 31 | First, run the `dev` task for `example-basic`'s dependencies: 32 | 33 | ```shell-session 34 | npx turbo run dev --filter example-basic^... 35 | ``` 36 | 37 | Next, open the VSCode debug panel (`Ctrl+Shift+D` on Windows/Linux, `⇧+⌘+D` on macOS) and run the `Debug examples/basic app` launch configuration. This will start Next's development process and attach a debugger. Any [`debugger;`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) statements you have added in Swingset's core should be triggered. 38 | 39 | ## Custom themes 40 | 41 | Swingset on its own is an unstyled core, exposing the data and utilities necessary to build your component documentation. To render a UI, swingset relies on themes. A theme exposes a number of components that swingset will use to render its UI. Currently, a theme must expose number of exports to render the full UI: 42 | 43 | - `default` - The root swingset layout, used on all pages 44 | - `page` - A named export, used to render documentation pages 45 | 46 | The component exported as `page` will receive two props: 47 | 48 | - `data` - The resolved `Entity` for the given page. Will be a `ComponentEntity` or a `DocsEntity`. See [the types](./packages/swingset/src/types.ts] for more details. 49 | - `content` - The rendered output for the page. 50 | 51 | A simple page implementation might look like this: 52 | 53 | ```tsx 54 | export const Page = ({ data, content }) => { 55 | return ( 56 |

{data.frontmatter.title}

57 |
{content}
58 | ) 59 | } 60 | ``` 61 | 62 | ## Internals 63 | 64 | At its core, Swingset's functionality revolves around a custom [Webpack loader](https://webpack.js.org/concepts/loaders). This loader enables Swingset to intercept imports for a number of different modules and inject custom metadata based on the detected component documentation. Swingset favors convention over configuration, and does its best to integrate into an existing Nextjs application without requiring significant structural changes or a ton of boilerplate code. 65 | 66 | ### `(swingset)` route group 67 | 68 | The main "boilerplate" code is the `(swingset)` [route group](https://nextjs.org/docs/app/building-your-application/routing/route-groups). To isolate the Swingset styles and routes from the rest of your application, Swingset generates a route group with the following structure: 69 | 70 | ``` 71 | (swingset)/ 72 | - layout.tsx 73 | - swingset/ 74 | - page.tsx 75 | - [...path]/ 76 | - page.tsx 77 | - docs/ # 👈 standalone documentation pages go here 78 | ``` 79 | 80 | Unless further customization is desired, The files generated here do not needed to be modified. Swingset will handle loading your documentation and the configured theme for rendering. 81 | 82 | ### Entities 83 | 84 | Each detected piece of documentation content is represented within Swingset's core as an "entity." The base entity type contains information necessary to load and render a piece of documentation content. Currently, there are two types of entities: component entities and standalone documentation entities. 85 | 86 | ### Module rules 87 | 88 | There are four distinct types of modules that, when requested, swingset will intercept: 89 | 90 | #### Content imports (`.mdx?`) 91 | 92 | Swingset will intercept `.mdx` and `.md` imports and compile the source into a render-able component. Internally, swingset dynamically imports each detected documentation file, and so those dynamically generated imports are then picked up by the this module rule. 93 | 94 | #### Component imports from content 95 | 96 | When importing component modules into documentation content, Swingset will attempt to parse out prop information and make it available for documenting. 97 | 98 | #### Meta import (`swingset/meta`) 99 | 100 | The main access point for Swingset's data is the `swingset/meta` import. This module will expose data for all of the detected entities, as well as some additional helper utilities for accessing and using this data. The data for each entity is used by a Swingset theme for rendering. 101 | 102 | #### Theme import (`swingset/theme`) 103 | 104 | Swingset's core on its own does not concern itself with rendering any sort of UI. This is handled by a [theme](#custom-themes). When a custom theme is provided to swingset, it is loaded by way of the `swingset/theme` import. This loader handles wrapping the theme's exposed components and making sure the necessary data is passed in. 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 HashiCorp, Inc. 2 | 3 | Mozilla Public License Version 2.0 4 | ================================== 5 | 6 | 1. Definitions 7 | -------------- 8 | 9 | 1.1. "Contributor" 10 | means each individual or legal entity that creates, contributes to 11 | the creation of, or owns Covered Software. 12 | 13 | 1.2. "Contributor Version" 14 | means the combination of the Contributions of others (if any) used 15 | by a Contributor and that particular Contributor's Contribution. 16 | 17 | 1.3. "Contribution" 18 | means Covered Software of a particular Contributor. 19 | 20 | 1.4. "Covered Software" 21 | means Source Code Form to which the initial Contributor has attached 22 | the notice in Exhibit A, the Executable Form of such Source Code 23 | Form, and Modifications of such Source Code Form, in each case 24 | including portions thereof. 25 | 26 | 1.5. "Incompatible With Secondary Licenses" 27 | means 28 | 29 | (a) that the initial Contributor has attached the notice described 30 | in Exhibit B to the Covered Software; or 31 | 32 | (b) that the Covered Software was made available under the terms of 33 | version 1.1 or earlier of the License, but not also under the 34 | terms of a Secondary License. 35 | 36 | 1.6. "Executable Form" 37 | means any form of the work other than Source Code Form. 38 | 39 | 1.7. "Larger Work" 40 | means a work that combines Covered Software with other material, in 41 | a separate file or files, that is not Covered Software. 42 | 43 | 1.8. "License" 44 | means this document. 45 | 46 | 1.9. "Licensable" 47 | means having the right to grant, to the maximum extent possible, 48 | whether at the time of the initial grant or subsequently, any and 49 | all of the rights conveyed by this License. 50 | 51 | 1.10. "Modifications" 52 | means any of the following: 53 | 54 | (a) any file in Source Code Form that results from an addition to, 55 | deletion from, or modification of the contents of Covered 56 | Software; or 57 | 58 | (b) any new file in Source Code Form that contains any Covered 59 | Software. 60 | 61 | 1.11. "Patent Claims" of a Contributor 62 | means any patent claim(s), including without limitation, method, 63 | process, and apparatus claims, in any patent Licensable by such 64 | Contributor that would be infringed, but for the grant of the 65 | License, by the making, using, selling, offering for sale, having 66 | made, import, or transfer of either its Contributions or its 67 | Contributor Version. 68 | 69 | 1.12. "Secondary License" 70 | means either the GNU General Public License, Version 2.0, the GNU 71 | Lesser General Public License, Version 2.1, the GNU Affero General 72 | Public License, Version 3.0, or any later versions of those 73 | licenses. 74 | 75 | 1.13. "Source Code Form" 76 | means the form of the work preferred for making modifications. 77 | 78 | 1.14. "You" (or "Your") 79 | means an individual or a legal entity exercising rights under this 80 | License. For legal entities, "You" includes any entity that 81 | controls, is controlled by, or is under common control with You. For 82 | purposes of this definition, "control" means (a) the power, direct 83 | or indirect, to cause the direction or management of such entity, 84 | whether by contract or otherwise, or (b) ownership of more than 85 | fifty percent (50%) of the outstanding shares or beneficial 86 | ownership of such entity. 87 | 88 | 2. License Grants and Conditions 89 | -------------------------------- 90 | 91 | 2.1. Grants 92 | 93 | Each Contributor hereby grants You a world-wide, royalty-free, 94 | non-exclusive license: 95 | 96 | (a) under intellectual property rights (other than patent or trademark) 97 | Licensable by such Contributor to use, reproduce, make available, 98 | modify, display, perform, distribute, and otherwise exploit its 99 | Contributions, either on an unmodified basis, with Modifications, or 100 | as part of a Larger Work; and 101 | 102 | (b) under Patent Claims of such Contributor to make, use, sell, offer 103 | for sale, have made, import, and otherwise transfer either its 104 | Contributions or its Contributor Version. 105 | 106 | 2.2. Effective Date 107 | 108 | The licenses granted in Section 2.1 with respect to any Contribution 109 | become effective for each Contribution on the date the Contributor first 110 | distributes such Contribution. 111 | 112 | 2.3. Limitations on Grant Scope 113 | 114 | The licenses granted in this Section 2 are the only rights granted under 115 | this License. No additional rights or licenses will be implied from the 116 | distribution or licensing of Covered Software under this License. 117 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 118 | Contributor: 119 | 120 | (a) for any code that a Contributor has removed from Covered Software; 121 | or 122 | 123 | (b) for infringements caused by: (i) Your and any other third party's 124 | modifications of Covered Software, or (ii) the combination of its 125 | Contributions with other software (except as part of its Contributor 126 | Version); or 127 | 128 | (c) under Patent Claims infringed by Covered Software in the absence of 129 | its Contributions. 130 | 131 | This License does not grant any rights in the trademarks, service marks, 132 | or logos of any Contributor (except as may be necessary to comply with 133 | the notice requirements in Section 3.4). 134 | 135 | 2.4. Subsequent Licenses 136 | 137 | No Contributor makes additional grants as a result of Your choice to 138 | distribute the Covered Software under a subsequent version of this 139 | License (see Section 10.2) or under the terms of a Secondary License (if 140 | permitted under the terms of Section 3.3). 141 | 142 | 2.5. Representation 143 | 144 | Each Contributor represents that the Contributor believes its 145 | Contributions are its original creation(s) or it has sufficient rights 146 | to grant the rights to its Contributions conveyed by this License. 147 | 148 | 2.6. Fair Use 149 | 150 | This License is not intended to limit any rights You have under 151 | applicable copyright doctrines of fair use, fair dealing, or other 152 | equivalents. 153 | 154 | 2.7. Conditions 155 | 156 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 157 | in Section 2.1. 158 | 159 | 3. Responsibilities 160 | ------------------- 161 | 162 | 3.1. Distribution of Source Form 163 | 164 | All distribution of Covered Software in Source Code Form, including any 165 | Modifications that You create or to which You contribute, must be under 166 | the terms of this License. You must inform recipients that the Source 167 | Code Form of the Covered Software is governed by the terms of this 168 | License, and how they can obtain a copy of this License. You may not 169 | attempt to alter or restrict the recipients' rights in the Source Code 170 | Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | (a) such Covered Software must also be made available in Source Code 177 | Form, as described in Section 3.1, and You must inform recipients of 178 | the Executable Form how they can obtain a copy of such Source Code 179 | Form by reasonable means in a timely manner, at a charge no more 180 | than the cost of distribution to the recipient; and 181 | 182 | (b) You may distribute such Executable Form under the terms of this 183 | License, or sublicense it under different terms, provided that the 184 | license for the Executable Form does not attempt to limit or alter 185 | the recipients' rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for 191 | the Covered Software. If the Larger Work is a combination of Covered 192 | Software with a work governed by one or more Secondary Licenses, and the 193 | Covered Software is not Incompatible With Secondary Licenses, this 194 | License permits You to additionally distribute such Covered Software 195 | under the terms of such Secondary License(s), so that the recipient of 196 | the Larger Work may, at their option, further distribute the Covered 197 | Software under the terms of either this License or such Secondary 198 | License(s). 199 | 200 | 3.4. Notices 201 | 202 | You may not remove or alter the substance of any license notices 203 | (including copyright notices, patent notices, disclaimers of warranty, 204 | or limitations of liability) contained within the Source Code Form of 205 | the Covered Software, except that You may alter any license notices to 206 | the extent required to remedy known factual inaccuracies. 207 | 208 | 3.5. Application of Additional Terms 209 | 210 | You may choose to offer, and to charge a fee for, warranty, support, 211 | indemnity or liability obligations to one or more recipients of Covered 212 | Software. However, You may do so only on Your own behalf, and not on 213 | behalf of any Contributor. You must make it absolutely clear that any 214 | such warranty, support, indemnity, or liability obligation is offered by 215 | You alone, and You hereby agree to indemnify every Contributor for any 216 | liability incurred by such Contributor as a result of warranty, support, 217 | indemnity or liability terms You offer. You may include additional 218 | disclaimers of warranty and limitations of liability specific to any 219 | jurisdiction. 220 | 221 | 4. Inability to Comply Due to Statute or Regulation 222 | --------------------------------------------------- 223 | 224 | If it is impossible for You to comply with any of the terms of this 225 | License with respect to some or all of the Covered Software due to 226 | statute, judicial order, or regulation then You must: (a) comply with 227 | the terms of this License to the maximum extent possible; and (b) 228 | describe the limitations and the code they affect. Such description must 229 | be placed in a text file included with all distributions of the Covered 230 | Software under this License. Except to the extent prohibited by statute 231 | or regulation, such description must be sufficiently detailed for a 232 | recipient of ordinary skill to be able to understand it. 233 | 234 | 5. Termination 235 | -------------- 236 | 237 | 5.1. The rights granted under this License will terminate automatically 238 | if You fail to comply with any of its terms. However, if You become 239 | compliant, then the rights granted under this License from a particular 240 | Contributor are reinstated (a) provisionally, unless and until such 241 | Contributor explicitly and finally terminates Your grants, and (b) on an 242 | ongoing basis, if such Contributor fails to notify You of the 243 | non-compliance by some reasonable means prior to 60 days after You have 244 | come back into compliance. Moreover, Your grants from a particular 245 | Contributor are reinstated on an ongoing basis if such Contributor 246 | notifies You of the non-compliance by some reasonable means, this is the 247 | first time You have received notice of non-compliance with this License 248 | from such Contributor, and You become compliant prior to 30 days after 249 | Your receipt of the notice. 250 | 251 | 5.2. If You initiate litigation against any entity by asserting a patent 252 | infringement claim (excluding declaratory judgment actions, 253 | counter-claims, and cross-claims) alleging that a Contributor Version 254 | directly or indirectly infringes any patent, then the rights granted to 255 | You by any and all Contributors for the Covered Software under Section 256 | 2.1 of this License shall terminate. 257 | 258 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 259 | end user license agreements (excluding distributors and resellers) which 260 | have been validly granted by You or Your distributors under this License 261 | prior to termination shall survive termination. 262 | 263 | ************************************************************************ 264 | * * 265 | * 6. Disclaimer of Warranty * 266 | * ------------------------- * 267 | * * 268 | * Covered Software is provided under this License on an "as is" * 269 | * basis, without warranty of any kind, either expressed, implied, or * 270 | * statutory, including, without limitation, warranties that the * 271 | * Covered Software is free of defects, merchantable, fit for a * 272 | * particular purpose or non-infringing. The entire risk as to the * 273 | * quality and performance of the Covered Software is with You. * 274 | * Should any Covered Software prove defective in any respect, You * 275 | * (not any Contributor) assume the cost of any necessary servicing, * 276 | * repair, or correction. This disclaimer of warranty constitutes an * 277 | * essential part of this License. No use of any Covered Software is * 278 | * authorized under this License except under this disclaimer. * 279 | * * 280 | ************************************************************************ 281 | 282 | ************************************************************************ 283 | * * 284 | * 7. Limitation of Liability * 285 | * -------------------------- * 286 | * * 287 | * Under no circumstances and under no legal theory, whether tort * 288 | * (including negligence), contract, or otherwise, shall any * 289 | * Contributor, or anyone who distributes Covered Software as * 290 | * permitted above, be liable to You for any direct, indirect, * 291 | * special, incidental, or consequential damages of any character * 292 | * including, without limitation, damages for lost profits, loss of * 293 | * goodwill, work stoppage, computer failure or malfunction, or any * 294 | * and all other commercial damages or losses, even if such party * 295 | * shall have been informed of the possibility of such damages. This * 296 | * limitation of liability shall not apply to liability for death or * 297 | * personal injury resulting from such party's negligence to the * 298 | * extent applicable law prohibits such limitation. Some * 299 | * jurisdictions do not allow the exclusion or limitation of * 300 | * incidental or consequential damages, so this exclusion and * 301 | * limitation may not apply to You. * 302 | * * 303 | ************************************************************************ 304 | 305 | 8. Litigation 306 | ------------- 307 | 308 | Any litigation relating to this License may be brought only in the 309 | courts of a jurisdiction where the defendant maintains its principal 310 | place of business and such litigation shall be governed by laws of that 311 | jurisdiction, without reference to its conflict-of-law provisions. 312 | Nothing in this Section shall prevent a party's ability to bring 313 | cross-claims or counter-claims. 314 | 315 | 9. Miscellaneous 316 | ---------------- 317 | 318 | This License represents the complete agreement concerning the subject 319 | matter hereof. If any provision of this License is held to be 320 | unenforceable, such provision shall be reformed only to the extent 321 | necessary to make it enforceable. Any law or regulation which provides 322 | that the language of a contract shall be construed against the drafter 323 | shall not be used to construe this License against a Contributor. 324 | 325 | 10. Versions of the License 326 | --------------------------- 327 | 328 | 10.1. New Versions 329 | 330 | Mozilla Foundation is the license steward. Except as provided in Section 331 | 10.3, no one other than the license steward has the right to modify or 332 | publish new versions of this License. Each version will be given a 333 | distinguishing version number. 334 | 335 | 10.2. Effect of New Versions 336 | 337 | You may distribute the Covered Software under the terms of the version 338 | of the License under which You originally received the Covered Software, 339 | or under the terms of any subsequent version published by the license 340 | steward. 341 | 342 | 10.3. Modified Versions 343 | 344 | If you create software not governed by this License, and you want to 345 | create a new license for such software, you may create and use a 346 | modified version of this License if you rename the license and remove 347 | any references to the name of the license steward (except to note that 348 | such modified license differs from this License). 349 | 350 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 351 | Licenses 352 | 353 | If You choose to distribute Source Code Form that is Incompatible With 354 | Secondary Licenses under the terms of this version of the License, the 355 | notice described in Exhibit B of this License must be attached. 356 | 357 | Exhibit A - Source Code Form License Notice 358 | ------------------------------------------- 359 | 360 | This Source Code Form is subject to the terms of the Mozilla Public 361 | License, v. 2.0. If a copy of the MPL was not distributed with this 362 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 363 | 364 | If it is not possible or desirable to put the notice in a particular 365 | file, then You may include the notice in a location (such as a LICENSE 366 | file in a relevant directory) where a recipient would be likely to look 367 | for such a notice. 368 | 369 | You may add additional accurate notices of copyright ownership. 370 | 371 | Exhibit B - "Incompatible With Secondary Licenses" Notice 372 | --------------------------------------------------------- 373 | 374 | This Source Code Form is "Incompatible With Secondary Licenses", as 375 | defined by the Mozilla Public License, v. 2.0. 376 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./packages/swingset/README.md -------------------------------------------------------------------------------- /examples/basic/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /examples/basic/app/(app)/layout.tsx: -------------------------------------------------------------------------------- 1 | export default function RootLayout({ 2 | children, 3 | }: { 4 | children: React.ReactNode 5 | }) { 6 | return ( 7 | 8 | {children} 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /examples/basic/app/(app)/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useRouter } from 'next/navigation' 3 | import { useEffect } from 'react' 4 | 5 | export default function Index() { 6 | const router = useRouter() 7 | 8 | useEffect(() => { 9 | router.push('/swingset') 10 | }, []) 11 | 12 | return

Hello World

13 | } 14 | -------------------------------------------------------------------------------- /examples/basic/app/(swingset)/layout.tsx: -------------------------------------------------------------------------------- 1 | import layout from 'swingset/theme' 2 | 3 | export default layout 4 | -------------------------------------------------------------------------------- /examples/basic/app/(swingset)/swingset/[...path]/page.tsx: -------------------------------------------------------------------------------- 1 | import { generateStaticParams } from 'swingset/meta' 2 | import { Page } from 'swingset/theme' 3 | 4 | export default Page 5 | export { generateStaticParams } 6 | -------------------------------------------------------------------------------- /examples/basic/app/(swingset)/swingset/content.mdx: -------------------------------------------------------------------------------- 1 | import { Button } from 'components/button' 2 | 3 | # Welcome to Swingset! 4 | 5 | Welcome to Swingset. Swingset is a drop-in component documentation system built for Next.js's App Router and React Server Components. 6 | 7 | ## Features 8 | 9 | ### App Router native 10 | 11 | Swingset is built for the new App Router and React Server Components. Running the bootstrap command will generate a route group for swingset: 12 | 13 | ``` 14 | $ swingset bootstrap 15 | 16 | Generating swingset route group... 17 | 18 | Success! Route group created: 19 | 20 | (swingset) 21 | ├ /layout.tsx 22 | └ /swingset 23 | ├ /page.tsx 24 | └ /[...path] 25 | └ /page.tsx 26 | ``` 27 | 28 | ### Component documentation 29 | 30 | Document your components with MDX by placing a `docs.mdx` file next to your component source: 31 | 32 | ``` 33 | components/ 34 | button/ 35 | docs.mdx 36 | index.tsx 37 | ``` 38 | 39 | #### Component prop extraction 40 | 41 | Swingset automatically exposes prop metadata for components imported into your documentation. 42 | 43 | ```tsx 44 | 45 | ``` 46 | 47 | 48 | 49 | ### Custom documentation 50 | 51 | Swingset also supports standalone documentation pages. By default, `.mdx` files in `/app/(swingset)/swingset/docs/` are rendered. 52 | 53 | ### Custom themes 54 | 55 | By default, Swingset only exposes the data necessary to fully render your documentation content. Swingset can be configured with a custom `theme` to control rendering. 56 | 57 | ```js 58 | import withSwingset from 'swingset' 59 | 60 | export default withSwingset({ 61 | componentRoot: './components', 62 | theme: 'swingset-theme-custom', 63 | })({ 64 | experimental: { 65 | appDir: true, 66 | }, 67 | }) 68 | ``` 69 | 70 | ### Custom remark and rehype plugins 71 | 72 | Want to add support for GitHub Flavored Markdown? Swingset accepts `remark` and `rehype` plugins. 73 | 74 | - [x] Add `remarkGfm` 75 | - [x] Restart your server 76 | - [ ] Render task lists! 77 | 78 | ```js 79 | import withSwingset from 'swingset' 80 | import remarkGfm from 'remark-gfm' 81 | 82 | export default withSwingset({ 83 | componentRoot: './components', 84 | remarkPlugins: [remarkGfm], 85 | })({ 86 | experimental: { 87 | appDir: true, 88 | }, 89 | }) 90 | ``` 91 | 92 | ### Inline Playgrounds 93 | 94 | Swingset allows you to inspect how components behave right here in the docs. By using Codesandbox's bundler it supports packages on the npm public registry. Here's an example using [MUI](https://mui.com/). 95 | 96 | ```` 97 | 102 | 103 | ```tsx filePath="App.tsx" 104 | import * as React from 'react' 105 | import Button from './components/Button.tsx' 106 | 107 | export default function MyApp() { 108 | return ( 109 |
110 | 111 |
112 | ) 113 | } 114 | ``` 115 | 116 | ```tsx filePath="components/Button.tsx" 117 | import * as React from 'react' 118 | import MUIButton from '@mui/material/Button' 119 | 120 | export default function Button() { 121 | return ( 122 |
123 | Button 124 |
125 | ) 126 | } 127 | ``` 128 | 129 |
130 | ```` 131 | 132 | Will render the following 133 | 134 | 139 | 140 | ```tsx filePath="App.tsx" 141 | import * as React from 'react' 142 | import Button from './components/Button.tsx' 143 | 144 | export default function MyApp() { 145 | return ( 146 |
147 | 148 |
149 | ) 150 | } 151 | ``` 152 | 153 | ```tsx filePath="components/Button.tsx" 154 | import * as React from 'react' 155 | import MUIButton from '@mui/material/Button' 156 | 157 | export default function Button() { 158 | return ( 159 |
160 | Button 161 |
162 | ) 163 | } 164 | ``` 165 | 166 |
167 | 168 | ### Integrates with `@next/mdx` 169 | 170 | Swingset integrates with `@next/mdx` by supporting the same `mdx-components.ts` file at the root of your application. Swingset will make the custom components declared there available. Example: 171 | 172 | I'm a custom component from `mdx-components.js` 173 | 174 | ### Use with Storybook 175 | 176 | Documentation pages within Swingset are treated as modules. This means that you can import other modules into your `.mdx` files as you would any other JavaScript file. This works great if you leverage Storybook for component development, as stories are directly consumable from your documentation: 177 | 178 | ```tsx 179 | import * as stories from './Button.stories' 180 | 181 | # This is the Primary story 182 | 183 | 184 | ``` 185 | -------------------------------------------------------------------------------- /examples/basic/app/(swingset)/swingset/docs/foundations/color.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Color' 3 | --- 4 | 5 | Colors are pretty neat. 6 | -------------------------------------------------------------------------------- /examples/basic/app/(swingset)/swingset/docs/foundations/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Hello world' 3 | --- 4 | 5 | ## Hello from MDX 6 | 7 | 👋 8 | 9 | [color](./foundations/color) 10 | -------------------------------------------------------------------------------- /examples/basic/app/(swingset)/swingset/page.tsx: -------------------------------------------------------------------------------- 1 | import Content from './content.mdx' 2 | 3 | export default async function SwingsetRoot() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /examples/basic/components/accordion/docs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Accordion' 3 | description: 'UI component named after a funky instrument 🪗' 4 | path: 'Content/Accordion' 5 | --- 6 | import {Accordion} from '.' 7 | 8 | -------------------------------------------------------------------------------- /examples/basic/components/accordion/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react' 2 | 3 | interface AccordionProps { 4 | color?: string 5 | children: ReactElement 6 | } 7 | 8 | export function Accordion({ children }: AccordionProps) { 9 | return ( 10 |
11 | Hello 👋 12 | Nice meeting you! How's everything going? 13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /examples/basic/components/button/docs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Button' 3 | description: The button is used widely across HashiCorp properties, and has many ways it can be customized. Let's start with its most basic form. 4 | --- 5 | 6 | import { Button } from '.' 7 | 8 | export const Simple = () => 9 | 10 | ## Simple 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/basic/components/button/docs/design.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Design' 3 | --- 4 | 5 | Button design documentation here. 6 | -------------------------------------------------------------------------------- /examples/basic/components/button/docs/nested.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Nested' 3 | --- 4 | 5 | Nested Button documentation here. 6 | -------------------------------------------------------------------------------- /examples/basic/components/button/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement } from 'react' 2 | 3 | interface ButtonProps { 4 | color?: string 5 | children: ReactElement 6 | } 7 | 8 | export function Button({ children }: ButtonProps) { 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /examples/basic/components/card/docs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Card' 3 | description: A container for all sorts of things. 4 | path: "Content/Card" 5 | --- 6 | 7 | import { Card } from '.' 8 | import { Button } from '../button' 9 | 10 | ## Basic 11 | 12 | This is a very important note 13 | 14 | 15 |
Hello there
16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/basic/components/card/index.tsx: -------------------------------------------------------------------------------- 1 | export function Card({ children }) { 2 | return ( 3 |
4 | {children} 5 |
6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /examples/basic/components/checkbox/docs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Checkbox' 3 | description: 'A checkbox' 4 | path: 'Components/Forms/Checkbox' 5 | --- 6 | 7 | import { Checkbox } from '.' 8 | 9 | ## Simple 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/basic/components/checkbox/index.tsx: -------------------------------------------------------------------------------- 1 | export function Checkbox(props) { 2 | return 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/components/nested/title/docs.mdx: -------------------------------------------------------------------------------- 1 | import { Title } from '.' 2 | 3 | Hello 4 | -------------------------------------------------------------------------------- /examples/basic/components/nested/title/index.tsx: -------------------------------------------------------------------------------- 1 | export function Title({ children }) { 2 | return

{children}

3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/components/text-input/docs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'TextInput' 3 | description: 'A text input' 4 | path: 'Components/Forms/TextInput' 5 | --- 6 | 7 | import { TextInput } from '.' 8 | 9 | ## Simple 10 | 11 | 12 | -------------------------------------------------------------------------------- /examples/basic/components/text-input/index.tsx: -------------------------------------------------------------------------------- 1 | export function TextInput(props) { 2 | return 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/components/text/docs.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Text' 3 | --- 4 | 5 | import Text from '.' 6 | 7 | Hi 8 | -------------------------------------------------------------------------------- /examples/basic/components/text/index.tsx: -------------------------------------------------------------------------------- 1 | export default function Text({ children }) { 2 | return {children} 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/mdx-components.js: -------------------------------------------------------------------------------- 1 | import themeComponents from 'swingset-theme-hashicorp/MDXComponents' 2 | 3 | export function useMDXComponents(components) { 4 | // Allows customizing built-in components, e.g. to add styling. 5 | return { 6 | Note({ children }) { 7 | return ( 8 |
16 | {children} 17 |
18 | ) 19 | }, 20 | ...themeComponents, 21 | ...components, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/basic/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/basic/next.config.mjs: -------------------------------------------------------------------------------- 1 | import withSwingset from 'swingset' 2 | import remarkGfm from 'remark-gfm' 3 | import rehypeMdxCodeProps from 'rehype-mdx-code-props' 4 | 5 | export default withSwingset({ 6 | componentRootPattern: './components', 7 | theme: 'swingset-theme-hashicorp', 8 | remarkPlugins: [remarkGfm], 9 | rehypePlugins: [rehypeMdxCodeProps], 10 | })({ 11 | experimental: { 12 | appDir: true, 13 | }, 14 | transpilePackages: ['@hashicorp/flight-icons'], 15 | }) 16 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-basic", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build" 8 | }, 9 | "dependencies": { 10 | "next": "^13.4.4", 11 | "react": "*", 12 | "react-dom": "*", 13 | "rehype-mdx-code-props": "^1.0.0", 14 | "remark-gfm": "^3.0.1", 15 | "swingset": "file:../../packages/swingset", 16 | "swingset-theme-hashicorp": "file:../../packages/swingset-theme-hashicorp" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "baseUrl": ".", 18 | "plugins": [ 19 | { 20 | "name": "next" 21 | } 22 | ] 23 | }, 24 | "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/basic/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "extends": ["//"], 4 | "pipeline": { 5 | "build": { 6 | "outputs": [".next/**", "!.next/cache/**"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /img/github-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/npm-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/share.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/swingset-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /img/swingset-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swingset-root", 3 | "description": "drop-in component library and documentation pages for next.js", 4 | "version": "0.17.0", 5 | "author": "Jeff Escalante", 6 | "bugs": { 7 | "url": "https://github.com/hashicorp/swingset/issues" 8 | }, 9 | "workspaces": [ 10 | "packages/*", 11 | "examples/*" 12 | ], 13 | "devDependencies": { 14 | "@changesets/changelog-github": "^0.4.8", 15 | "@changesets/cli": "^2.26.1", 16 | "@types/react": "^18.2.7", 17 | "@types/react-dom": "^18.2.4", 18 | "concurrently": "^8.0.1", 19 | "jest": "^29.5.0", 20 | "next": "^13.4.4", 21 | "react": "^18.2.0", 22 | "react-dom": "^18.2.0", 23 | "tsup": "^6.7.0", 24 | "turbo": "^1.10.0", 25 | "typescript": "^5.0.4" 26 | }, 27 | "homepage": "https://github.com/hashicorp/swingset#readme", 28 | "keywords": [ 29 | "component", 30 | "component-library", 31 | "library", 32 | "next.js", 33 | "storybook" 34 | ], 35 | "license": "MIT", 36 | "main": "index.js", 37 | "peerDependencies": { 38 | "next": ">=12.x <=13.x", 39 | "react": ">=16.x <=18.x", 40 | "react-dom": ">=16.x <=18.x" 41 | }, 42 | "publishConfig": { 43 | "access": "public" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/hashicorp/swingset.git" 48 | }, 49 | "scripts": { 50 | "prerelease": "turbo build", 51 | "prerelease:canary": "turbo build", 52 | "release": "changeset publish", 53 | "release:canary": "changeset publish --tag canary", 54 | "test": "npx turbo run test --concurrency 1 --continue" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | style.css 3 | turbo/ 4 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/README.md: -------------------------------------------------------------------------------- 1 | # `swingset-theme-hashicorp` 2 | 3 | A Swingset theme build for HashiCorp. 4 | 5 | ## Usage 6 | 7 | First, install the theme package: 8 | 9 | ``` 10 | npm install swingset-theme-hashicorp 11 | ``` 12 | 13 | Then update your swingset configuration to specify `swingset-theme-hashicorp`: 14 | 15 | ```js 16 | import withSwingset from 'swingset' 17 | 18 | export default withSwingset({ 19 | theme: 'swingset-theme-hashicorp', 20 | })() 21 | ``` 22 | 23 | ## Developing 24 | 25 | This theme uses [tailwind](https://tailwindcss.com/) for styling. The tailwind classes are prefixed with `ss-` to ensure there are no collisions. 26 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swingset-theme-hashicorp", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "build:js": "tsup", 7 | "build:styles": "postcss src/css/styles.css -o style.css --verbose", 8 | "build": "concurrently npm:build:*", 9 | "dev:js": "tsup --watch", 10 | "dev:styles": "TAILWIND_MODE=watch postcss src/css/styles.css -o style.css --verbose --watch", 11 | "dev": "concurrently npm:dev:*" 12 | }, 13 | "exports": { 14 | ".": { 15 | "import": "./dist/index.js", 16 | "types": "./dist/index.d.ts" 17 | }, 18 | "./style.css": "./style.css", 19 | "./*": { 20 | "import": "./dist/*.js", 21 | "types": "./dist/*.d.ts" 22 | } 23 | }, 24 | "files": [ 25 | "./dist", 26 | "./style.css" 27 | ], 28 | "peerDependencies": { 29 | "swingset": "*" 30 | }, 31 | "devDependencies": { 32 | "autoprefixer": "^10.4.14", 33 | "next": "^13.4.4", 34 | "postcss": "^8.4.24", 35 | "postcss-cli": "^10.1.0", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "swingset": "file:../swingset", 39 | "tailwindcss": "^3.3.2" 40 | }, 41 | "dependencies": { 42 | "@codesandbox/sandpack-react": "^2.6.9", 43 | "@hashicorp/flight-icons": "^2.14.0", 44 | "class-variance-authority": "^0.6.0", 45 | "prism-react-renderer": "^2.0.6" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/MDXComponents.tsx: -------------------------------------------------------------------------------- 1 | import { PropsTable } from './components/props-table' 2 | import { Heading, Body } from './components/text' 3 | import { LiveComponent } from './components/live-component' 4 | import { CodeBlock } from './components/code-block' 5 | 6 | const MDXComponents = { 7 | h1: (props: any) => , 8 | h2: (props: any) => , 9 | h3: (props: any) => , 10 | h4: (props: any) => , 11 | h5: (props: any) => , 12 | h6: (props: any) => , 13 | p: (props: any) => , 14 | pre: (props: any) => , 15 | PropsTable, 16 | LiveComponent, 17 | CodeBlock, 18 | } 19 | 20 | export default MDXComponents 21 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/app-wrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { SideNavigation } from './side-nav' 3 | import { categories } from 'swingset/meta' 4 | import { useState, ReactNode } from 'react' 5 | import { cx } from 'class-variance-authority' 6 | 7 | export function AppWrapper({ children }: { children: ReactNode }) { 8 | const [isOpen, setIsOpen] = useState(true) 9 | const toggle = () => setIsOpen((curr) => !curr) 10 | 11 | return ( 12 |
13 | 14 |
15 |
21 | {children} 22 |
23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/code-block/code-block.tsx: -------------------------------------------------------------------------------- 1 | 'use client' //Library calls hooks internally 2 | import { Highlight, themes } from 'prism-react-renderer' 3 | import { parseCode, parseLanguage } from './helpers' 4 | import { MDXPreClass, MDXPreElement } from '@/types' 5 | import { CopyButton } from './copy-button' 6 | 7 | interface CodeBlockProps { 8 | children: MDXPreElement 9 | className: MDXPreClass 10 | } 11 | 12 | function CodeBlock(props: CodeBlockProps) { 13 | const { children } = props 14 | const code = parseCode(children) 15 | const language = parseLanguage(children.props.className ?? 'language-jsx') 16 | 17 | return ( 18 |
19 | 20 | {({ className, style, tokens, getLineProps, getTokenProps }) => { 21 | return ( 22 |
23 |               {tokens.map((line, i) => (
24 |                 
25 | {line.map((token, key) => ( 26 | 27 | ))} 28 |
29 | ))} 30 |
31 | ) 32 | }} 33 |
34 | 35 |
36 | ) 37 | } 38 | 39 | export { CodeBlock } 40 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/code-block/copy-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { 3 | IconDuplicate16, 4 | IconFileCheck16, 5 | } from '@hashicorp/flight-icons/svg-react' 6 | import type { MouseEventHandler } from 'react' 7 | import { useState, useEffect, useCallback } from 'react' 8 | import { cx } from 'class-variance-authority' 9 | 10 | function CopyButton({ code }: { code: string }) { 11 | const [isCopied, setIsCopied] = useState(false) 12 | 13 | useEffect(() => { 14 | const timeoutDuration = 2000 15 | if (!isCopied) return 16 | const timerId = setTimeout(() => { 17 | setIsCopied(false) 18 | }, timeoutDuration) 19 | 20 | return () => { 21 | clearTimeout(timerId) 22 | } 23 | }, [isCopied]) 24 | 25 | const handleClick = useCallback(async () => { 26 | setIsCopied(true) 27 | if (!navigator?.clipboard) { 28 | console.error('Access to clipboard rejected!') 29 | } 30 | try { 31 | await navigator.clipboard.writeText(code) 32 | } catch { 33 | console.error('Failed to copy!') 34 | } 35 | }, [code]) 36 | 37 | const Icon = isCopied ? IconFileCheck16 : IconDuplicate16 38 | 39 | return ( 40 | 50 | ) 51 | } 52 | 53 | export { CopyButton } 54 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/code-block/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Language, MDXPreClass, MDXPreElement } from '@/types' 2 | 3 | export const parseCode = (toParse: MDXPreElement | string): string => { 4 | if (typeof toParse === 'string') return toParse.trimEnd() 5 | return toParse.props.children.trimEnd() 6 | } 7 | 8 | export const parseLanguage = (langString: MDXPreClass): Language => { 9 | const MDXprefix = 'language-' 10 | const lang = langString.replace(MDXprefix, '') 11 | 12 | if (isSupportedLanguage(lang)) { 13 | return lang as Language 14 | } 15 | 16 | throw new Error( 17 | `Expected (js | ts | jsx | tsx) in your CodeBlock meta data. Received ${lang}` 18 | ) 19 | } 20 | 21 | const isSupportedLanguage = (lang: string): boolean => { 22 | return lang === 'js' || lang === 'jsx' || lang === 'ts' || lang === 'tsx' 23 | } 24 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/code-block/index.tsx: -------------------------------------------------------------------------------- 1 | import { CodeBlock } from './code-block' 2 | 3 | export { CodeBlock } 4 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/code-block/theme.ts: -------------------------------------------------------------------------------- 1 | export const CustomTheme = { 2 | colors: { 3 | accent: 'inherit', 4 | base: 'inherit', 5 | clickable: 'inherit', 6 | disabled: 'inherit', 7 | error: 'inherit', 8 | errorSurface: 'inherit', 9 | hover: 'inherit', 10 | surface1: 'inherit', 11 | surface2: 'inherit', 12 | surface3: 'inherit', 13 | warning: 'inherit', 14 | warningSurface: 'inherit', 15 | }, 16 | syntax: { 17 | plain: 'inherit', 18 | comment: 'inherit', 19 | keyword: 'inherit', 20 | tag: 'inherit', 21 | punctuation: 'inherit', 22 | definition: 'inherit', 23 | property: 'inherit', 24 | static: 'inherit', 25 | string: 'inherit', 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/link.tsx: -------------------------------------------------------------------------------- 1 | import { type ComponentProps } from 'react' 2 | import NextLink from 'next/link' 3 | import { cx } from 'class-variance-authority' 4 | 5 | type NextLinkProps = ComponentProps 6 | 7 | export type LinkProps = NextLinkProps & { className?: string } 8 | 9 | export function Link({ className, ...restProps }: LinkProps) { 10 | return ( 11 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/live-component/code-theme.tsx: -------------------------------------------------------------------------------- 1 | import { SandpackTheme } from '@codesandbox/sandpack-react/types' 2 | 3 | const sandpackTheme: SandpackTheme = { 4 | colors: { 5 | surface1: '#011627', 6 | surface2: '#243b4c', 7 | surface3: '#112331', 8 | clickable: '#6988a1', 9 | base: '#808080', 10 | disabled: '#4D4D4D', 11 | hover: '#c5e4fd', 12 | accent: '#c5e4fd', 13 | error: '#ffcdca', 14 | errorSurface: '#811e18', 15 | }, 16 | syntax: { 17 | plain: '#d6deeb', 18 | comment: { 19 | color: '#999999', 20 | fontStyle: 'italic', 21 | }, 22 | keyword: { 23 | color: '#c792ea', 24 | fontStyle: 'italic', 25 | }, 26 | tag: '#7fdbca', 27 | punctuation: '#7fdbca', 28 | definition: '#82aaff', 29 | property: { 30 | color: '#addb67', 31 | fontStyle: 'italic', 32 | }, 33 | static: '#f78c6c', 34 | string: '#ecc48d', 35 | }, 36 | font: { 37 | body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"', 38 | mono: '"Fira Mono", "DejaVu Sans Mono", Menlo, Consolas, "Liberation Mono", Monaco, "Lucida Console", monospace', 39 | size: '13px', 40 | lineHeight: '20px', 41 | }, 42 | } 43 | 44 | export { sandpackTheme } 45 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/live-component/get-file-map.ts: -------------------------------------------------------------------------------- 1 | import type { SandpackFile } from '@codesandbox/sandpack-react' 2 | import { parseCode } from '../code-block/helpers' 3 | import { MDXPreElement } from '@/types' 4 | 5 | export const getFileMap = (codeSnippets: MDXPreElement[]) => { 6 | const fileMap = codeSnippets.reduce( 7 | (result: Record, codeSnippet) => { 8 | const { props } = codeSnippet 9 | 10 | const { filePath, hidden, active, children } = props 11 | 12 | const code = parseCode(children) 13 | //TODO: LINK TO README, CodeSandbox docs aren't very helpful here 14 | if (!filePath) { 15 | throw new Error( 16 | ` is missing a fileName prop. See docs: https://sandpack.codesandbox.io/docs/getting-started/usage#files ` 17 | ) 18 | } 19 | 20 | if (!!result[filePath]) { 21 | throw new Error( 22 | `File ${filePath} was defined multiple times. Each file snippet should have a unique path.` 23 | ) 24 | } 25 | result[filePath] = { 26 | code, 27 | hidden, 28 | active, 29 | } 30 | 31 | return result 32 | }, 33 | {} 34 | ) 35 | 36 | return fileMap 37 | } 38 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/live-component/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { 3 | SandpackProvider, 4 | SandpackCodeEditor, 5 | SandpackPreview, 6 | } from '@codesandbox/sandpack-react' 7 | import { Children } from 'react' 8 | import { getFileMap } from './get-file-map' 9 | import { sandpackTheme } from './code-theme' 10 | import { MDXPreElement } from '@/types' 11 | 12 | type LiveComponentProps = { 13 | children: MDXPreElement 14 | deps?: Record 15 | } 16 | 17 | export function LiveComponent({ children, deps }: LiveComponentProps) { 18 | const codeBlocks = Children.toArray(children) 19 | const fileMap = getFileMap(codeBlocks as MDXPreElement[]) 20 | 21 | return ( 22 | 28 |
29 | 30 |
31 | 37 |
38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/nav-bar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Logo } from './logo' 2 | import Link from 'next/link' 3 | import { IconGithub24 } from '@hashicorp/flight-icons/svg-react' 4 | 5 | function NavBar() { 6 | return ( 7 |
8 | 33 |
34 | ) 35 | } 36 | 37 | export { NavBar } 38 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/nav-bar/logo.tsx: -------------------------------------------------------------------------------- 1 | import { IconHashicorp24 } from '@hashicorp/flight-icons/svg-react' 2 | 3 | function Logo() { 4 | return ( 5 |
6 | 7 | 8 | 9 |
10 |

11 | HashiCorp 12 |

13 |

14 | Web Presence 15 |

16 |
17 |
18 | ) 19 | } 20 | 21 | export { Logo } 22 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/open-in-editor.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | export function OpenInEditor({ path }: { path: string }) { 4 | if (process.env.NODE_ENV !== 'development') return null 5 | 6 | return ( 7 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/props-table.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, ReactNode } from 'react' 2 | 3 | interface PropsTableProps { 4 | component: ComponentType & { propsMetadata: any } 5 | } 6 | 7 | function TableData({ children }: { children: ReactNode }) { 8 | return ( 9 | {children} 10 | ) 11 | } 12 | 13 | function InlineCode({ children }: { children: ReactNode }) { 14 | return ( 15 | 16 | {children} 17 | 18 | ) 19 | } 20 | 21 | export function PropsTable({ component }: PropsTableProps) { 22 | if (!component?.propsMetadata?.props) { 23 | return null 24 | } 25 | 26 | const headers = ['Name', 'Type', 'Description', 'Required'] 27 | 28 | const rows = Object.entries(component.propsMetadata.props).map( 29 | ([key, data]: [string, any]) => ( 30 | 31 | {key} 32 | 33 | {data.tsType?.name} 34 | 35 | {data.description ?? '-'} 36 | 37 | {String(data.required)} 38 | 39 | 40 | ) 41 | ) 42 | 43 | return ( 44 | 45 | 46 | 47 | {headers.map((header) => ( 48 | 54 | ))} 55 | 56 | 57 | {rows.map((row) => row)} 58 |
52 | {header} 53 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/side-nav/category.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationNode, ComponentNode } from 'swingset/types' 2 | import { LinkItem } from './link-item' 3 | import { cx } from 'class-variance-authority' 4 | 5 | function Category({ 6 | title, 7 | items, 8 | }: { 9 | title: string 10 | items: NavigationNode[] 11 | }) { 12 | return ( 13 |
  • 14 |
    15 | {title} 16 | 17 |
    18 |
  • 19 | ) 20 | } 21 | //Swap this out for already existing heading && Enquire about semantics, https://helios.hashicorp.design/components/application-state uses
    22 | function CategoryHeading({ children }: { children: string }) { 23 | return ( 24 |
    25 | {children} 26 |
    27 | ) 28 | } 29 | 30 | function ComponentList({ 31 | isNested, 32 | items, 33 | }: { 34 | isNested?: true 35 | items: NavigationNode[] 36 | }) { 37 | return ( 38 |
      44 | {items.map((item) => { 45 | const isFolder = item.__type === 'folder' 46 | 47 | if (isFolder) { 48 | return ( 49 | 50 | ) 51 | } 52 | 53 | return ( 54 |
    • 55 | 56 |
    • 57 | ) 58 | })} 59 |
    60 | ) 61 | } 62 | 63 | function Folder({ 64 | title, 65 | items, 66 | }: { 67 | title: ComponentNode['title'] 68 | items: ComponentNode[] 69 | }) { 70 | return ( 71 |
    72 | 78 | {title} 79 | 80 | 81 |
    82 | ) 83 | } 84 | 85 | export default Category 86 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/side-nav/index.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationTree } from 'swingset/types' 2 | import Category from './category' 3 | import { Link } from '../link' 4 | import { cx } from 'class-variance-authority' 5 | import { ToggleButton } from './toggle-button' 6 | 7 | type SideNavBarProps = { 8 | categories: NavigationTree 9 | isOpen: boolean 10 | toggle: () => void 11 | } 12 | 13 | function SideNavigation({ categories, isOpen, toggle }: SideNavBarProps) { 14 | const renderedCategories = categories.map((category) => ( 15 | 20 | )) 21 | 22 | return ( 23 | <> 24 | 36 | 37 | ) 38 | } 39 | 40 | export { SideNavigation } 41 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/side-nav/link-item.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '../link' 2 | import { cx } from 'class-variance-authority' 3 | 4 | type LinkItemProps = { 5 | title: string 6 | to: string 7 | } 8 | 9 | function LinkItem({ title, to }: LinkItemProps) { 10 | return ( 11 | 18 | {title} 19 | 20 | ) 21 | } 22 | 23 | export { LinkItem } 24 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/side-nav/toggle-button.tsx: -------------------------------------------------------------------------------- 1 | import { IconBottom24 } from '@hashicorp/flight-icons/svg-react/bottom-24' 2 | import { cx } from 'class-variance-authority' 3 | 4 | type ToggleButtonProps = { 5 | toggle: () => void 6 | isOpen: boolean 7 | } 8 | 9 | export function ToggleButton({ toggle, isOpen }: ToggleButtonProps) { 10 | const ariaLabel = isOpen 11 | ? 'Collapse navigation menu' 12 | : 'Expand navigation menu' 13 | 14 | return ( 15 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/text/body.tsx: -------------------------------------------------------------------------------- 1 | import { cva, VariantProps, cx } from 'class-variance-authority' 2 | import { type HTMLAttributes } from 'react' 3 | 4 | type BodyProps = HTMLAttributes 5 | 6 | export function Body({ children, className, ...props }: BodyProps) { 7 | const defaultStyles = 8 | 'ss-my-3 ss-p-0 ss-text-primary ss-text-base ss-font-normal' 9 | 10 | return ( 11 |

    12 | {children} 13 |

    14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/text/heading.tsx: -------------------------------------------------------------------------------- 1 | import { cva, VariantProps, cx } from 'class-variance-authority' 2 | import { type HTMLAttributes } from 'react' 3 | 4 | const styles = cva( 5 | 'ss-m-0 ss-p-0 ss-font-bold ss-leading-normal ss-text-dark', 6 | { 7 | variants: { 8 | as: { 9 | h1: 'ss-text-6xl ss-tracking-tight', 10 | h2: 'ss-text-4xl', 11 | h3: 'ss-text-2xl', 12 | h4: 'ss-text-xl ss-font-extrabold', 13 | h5: 'ss-text-xl', 14 | h6: 'ss-text-lg', 15 | }, 16 | }, 17 | defaultVariants: { 18 | as: 'h3', 19 | }, 20 | } 21 | ) 22 | 23 | type HeadingProps = HTMLAttributes & 24 | VariantProps 25 | 26 | export function Heading({ as, children, className, ...props }: HeadingProps) { 27 | const Element = as ?? 'h3' 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/components/text/index.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from './heading' 2 | import { Body } from './body' 3 | 4 | export { Heading, Body } 5 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/css/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/index.tsx: -------------------------------------------------------------------------------- 1 | // NOTE: global css import needs to be at the top so component-specific CSS is loaded after the theme reset (component-specific CSS is loaded as a result of the swingset/meta import below) 2 | import '../style.css' 3 | import React from 'react' 4 | import { AppWrapper } from './components/app-wrapper' 5 | import { NavBar } from './components/nav-bar' 6 | import { meta } from 'swingset/meta' 7 | import Page from './page' 8 | 9 | export default function SwingsetLayout({ 10 | children, 11 | }: { 12 | children: React.ReactNode 13 | }) { 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 24 | 25 | 26 | ) 27 | } 28 | 29 | export { Page } 30 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/page.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentEntity, EvaluatedEntity } from 'swingset/types' 2 | import { Heading, Body } from './components/text' 3 | import { OpenInEditor } from './components/open-in-editor' 4 | 5 | export default async function Page({ 6 | data, 7 | content, 8 | }: { 9 | data: EvaluatedEntity 10 | content: React.ReactNode 11 | }) { 12 | const title = (data?.frontmatter?.title ?? 13 | (data as EvaluatedEntity)?.slug) as string 14 | 15 | return ( 16 | <> 17 |
    18 | {title} 19 | {data?.frontmatter?.description ? ( 20 | {data?.frontmatter?.description as string} 21 | ) : null} 22 |
    23 |
    {content}
    24 |
    25 | 26 |
    27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/src/types.ts: -------------------------------------------------------------------------------- 1 | export type Language = 'ts' | 'js' | 'jsx' | 'tsx' 2 | 3 | export type MDXPreClass = `language-${Language}` 4 | 5 | export type MDXPreElement = React.ReactElement<{ 6 | children: string 7 | className?: MDXPreClass 8 | filePath?: string 9 | hidden: boolean 10 | active?: boolean 11 | }> 12 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const colors = require('tailwindcss/colors') 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | prefix: 'ss-', 6 | content: [ 7 | // Or if using `src` directory: 8 | './src/**/*.{js,ts,jsx,tsx}', 9 | ], 10 | theme: { 11 | extend: { 12 | textColor: { 13 | dark: colors.slate[700], 14 | primary: colors.slate[600], 15 | faint: colors.gray[500], 16 | action: '#1060ff', 17 | }, 18 | backgroundColor: { 19 | 'surface-action': '#f2f8ff', 20 | 'surface-faint': '#fafafa', 21 | }, 22 | borderColor: { 23 | action: '#cce3fe', 24 | faint: '#656a761a', 25 | }, 26 | }, 27 | }, 28 | plugins: [], 29 | } 30 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "noEmit": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "jsx": "react-jsx", 12 | "lib": ["es2022", "dom"], 13 | "moduleResolution": "node", 14 | "types": ["vitest/globals", "webpack-env"], 15 | "resolveJsonModule": true, 16 | "baseUrl": "./", 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | } 20 | }, 21 | "include": ["src/**/*"], 22 | "exclude": ["dist/**/*"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/swingset-theme-hashicorp/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer' 2 | import { defineConfig } from 'tsup' 3 | 4 | /** 5 | * Ensure that the "use client"; directive for RSC is always at the top of the output file even after the build is finished. 6 | */ 7 | const UseClientDirectivePlugin = { 8 | name: 'use-client-directive', 9 | setup(build) { 10 | const DIRECTIVE_STRING = `"use client";` 11 | 12 | build.onEnd((result) => { 13 | // no errors, add directives 14 | if (result.errors.length === 0) { 15 | for (const file of result.outputFiles) { 16 | if (/"use client";/.test(file.text)) { 17 | const buf = Buffer.from(file.contents) 18 | const directiveIndex = buf.indexOf(DIRECTIVE_STRING) 19 | 20 | // splice the original directive out of the contents buffer, concat the directive at the top of the file so it doesn't cause errors 21 | file.contents = Buffer.concat([ 22 | Buffer.from(`"use client";\n`), 23 | buf.subarray(0, directiveIndex), 24 | buf.subarray(directiveIndex + DIRECTIVE_STRING.length), 25 | ]) 26 | } 27 | } 28 | } 29 | }) 30 | }, 31 | } 32 | 33 | export default defineConfig([ 34 | { 35 | name: 'swingset-theme-hashicorp', 36 | entry: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], 37 | format: 'esm', 38 | dts: true, 39 | bundle: false, 40 | splitting: false, 41 | external: ['swingset'], 42 | target: 'es2020', 43 | esbuildPlugins: [UseClientDirectivePlugin], 44 | }, 45 | ]) 46 | -------------------------------------------------------------------------------- /packages/swingset/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | style.css 3 | .turbo/ 4 | -------------------------------------------------------------------------------- /packages/swingset/.npmignore: -------------------------------------------------------------------------------- 1 | src/** 2 | -------------------------------------------------------------------------------- /packages/swingset/README.md: -------------------------------------------------------------------------------- 1 | # swingset 2 | 3 | Welcome to Swingset. Swingset is a drop-in component documentation system built for Next.js's App Router and React Server Components. 4 | 5 | > **Note** 6 | > Swingset is currently under active development. We're actively iterating on the core features and APIs, and some things may change. 7 | 8 | ## Installation 9 | 10 | Install `swingset` with your package manager of choice: 11 | 12 | ``` 13 | npm install swingset 14 | ``` 15 | 16 | Import the plugin in your `next.config.mjs` file: 17 | 18 | ```js 19 | // next.config.mjs 20 | import withSwingset from 'swingset' 21 | 22 | export default withSwingset({ 23 | componentRoot: './components', 24 | theme: 'swingset-theme-custom', 25 | })() 26 | ``` 27 | 28 | ## Features 29 | 30 | ### App Router native 31 | 32 | Swingset is built for the new App Router and React Server Components. Running the bootstrap command will generate a route group for swingset: 33 | 34 | ``` 35 | $ swingset bootstrap 36 | 37 | Generating swingset route group... 38 | 39 | Success! Route group created: 40 | 41 | (swingset) 42 | ├ /layout.tsx 43 | └ /swingset 44 | ├ /page.tsx 45 | └ /[...path] 46 | └ /page.tsx 47 | ``` 48 | 49 | ### Component documentation 50 | 51 | Document your components with MDX by placing a `docs.mdx` file next to your component source: 52 | 53 | ``` 54 | components/ 55 | button/ 56 | docs.mdx 57 | index.tsx 58 | ``` 59 | 60 | #### Component prop extraction 61 | 62 | Swingset automatically exposes prop metadata for components imported into your documentation. 63 | 64 | ```typescript 65 | 66 | ``` 67 | 68 | ### Custom documentation 69 | 70 | Swingset also supports standalone documentation pages. By default, `.mdx` files in `/app/(swingset)/swingset/docs/` are rendered. 71 | 72 | ### Custom themes 73 | 74 | By default, Swingset only exposes the data necessary to fully render your documentation content. Swingset can be configured with a custom `theme` to control rendering. 75 | 76 | ```js 77 | // next.config.mjs 78 | import withSwingset from 'swingset' 79 | 80 | export default withSwingset({ 81 | componentRoot: './components', 82 | theme: 'swingset-theme-custom', 83 | })() 84 | ``` 85 | 86 | ### Custom remark and rehype plugins 87 | 88 | Want to add support for GitHub Flavored Markdown? Swingset accepts `remark` and `rehype` plugins. 89 | 90 | - [x] Add `remarkGfm` 91 | - [x] Restart your server 92 | - [ ] Render task lists! 93 | 94 | ```js 95 | // next.config.mjs 96 | import withSwingset from 'swingset' 97 | import remarkGfm from 'remark-gfm' 98 | 99 | export default withSwingset({ 100 | componentRoot: './components', 101 | remarkPlugins: [remarkGfm], 102 | })() 103 | ``` 104 | 105 | ### Integrates with `@next/mdx` 106 | 107 | Swingset integrates with `@next/mdx` by supporting the same `mdx-components.ts` file at the root of your application. Swingset will make the custom components declared there available. 108 | 109 | ### Use with Storybook 110 | 111 | Documentation pages within Swingset are treated as modules. This means that you can import other modules into your `.mdx` files as you would any other JavaScript file. This works great if you leverage Storybook for component development, as stories are directly consumable from your documentation: 112 | 113 | ```tsx 114 | import * as stories from './Button.stories' 115 | 116 | # This is the Primary story 117 | 118 | 119 | ``` 120 | 121 | ## Contributing 122 | 123 | See [CONTRIBUTING.md](/CONTRIBUTING.md). 124 | -------------------------------------------------------------------------------- /packages/swingset/__tests__/get-navigation-tree.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest' 2 | import { getNavigationTree } from '../src/get-navigation-tree' 3 | import { ComponentEntity, NavigationTree } from '../src/types' 4 | 5 | describe(getNavigationTree.name, () => { 6 | it('Builds the Navigation Tree', ({ expect }) => { 7 | const input: ComponentEntity[] = [ 8 | { 9 | __type: 'component', 10 | category: 'default', 11 | componentPath: '../../..', 12 | filepath: '../../..', 13 | frontmatter: {}, 14 | normalizedPath: '', 15 | relativePath: '', 16 | slug: '', 17 | title: '', 18 | load: '', 19 | }, 20 | ] 21 | 22 | const expectation: NavigationTree = [ 23 | { 24 | children: [ 25 | { 26 | __type: 'component', 27 | componentPath: '../../..', 28 | slug: '', 29 | title: '', 30 | }, 31 | ], 32 | title: 'default', 33 | __type: 'category', 34 | }, 35 | ] 36 | 37 | const result = getNavigationTree(input) 38 | 39 | expect(result).toEqual(expectation) 40 | }) 41 | it('Supports duplicate entities', ({ expect }) => { 42 | const input: ComponentEntity[] = [ 43 | { 44 | __type: 'component', 45 | category: 'default', 46 | componentPath: '../../..', 47 | filepath: '../../..', 48 | frontmatter: {}, 49 | normalizedPath: '', 50 | relativePath: '', 51 | slug: '', 52 | title: '', 53 | load: '', 54 | }, 55 | { 56 | __type: 'component', 57 | category: 'default', 58 | componentPath: '../../..', 59 | filepath: '../../..', 60 | frontmatter: {}, 61 | normalizedPath: '', 62 | relativePath: '', 63 | slug: '', 64 | title: '', 65 | load: '', 66 | }, 67 | ] 68 | 69 | const expectation: NavigationTree = [ 70 | { 71 | children: [ 72 | { 73 | __type: 'component', 74 | componentPath: '../../..', 75 | slug: '', 76 | title: '', 77 | }, 78 | { 79 | __type: 'component', 80 | componentPath: '../../..', 81 | slug: '', 82 | title: '', 83 | }, 84 | ], 85 | title: 'default', 86 | __type: 'category', 87 | }, 88 | ] 89 | 90 | const result = getNavigationTree(input) 91 | 92 | expect(result).toEqual(expectation) 93 | }) 94 | it('Returns an empty array if it receives one', ({ expect }) => { 95 | const input: ComponentEntity[] = [] 96 | const expectation: NavigationTree = [] 97 | const result = getNavigationTree(input) 98 | 99 | expect(result).toEqual(expectation) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /packages/swingset/__tests__/parse-component-path.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'vitest' 2 | import { parseComponentPath } from '../src/parse-component-path' 3 | 4 | describe(parseComponentPath.name, () => { 5 | it('Converts a 3 segment path to a navigationData object', ({ expect }) => { 6 | const expectation = { 7 | category: 'Components', 8 | folder: 'Forms', 9 | page: 'Input', 10 | } 11 | 12 | const result = parseComponentPath('Components/Forms/Input') 13 | 14 | expect(result).toEqual(expectation) 15 | }) 16 | it('Converts a 2 segment path to a navigationData object', ({ expect }) => { 17 | const expectation = { 18 | category: 'Components', 19 | page: 'Button', 20 | } 21 | 22 | const result = parseComponentPath('Components/Button') 23 | 24 | expect(result).toEqual(expectation) 25 | }) 26 | it('Converts a 1 segment path to a navigationData object', ({ expect }) => { 27 | const expectation = { 28 | category: 'default', 29 | page: 'Button', 30 | } 31 | 32 | const result = parseComponentPath('Button') 33 | 34 | expect(result).toEqual(expectation) 35 | }) 36 | it('Throws an error when too many segments are received', ({ expect }) => { 37 | const input = 'edibles/fruits/berries/blueberries' 38 | 39 | const errorSnapshot = 40 | "\"Received Component path with more than 3 segments: 'edibles/fruits/berries/blueberries'. Remove the extra segments. Expected format: '[Category]/[Folder]/[Page]'\"" 41 | 42 | expect(() => parseComponentPath(input)).toThrowErrorMatchingInlineSnapshot( 43 | errorSnapshot 44 | ) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /packages/swingset/loader.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import('webpack').LoaderContext} LoaderContext 3 | * 4 | * ref: https://github.com/mdx-js/mdx/blob/main/packages/loader/index.cjs 5 | */ 6 | 7 | /** 8 | * Webpack loader 9 | * 10 | * @todo once webpack supports ESM loaders, remove this wrapper. 11 | * 12 | * @this {LoaderContext} 13 | * @param {string} code 14 | */ 15 | module.exports = function (code) { 16 | const callback = this.async() 17 | // Note that `import()` caches, so this should be fast enough. 18 | import('./dist/loader.js').then((module) => 19 | module 20 | .loader(this, code) 21 | .then((result) => callback(null, result)) 22 | .catch((err) => callback(err)) 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /packages/swingset/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swingset", 3 | "version": "0.17.0", 4 | "type": "module", 5 | "scripts": { 6 | "build:js": "tsup", 7 | "build:styles": "postcss src/default-theme/css/styles.css -o style.css --verbose", 8 | "build": "concurrently npm:build:*", 9 | "dev:js": "tsup --watch", 10 | "dev:styles": "TAILWIND_MODE=watch postcss src/default-theme/css/styles.css -o style.css --verbose --watch", 11 | "dev": "concurrently npm:dev:*", 12 | "test": "vitest run" 13 | }, 14 | "bin": "./dist/cli/index.js", 15 | "dependencies": { 16 | "@mdx-js/mdx": "^2.3.0", 17 | "detect-package-manager": "^2.0.1", 18 | "github-slugger": "^2.0.0", 19 | "globby": "^13.1.4", 20 | "react-docgen": "^6.0.0-beta.5", 21 | "readdirp": "^3.6.0", 22 | "vfile": "^5.3.7", 23 | "vfile-matter": "^4.0.1" 24 | }, 25 | "devDependencies": { 26 | "@types/webpack": "^5.28.1", 27 | "@types/webpack-env": "^1.18.1", 28 | "autoprefixer": "^10.4.14", 29 | "next": "^13.4.4", 30 | "postcss": "^8.4.24", 31 | "postcss-cli": "^10.1.0", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "tailwindcss": "^3.3.2", 35 | "vitest": "^0.31.3" 36 | }, 37 | "peerDependencies": { 38 | "next": "13.x", 39 | "react": ">=18.2.0", 40 | "react-dom": ">=18.2.0" 41 | }, 42 | "exports": { 43 | ".": { 44 | "import": "./dist/index.js", 45 | "types": "./dist/index.d.ts" 46 | }, 47 | "./loader": "./loader.cjs", 48 | "./meta": { 49 | "import": "./dist/meta.js", 50 | "types": "./dist/meta.d.ts" 51 | }, 52 | "./theme": { 53 | "import": "./dist/theme.js", 54 | "types": "./dist/theme.d.ts" 55 | }, 56 | "./default-theme": { 57 | "import": "./dist/default-theme/index.js", 58 | "types": "./dist/default-theme/index.d.ts" 59 | }, 60 | "./style.css": "./style.css", 61 | "./*": { 62 | "import": "./dist/*.js", 63 | "types": "./dist/*.d.ts" 64 | } 65 | }, 66 | "typesVersions": { 67 | "*": { 68 | "*": [ 69 | "./dist/*" 70 | ] 71 | } 72 | }, 73 | "files": [ 74 | "./dist", 75 | "./loader.cjs", 76 | "./style.css" 77 | ] 78 | } 79 | -------------------------------------------------------------------------------- /packages/swingset/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': {}, 4 | 'tailwindcss/nesting': {}, 5 | tailwindcss: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /packages/swingset/src/cli/commands/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { Logs, codeText } from '../utils/logs' 3 | import childProcess from 'child_process' 4 | import { getPkgInstallCmd } from '../utils/get-pkg-install-cmd' 5 | import { FILES } from '../utils/constants' 6 | 7 | /* 8 | This command generates the following file structure 9 | ``` 10 | src/ 11 | ├─ app/ 12 | │ ├─ layout.tsx 13 | │ ├─ (swingset)/ 14 | │ │ ├─ swingset/ 15 | │ │ │ ├─ [...path]/ 16 | │ │ │ │ ├─ page.tsx 17 | │ │ │ ├─ content.mdx 18 | │ │ │ ├─ page.tsx 19 | ├─ next.config.js 20 | ``` 21 | May slightly vary if person isn't using src/ directory 22 | */ 23 | 24 | const bootstrap = { 25 | name: 'bootstrap', 26 | description: 'Creates a swingset template in the `app` directory', 27 | builder: {}, 28 | handler: async () => { 29 | Logs.bootstrap.start() 30 | 31 | const installSwingset = await getPkgInstallCmd() 32 | console.log('Running', codeText(installSwingset)) 33 | childProcess.execSync(installSwingset, { stdio: 'inherit' }) 34 | 35 | /* 36 | Attempt to Create Swingset root: src/app/(swingset)/ OR ./app/(swingset)/ 37 | If src/app or ./app already exist, they will be ignored 38 | if ./app/(swingset) exists, log error and exit 1 39 | */ 40 | 41 | const hasSwingset = fs.existsSync(FILES.swingsetRoot) 42 | 43 | if (hasSwingset) { 44 | Logs.bootstrap.hasSwingset() 45 | process.exit(1) 46 | } 47 | fs.mkdirSync(FILES.swingsetRoot, { recursive: true }) 48 | 49 | // Creates swingset layout: [root]/(swingset)/layout.tsx 50 | fs.writeFileSync(FILES.layout.path, FILES.layout.content, 'utf-8') 51 | 52 | /* 53 | * Create home page: 54 | * [root](swingset)/swingset/page.tsx/ 55 | * && 56 | * Dynamic route 57 | * [root](swingset)/swingset/[...page]/page.tsx 58 | */ 59 | 60 | fs.writeFileSync(FILES.page.path, FILES.page.content, 'utf-8') 61 | fs.mkdirSync(FILES.dynamicPath.path) 62 | fs.writeFileSync( 63 | FILES.dynamicPath.pagePath, 64 | FILES.dynamicPath.pageContent, 65 | 'utf-8' 66 | ) 67 | 68 | //[root](swingset)/swingset/content.mdx 69 | fs.writeFileSync(FILES.content.path, FILES.content.content, 'utf-8') 70 | 71 | /* 72 | * If user has nextconfig already, exit and point them to docs, 73 | * if not, create next config with swingset 74 | */ 75 | 76 | // TODO: explore using codemod tool to add the swingset plugin to an existing next config 77 | 78 | const hasNextConfig = fs.existsSync(FILES.nextConfig.path) 79 | if (!hasNextConfig) { 80 | fs.writeFileSync(FILES.nextConfig.path, FILES.nextConfig.content) 81 | Logs.bootstrap.complete() 82 | } else { 83 | Logs.bootstrap.completeNoConfig() 84 | } 85 | }, 86 | } 87 | 88 | export { bootstrap } 89 | -------------------------------------------------------------------------------- /packages/swingset/src/cli/commands/default.ts: -------------------------------------------------------------------------------- 1 | import { Logs } from '../utils/logs' 2 | 3 | export const defaultCMD = { 4 | name: '$0', 5 | description: 'The default "swingset" command, currently unused', 6 | builder: {}, 7 | handler: () => Logs.default(), 8 | } 9 | -------------------------------------------------------------------------------- /packages/swingset/src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import yargs from 'yargs' 3 | import { bootstrap } from './commands/bootstrap' 4 | import { defaultCMD } from './commands/default' 5 | 6 | const { argv } = yargs(process.argv.slice(2)) 7 | .command( 8 | defaultCMD.name, 9 | defaultCMD.description, 10 | defaultCMD.builder, 11 | defaultCMD.handler 12 | ) 13 | .command( 14 | bootstrap.name, 15 | bootstrap.description, 16 | bootstrap.builder, 17 | bootstrap.handler 18 | ) 19 | -------------------------------------------------------------------------------- /packages/swingset/src/cli/utils/constants.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | 3 | const usingSrc = fs.existsSync('./src') 4 | const appRoot = usingSrc ? './src/app' : './app' 5 | const routeGroupDir = `${appRoot}/(swingset)` 6 | const swingsetRoot = `${routeGroupDir}/swingset` 7 | 8 | export const FILES = { 9 | appRoot, 10 | routeGroupDir, 11 | swingsetRoot, 12 | nextConfig: { 13 | path: './next.config.js', 14 | content: `import withSwingset from 'swingset' 15 | 16 | export default withSwingset({ 17 | componentRootPattern: './components', 18 | theme: 'swingset-theme-hashicorp', 19 | })`, 20 | }, 21 | page: { 22 | path: `${swingsetRoot}/page.tsx`, 23 | content: `import Content from './content.mdx' 24 | 25 | export default async function SwingsetRoot() { 26 | return 27 | }`, 28 | }, 29 | dynamicPath: { 30 | path: `${swingsetRoot}/[...path]`, 31 | pagePath: `${swingsetRoot}/[...path]/page.tsx`, 32 | pageContent: `import Content from './content.mdx' 33 | 34 | export default async function SwingsetRoot() { 35 | return 36 | }`, 37 | }, 38 | layout: { 39 | path: `${routeGroupDir}/layout.tsx`, 40 | content: `import layout from 'swingset/theme' 41 | 42 | export default layout`, 43 | }, 44 | content: { 45 | path: `${swingsetRoot}/content.mdx`, 46 | content: `# Swingset! 47 | 48 | Welcome to Swingset. Swingset is a drop-in component documentation system built for Next.js's App Router and React Server Components. 49 | 50 | Check out our [docs](https://github.com/hashicorp/swingset#readme) to get started 51 | `, 52 | }, 53 | } as const 54 | -------------------------------------------------------------------------------- /packages/swingset/src/cli/utils/get-pkg-install-cmd.ts: -------------------------------------------------------------------------------- 1 | import { detect, PM } from 'detect-package-manager' 2 | import { error, Logs } from './logs' 3 | 4 | export async function getPkgInstallCmd() { 5 | let pkg: PM = 'npm' 6 | try { 7 | pkg = await detect() 8 | } catch (err) { 9 | Logs.bootstrap.unableToInstall() 10 | console.log(error(err as string)) 11 | process.exit(1) 12 | } 13 | const installSwingsetCMD = 14 | pkg === 'yarn' ? `${pkg} add swingset` : `${pkg} install swingset` 15 | 16 | return installSwingsetCMD 17 | } 18 | -------------------------------------------------------------------------------- /packages/swingset/src/cli/utils/logs.ts: -------------------------------------------------------------------------------- 1 | import { FILES } from './constants' 2 | const redTxt = '\x1b[31m' 3 | const endTxt = '\x1b[0m' 4 | const greenTxt = '\x1b[32m' 5 | const grayBg = '\x1b[100m' 6 | const yellowTxt = '\x1b[33m' 7 | const blueTxt = '\x1b[34m' 8 | 9 | /* 10 | Helpers to semantically color console outout, 11 | if the cli grows for some reason we can look into a https://github.com/chalk/chalk#readme or a similar pkg 12 | */ 13 | export const error = (txt: string) => `${redTxt}ERROR:${endTxt} ${txt}` 14 | export const success = (txt: string) => `${greenTxt}SUCCESS:${endTxt} ${txt}` 15 | export const codeText = (txt: string) => `${grayBg}${yellowTxt}${txt}${endTxt}` 16 | export const linkText = (txt: string) => `${blueTxt}${txt}${endTxt}` 17 | 18 | export const Logs = { 19 | bootstrap: { 20 | start: () => { 21 | console.log('Getting you started with swingset...') 22 | }, 23 | hasSwingset: () => { 24 | console.error( 25 | `${error( 26 | 'Unable to generate swingset template.' 27 | )} Route group ${codeText(FILES.routeGroupDir)} already exists.` 28 | ) 29 | }, 30 | unableToInstall: () => { 31 | console.error( 32 | error('Unable to install swingset package, received following error:') 33 | ) 34 | }, 35 | complete: () => { 36 | console.log( 37 | `${success('Checkout')} ${codeText( 38 | FILES.routeGroupDir 39 | )} to get started.` 40 | ) 41 | }, 42 | completeNoConfig: () => { 43 | console.log( 44 | `${success('Add the swingset plugin to your')} ${codeText( 45 | 'next.config' 46 | )} to get started. (documentation: ${linkText( 47 | 'https://github.com/hashicorp/swingset#installation' 48 | )})` 49 | ) 50 | }, 51 | }, 52 | default: () => { 53 | console.log(`Try running ${codeText('swingset bootstrap')} to get started.`) 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /packages/swingset/src/config.ts: -------------------------------------------------------------------------------- 1 | import { CompileOptions } from '@mdx-js/mdx' 2 | 3 | const DEFAULT_CONFIG = { 4 | componentRoot: './components', 5 | docsRoot: './app/(swingset)/swingset/docs', 6 | theme: 'swingset/default-theme', 7 | remarkPlugins: [], 8 | rehypePlugins: [], 9 | } 10 | 11 | export interface SwingsetConfig { 12 | componentRoot: string 13 | docsRoot: string 14 | theme: string 15 | remarkPlugins: CompileOptions['remarkPlugins'] 16 | rehypePlugins: CompileOptions['rehypePlugins'] 17 | } 18 | 19 | export function applyConfigDefaults( 20 | config: Partial 21 | ): SwingsetConfig { 22 | return { 23 | ...DEFAULT_CONFIG, 24 | ...config, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/swingset/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const MARKDOWN_EXTENSION_REGEX = /\.mdx?$/ 2 | export const COMPONENT_DOCS_FILENAME = 'docs.mdx' 3 | 4 | // this should stay in-sync with: https://github.com/vercel/next.js/blob/canary/packages/next-mdx/index.js 5 | export const NEXT_MDX_COMPONENTS_ALIAS = 'next-mdx-import-source-file' 6 | -------------------------------------------------------------------------------- /packages/swingset/src/create-page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | // note: this uses swingset/meta as the import statement, instead of a relative path, so the application build step can pick this up 3 | import { getEntity } from 'swingset/meta' 4 | import { RenderDocs } from './render' 5 | 6 | type SwingsetPageProps = { 7 | params: { path: string[] } 8 | } 9 | 10 | export function createPage(ChildPage: React.ComponentType) { 11 | return function SwingsetPage( 12 | props: React.ComponentProps & SwingsetPageProps 13 | ) { 14 | const { params } = props 15 | const slug = params.path.join('/') 16 | 17 | const entity = getEntity(slug) 18 | 19 | return ( 20 | } 25 | /> 26 | ) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/swingset/src/default-theme/css/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | h1 { 7 | @apply ss-text-3xl; 8 | } 9 | 10 | h2 { 11 | @apply ss-text-2xl; 12 | } 13 | 14 | h3 { 15 | @apply ss-text-xl; 16 | } 17 | 18 | h4 { 19 | @aplpy ss-text-lg; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/swingset/src/default-theme/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | // note: this uses swingset/meta as the import statement, instead of a relative path, so the application build step can pick this up 4 | import { meta, categories } from 'swingset/meta' 5 | 6 | import Page from './page' 7 | 8 | export default function SwingsetLayout({ 9 | children, 10 | }: { 11 | children: React.ReactNode 12 | }) { 13 | return ( 14 | 15 | 16 |
    17 | 44 |
    {children}
    45 |
    46 | 51 | 52 | 53 | ) 54 | } 55 | 56 | export { Page } 57 | -------------------------------------------------------------------------------- /packages/swingset/src/default-theme/page.tsx: -------------------------------------------------------------------------------- 1 | export default async function Page({ 2 | data, 3 | content, 4 | }: { 5 | data: any 6 | content: React.ReactNode 7 | }) { 8 | return ( 9 | <> 10 |

    {data?.frontmatter?.title ?? data?.slug}

    11 |

    {data?.frontmatter?.description}

    12 | {content} 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /packages/swingset/src/get-frontmatter.ts: -------------------------------------------------------------------------------- 1 | import { VFile } from 'vfile' 2 | import { matter } from 'vfile-matter' 3 | 4 | export function getFileFrontmatter(source: string) { 5 | const vfile = new VFile(source) 6 | 7 | matter(vfile) 8 | 9 | return vfile.data.matter as Record 10 | } 11 | -------------------------------------------------------------------------------- /packages/swingset/src/get-navigation-tree.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentEntity, 3 | DocsEntity, 4 | ComponentNode, 5 | CategoryNode, 6 | NavigationTree, 7 | NavigationNode, 8 | } from './types.js' 9 | 10 | export function getNavigationTree( 11 | entities: (ComponentEntity | DocsEntity)[] 12 | ): NavigationTree { 13 | const componentEntities = entities.filter( 14 | (entity) => entity.__type === 'component' 15 | ) as ComponentEntity[] 16 | 17 | const componentEntitiesWithChildren = componentEntities.map( 18 | (componentEntity) => { 19 | if (componentEntity.isNested) return componentEntity 20 | 21 | componentEntity.children = componentEntities.filter( 22 | (childEntity) => 23 | childEntity.isNested && 24 | childEntity.componentPath === componentEntity.componentPath 25 | ) 26 | 27 | return componentEntity 28 | } 29 | ) 30 | 31 | const categories = new Map() 32 | 33 | // bucket components into categories, nested documents are categorized under their component's path 34 | for (const entity of componentEntitiesWithChildren) { 35 | if (entity.isNested) continue 36 | 37 | const componentNode: ComponentNode = { 38 | __type: 'component', 39 | title: entity.title, 40 | slug: entity.slug, 41 | componentPath: entity.componentPath, 42 | } 43 | 44 | const categoryTitle = entity.navigationData?.category || 'default' 45 | 46 | const hasCategory = categories.has(categoryTitle) 47 | 48 | if (!hasCategory) { 49 | categories.set(categoryTitle, { 50 | __type: 'category', 51 | title: categoryTitle, 52 | children: [], 53 | }) 54 | } 55 | 56 | const storedCategory = categories.get(categoryTitle)! 57 | 58 | const folderTitle = entity.navigationData?.folder 59 | const hasFolder = !!folderTitle 60 | const folder = storedCategory.children.find( 61 | (node) => node.title === folderTitle 62 | ) 63 | 64 | //if node belongs in a folder, and folder doesnt exist, create folder with node 65 | if (hasFolder && !!folder === false) { 66 | storedCategory.children.push({ 67 | __type: 'folder', 68 | title: folderTitle, 69 | parentCategory: categoryTitle, 70 | children: [componentNode], 71 | }) 72 | continue 73 | } 74 | 75 | //if node belongs in a folder, and folder already exists, add node to folder 76 | if (hasFolder && !!folder && folder.__type === 'folder') { 77 | folder.children ||= [] 78 | folder.children.push(componentNode) 79 | continue 80 | } 81 | 82 | //if node doesnt belong in folder, add node 83 | storedCategory.children.push(componentNode) 84 | } 85 | 86 | const tree = Array.from(categories.values()) 87 | 88 | deepSort(tree) 89 | 90 | return tree 91 | } 92 | 93 | /** 94 | * 95 | * Sorts tree alphabetically 96 | */ 97 | function deepSort(nodes: (CategoryNode | NavigationNode)[]) { 98 | nodes.sort(compareTitleSort) 99 | 100 | for (const node of nodes) { 101 | const hasChildren = 'children' in node 102 | 103 | if (hasChildren) { 104 | deepSort(node.children) 105 | } 106 | } 107 | } 108 | 109 | function compareTitleSort( 110 | a: T, 111 | b: T 112 | ): -1 | 0 | 1 { 113 | if (a.title > b.title) { 114 | return 1 115 | } 116 | if (b.title > a.title) { 117 | return -1 118 | } 119 | return 0 120 | } 121 | -------------------------------------------------------------------------------- /packages/swingset/src/get-props.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import fs from 'node:fs' 3 | 4 | import { parse } from 'react-docgen' 5 | 6 | /** 7 | * Extracts prop information from a component file. 8 | */ 9 | export async function getRelatedComponentPropsMetadata({ 10 | directory, 11 | filepath, 12 | source, 13 | }: { 14 | directory: string 15 | filepath?: string 16 | source?: string 17 | }) { 18 | if (source) { 19 | return parse(source, { filename: filepath }) 20 | } 21 | 22 | // TODO: support additional extensions 23 | const componentSourcePath = path.resolve(directory, './index.tsx') 24 | 25 | if (fs.existsSync(componentSourcePath)) { 26 | const componentSource = await fs.promises.readFile( 27 | componentSourcePath, 28 | 'utf-8' 29 | ) 30 | const propsMeta = parse(componentSource, { filename: componentSourcePath }) 31 | 32 | return propsMeta 33 | } 34 | 35 | return [] 36 | } 37 | -------------------------------------------------------------------------------- /packages/swingset/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next' 2 | import { 3 | MARKDOWN_EXTENSION_REGEX, 4 | NEXT_MDX_COMPONENTS_ALIAS, 5 | } from './constants.js' 6 | import { applyConfigDefaults, SwingsetConfig } from './config.js' 7 | 8 | const DEFAULT_EXTENSIONS = ['js', 'jsx', 'ts', 'tsx'] 9 | 10 | // c.f. https://github.com/vercel/next.js/blob/canary/packages/next-mdx/index.js 11 | function addNextMdxImportAlias(config: any) { 12 | config.resolve.alias[NEXT_MDX_COMPONENTS_ALIAS] ||= [ 13 | 'private-next-root-dir/src/mdx-components', 14 | 'private-next-root-dir/mdx-components', 15 | '@mdx-js/react', 16 | ] 17 | } 18 | 19 | export default function swingset(swingsetConfig: Partial) { 20 | return function withSwingset( 21 | nextConfig: Partial = {} 22 | ): NextConfig { 23 | const resolvedConfig = applyConfigDefaults(swingsetConfig) 24 | 25 | return { 26 | ...nextConfig, 27 | pageExtensions: [ 28 | ...(nextConfig.pageExtensions ?? DEFAULT_EXTENSIONS), 29 | 'mdx', 30 | ], 31 | webpack(config, options) { 32 | // Allow re-use of the pattern for defining component overrides provided by @next/mdx 33 | addNextMdxImportAlias(config) 34 | 35 | // Load .mdx files as modules 36 | config.module.rules.push({ 37 | test: MARKDOWN_EXTENSION_REGEX, 38 | issuer: (request: string | null) => 39 | Boolean(request) || request === null, 40 | use: [ 41 | options.defaultLoaders.babel, 42 | { 43 | loader: 'swingset/loader', 44 | options: { 45 | isContentImport: true, 46 | ...resolvedConfig, 47 | }, 48 | }, 49 | ], 50 | }) 51 | 52 | // Load tsx files from within an .mdx file 53 | config.module.rules.push({ 54 | test: /\.tsx$/, 55 | issuer: (request: string | null) => 56 | request && MARKDOWN_EXTENSION_REGEX.test(request), 57 | use: [ 58 | { 59 | loader: 'swingset/loader', 60 | options: { 61 | isComponentImport: true, 62 | ...resolvedConfig, 63 | }, 64 | }, 65 | ], 66 | }) 67 | 68 | // Load swingset metadata 69 | config.module.rules.push({ 70 | test: /swingset\/(dist\/)?meta/, 71 | use: [ 72 | options.defaultLoaders.babel, 73 | { 74 | loader: 'swingset/loader', 75 | options: { 76 | isMetaImport: true, 77 | ...resolvedConfig, 78 | }, 79 | }, 80 | ], 81 | }) 82 | 83 | // Load swingset theme 84 | config.module.rules.push({ 85 | test: /swingset\/(dist\/)?theme/, 86 | use: [ 87 | options.defaultLoaders.babel, 88 | { 89 | loader: 'swingset/loader', 90 | options: { 91 | isThemeImport: true, 92 | ...resolvedConfig, 93 | }, 94 | }, 95 | ], 96 | }) 97 | 98 | return config 99 | }, 100 | } as NextConfig 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/swingset/src/loader.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import { compile, CompileOptions } from '@mdx-js/mdx' 3 | import { VFile } from 'vfile' 4 | import { matter } from 'vfile-matter' 5 | import { type LoaderContext } from 'webpack' 6 | import { resolveComponents } from './resolvers/component.js' 7 | import { stringifyEntity } from './resolvers/stringify-entity.js' 8 | import { getNavigationTree } from './get-navigation-tree.js' 9 | import { resolveDocs } from './resolvers/doc.js' 10 | import { NEXT_MDX_COMPONENTS_ALIAS } from './constants.js' 11 | import { type SwingsetConfig } from './config.js' 12 | import { type Entity } from './types.js' 13 | import { getRelatedComponentPropsMetadata } from './get-props.js' 14 | 15 | type LoaderOptions = { 16 | isMetaImport: boolean 17 | isContentImport: boolean 18 | isComponentImport: boolean 19 | isThemeImport: boolean 20 | } & SwingsetConfig 21 | 22 | function getCompileOptions(options?: CompileOptions): CompileOptions { 23 | return { 24 | outputFormat: 'program', 25 | providerImportSource: NEXT_MDX_COMPONENTS_ALIAS, 26 | ...options, 27 | } 28 | } 29 | 30 | async function compileMDX(source: string, options?: CompileOptions) { 31 | const vfile = new VFile(source) 32 | 33 | matter(vfile, { strip: true }) 34 | 35 | const result = await compile(vfile, getCompileOptions(options)) 36 | 37 | return { result, frontmatter: vfile.data.matter } 38 | } 39 | 40 | export async function loader( 41 | context: LoaderContext, 42 | source: string 43 | ): Promise { 44 | const { 45 | isMetaImport, 46 | isContentImport, 47 | isThemeImport, 48 | isComponentImport, 49 | componentRoot, 50 | docsRoot, 51 | theme, 52 | remarkPlugins, 53 | rehypePlugins, 54 | } = context.getOptions() 55 | 56 | context.cacheable(true) 57 | 58 | if (isMetaImport) { 59 | const entities: Entity[] = [ 60 | ...(await resolveComponents({ componentRoot })), 61 | ...(await resolveDocs({ docsRoot })), 62 | ] 63 | context.addContextDependency(path.join(process.cwd(), componentRoot)) 64 | context.addContextDependency(path.join(process.cwd(), docsRoot)) 65 | 66 | const result = ` 67 | export const meta = { 68 | ${entities 69 | .map((entity) => `'${entity.slug}': ${stringifyEntity(entity)}`) 70 | .join(',\n')} 71 | }; 72 | 73 | export function getEntity(slug) { 74 | return meta[slug] 75 | } 76 | 77 | export function getNestedEntities(slug) { 78 | const entity = meta[slug] 79 | 80 | if (!entity) return [] 81 | 82 | return Object.values(meta).filter(e => e.isNested && e.componentPath === entity.componentPath) 83 | } 84 | 85 | export function generateStaticParams() { 86 | return Object.keys(meta).map(slug => ({ path: slug.split('/') })) 87 | } 88 | 89 | export const categories = ${JSON.stringify(getNavigationTree(entities))} 90 | ` 91 | 92 | return result 93 | } 94 | 95 | if (isContentImport) { 96 | const { result, frontmatter } = await compileMDX(source, { 97 | jsx: true, 98 | format: 'detect', 99 | remarkPlugins, 100 | rehypePlugins, 101 | }) 102 | 103 | const mdxModule = String(result) 104 | const stringifiedFrontmatter = JSON.stringify(frontmatter) || '{}' 105 | 106 | const mod = `${mdxModule} 107 | 108 | export const frontmatter = ${stringifiedFrontmatter}; 109 | ` 110 | 111 | return mod 112 | } 113 | 114 | // Append prop metadata to a component when imported from an mdx file 115 | if (isComponentImport) { 116 | let mod = source 117 | 118 | try { 119 | const propsMetadata = await getRelatedComponentPropsMetadata({ 120 | source, 121 | filepath: context.resourcePath, 122 | directory: path.dirname(context.resourcePath), 123 | }) 124 | 125 | // TODO: this breaks if the function name is not the same as the computed displayName from react-docgen 126 | mod = `${source} 127 | 128 | ${propsMetadata.map((metadata) => { 129 | // @ts-expect-error -- displayName exists on the actual object. Fixed upstream 130 | return `${metadata.displayName}.propsMetadata = ${JSON.stringify(metadata)};` 131 | })} 132 | ` 133 | } catch (error) { 134 | console.log( 135 | `[swingset/loader] error detecting props metadata for component file: ${context.resourcePath}`, 136 | error 137 | ) 138 | } 139 | 140 | return mod 141 | } 142 | 143 | if (isThemeImport) { 144 | return ` 145 | import Theme, { Page as ThemePage } from '${theme}'; 146 | import { createPage } from 'swingset/create-page'; 147 | 148 | export default Theme; 149 | export const Page = createPage(ThemePage); 150 | ` 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /packages/swingset/src/meta.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is a stub for a loader target and should never be imported directly 3 | */ 4 | import { EvaluatedEntity, NavigationTree, ComponentEntity } from './types' 5 | 6 | export const meta: Record 7 | 8 | export const categories: NavigationTree 9 | 10 | export function getEntity(slug: string): EvaluatedEntity | undefined 11 | 12 | export function getNestedEntities(slug: string): ComponentEntity[] 13 | 14 | export const generateStaticParams: () => { path: string[] }[] 15 | -------------------------------------------------------------------------------- /packages/swingset/src/meta.js: -------------------------------------------------------------------------------- 1 | export default '' 2 | -------------------------------------------------------------------------------- /packages/swingset/src/parse-component-path.ts: -------------------------------------------------------------------------------- 1 | import { ComponentEntity } from './types' 2 | 3 | /** 4 | * Takes in the front matter path property, and parses it into 5 | * navigation metadata 6 | * Example: 7 | * parseComponentPath('Components/Forms/Input') 8 | * outputs: { category: 'Components', folder: 'Forms', page: 'Input' } 9 | */ 10 | export function parseComponentPath(rawPath: string): ComponentEntity['navigationData'] { 11 | 12 | 13 | 14 | const rawPathArr = rawPath.split('/'); 15 | 16 | 17 | 18 | 19 | if (rawPathArr.length > 3) { 20 | throw new Error( 21 | `Received Component path with more than 3 segments: '${rawPath}'. Remove the extra segments. Expected format: '[Category]/[Folder]/[Page]'` 22 | ) 23 | } 24 | 25 | let result: ComponentEntity['navigationData'] = { 26 | category: 'default', 27 | page: '', 28 | } 29 | 30 | if (rawPathArr.length === 3) { 31 | result = { 32 | category: rawPathArr[0], 33 | folder: rawPathArr[1], 34 | page: rawPathArr[2], 35 | } 36 | } 37 | if (rawPathArr.length === 2) { 38 | result = { 39 | category: rawPathArr[0], 40 | page: rawPathArr[1], 41 | } 42 | } 43 | 44 | if (rawPathArr.length === 1) { 45 | result = { 46 | category: 'default', 47 | page: rawPathArr[0], 48 | } 49 | } 50 | 51 | return result 52 | } 53 | -------------------------------------------------------------------------------- /packages/swingset/src/render.tsx: -------------------------------------------------------------------------------- 1 | // note: this uses swingset/meta as the import statement, instead of a relative path, so the application build step can pick this up 2 | import { getEntity } from 'swingset/meta' 3 | import { type Entity, type EvaluatedEntity } from './types' 4 | 5 | interface RenderDocsProps { 6 | slug?: string 7 | data?: EvaluatedEntity 8 | } 9 | 10 | export async function RenderDocs({ data, slug }: RenderDocsProps) { 11 | let entity = data 12 | 13 | if (!entity && slug) { 14 | entity = getEntity(slug) 15 | } 16 | 17 | if (entity) { 18 | const Content = await entity.load() 19 | 20 | return 21 | } 22 | 23 | return null 24 | } 25 | -------------------------------------------------------------------------------- /packages/swingset/src/resolvers/build-load-function.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Construct a stringified loader for an entity. Returns the default export 3 | */ 4 | export function buildLoadFunction(filepath: string) { 5 | return `() => import("${filepath}").then(mod => mod.default)` 6 | } 7 | -------------------------------------------------------------------------------- /packages/swingset/src/resolvers/component.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { globbyStream } from 'globby' 4 | import { slug } from 'github-slugger' 5 | import { getFileFrontmatter } from '../get-frontmatter.js' 6 | import { ComponentEntity } from '../types.js' 7 | import { buildLoadFunction } from './build-load-function.js' 8 | import { parseComponentPath } from '../parse-component-path.js' 9 | 10 | const DOCS_DIRECTORY = 'docs' 11 | 12 | interface ComponentResolverOptions { 13 | componentRoot: string 14 | } 15 | 16 | // TODO: how to support additional functionality here? We should support attaching additional metadata, like parsing types for props 17 | export async function resolveComponents({ 18 | componentRoot, 19 | }: ComponentResolverOptions) { 20 | const componentRootPath = path.join(process.cwd(), componentRoot) 21 | const result: ComponentEntity[] = [] 22 | 23 | for await (const filepathRaw of globbyStream([componentRoot], { 24 | expandDirectories: { 25 | extensions: ['mdx'], 26 | }, 27 | onlyFiles: true, 28 | absolute: true, 29 | })) { 30 | const filepath = String(filepathRaw) 31 | const contents = await fs.promises.readFile(filepath, 'utf-8') 32 | 33 | const frontmatter = getFileFrontmatter(contents) 34 | const componentPath = path.relative(process.cwd(), path.dirname(filepath)) 35 | const relativePath = path.relative(process.cwd(), filepath) 36 | const normalizedPath = path.relative(componentRootPath, componentPath) 37 | 38 | const componentSlug = slug(componentPath.split('/').pop() as string) 39 | 40 | const isNestedDocument = componentSlug === DOCS_DIRECTORY 41 | 42 | if (isNestedDocument) { 43 | const filename = path.basename(filepath, '.mdx') 44 | 45 | // detect index.mdx 46 | // TODO: warn if docs.mdx and docs/index.mdx exist 47 | const isIndexDocument = filename === 'index' 48 | 49 | // correctly detect the component path 50 | const parentComponentPath = path.dirname(componentPath) 51 | const normalizedNestedPath = path.relative( 52 | componentRootPath, 53 | path.join(parentComponentPath, filename) 54 | ) 55 | const title = 56 | (frontmatter.title as string) ?? 57 | `${parentComponentPath.split('/').pop()} ${filename}` 58 | 59 | result.push({ 60 | __type: 'component', 61 | category: parentComponentPath, 62 | componentPath: parentComponentPath, 63 | filepath, 64 | frontmatter, 65 | isNested: true, 66 | load: buildLoadFunction(filepath), 67 | normalizedPath: normalizedNestedPath, 68 | relativePath, 69 | slug: normalizedNestedPath, 70 | title, 71 | }) 72 | } else { 73 | const navigationData = parseComponentPath( 74 | (frontmatter.path ?? '') as string 75 | ) 76 | 77 | let computedSlug = componentSlug 78 | 79 | if (navigationData?.folder) { 80 | computedSlug = path.join(slug(navigationData.folder), componentSlug) 81 | } 82 | 83 | result.push({ 84 | __type: 'component', 85 | category: (frontmatter.category as string) ?? 'default', 86 | componentPath, 87 | filepath, 88 | frontmatter, 89 | load: buildLoadFunction(filepath), 90 | normalizedPath, 91 | relativePath, 92 | slug: computedSlug, 93 | title: (frontmatter.title as string) ?? componentSlug, 94 | navigationData, 95 | }) 96 | } 97 | } 98 | 99 | return result 100 | } 101 | -------------------------------------------------------------------------------- /packages/swingset/src/resolvers/doc.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import readdirp from 'readdirp' 4 | import { getFileFrontmatter } from '../get-frontmatter' 5 | import { DocsEntity } from '../types' 6 | import { buildLoadFunction } from './build-load-function' 7 | 8 | interface DocResolverOptions { 9 | docsRoot: string 10 | } 11 | 12 | export async function resolveDocs({ docsRoot }: DocResolverOptions) { 13 | const result: DocsEntity[] = [] 14 | 15 | for await (const f of readdirp(docsRoot, { 16 | fileFilter: '*.mdx', 17 | type: 'files', 18 | })) { 19 | const filepath = f.fullPath 20 | const contents = await fs.promises.readFile(filepath, 'utf-8') 21 | 22 | const frontmatter = getFileFrontmatter(contents) 23 | const relativePath = path.relative(docsRoot, filepath) 24 | const normalizedPath = relativePath 25 | .replace('.mdx', '') 26 | .replace(/\/index$/, '') 27 | 28 | result.push({ 29 | __type: 'doc', 30 | frontmatter, 31 | filepath, 32 | relativePath, 33 | normalizedPath, 34 | slug: normalizedPath, 35 | load: buildLoadFunction(filepath), 36 | }) 37 | } 38 | 39 | return result 40 | } 41 | -------------------------------------------------------------------------------- /packages/swingset/src/resolvers/stringify-entity.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '../types' 2 | 3 | export function stringifyEntity(entity: Entity) { 4 | return `{ 5 | ${Object.entries(entity) 6 | .map(([key, value]) => { 7 | // We don't want the load function to stringified as a string, so drop it in without quotes 8 | if (key === 'load') { 9 | return `load: ${value}` 10 | } 11 | 12 | return `${key}: ${JSON.stringify(value)}` 13 | }) 14 | .join(',\n')} 15 | }` 16 | } 17 | -------------------------------------------------------------------------------- /packages/swingset/src/theme.d.ts: -------------------------------------------------------------------------------- 1 | import { type ElementType, type ReactElement } from 'react' 2 | import { EvaluatedEntity, SwingsetPageProps } from './types' 3 | 4 | declare const Layout: ElementType<{ children: ReactElement }> 5 | 6 | export default Layout 7 | 8 | export const Page: ElementType 9 | -------------------------------------------------------------------------------- /packages/swingset/src/theme.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is a stub for a loader target and should never be imported directly 3 | */ 4 | export default '' 5 | -------------------------------------------------------------------------------- /packages/swingset/src/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export type SwingsetPageProps = { 4 | params: { path: string[] } 5 | } 6 | 7 | export interface Entity { 8 | __type: any 9 | filepath: string 10 | frontmatter: Record 11 | load: string 12 | normalizedPath: string 13 | relativePath: string 14 | slug: string 15 | } 16 | 17 | // TODO: support subpaths? e.g. components/button/docs/accessibility.mdx 18 | export interface ComponentEntity extends Entity { 19 | __type: 'component' 20 | category: string 21 | componentPath: string 22 | title: string 23 | isNested?: boolean 24 | children?: ComponentEntity[] 25 | navigationData?: { 26 | category: string 27 | folder?: string 28 | page: string 29 | } 30 | } 31 | 32 | export interface DocsEntity extends Entity { 33 | __type: 'doc' 34 | } 35 | 36 | /** 37 | * This helper type is used to provide a type for the load function, which is passed around internally as a string and returned from the loader. 38 | */ 39 | export type EvaluatedEntity = 40 | T & { 41 | load: () => Promise 42 | } 43 | 44 | export type ComponentNode = Pick< 45 | ComponentEntity, 46 | '__type' | 'title' | 'slug' | 'componentPath' 47 | > 48 | 49 | export type FolderNode = { 50 | __type: 'folder' 51 | title: string 52 | parentCategory: string 53 | children: ComponentNode[] 54 | } 55 | 56 | export type NavigationNode = ComponentNode | FolderNode 57 | 58 | export type CategoryNode = { 59 | __type: 'category' 60 | title: string 61 | children: NavigationNode[] 62 | } 63 | 64 | export type NavigationTree = CategoryNode[] 65 | -------------------------------------------------------------------------------- /packages/swingset/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | prefix: 'ss-', 4 | content: [ 5 | // Or if using `src` directory: 6 | './src/**/*.{js,ts,jsx,tsx}', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | } 13 | -------------------------------------------------------------------------------- /packages/swingset/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "ESNext", 5 | "declaration": true, 6 | "noEmit": true, 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "allowJs": true, 11 | "jsx": "react-jsx", 12 | "lib": ["es2022", "dom"], 13 | "moduleResolution": "node", 14 | "types": ["vitest/globals", "webpack-env"], 15 | "resolveJsonModule": true 16 | }, 17 | "include": ["src/**/*", "__tests__/**/*"], 18 | "exclude": ["dist/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /packages/swingset/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig([ 4 | { 5 | name: 'swingset', 6 | entry: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], 7 | format: 'esm', 8 | dts: true, 9 | bundle: true, 10 | splitting: false, 11 | external: ['swingset'], 12 | target: 'es2020', 13 | }, 14 | { 15 | name: 'swingset-decl', 16 | entry: ['src/**/*.d.ts', 'src/**/*.js'], 17 | format: 'esm', 18 | loader: { 19 | '.d.ts': 'copy', 20 | }, 21 | }, 22 | ]) 23 | -------------------------------------------------------------------------------- /packages/swingset/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: {}, 5 | }) 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "esnext", 5 | "lib": ["es6", "dom"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "moduleResolution": "node", 11 | "esModuleInterop": true, 12 | "jsx": "react-jsx", 13 | "declaration": true, 14 | "declarationDir": "./dist", 15 | "outDir": "./dist" 16 | }, 17 | "exclude": ["__tests__", "node_modules", "examples"], 18 | "include": ["types.d.ts", "**/*.ts", "**/*.tsx"] 19 | } 20 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**/*", "style.css"] 7 | }, 8 | "dev": { 9 | "cache": false, 10 | "persistent": true 11 | }, 12 | "test": {} 13 | } 14 | } 15 | --------------------------------------------------------------------------------