swingset
Version:
drop-in component library and documentation pages for next.js
167 lines (151 loc) • 5.4 kB
JavaScript
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,
})
.filter((folderPath) => {
return !folderPath.includes('node_modules')
})
const usedComponents = removeComponentsWithoutDocs(
allComponents,
pluginOptions
)
// add components directory as a webpack dependency
this.addContextDependency(pluginOptions.componentsRoot.split('*')[0])
const componentsWithNames = formatComponentsWithNames(usedComponents)
// 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.
* @param {string[]} components
* @param {import('./types').PluginOptions} pluginOptions
* @returns {string[]}
*/
function removeComponentsWithoutDocs(components, pluginOptions) {
return components.reduce((memo, componentDir) => {
if (existsSync(path.join(componentDir, 'docs.mdx'))) {
memo.push(componentDir)
} else {
pluginOptions.verbose &&
console.warn(
`The component "${componentDir}" does not have a "docs.mdx" file and therefore will not be documented.`
)
}
return memo
}, [])
}
/**
* 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' }]
* @param {string[]} docs
* @returns {import('./types').FormattedFileEntry[]}
*/
function formatDocsFilesWithNames(docs) {
return docs.map((docsFile) => {
const fileContent = fs.readFileSync(docsFile, 'utf8')
const { data } = matter(fileContent)
if (!data.name) {
throw new Error(
`The docs file at "${docsFile}" 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' }]
* @param {string[]} components
* @returns {import('./types').FormattedFileEntry[]}
*/
function formatComponentsWithNames(components) {
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 "${componentDir}" 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',
exports: ComponentNameExports,
data: { componentName: 'ComponentName' }
},
...
}
```
* @param {import('./types').FormattedFileEntry[]} components
* @param {import('./types').FormattedFileEntry[]} docsFiles
* @returns
*/
function generateMetadataFile(components, docsFiles) {
const imports = components.reduce((memo, component) => {
memo += `import * as ${component.name}Exports 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}',
exports: ${component.name}Exports,
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
}