From e9f73227289013f257148dc9596da7bfc41697d8 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Fri, 4 Jun 2021 14:40:05 -0500 Subject: [PATCH] 0.9.0 --- .../basic/.yalc/swingset/.circleci/config.yml | 8 + examples/basic/.yalc/swingset/README.md | 443 ++++++++++++++++++ .../basic/.yalc/swingset/__swingset_data.js | 2 + .../basic/.yalc/swingset/components-loader.js | 162 +++++++ .../components/knobs-component/index.jsx | 155 ++++++ .../knobs-component/style.module.css | 122 +++++ .../components/live-component/index.jsx | 72 +++ .../live-component/style.module.css | 38 ++ .../.yalc/swingset/components/nav/index.jsx | 38 ++ .../swingset/components/nav/style.module.css | 24 + .../swingset/components/props-table/index.jsx | 112 +++++ .../components/props-table/style.module.css | 89 ++++ .../swingset/components/shared.module.css | 60 +++ examples/basic/.yalc/swingset/img/share.svg | 4 + .../.yalc/swingset/img/swingset-dark.svg | 75 +++ .../.yalc/swingset/img/swingset-light.svg | 76 +++ examples/basic/.yalc/swingset/index.js | 39 ++ examples/basic/.yalc/swingset/package.json | 56 +++ examples/basic/.yalc/swingset/page.jsx | 132 ++++++ examples/basic/.yalc/swingset/server.js | 121 +++++ .../basic/.yalc/swingset/style.module.css | 120 +++++ examples/basic/.yalc/swingset/testing.js | 62 +++ examples/basic/.yalc/swingset/testing.test.js | 144 ++++++ examples/basic/.yalc/swingset/utils/base64.js | 11 + .../basic/.yalc/swingset/utils/create-id.js | 10 + .../.yalc/swingset/utils/create-scope.js | 19 + .../.yalc/swingset/utils/editor-theme.js | 93 ++++ .../basic/.yalc/swingset/utils/find-entity.js | 12 + .../swingset/utils/get-peer-components.js | 20 + .../.yalc/swingset/utils/scroll-to-element.js | 7 + .../basic/.yalc/swingset/utils/simplehash.js | 10 + .../basic/.yalc/swingset/utils/url-state.js | 26 + .../.yalc/swingset/utils/use-base-route.js | 6 + examples/basic/.yalc/swingset/yalc.sig | 1 + examples/basic/yalc.lock | 9 + package-lock.json | 2 +- package.json | 2 +- 37 files changed, 2380 insertions(+), 2 deletions(-) create mode 100644 examples/basic/.yalc/swingset/.circleci/config.yml create mode 100644 examples/basic/.yalc/swingset/README.md create mode 100644 examples/basic/.yalc/swingset/__swingset_data.js create mode 100644 examples/basic/.yalc/swingset/components-loader.js create mode 100644 examples/basic/.yalc/swingset/components/knobs-component/index.jsx create mode 100644 examples/basic/.yalc/swingset/components/knobs-component/style.module.css create mode 100644 examples/basic/.yalc/swingset/components/live-component/index.jsx create mode 100644 examples/basic/.yalc/swingset/components/live-component/style.module.css create mode 100644 examples/basic/.yalc/swingset/components/nav/index.jsx create mode 100644 examples/basic/.yalc/swingset/components/nav/style.module.css create mode 100644 examples/basic/.yalc/swingset/components/props-table/index.jsx create mode 100644 examples/basic/.yalc/swingset/components/props-table/style.module.css create mode 100644 examples/basic/.yalc/swingset/components/shared.module.css create mode 100644 examples/basic/.yalc/swingset/img/share.svg create mode 100644 examples/basic/.yalc/swingset/img/swingset-dark.svg create mode 100644 examples/basic/.yalc/swingset/img/swingset-light.svg create mode 100644 examples/basic/.yalc/swingset/index.js create mode 100644 examples/basic/.yalc/swingset/package.json create mode 100644 examples/basic/.yalc/swingset/page.jsx create mode 100644 examples/basic/.yalc/swingset/server.js create mode 100644 examples/basic/.yalc/swingset/style.module.css create mode 100644 examples/basic/.yalc/swingset/testing.js create mode 100644 examples/basic/.yalc/swingset/testing.test.js create mode 100644 examples/basic/.yalc/swingset/utils/base64.js create mode 100644 examples/basic/.yalc/swingset/utils/create-id.js create mode 100644 examples/basic/.yalc/swingset/utils/create-scope.js create mode 100644 examples/basic/.yalc/swingset/utils/editor-theme.js create mode 100644 examples/basic/.yalc/swingset/utils/find-entity.js create mode 100644 examples/basic/.yalc/swingset/utils/get-peer-components.js create mode 100644 examples/basic/.yalc/swingset/utils/scroll-to-element.js create mode 100644 examples/basic/.yalc/swingset/utils/simplehash.js create mode 100644 examples/basic/.yalc/swingset/utils/url-state.js create mode 100644 examples/basic/.yalc/swingset/utils/use-base-route.js create mode 100644 examples/basic/.yalc/swingset/yalc.sig create mode 100644 examples/basic/yalc.lock diff --git a/examples/basic/.yalc/swingset/.circleci/config.yml b/examples/basic/.yalc/swingset/.circleci/config.yml new file mode 100644 index 0000000..586f675 --- /dev/null +++ b/examples/basic/.yalc/swingset/.circleci/config.yml @@ -0,0 +1,8 @@ +version: 2.1 +orbs: + node: circleci/node@3.0.0 +workflows: + test: + jobs: + - node/test: + run-command: test diff --git a/examples/basic/.yalc/swingset/README.md b/examples/basic/.yalc/swingset/README.md new file mode 100644 index 0000000..01b096b --- /dev/null +++ b/examples/basic/.yalc/swingset/README.md @@ -0,0 +1,443 @@ +![Swingset](https://p176.p0.n0.cdn.getcloudapp.com/items/NQu14DNO/swingset-light-github.svg) + +An opinionated, drop-in component library for next.js apps. + +> **NOTE**: This project is in early alpha stages, and the readme is still not accurate. It is not recommended for use currently. If you really want to use it still, reach out to the author. + +### Installation + +Install via npm with `npm i swingset`, then add the plugin to your `next.config.js` as such: + +```js +const withSwingset = require('swingset') + +module.exports = + withSwingset(/* swingset options */)(/* normal nextjs config */) +``` + +You then need to create a page in your nextjs app where swingset will live. You can "inject" swingset on to any page of your choosing. Something like `/components` might be a nice choice. When you have decided on a page, swingset can be injected as follows: + +> **Note:** `createStaticProps` accepts `mdxOptions`, which allow you to customize how your markup is rendered. For details, see [`next-mdx-remote`](https://github.com/hashicorp/next-mdx-remote/blob/main/render-to-string.d.ts#L36-L42) + +```jsx +import createPage from 'swingset/page' +import { createStaticProps, createStaticPath } from 'swingset/server' + +export default createPage() +export const getStaticPaths = createStaticPaths() +export const getStaticProps = createStaticProps({ + /* mdxOptions = {} */ +}) +``` + +With this in place, if you go to the page you injected it on, it should work, although it will be empty. Next, let's talk about how to get some components loaded in there. + +### Usage + +Swingset points to `components/*` as its default location for components, in line with next.js convention. This is configurable if needed though, see [the options section, below](#options). It expects your components to live in folders, perhaps they contain an index where the components lives, and some other stuff like css. It doesn't matter what else is in the folder, as long as it has an index. To start writing docs for a specific component, add a `docs.mdx` file to the component's folder, and make sure that this file has a front matter block containing a key/value pair for `componentName`. Let's look at an example. Here's how your folder structure might look: + +``` +. +├── pages +│ ├── index.jsx +│   └── [[...swingset]].jsx <- here's where you injected swingset +└── components + └── button +    ├── index.jsx <- this is what's returned when you import `components/button` +    ├── style.module.css +    └── docs.mdx <- here's the docs file you created for swingset +``` + +So, you have only now added two things to your app -- a file in `pages` called `[[...swingset]].jsx` where you injected the component library itself, and a `docs.mdx` file in one of your components. And remember, the `docs.mdx` file needs frontmatter, or you will get an error. Here's how a minimal `docs.mdx` file might look: + +``` +--- +componentName: 'Button' +peerComponents: + - 'ArrowIcon' +--- + +Hi there, welcome to the button docs! + +Here's an example with an arrow: + + +``` + +With this in place, you should see your component's name render in the sidebar and show the contents of your markdown file. Not so bad! You are of course welcome to add docs files to multiple components, we're just starting with one. + +Now let's actually make these docs useful. There are a few components that are made available within `docs.mdx` files that will help you to showcase your components. + +1. Your actual component. So in the example above, you can use ``} +``` + +This is best used when you want to represent another component in your example, but the actual implementation of that component would distract from the point trying to be made in the example. + +There's one more useful prop to `LiveComponent` -- `collapsed`. If this prop is set to `true`, the code editor will be collapsed by default - when clicked it will expand. This is useful for examples that contain a lot of code - you can collapse the editor by default to make it easier for users to scroll through examples, then expand the code editor only when they want to see/edit the source code. It is `true` by default. + +TODO: screenshot here + +#### `` + +As usual, a usage example upfront: + +```jsx + +``` + +This would render your component, like ``, but rather than a code editor, a set of UI controls. These UI controls follow the [props spec](#props), and using a props file with this component is strongly recommended. + +TODO: screenshot here + +Nested props are supported as well, to infinite depth. For example, a nested `theme` prop might look like this: + +```jsx + +``` + +Control types currently available are: + +- `text` +- `select` -- requires `options` to be set +- `checkbox` +- `json` - freeform input for any js object, not recommended + +TODO: screenshot here + +#### `` + +Example as always: + +```jsx + +``` + +This component is quite straightforward as well, given an object containing data about props, it displays a nicely formatted table that shows the component's props. This component pairs particularly well with the [props file](#props), which we will discuss below, as its objects can be piped in directly. + +TODO: screenshot here + +### Props + +An additional, optional convention is to define your component's props in a separate file. You may ask yourself, "but why can't I use typescript, or jsdocs in my component, or PropTypes?!" The answer in this case is because swingset does not want to impose anything upon the way that you choose to build your components, so instead it offers an optional manner of detailing your props outside of your actual component. + +If you include a `props.js` file in the folder with your component, it will be picked up, parsed, and injected into your `docs.mdx` file as `componentProps`. You can then pass it into the `` and/or `` components, either fully, or splitting out individual props or sets of props, to save yourself lots of repetition and make your docs file much more terse. + +The `props.js` file does have an expected object structure, which is detailed below in psuedo-typescript style: + +```typescript +interface Properties = { + propName: { + type?: string, // write out the type you expect however you please + description?: string, // a short description of your prop + required?: boolean, // is it a required prop? + control?: {, // for knobs, see docs above + type: string, // type of control + value?: any // starting value for the control + }, + options?: []string, // if there are only a specific set of values allowed, detail them here + default?: string, // if there is a default value to this prop + testValue?: any, // value to be used as a test fixture, pairs with `fixtureFromProps` + properties: Properties | []Properties // if the prop is an array or object with nested items + } +} +``` + +As with other components, props can be nested here as well. There are a few specific caveats with the `control` value in nested properties though: + +- + +Let's lock this all in with a real example of a simple `props.js` file: + +```js +module.exports = { + headline: { + type: 'string', + description: 'The headline displayed above the content', + required: true, + testValue: 'Test Headline', + control: { type: 'text' }, + }, + data: { + type: 'object', + description: 'data that the component will render', + properties: { + theme: { + type: 'string', + description: 'color theme of the rendered data', + options: ['dark', 'light'], + control: { type: 'text' }, + default: 'light', + }, + logos: { + type: 'array', + description: + 'company logos to be displayed and show how cool your product is', + control: { type: 'json' }, + properties: [ + { + type: 'string', + description: + 'a string specifying a known company slug for which the logo will be displayed', + }, + { + type: 'object', + description: + 'if its not a known company, a custom object containing the necessary info to render', + properties: { + name: { + type: 'string', + description: 'the company name', + }, + logo: { + type: 'string', + description: 'url of the company logo to be displayed', + }, + }, + }, + ], + }, + }, + }, +} +``` + +### Options + +When initializing swingset in `next.config.js`, there are a few options you can pass it to customize its behavior. The example below shows how that might be done. None of the options are required, they all have defaults. + +```jsx +const withSwingset = require('swingset') + +module.exports = withSwingset({ + // Where your components live. "components/*" is the default. + componentsRoot: 'components/*', + // Where your generic docs pages live. No default + docsRoot: 'docs/*', + // Extra logging. Default is false + verbose: false, +})(/* normal nextjs config */) +``` + +There are some additional options that can be passed in to the page configuration for customization, example below: + +```js +import createPage from 'swingset/page' +import { createStaticProps } from 'swingset/server' + +const swingsetOptions = { + // if you have custom components you'd like to have available for use across all docs pages, + // the can be added here. No default. + components: { Tester: () =>

testing 123

}, + // Any React element + logo: , +} + +export default createPage(swingsetOptions) +export const getStaticPaths = createStaticPaths() +export const getStaticProps = createStaticProps(swingsetOptions) +``` + +### Test Utilities + +Swingset currently ships with a single test utility that can be used to extract deep-nested `testValue` data from props for use as test fixtures. Lets go through an example, starting with a sample `props.js` file: + +```js +module.exports = { + foo: { + type: 'string', + description: '...', + testValue: 'value', + }, + bar: { + type: 'object', + description: '...', + properties: { + baz: { + type: 'string', + testValue: 'value', + }, + }, + testValue: {}, + }, +} +``` + +Now let's look at how this could be used in some tests: + +```js +const props = require('./props') +const { getTestValues } = require('swingset/testing') + +getTestValues(props) // => { foo: 'value', bar: { baz: 'value' } } +``` + +This set of props can now be used as a fixture for component tests, perhaps like this with `jest`: + +```js +const props = require('./props') +const Component = require('./') +const { render } = require('@testing-library/react') +const { getTestValues } = require('swingset/testing') + +const defaultProps = getTestValues(props) + +test('default props renders without error', () => { + const render() + // .... +}) +``` + +It's worth noting that for nested props, the root and children can both have `testValue`s, and they will be merged together. For example, the following `props.js` file: + +```js +module.exports = { + foo: { + type: 'object', + properties: { + bar: { + type: 'string', + testValue: 'value', + }, + baz: { + type: 'string', + }, + }, + testValue: { bar: 'root value', baz: 'root value' }, + }, +} +``` + +...would produce `{ foo: { bar: 'value', baz: 'root value' } }`. If the root does not have a `testValue`, however, none of its children will be reflected in the output at all. So the following `props.js` file: + +```js +module.exports = { + foo: { + type: 'object', + properties: { + bar: { + type: 'string', + testValue: 'value', + }, + }, + }, +} +``` + +Would produce `{}` as its output, since `foo` does not have a `testValue`. There are two options if this is not your desired output. First, supply parent values with an empty object/array default, depending on the property type, as such: + +```js +module.exports = { + foo: { + type: 'object', + properties: { + bar: { + type: 'string', + testValue: 'value', + }, + }, + testValue: {}, + }, +} +``` + +Second, supply your entire fixture at the root level, rather than breaking it apart and distributing it to sub-properties, as such: + +```js +module.exports = { + foo: { + type: 'object', + properties: { + bar: { + type: 'string', + }, + }, + testValue: { bar: 'value' }, + }, +} +``` + +Choose whichever option feels more clear for your use! + +### Notes + +Any global styles that you specify by importing to `_app.jsx` will be reflected in your component library. Normally, this is a good thing, as your components will be showcased as they normally would within your app, but if any styles are not rendering as expected in the component library, it may be due to global overrides. diff --git a/examples/basic/.yalc/swingset/__swingset_data.js b/examples/basic/.yalc/swingset/__swingset_data.js new file mode 100644 index 0000000..0003d8c --- /dev/null +++ b/examples/basic/.yalc/swingset/__swingset_data.js @@ -0,0 +1,2 @@ +// this import is always intercepted by a loader +module.exports = {} diff --git a/examples/basic/.yalc/swingset/components-loader.js b/examples/basic/.yalc/swingset/components-loader.js new file mode 100644 index 0000000..5d26501 --- /dev/null +++ b/examples/basic/.yalc/swingset/components-loader.js @@ -0,0 +1,162 @@ +const globby = require('globby') +const fs = require('fs') +const path = require('path') +const { existsSync } = require('fsexists') +const matter = require('gray-matter') +const { getOptions } = require('loader-utils') +const slugify = require('slugify') + +module.exports = function swingsetComponentsLoader() { + const { pluginOptions, webpackConfig } = getOptions(this) + + // Resolve components glob + const allComponents = globby.sync(`${pluginOptions.componentsRoot}`, { + onlyFiles: false, + }) + + const usedComponents = removeComponentsWithoutDocs( + allComponents, + pluginOptions, + webpackConfig + ) + + addWebpackDependencies.call(this, usedComponents) + const componentsWithNames = formatComponentsWithNames( + usedComponents, + webpackConfig + ) + + // Resolve docs glob + const allDocs = globby.sync(`${pluginOptions.docsRoot}`) + const docsWithNames = formatDocsFilesWithNames(allDocs) + return generateMetadataFile(componentsWithNames, docsWithNames) +} + +// Go through each component and remove any components folders that don't have +// a "docs.mdx" file, since we don't need to display them. If a component exists +// without a docs page, and verbose mode is active, log a warning. +function removeComponentsWithoutDocs(components, pluginOptions, config) { + return components.reduce((memo, componentDir) => { + if (existsSync(path.join(componentDir, 'docs.mdx'))) { + memo.push(componentDir) + } else { + pluginOptions.verbose && + console.warn( + `The component "${componentDir.replace( + config.context, + '' + )}" does not have a "docs.mdx" file and therefore will not be documented.` + ) + } + return memo + }, []) +} + +// Add the component folder as a dependency so webpack knows when to reload +function addWebpackDependencies(components) { + components.map((componentDir) => { + this.addContextDependency(componentDir) + }) +} + +// Read the docs file name from the docs file, return the name and path. +// If the docs file doesn't have a name, throw a clear error. +// The format ends up like this: [{ name: 'Test', path: '/path/to/component' }] +function formatDocsFilesWithNames(docs, config) { + return docs.map((docsFile) => { + const fileContent = fs.readFileSync(docsFile, 'utf8') + const { data } = matter(fileContent) + if (!data.name) { + throw new Error( + `The docs file at "${path.replace( + config.context, + '' + )}" is missing metadata. Please add a human-readable name to display in the sidebar as "name" to the front matter at the top of the file.` + ) + } + return { + name: data.name, + path: docsFile, + slug: path.basename(docsFile, path.extname(docsFile)), + data, + } + }) +} + +// Read the component name from the docs file, return the name and path. +// If the docs file doesn't have a name, throw a clear error. +// The format ends up like this: [{ name: 'Test', path: '/path/to/component' }] +function formatComponentsWithNames(components, config) { + return components.map((componentDir) => { + const docsFileContent = fs.readFileSync( + path.join(componentDir, 'docs.mdx'), + 'utf8' + ) + const { data } = matter(docsFileContent) + if (!data.componentName) { + throw new Error( + `The docs file at "${path.replace( + config.context, + '' + )}" is missing metadata. Please add the component's name as you would like it to be imported as "componentName" to the front matter at the top of the file.` + ) + } + return { + name: data.componentName, + path: componentDir, + slug: slugify(data.componentName, { lower: true }), + data, + } + }) +} + +// Write out the component metadata to a file, which is formatted as such: +// +// ``` +// import ComponentName from '/absolute/path/to/component' +// +// export default { +// ComponentName: { +// path: '/absolute/path/to/component', +// docsPath: '/absolute/path/to/component/docs.mdx', +// propsPath: '/absolute/path/to/component/props.js', +// slug: 'componentname', +// src: ComponentName, +// data: { componentName: 'ComponentName' } +// }, +// ... +// } +// ``` +function generateMetadataFile(components, docsFiles) { + const imports = components.reduce((memo, component) => { + memo += `import ${component.name} from '${component.path}'\n` + return memo + }, '') + + const componentsData = components.reduce((acc, component) => { + // We can't just stringify here, because we need eg + // src: Button, <<< Button NOT in quotes + acc += ` '${component.name}': { + path: '${component.path}', + docsPath: '${path.join(component.path, 'docs.mdx')}', + propsPath: '${path.join(component.path, 'props.js')}', + slug: '${component.slug}', + src: ${component.name}, + data: ${JSON.stringify(component.data, null, 2)} + }, + ` + return acc + }, '') + + const docsData = docsFiles.reduce((acc, docsEntry) => { + acc[docsEntry.slug] = docsEntry + return acc + }, {}) + + let contents = '' + contents += imports + '\n' + contents += `export const components = {\n${componentsData}\n}\n` + contents += `export const docs = ${JSON.stringify(docsData, null, 2)}\n` + + return contents +} diff --git a/examples/basic/.yalc/swingset/components/knobs-component/index.jsx b/examples/basic/.yalc/swingset/components/knobs-component/index.jsx new file mode 100644 index 0000000..f0443c5 --- /dev/null +++ b/examples/basic/.yalc/swingset/components/knobs-component/index.jsx @@ -0,0 +1,155 @@ +import sg from '../shared.module.css' +import s from './style.module.css' +import { useState } from 'react' +import { useRestoreUrlState, setUrlState } from '../../utils/url-state' +import createId from '../../utils/create-id' +import scrollToElement from '../../utils/scroll-to-element' + +export default function createKnobsComponent(scope) { + const name = Object.keys(scope)[0] + const Component = Object.values(scope)[0] + + return function KnobsComponent({ knobs }) { + const id = createId(knobs) + const [values, setValues] = useState(knobs) + + // if there's url state and it applies to this element, restore it + useRestoreUrlState((qs) => { + if (qs.id == id) { + setValues(qs.values) + scrollToElement(id) + } + }) + + return ( +
+
setUrlState(name, id, values, true)} + > + Share +
+ +
+
Controls
+ {renderControls(values, setValues)} +
+
+ ) + } +} + +function renderControls(values, setValues, indentLevel = 0) { + return Object.keys(values).map((k) => { + const valuesCopy = JSON.parse(JSON.stringify(values)) + const v = values[k] + let control + + if (v.control === 'text') { + control = ( + { + valuesCopy[k].value = e.target.value + setValues(valuesCopy) + }} + /> + ) + } + + if (v.control === 'select') { + control = ( + + ) + } + + if (v.control === 'checkbox') { + control = ( + { + valuesCopy[k].value = e.target.checked + setValues(valuesCopy) + }} + /> + ) + } + + if (v.control === 'json') { + control = ( +